From f6eb1d43649eef6b880dbc0b4317375516df523d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 31 Oct 2026 18:11:49 +0100 Subject: [PATCH] feat(ios): offer the phone's time zone to a box on UTC A Docker box keeps no household clock: it runs on UTC, so Erik's car goal of 17:00 was met at 18:00 in Stockholm. When the box's hello says UTC and this phone may change setup, the app offers the phone's 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. The demo box takes it the same way. Registry copy is byte-identical with srcfl/ftw. Co-Authored-By: Claude Opus 5.5 --- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 12 ++++ .../Sources/FTWDemo/SimulatedHouse.swift | 3 +- .../Sources/FTWKit/State/HouseholdClock.swift | 25 +++++++ .../Sources/FTWKit/State/SiteModel.swift | 19 +++++- .../Sources/FTWWire/Protocol/Contract.swift | 7 +- .../FTWKitTests/HouseholdClockTests.swift | 68 +++++++++++++++++++ protocol/registry.yaml | 1 + 7 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/HouseholdClock.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/HouseholdClockTests.swift diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index f2090947..b3aff6fd 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -429,6 +429,18 @@ public final class SimulatedBox { result(cmdId, "applied", observed: Double(Self.modes.firstIndex { $0.key == mode } ?? 0)) // Core sends the plan after an applied command on the session. sendBulk(Envelope(t: "plan", b: sessionPlan().wire)) + case Contract.opSiteTimeZonePropose: + guard let tz = args["tz"]?.string, let zone = TimeZone(identifier: tz) else { + result(cmdId, "rejected", error: ("E_UNKNOWN_OP", [("op", .text(op)), ("arg", .text("tz"))])) + return + } + guard house.timeZone.identifier == "UTC" || house.timeZone.identifier == "GMT" else { + result(cmdId, "rejected", error: ("E_PRECONDITION", [("op", .text(op)), ("tz", .text(house.timeZone.identifier))])) + return + } + // Core stores it and restarts; the next hello names it. + house.timeZone = zone + result(cmdId, "applied") case Contract.opPlannerPrefsSet: let k = args["safety_k"]?.double let export = args["battery_export"]?.string diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift index de52510f..a176ca8d 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift @@ -12,7 +12,8 @@ public final class SimulatedHouse { public let batteryCapacityWh: Double = 13_500 public let batteryMaxW: Double = 5_000 public let fuseAmps: Double = 25 - public let timeZone: TimeZone + /// The household clock. A box on UTC takes the phone's zone once. + public var timeZone: TimeZone /// A clear midsummer's day whatever the date, for showing a day that /// sells more than it buys. public var sunny = false diff --git a/appleApp/FTWKit/Sources/FTWKit/State/HouseholdClock.swift b/appleApp/FTWKit/Sources/FTWKit/State/HouseholdClock.swift new file mode 100644 index 00000000..1d999400 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/HouseholdClock.swift @@ -0,0 +1,25 @@ +import Foundation + +/// The zone the phone offers its box as the household clock. +/// +/// A Docker box runs on UTC whatever house it is in, so a car goal of 17:00 +/// was met at 18:00 in a Stockholm winter. The phone offers its own zone to a +/// box that says UTC; the box decides, and takes it only while it keeps none. +public enum HouseholdClock { + /// Names that mean UTC. A box that says one keeps no household clock; a + /// phone that says one knows nothing about the house. + static let utcNames: Set = ["UTC", "UCT", "GMT", "GMT0", "GMT+0", "GMT-0", "Greenwich", "Universal", "Zulu"] + + public static func isUTC(_ zone: String) -> Bool { + let name = zone.trimmingCharacters(in: .whitespaces) + return utcNames.contains(name.hasPrefix("Etc/") ? String(name.dropFirst(4)) : name) + } + + /// The zone to offer, or nil: only to a box on UTC, from a phone on a + /// zone of its own that may change setup. + public static func zoneToOffer(boxTZ: String?, phoneTZ: String?, scopes: Set) -> String? { + guard let boxTZ, isUTC(boxTZ), let phoneTZ, !phoneTZ.isEmpty, !isUTC(phoneTZ), + scopes.contains(AccessWire.scopeSetup) else { return nil } + return phoneTZ + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 1be6192b..911557e6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -98,6 +98,10 @@ public final class SiteModel { @ObservationIgnored private var writing: Task? /// Called after every write to `shared`, so the app can wake its widgets. @ObservationIgnored public var sharedWritten: (@MainActor () -> Void)? + /// The phone's zone, offered to a box on UTC. A seam for tests. + @ObservationIgnored var phoneTimeZone: @MainActor () -> String? = { TimeZone.current.identifier } + /// Boxes this run has offered the phone's zone, by box id. + @ObservationIgnored private var zoneOffered: Set = [] @ObservationIgnored private var attemptStartedAtMs: Double /// The app went to the background: the next return starts a new /// attempt (`returned()`). @@ -438,12 +442,25 @@ public final class SiteModel { if s.heardFromBox, let boot = s.cloud?.boot, [.relay, .tunnel, .webrtc].contains(s.carrier), s.uptimeMs != previous.uptimeMs || !previous.heardFromBox { learnRuleU(boot: boot, uptimeMs: s.uptimeMs, phoneMs: now) } - if s.phase == .streaming { scheduleSnapshot() } + if s.phase == .streaming { + scheduleSnapshot() + offerTimeZone(s) + } if reading { first("relay reading") } publish() scheduleClock() } + /// Offers the phone's zone to a box on UTC, once per box per run. The box + /// decides; once it takes the zone it restarts, and the next hello names + /// it. + private func offerTimeZone(_ s: SessionState) { + guard let box = s.box, !zoneOffered.contains(box.id), + let zone = HouseholdClock.zoneToOffer(boxTZ: box.tz, phoneTZ: phoneTimeZone(), scopes: s.scopes) else { return } + zoneOffered.insert(box.id) + Task { _ = try? await self.command(Contract.opSiteTimeZonePropose, args: [("tz", .text(zone))]) } + } + /// Logs once per home how long after `start` something first happened. func first(_ what: String) { guard startedAtMs > 0, firsts.insert(what).inserted else { return } diff --git a/appleApp/FTWKit/Sources/FTWWire/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWWire/Protocol/Contract.swift index c08c010c..fa41989f 100644 --- a/appleApp/FTWKit/Sources/FTWWire/Protocol/Contract.swift +++ b/appleApp/FTWKit/Sources/FTWWire/Protocol/Contract.swift @@ -132,6 +132,10 @@ public enum Contract { public static let opSetMode = "site.mode.set" public static let opPlannerPrefsSet = "planner.prefs.set" + /// Offer the phone's time zone as the household clock. The box takes it + /// only while it keeps none; a box that keeps one answers E_PRECONDITION + /// with the zone it keeps in args.tz. + public static let opSiteTimeZonePropose = "site.time_zone.propose" public static let opBatteryHold = "battery.hold" public static let opLoadpointHold = "loadpoint.hold" public static let opLoadpointBoost = "loadpoint.boost" @@ -146,7 +150,7 @@ public enum Contract { public static let opCarRemove = "car.remove" public static let ops = [ - opSetMode, opPlannerPrefsSet, opBatteryHold, opLoadpointHold, opLoadpointBoost, opLoadpointSocSet, opLoadpointSurplusOnlySet, + opSetMode, opPlannerPrefsSet, opSiteTimeZonePropose, opBatteryHold, opLoadpointHold, opLoadpointBoost, opLoadpointSocSet, opLoadpointSurplusOnlySet, opCarGoalSet, opCarSocSet, opSessionCarSet, opCarAdd, opCarUpdate, opCarRemove, ] @@ -168,6 +172,7 @@ public enum Contract { public static let opDescs: [String: String] = [ opSetMode: "Change the site operating mode", opPlannerPrefsSet: "Set household planner preferences", + opSiteTimeZonePropose: "Offer the phone's time zone to a box that keeps none", opBatteryHold: "Hold the battery at a fixed setpoint", opLoadpointHold: "Charge the car now at a fixed current", opLoadpointBoost: "Boost the car from the house battery", diff --git a/appleApp/FTWKit/Tests/FTWKitTests/HouseholdClockTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/HouseholdClockTests.swift new file mode 100644 index 00000000..a4c85c4f --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/HouseholdClockTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWDemo + +@Suite struct HouseholdClockTests { + let owner: Set = ["ftw.live.read", "ftw.setup.write"] + + @Test func aBoxOnUTCIsOfferedThePhonesZone() { + #expect(HouseholdClock.zoneToOffer(boxTZ: "UTC", phoneTZ: "Europe/Stockholm", scopes: owner) == "Europe/Stockholm") + #expect(HouseholdClock.zoneToOffer(boxTZ: "Etc/UTC", phoneTZ: "Europe/Stockholm", scopes: owner) == "Europe/Stockholm") + } + + @Test func aBoxWithAZoneOfItsOwnIsOfferedNothing() { + #expect(HouseholdClock.zoneToOffer(boxTZ: "Europe/Stockholm", phoneTZ: "America/New_York", scopes: owner) == nil) + } + + @Test func aPhoneOnUTCOrWithoutSetupOffersNothing() { + #expect(HouseholdClock.zoneToOffer(boxTZ: "UTC", phoneTZ: "GMT", scopes: owner) == nil) + #expect(HouseholdClock.zoneToOffer(boxTZ: "UTC", phoneTZ: nil, scopes: owner) == nil) + #expect(HouseholdClock.zoneToOffer(boxTZ: "UTC", phoneTZ: "Europe/Stockholm", scopes: ["ftw.live.read"]) == nil) + } +} + +/// Erik's box (31 October 2026): Docker on UTC, so 17:00 was met at 18:00. +@MainActor +@Suite struct HouseholdClockFlowTests { + func start(boxZone: TimeZone) throws -> (AppModel, SimulatedBox, ManualScheduler) { + let scheduler = ManualScheduler(nowMs: CarTextTests.t) + var made: SimulatedBox? + let app = AppModel(store: MemoryStore(), files: nil, passkeys: nil, scheduler: scheduler, build: "test", ua: "test", demo: { s in + let box = SimulatedBox(scheduler: s, house: SimulatedHouse(timeZone: boxZone)) + made = box + return LoopbackCarrier(box: box, scheduler: s) + }) + app.startDemo() + let site = try #require(app.site) + site.phoneTimeZone = { "Europe/Stockholm" } + return (app, try #require(made), scheduler) + } + + /// Runs the clock for `ms`, or until `done` holds. + func run(_ scheduler: ManualScheduler, _ ms: Double, until done: () -> Bool = { false }) async { + var left = ms + while left > 0, !done() { + for _ in 0..<10 { await Task.yield() } + scheduler.advance(min(50, left)) + left -= 50 + } + for _ in 0..<10 { await Task.yield() } + } + + @Test func aBoxOnUTCTakesThePhonesZoneOnce() async throws { + let (app, box, scheduler) = try start(boxZone: TimeZone(identifier: "UTC")!) + defer { withExtendedLifetime(app) {} } + await run(scheduler, 2_000) { box.house.timeZone.identifier == "Europe/Stockholm" } + await run(scheduler, 2_000) + #expect(box.receivedCommands.filter { $0 == Contract.opSiteTimeZonePropose }.count == 1) + #expect(box.house.timeZone.identifier == "Europe/Stockholm") + } + + @Test func aBoxThatKeepsAZoneIsNotOfferedOne() async throws { + let (app, box, scheduler) = try start(boxZone: CarTextTests.stockholm) + defer { withExtendedLifetime(app) {} } + await run(scheduler, 2_000) { box.receivedCommands.contains(Contract.opSiteTimeZonePropose) } + #expect(!box.receivedCommands.contains(Contract.opSiteTimeZonePropose)) + } +} diff --git a/protocol/registry.yaml b/protocol/registry.yaml index a874b6af..94d7f805 100644 --- a/protocol/registry.yaml +++ b/protocol/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 }