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
12 changes: 12 additions & 0 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/HouseholdClock.swift
Original file line number Diff line number Diff line change
@@ -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<String> = ["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>) -> String? {
guard let boxTZ, isUTC(boxTZ), let phoneTZ, !phoneTZ.isEmpty, !isUTC(phoneTZ),
scopes.contains(AccessWire.scopeSetup) else { return nil }
return phoneTZ
}
}
19 changes: 18 additions & 1 deletion appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ public final class SiteModel {
@ObservationIgnored private var writing: Task<Void, Never>?
/// 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<String> = []
@ObservationIgnored private var attemptStartedAtMs: Double
/// The app went to the background: the next return starts a new
/// attempt (`returned()`).
Expand Down Expand Up @@ -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 }
Expand Down
7 changes: 6 additions & 1 deletion appleApp/FTWKit/Sources/FTWWire/Protocol/Contract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
]

Expand All @@ -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",
Expand Down
68 changes: 68 additions & 0 deletions appleApp/FTWKit/Tests/FTWKitTests/HouseholdClockTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Foundation
import Testing
@testable import FTWKit
import FTWDemo

@Suite struct HouseholdClockTests {
let owner: Set<String> = ["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))
}
}
1 change: 1 addition & 0 deletions protocol/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
Loading