From 0b90e7238e8d999197352f2d6140d6c156e4649f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 31 Oct 2026 18:11:42 +0100 Subject: [PATCH] feat(session): offer the device's time zone to a box on UTC A Docker box keeps no household clock: it runs on UTC, so a car goal of 17:00 was met at 18:00 in a Stockholm winter. When the box's hello says UTC and this device may change setup, the app offers its own zone once per box with site.time_zone.propose. The box decides; it takes the zone only while it keeps none, and restarts to run on it. Registry copy is byte-identical with srcfl/ftw. Co-Authored-By: Claude Opus 5.5 --- contract/registry.yaml | 1 + src/lib/protocol/contract.ts | 3 +++ src/lib/protocol/messages.ts | 6 +++++ src/lib/sim/box.ts | 27 ++++++++++++++++++- src/lib/state/site.svelte.ts | 21 ++++++++++++++- src/lib/state/site.test.ts | 33 +++++++++++++++++++++++ src/lib/state/time-zone-offer.test.ts | 24 +++++++++++++++++ src/lib/state/time-zone-offer.ts | 38 +++++++++++++++++++++++++++ 8 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 src/lib/state/time-zone-offer.test.ts create mode 100644 src/lib/state/time-zone-offer.ts diff --git a/contract/registry.yaml b/contract/registry.yaml index a874b6a..94d7f80 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -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 } diff --git a/src/lib/protocol/contract.ts b/src/lib/protocol/contract.ts index 1da9ebb..d1f3dcc 100644 --- a/src/lib/protocol/contract.ts +++ b/src/lib/protocol/contract.ts @@ -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' diff --git a/src/lib/protocol/messages.ts b/src/lib/protocol/messages.ts index c2a42af..93bc30f 100644 --- a/src/lib/protocol/messages.ts +++ b/src/lib/protocol/messages.ts @@ -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' diff --git a/src/lib/sim/box.ts b/src/lib/sim/box.ts index bc96065..be0ac42 100644 --- a/src/lib/sim/box.ts +++ b/src/lib/sim/box.ts @@ -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, @@ -242,6 +243,7 @@ const DEFAULT_MODE: SiteMode = 'planner_passive_arbitrage' const OP_SCOPES: Record = { [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', @@ -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'] @@ -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 @@ -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({ @@ -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 } : {}), @@ -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), diff --git a/src/lib/state/site.svelte.ts b/src/lib/state/site.svelte.ts index 67b1971..0ac283c 100644 --- a/src/lib/state/site.svelte.ts +++ b/src/lib/state/site.svelte.ts @@ -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' @@ -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() /** 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. */ @@ -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) } }) } @@ -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 } diff --git a/src/lib/state/site.test.ts b/src/lib/state/site.test.ts index 2413748..c268b89 100644 --- a/src/lib/state/site.test.ts +++ b/src/lib/state/site.test.ts @@ -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() + }) +}) diff --git a/src/lib/state/time-zone-offer.test.ts b/src/lib/state/time-zone-offer.test.ts new file mode 100644 index 0000000..f726220 --- /dev/null +++ b/src/lib/state/time-zone-offer.test.ts @@ -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() + }) +}) diff --git a/src/lib/state/time-zone-offer.ts b/src/lib/state/time-zone-offer.ts new file mode 100644 index 0000000..bfc2b15 --- /dev/null +++ b/src/lib/state/time-zone-offer.ts @@ -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 | null { + if (!boxTz || !isUtcName(boxTz.trim())) return null + if (!deviceTz || isUtcName(deviceTz)) return null + return scopes.has(SCOPE_SETUP_WRITE) ? deviceTz : null +}