Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ cloud_purposes: [serve, stats]
ops:
- { name: site.mode.set, scope: ftw.mode.write, agent: ftw.goals.request, desc: Change the site operating mode }
- { name: planner.prefs.set, scope: ftw.mode.write, agent: ftw.goals.request, desc: Set household planner preferences }
- { name: site.time_zone.propose, scope: ftw.setup.write, desc: Offer the phone's time zone to a box that keeps none }
- { name: battery.hold, scope: ftw.dispatch.write, desc: Hold the battery at a fixed setpoint }
- { name: loadpoint.hold, scope: ftw.dispatch.write, agent: ftw.dispatch.hold, desc: Charge the car now at a fixed current }
- { name: loadpoint.boost, scope: ftw.dispatch.write, desc: Boost the car from the house battery }
Expand Down
3 changes: 3 additions & 0 deletions src/lib/protocol/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ export function roleHasScope(role: string, scope: string): boolean {
/** The scope the box checks for OP_SET_MODE. */
export const SCOPE_MODE_WRITE: Scope = 'ftw.mode.write'

/** The scope the box checks for OP_SITE_TIME_ZONE_PROPOSE. */
export const SCOPE_SETUP_WRITE: Scope = 'ftw.setup.write'

/** The capability a box offers when its optimizer accepts dispatch. */
export const CAP_PLAN_DISPATCH = 'plan.dispatch'

Expand Down
6 changes: 6 additions & 0 deletions src/lib/protocol/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,12 @@ export function carriesOverSession(contentType: string | undefined): boolean {
*/
export const OP_SET_MODE = 'site.mode.set'
export const OP_PLANNER_PREFS_SET = 'planner.prefs.set'
/**
* Offer this device's time zone as the household clock. The box takes it only
* while it keeps none, and restarts to run on it. A box that keeps one answers
* E_PRECONDITION with the zone it keeps in args.tz.
*/
export const OP_SITE_TIME_ZONE_PROPOSE = 'site.time_zone.propose'
export const OP_BATTERY_HOLD = 'battery.hold'
export const OP_LOADPOINT_HOLD = 'loadpoint.hold'
export const OP_LOADPOINT_BOOST = 'loadpoint.boost'
Expand Down
27 changes: 26 additions & 1 deletion src/lib/sim/box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
API_CHUNK_BYTES,
API_MAX_BYTES,
OP_PLANNER_PREFS_SET,
OP_SITE_TIME_ZONE_PROPOSE,
OP_SET_MODE,
OP_BATTERY_HOLD,
OP_LOADPOINT_HOLD,
Expand Down Expand Up @@ -242,6 +243,7 @@ const DEFAULT_MODE: SiteMode = 'planner_passive_arbitrage'
const OP_SCOPES: Record<string, string> = {
[OP_SET_MODE]: 'ftw.mode.write',
[OP_PLANNER_PREFS_SET]: 'ftw.mode.write',
[OP_SITE_TIME_ZONE_PROPOSE]: 'ftw.setup.write',
[OP_BATTERY_HOLD]: 'ftw.dispatch.write',
[OP_LOADPOINT_HOLD]: 'ftw.dispatch.write',
[OP_LOADPOINT_BOOST]: 'ftw.dispatch.write',
Expand Down Expand Up @@ -327,6 +329,8 @@ export interface SimBoxOptions {
* gets wrong and cannot notice.
*/
scopes?: string[]
/** The zone the box runs on. A Docker box says UTC. */
tz?: string
/** False models a box from before hello could carry a subscription. */
inlineSubscribe?: boolean
cloud?: HelloOk['cloud']
Expand All @@ -342,6 +346,8 @@ export interface SimBoxOptions {
export class SimBox {
readonly house: HouseConfig
faults: SimFaults
/** The zone hello names. */
tz: string

#now: () => number
#startedAtMs: number
Expand Down Expand Up @@ -410,6 +416,7 @@ export class SimBox {
this.#role = opts.role ?? ROLE_OWNER
this.#scopes = opts.scopes ?? null
this.#inlineSubscribe = opts.inlineSubscribe ?? true
this.tz = opts.tz ?? 'Europe/Stockholm'
this.#cloud = opts.cloud
if (opts.mode && MODE_KEYS.includes(opts.mode)) this.#mode = opts.mode
this.#api = new SimApi({
Expand Down Expand Up @@ -635,7 +642,7 @@ export class SimBox {
const b: HelloOk = {
proto,
mode: this.faults.booting ? 'booting' : proto === PROTO_FLOOR ? 'floor' : 'full',
box: { id: 'sim-0001', build: 'sim', tz: 'Europe/Stockholm' },
box: { id: 'sim-0001', build: 'sim', tz: this.tz },
clock: { source: 'ntp', syncedAtMs: this.#startedAtMs, uptimeMs: this.uptimeMs },
caps: proto === PROTO_FLOOR ? ['status.core'] : this.#cloud ? [...CAPS, CAP_CLOUD_TUN] : CAPS,
...(this.#cloud ? { cloud: this.#cloud } : {}),
Expand Down Expand Up @@ -806,6 +813,24 @@ export class SimBox {
// is the driver reading the value back, which may simply not happen.
if (this.faults.neverConfirm) return

// The household clock. Taken only while the box keeps none; a real box
// then restarts, and the next hello names it.
if (cmd.op === OP_SITE_TIME_ZONE_PROPOSE) {
const tz = cmd.args['tz']
if (typeof tz !== 'string' || tz === '' || tz === 'Local') {
this.#cmdResult(cmd.cmdId, 'rejected', {
code: 'E_UNKNOWN_OP',
args: { op: cmd.op, arg: 'tz', value: tz ?? null },
})
} else if (this.tz === 'UTC' || this.tz === 'Etc/UTC') {
this.tz = tz
this.#cmdResult(cmd.cmdId, 'applied')
} else {
this.#cmdResult(cmd.cmdId, 'rejected', { code: 'E_PRECONDITION', args: { op: cmd.op, tz: this.tz } })
}
return
}

if (cmd.op === OP_SET_MODE) {
this.#cmdResult(cmd.cmdId, 'applied', undefined, {
value: MODE_KEYS.indexOf(this.#mode),
Expand Down
21 changes: 20 additions & 1 deletion src/lib/state/site.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
import { Session, type SessionState, type ApiResponse } from '$lib/protocol/session'
import type { Fid } from '$lib/protocol/types'
import type { Plan, PriceQuery, Prices, CmdResult, Guard, ApiReq, Role } from '$lib/protocol/messages'
import { ROLE_OWNER } from '$lib/protocol/messages'
import { OP_SITE_TIME_ZONE_PROPOSE, ROLE_OWNER } from '$lib/protocol/messages'
import { deviceTimeZone, timeZoneToOffer } from './time-zone-offer'
import type { HistQuery, HistChunk, HistEnd } from '$lib/protocol/messages'
import type { Carrier } from '$lib/carrier/carrier'
import { explain, FID, type Explanation } from '$lib/format/explanation'
Expand Down Expand Up @@ -93,6 +94,8 @@ export class SiteStore {
/** Invalidates cache reads started for an older home or a dead store. */
#startGeneration = 0
#markedLive = false
/** Boxes this run has offered the device's time zone, by box id. */
#zoneOffered = new Set<string>()
/** Set for good in destroy(). A dead store must not accept a carrier. */
#destroyed = false
/** Wall clock when the last frame arrived. Null until one does. */
Expand Down Expand Up @@ -170,6 +173,7 @@ export class SiteStore {
this.cachedAtMs = null
const siteId = this.#siteId
if (siteId) this.#writer.offer(() => snapshotFromSession(siteId, s))
this.#offerTimeZone(s)
}
})
}
Expand Down Expand Up @@ -352,6 +356,21 @@ export class SiteStore {
return this.#session.command(op, args, guards).promise
}

/**
* Offer this device's zone to a box on UTC, once per box per run. A Docker
* box keeps no household clock, so a car goal of 17:00 was met at 18:00 in
* a Stockholm winter. The box decides whether to take it; a refusal names
* the zone it keeps, and the next hello says which one it runs on.
*/
#offerTimeZone(s: SessionState): void {
const box = s.box
if (!box || this.#zoneOffered.has(box.id)) return
const zone = timeZoneToOffer(box.tz, deviceTimeZone(), s.scopes)
if (!zone) return
this.#zoneOffered.add(box.id)
this.#session.command(OP_SITE_TIME_ZONE_PROPOSE, { tz: zone }).promise.catch(() => {})
}

get paired(): boolean {
return this.session.box !== null || this.session.fields.size > 0
}
Expand Down
33 changes: 33 additions & 0 deletions src/lib/state/site.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,36 @@ describe('a carrier that finishes connecting after sign-out', () => {
site.destroy()
})
})

describe('the household clock', () => {
function onDevice(zone: string) {
const real = Intl.DateTimeFormat.prototype.resolvedOptions
vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockImplementation(function (
this: Intl.DateTimeFormat,
) {
return { ...real.call(this), timeZone: zone }
})
}

afterEach(() => vi.restoreAllMocks())

it('offers the phone zone to a box on UTC, once', async () => {
onDevice('Europe/Stockholm')
const box = new SimBox({ tz: 'UTC' })
const site = new SiteStore('test')
site.connect(new LoopbackCarrier(box, { latencyMs: 0 }))
await vi.waitFor(() => expect(box.tz).toBe('Europe/Stockholm'), { timeout: 2_000 })
site.destroy()
})

it('leaves a zone the box keeps alone', async () => {
onDevice('America/New_York')
const box = new SimBox({ tz: 'Europe/Stockholm' })
const site = new SiteStore('test')
site.connect(new LoopbackCarrier(box, { latencyMs: 0 }))
await vi.waitFor(() => expect(site.session.phase).toBe('streaming'), { timeout: 2_000 })
await new Promise((r) => setTimeout(r, 20))
expect(box.tz).toBe('Europe/Stockholm')
site.destroy()
})
})
24 changes: 24 additions & 0 deletions src/lib/state/time-zone-offer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { timeZoneToOffer } from './time-zone-offer'

const owner = new Set(['ftw.live.read', 'ftw.setup.write'])

describe('the time zone a device offers its box', () => {
it('offers the device zone to a box on UTC', () => {
expect(timeZoneToOffer('UTC', 'Europe/Stockholm', owner)).toBe('Europe/Stockholm')
expect(timeZoneToOffer('Etc/UTC', 'Europe/Stockholm', owner)).toBe('Europe/Stockholm')
})

it('offers nothing to a box that names a zone of its own', () => {
expect(timeZoneToOffer('Europe/Stockholm', 'America/New_York', owner)).toBeNull()
})

it('offers nothing from a device that is itself on UTC', () => {
expect(timeZoneToOffer('UTC', 'UTC', owner)).toBeNull()
expect(timeZoneToOffer('UTC', null, owner)).toBeNull()
})

it('offers nothing from a device that may not change setup', () => {
expect(timeZoneToOffer('UTC', 'Europe/Stockholm', new Set(['ftw.live.read']))).toBeNull()
})
})
38 changes: 38 additions & 0 deletions src/lib/state/time-zone-offer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { SCOPE_SETUP_WRITE } from '$lib/protocol/contract'

/**
* Names that mean UTC. A box that says one has no household clock of its own:
* a Docker host runs on UTC whatever house it is in. A device that says one
* knows nothing about the house either.
*/
const UTC_NAME = /^(Etc\/)?(UTC|UCT|GMT|GMT0|GMT[+-]0|Greenwich|Universal|Zulu)$/

export function isUtcName(zone: string): boolean {
return UTC_NAME.test(zone)
}

/** This device's IANA zone, or null where the platform does not say. */
export function deviceTimeZone(): string | null {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || null
} catch {
return null
}
}

/**
* The zone this device offers the box as its household clock, or null.
*
* Only a box on UTC is offered one, and only by a device allowed to change
* setup. The box decides: it takes the zone only while it keeps none, so a
* phone abroad cannot move a house's clock.
*/
export function timeZoneToOffer(
boxTz: string | null | undefined,
deviceTz: string | null | undefined,
scopes: ReadonlySet<string>,
): string | null {
if (!boxTz || !isUtcName(boxTz.trim())) return null
if (!deviceTz || isUtcName(deviceTz)) return null
return scopes.has(SCOPE_SETUP_WRITE) ? deviceTz : null
}
Loading