From 679e4e16d4e944754f342fb5fe4dedde6c53c2a8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 19:50:45 +0200 Subject: [PATCH] iPhone: open on the last truth, from a sealed home file (WP4) A cold start drew a grey copy of Now and filled it in one part at a time: only the six readings reached the disk. The app now keeps the rest of what a screen shows in a sealed home- file, and every screen opens on it in its final shape, dated, until the box answers. - Stamped: a value with when it was true and where it came from (cache, relay, cloud). The same value again changes nothing. - home-: what hello said, the planner's preferences, the plan, the forecast, the prices, the chargers with their goals and windows, and Now's nodes. Written on the snapshot's triggers, only when it differs. - Session.restore applies the kept hello with the readings in one change, and leaves heardFromBox false. Controls are drawn from the kept caps and role, and stay inert until the box has spoken. - The plan keeps its age until the box answers; "No plan" only after an answer. Before any answer with nothing kept, the line is a placeholder. - plan- and prices- are read back from the App Group when the home file has none. Prices are kept over midnight. - The car keeps its last charger watts, with their age; an old read never changes the live home figure. - FTW Cloud's prices and chargers reach the models as stamped values. - Cached readings age against the uptime they were taken on, so hello no longer moves their age. - The demo plans once a quarter, as Core does, and -FTWDemoCache YES (Debug) lets it keep its cache, to record and time a cold start. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Car/CarView.swift | 5 + appleApp/FTW/FTWApp.swift | 3 + appleApp/FTW/UI/NodeView.swift | 9 +- appleApp/FTW/UI/NowView.swift | 29 +- appleApp/FTW/UI/PlanCard.swift | 16 +- appleApp/FTW/UI/PlanView.swift | 13 +- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 3 + .../Sources/FTWDemo/SimulatedHistory.swift | 8 +- .../FTWKit/Sources/FTWKit/Format/EV.swift | 10 +- .../Sources/FTWKit/Protocol/HistoryPlan.swift | 4 +- .../Sources/FTWKit/Protocol/Messages.swift | 4 +- .../Sources/FTWKit/Protocol/Session.swift | 41 ++- .../Sources/FTWKit/State/AppModel.swift | 26 +- .../Sources/FTWKit/State/EnergyNode.swift | 8 +- .../Sources/FTWKit/State/HomeCache.swift | 68 ++++ .../FTWKit/State/LoadpointsModel.swift | 91 ++++- .../Sources/FTWKit/State/ModeModel.swift | 24 +- .../FTWKit/Sources/FTWKit/State/NowFlow.swift | 50 ++- .../Sources/FTWKit/State/NowModels.swift | 43 ++- .../Sources/FTWKit/State/PlanModel.swift | 59 +++- .../Sources/FTWKit/State/PlanShare.swift | 51 +-- .../Sources/FTWKit/State/SiteModel.swift | 82 ++++- .../FTWKit/Sources/FTWKit/State/Stamped.swift | 63 ++++ .../FTWKit/Sources/FTWShared/PlanGlance.swift | 31 +- .../FTWShared/Resources/en.lproj/Plan.strings | 1 + .../FTWShared/Resources/sv.lproj/Plan.strings | 1 + .../Tests/FTWKitTests/HomeCacheTests.swift | 333 ++++++++++++++++++ 27 files changed, 924 insertions(+), 152 deletions(-) create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/HomeCache.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/Stamped.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/HomeCacheTests.swift diff --git a/appleApp/FTW/Car/CarView.swift b/appleApp/FTW/Car/CarView.swift index 3db2039..391d6c7 100644 --- a/appleApp/FTW/Car/CarView.swift +++ b/appleApp/FTW/Car/CarView.swift @@ -23,8 +23,12 @@ struct CarView: View { List { if let lp { StatusSection(lp: lp, model: model, site: home.site) + // Drawn from what the last launch kept; inert until the box + // has spoken in this session. LevelSection(lp: lp, model: model, canControl: home.site.canConfigure) + .disabled(!home.site.heardFromBox) GoalSection(lp: lp, model: model, site: home.site, canControl: home.site.canConfigure, editing: $editingGoal) + .disabled(!home.site.heardFromBox) if app.isDemo { DemoSection(lp: lp, model: model) } } else if model.loaded { Section { Text(L("This box has no charger.")) } @@ -39,6 +43,7 @@ struct CarView: View { .safeAreaBar(edge: .bottom) { if let lp, lp.pluggedIn, home.site.canConfigure { ControlBar(lp: lp, model: model) + .disabled(!home.site.heardFromBox) } } .scrollEdgeEffectStyle(.hard, for: .bottom) diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index e309585..9d65ab4 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -38,7 +38,10 @@ struct FTWApp: App { #if DEBUG // For screenshots: `-FTWDemo YES` opens the simulated home, and // `-FTWDemoWidgets YES` lets it leave its records for the widgets. + // `-FTWDemoCache YES` lets it keep its sealed cache like a paired + // home, to record and time a cold start. model.demoSharesWidgets = UserDefaults.standard.bool(forKey: "FTWDemoWidgets") + model.demoKeepsCache = UserDefaults.standard.bool(forKey: "FTWDemoCache") if UserDefaults.standard.bool(forKey: "FTWDemo") { model.startDemo() } // For end-to-end tests: `-FTWPairLink ` offers a pairing link // as a scanned QR would, where the simulator has no camera. diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index bfe2b53..03540ce 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -499,7 +499,13 @@ private struct PeriodSection: View { return NodeSummary.energy(node, e, today: period == .today) case .ahead: let now = site.nowMs + // "No plan" only once the box has answered without one; until + // then the line keeps its place. switch node { + case .battery, .grid where models.plan.plan == nil && !models.plan.loaded: + return models.plan.problem ?? " " + case .solar, .home where models.plan.forecastStamp == nil && !models.plan.forecastLoaded: + return " " case .battery: return PlanText.battery(models.plan.ahead(nowMs: now), stale: models.plan.plan?.stale ?? false, nowMs: now, zone: zone) case .grid: return PlanText.grid(models.plan.ahead(nowMs: now), stale: models.plan.plan?.stale ?? false) case .solar: return PlanText.solar(models.plan.forecastAhead(nowMs: now)) @@ -591,10 +597,11 @@ private struct ModeSection: View { } @ViewBuilder private var change: some View { - if mode.canControl { + if mode.mayControl { Button(action: choose) { Text(L("Change")).lineLimit(1).fixedSize() } + .disabled(!mode.canControl) .buttonStyle(.bordered) .buttonBorderShape(.capsule) .controlSize(.small) diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift index 1fa0cec..3ae93ec 100644 --- a/appleApp/FTW/UI/NowView.swift +++ b/appleApp/FTW/UI/NowView.swift @@ -107,7 +107,9 @@ private struct NowBody: View { Card { Text(L("This app is older than your box. Some things are hidden until it updates.")) } } StatusSection(site: site, mode: home.nodes.mode, choose: chooseMode) - FlowSection(site: site, loadpoints: home.loadpoints, zoom: zoom, fill: fill, canCommand: site.canConfigure) + // Controls wait for the box: a role kept from an earlier launch + // draws the home, never a command. + FlowSection(site: site, loadpoints: home.loadpoints, zoom: zoom, fill: fill, canCommand: site.canConfigure && site.heardFromBox) PlanSection(site: site, models: home.nodes, prices: home.prices, zoom: zoom) AboutLink(app: app) .padding(.top, -18) @@ -155,7 +157,8 @@ private struct StatusSection: View { sources: site.session.sources ), mode: mode.label.map { ModeLabel(text: $0, sending: mode.sending, doubt: mode.doubt) }, - canChoose: mode.canControl, + canChoose: mode.mayControl, + canPress: mode.canControl, choose: choose ) .equatable() @@ -177,11 +180,14 @@ struct ModeLabel: Equatable { struct StatusRow: View, Equatable { let answer: InControl.Answer let mode: ModeLabel? + /// This phone may change the mode: the choice is drawn. let canChoose: Bool + /// The box has spoken, so the choice opens. Until then it stays inert. + var canPress = true let choose: () -> Void nonisolated static func == (a: StatusRow, b: StatusRow) -> Bool { - a.answer == b.answer && a.mode == b.mode && a.canChoose == b.canChoose + a.answer == b.answer && a.mode == b.mode && a.canChoose == b.canChoose && a.canPress == b.canPress } var body: some View { @@ -282,7 +288,7 @@ struct StatusRow: View, Equatable { .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(!canChoose) + .disabled(!canChoose || !canPress) .accessibilityLabel(L("Mode")) .accessibilityValue(mode.text) .accessibilityHint(canChoose ? L("Change mode") : "") @@ -320,16 +326,8 @@ private struct FlowSection: View { var body: some View { // The stream carries the car's watts; a box that leaves them out - // gets the charger's last answer. - let evW = loadpoints.stale ? 0 : Flow.loadpointChargeW(loadpoints.points) - let fields = Flow.withLoadpointEV(site.session.fields, evW: evW) - let car = loadpoints.points.first.flatMap { loadpoints.level($0) } - let readings = NowFlow.readings( - fields: fields, - freshness: { site.freshness(fid: $0) }, - carLevel: car.map { ($0.pct, $0.origin == .assumed || $0.origin == .unconfirmed) }, - hasCar: !loadpoints.points.isEmpty - ) + // gets the charger's last answer, with its age. + let readings = NowFlow.readings(site: site, loadpoints: loadpoints) // The drawing has fixed places and stops growing at xLarge. At the // accessibility sizes each node gets a row of its own instead, so // its figure can grow with the text. @@ -432,12 +430,11 @@ private struct PlanSection: View { // The plan marks the minute, not the second: it redraws once a // minute however often the clock ticks. let minute = (site.nowMs / 60_000).rounded(.down) * 60_000 - let entry = models.planShare.entry let window = prices.prices let current = window.flatMap { w in w.slots.first { minute >= $0.startMs && minute < $0.startMs + $0.durationMs } } NavigationLink(value: HomeRoute.plan) { PlanCard( - moment: PlanGlance.live(entry, nowMs: minute), + moment: models.planShare.moment(nowMs: minute), priceNow: current.map { PriceStrip.text($0.totalMinor, window?.currency) }, unit: PriceUnits.unit(window?.currency).perKwh ) diff --git a/appleApp/FTW/UI/PlanCard.swift b/appleApp/FTW/UI/PlanCard.swift index 5a07a33..2736add 100644 --- a/appleApp/FTW/UI/PlanCard.swift +++ b/appleApp/FTW/UI/PlanCard.swift @@ -16,6 +16,15 @@ struct PlanCard: View, Equatable { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 8) { Kicker(PlanGlance.title) + // A plan kept from an earlier launch says when the box made + // it, until the box answers. + if let age = moment.ageText { + Text(age) + .font(.caption2) + .foregroundStyle(Theme.fgMuted) + .lineLimit(1) + .transition(.opacity) + } Spacer(minLength: 8) if let priceNow { Text("\(Text(L("Price now")).font(.caption2).foregroundStyle(Theme.fgMuted)) \(Text(AppWords.decimal(priceNow)).font(Theme.number(13, weight: .bold)).foregroundStyle(Theme.fg)) \(Text(unit).font(.caption2).foregroundStyle(Theme.fgMuted))") @@ -27,12 +36,16 @@ struct PlanCard: View, Equatable { .font(.caption.weight(.semibold)) .foregroundStyle(Theme.fgMuted) } + // Two lines kept whatever the sentence, so the card holds its + // height when the plan lands. Before the box's first answer + // the line is a placeholder, not a claim about the plan. Text(moment.summary) .font(.subheadline.weight(.semibold)) .foregroundStyle(moment.isGrey ? Theme.fgDim : Theme.fg) - .lineLimit(2) + .lineLimit(2, reservesSpace: true) .minimumScaleFactor(0.85) .fixedSize(horizontal: false, vertical: true) + .redacted(reason: moment.placeholder ? .placeholder : []) .contentTransition(.opacity) PlanLanes(moment: moment, compact: true) .frame(height: 50) @@ -44,6 +57,7 @@ struct PlanCard: View, Equatable { .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) .contentShape(RoundedRectangle(cornerRadius: Theme.radius)) .animation(Theme.spring, value: moment.summary) + .animation(Theme.spring, value: moment.ageText) .accessibilityElement(children: .ignore) .accessibilityLabel(PlanGlance.title) .accessibilityValue([moment.summary, priceNow.map { "\(L("Price now")) \($0) \(unit)" }].compactMap { $0 }.joined(separator: ". ")) diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index cf5a383..162578d 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -45,12 +45,13 @@ private struct PlanBody: View { var body: some View { // The plan marks the minute: it redraws once a minute, not a second. let minute = (site.nowMs / 60_000).rounded(.down) * 60_000 - let moment = PlanGlance.live(models.planShare.entry, nowMs: minute) + let moment = models.planShare.moment(nowMs: minute) VStack(alignment: .leading, spacing: 4) { Text(moment.summary) .font(.headline) - .foregroundStyle(Theme.fg) + .foregroundStyle(moment.isGrey ? Theme.fgDim : Theme.fg) .fixedSize(horizontal: false, vertical: true) + .redacted(reason: moment.placeholder ? .placeholder : []) Text(stateLine(moment)) .font(.footnote) .foregroundStyle(moment.state == .boxStale ? Theme.generation : Theme.fgDim) @@ -73,8 +74,8 @@ private struct PlanBody: View { /// When the box made it, or that it could not. private func stateLine(_ m: PlanGlance.Moment) -> String { if m.state == .boxStale { return L("Your box couldn't plan ahead just now, so it runs on safe defaults.") } - guard let at = models.plan.receivedAtMs else { return L("Reading the plan from your box…") } - return NodeWords.format("Planned by your box at %@. It plans again every quarter of an hour.", AppWords.clock(at, zone: .current)) + guard let at = models.plan.receivedAtMs ?? m.readAtMs else { return L("Reading the plan from your box…") } + return NodeWords.format("Planned by your box at %@. It plans again every quarter of an hour.", AppWords.clock(at, zone: m.timeZone)) } } @@ -245,8 +246,8 @@ private struct CompactMode: View { Spacer(minLength: 8) Text(label) .font(.subheadline.weight(.semibold)) - .foregroundStyle(mode.canControl ? Theme.accent : Theme.fg) - if mode.canControl { + .foregroundStyle(mode.mayControl ? Theme.accent : Theme.fg) + if mode.mayControl { Image(systemName: "chevron.up.chevron.down") .font(.caption2.weight(.bold)) .foregroundStyle(Theme.accent) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index a095873..282cce2 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -35,6 +35,9 @@ public final class SimulatedBox { /// estimate meanwhile and the data grows into it. 0 sends all at once. public var holdTileMs: Double = 0 private var heldTile = false + /// The plan as last made, with the quarter and revision it was made + /// for. Core plans each quarter and sends the same plan until the next. + var madePlan: (quarter: Double, rev: UInt64, plan: Plan)? /// The share of today the box has prices for, as /// `priced_coverage_pct`. Below 0.95 the grid widget marks its cost /// partial. diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift index b0313e9..5be873c 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift @@ -206,15 +206,21 @@ extension SimulatedBox { } /// The plan as `plan.get` answers it. + /// The plan as Core sends it: made once a quarter, or when the mode + /// or the preferences change, and the same plan until then. func sessionPlan() -> Plan { + let quarter = (scheduler.nowMs / 900_000).rounded(.down) + if let made = madePlan, made.quarter == quarter, made.rev == house.planRev { return made.plan } let steps = house.plan(nowMs: scheduler.nowMs) - return Plan( + let plan = Plan( rev: house.planRev, uptimeMs: uptimeMs, slots: steps.map { PlanSlot(startMs: $0.startMs.rounded(), durationMs: $0.durationMs.rounded(), batteryW: $0.batteryW, gridW: $0.gridW, priceMinor: $0.priceMinor, reason: $0.reason) }, stale: false, ceilingW: nil ) + madePlan = (quarter, house.planRev, plan) + return plan } /// The planner's own document's actions, as GET /api/mpc/plan carries diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift index 9b723e1..00192e5 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -5,8 +5,8 @@ import FTWShared /// sentences that describe it. No sentence claims what the box has not /// said: a charger that does not know the car's charge is described by what /// it does know, never by an invented percentage. -public struct Loadpoint: Equatable, Sendable, Identifiable { - public struct Schedule: Equatable, Sendable { +public struct Loadpoint: Equatable, Sendable, Identifiable, Codable { + public struct Schedule: Equatable, Sendable, Codable { public var socPct: Double /// The ready time as the box sends it: the UTC minute of day of that /// time on today's date. @@ -31,7 +31,7 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { } } - public struct Manual: Equatable, Sendable { + public struct Manual: Equatable, Sendable, Codable { public var state: String? public var requestedA: Double? public var requestedW: Double? @@ -41,14 +41,14 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { public var chargerUpdatedAtMs: Double? } - public struct Charger: Equatable, Sendable { + public struct Charger: Equatable, Sendable, Codable { public var known: Bool public var available: Bool? public var updatedAtMs: Double? public var reason: String? } - public struct Window: Equatable, Sendable, Identifiable { + public struct Window: Equatable, Sendable, Identifiable, Codable { public var fromMs: Double public var toMs: Double public var peakW: Double? diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift index ddc0803..d7560b0 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift @@ -123,7 +123,7 @@ public struct HistEnd: Sendable, Equatable { // MARK: - The plan -public struct PlanSlot: Equatable, Sendable, Identifiable { +public struct PlanSlot: Equatable, Sendable, Identifiable, Codable { /// Wall clock: these are future times a person reads. public var startMs: Double public var durationMs: Double @@ -151,7 +151,7 @@ public struct PlanSlot: Equatable, Sendable, Identifiable { /// What the box means to do, slot by slot. Intent, not prophecy: the box /// replans as the day turns out. -public struct Plan: Equatable, Sendable { +public struct Plan: Equatable, Sendable, Codable { public var rev: UInt64 public var uptimeMs: Double public var slots: [PlanSlot] diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift index 697ec92..b5ed46f 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift @@ -58,7 +58,7 @@ public struct FieldDef: Equatable, Sendable, Codable { } } -public struct ModeInfo: Equatable, Sendable, Identifiable { +public struct ModeInfo: Equatable, Sendable, Identifiable, Codable { public var key: String public var label: String public var tooltip: String @@ -80,7 +80,7 @@ public struct BootProgress: Equatable, Sendable { public var etaMs: Double? } -public struct BoxInfo: Equatable, Sendable { +public struct BoxInfo: Equatable, Sendable, Codable { public var id: String public var build: String public var tz: String diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift index c671560..c0ff575 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift @@ -176,28 +176,33 @@ public final class Session { // MARK: Lifecycle - /// Seed state from the cache before any carrier exists. The readings - /// were true when captured and the band says how long ago; carrier stays - /// `cache`, which is a carrier and not a failure. + /// Seed state from the cache before any carrier exists, in one change. + /// The readings were true when captured and the band says how long ago; + /// carrier stays `cache`, which is a carrier and not a failure. + /// + /// `hello` is what the box said at hello on an earlier launch: its caps, + /// its modes, the box and this phone's role, so the mode label and the + /// household's clock are there in the first frame. `heardFromBox` stays + /// false, so none of it unlocks a control, and hello replaces it all. /// /// The cache read races the connect. Landing mid-handshake, only the /// data paints: the phase, carrier and clock belong to the connection. - public func restore(_ snapshot: CachedSnapshot) { - switch state.phase { - case .streaming: - return - case .handshaking, .subscribing, .booting: - if !state.fields.isEmpty { return } - var s = state - snapshot.apply(to: &s, includeClock: false) - state = s - default: - var s = state - snapshot.apply(to: &s, includeClock: true) - s.phase = .idle - s.carrier = .cache - state = s + public func restore(_ snapshot: CachedSnapshot?, hello: HomeCache.Hello? = nil) { + var s = state + if let hello, !s.heardFromBox { hello.apply(to: &s) } + if let snapshot { + switch s.phase { + case .streaming: + break + case .handshaking, .subscribing, .booting: + if s.fields.isEmpty { snapshot.apply(to: &s, includeClock: false) } + default: + snapshot.apply(to: &s, includeClock: true) + s.phase = .idle + s.carrier = .cache + } } + state = s } /// Attach a carrier and start the handshake. Replaces any current one. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index f77882c..865485b 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -17,15 +17,23 @@ public final class HomeModels { /// Today over the grid, for the grid widget. public let gridToday: GridTodayShare - init(site: SiteModel) { + /// `cache` is what the app left for its widgets. Each model opens on + /// the sealed home file first, and on these records where it has none. + init(site: SiteModel, cache: CacheReader?) { self.site = site - prices = PriceModel(site: site) + prices = PriceModel(site: site, cache: cache) status = StatusWatch(site: site) charging = ChargingWatch(site: site) savings = SavingsModel(site: site) - loadpoints = LoadpointsModel(site: site, charging: charging) - nodes = NodeModels(site: site, loadpoints: loadpoints, prices: prices) + loadpoints = LoadpointsModel(site: site, charging: charging, cache: cache) + nodes = NodeModels(site: site, loadpoints: loadpoints, prices: prices, cache: cache) gridToday = GridTodayShare(site: site, history: nodes.history, prices: prices) + // The nodes Now drew, so the next launch draws them in their places + // before its first reading. + site.keep { [weak site, weak loadpoints] home in + guard let site, let loadpoints else { return } + home.nodes = site.lastFrameAtMs == nil ? site.kept?.nodes : NowFlow.nodes(fields: site.session.fields, hasCar: !loadpoints.points.isEmpty) + } } } @@ -61,6 +69,9 @@ public final class AppModel { /// Only a Debug build sets it, to photograph the widgets; the demo /// otherwise writes nothing. public var demoSharesWidgets = false + /// The demo keeps its sealed cache like a paired home, so a cold start + /// can be recorded and timed. Only a Debug build sets it. + public var demoKeepsCache = false @ObservationIgnored public let vault: Vault @ObservationIgnored public let sites: SiteList @@ -128,7 +139,7 @@ public final class AppModel { let site = SiteModel(siteId: siteId, build: build, ua: ua, scheduler: scheduler, files: files, shared: shared) site.stepUp = { [weak self] in await self?.stepUp() ?? .unavailable } site.sharedWritten = sharedWritten - home = HomeModels(site: site) + home = HomeModels(site: site, cache: shared.map { CacheReader(sources: [$0]) }) sites.setCurrent(siteId) site.start() recovering = false @@ -266,10 +277,11 @@ public final class AppModel { guard let demo else { return } home?.site.destroy() let shares = demoSharesWidgets && shared != nil - let site = SiteModel(siteId: shares ? "demo" : nil, build: build, ua: ua, scheduler: scheduler, files: nil, shared: shares ? shared : nil, isDemo: true, demoShares: shares) + let keeps = demoKeepsCache && files != nil + let site = SiteModel(siteId: shares || keeps ? "demo" : nil, build: build, ua: ua, scheduler: scheduler, files: keeps ? files : nil, shared: shares ? shared : nil, isDemo: true, demoShares: shares) if shares { site.sharedWritten = sharedWritten } site.ceilingW = 11_000 - home = HomeModels(site: site) + home = HomeModels(site: site, cache: shares ? shared.map { CacheReader(sources: [$0]) } : nil) isDemo = true recovering = false offeredLink = nil diff --git a/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift b/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift index 8514e2f..bc54376 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift @@ -3,7 +3,7 @@ import FTWShared /// The five places energy goes on Now, each a door to its own view: the /// sun, the house battery, the home's own use, the grid and the car. -public enum EnergyNode: String, CaseIterable, Hashable, Sendable, Identifiable { +public enum EnergyNode: String, CaseIterable, Hashable, Sendable, Identifiable, Codable { case solar, battery, home, grid, car public var id: String { rawValue } @@ -139,7 +139,7 @@ extension SiteModel { public func freshness(fid: Int) -> NodeFreshness { let f = NodeFreshness.of( fid: fid, - session: session, + session: asCached(session), carrier: carrier, streamQuietMs: sinceLastFrameMs, shelfMs: carrier == .cache ? cachedAtMs.map { max(0, nowMs - $0) } : nil, @@ -166,14 +166,14 @@ public final class NodeModels { /// The plan as Now draws it, and as the plan widget reads it. public let planShare: PlanShare - public init(site: SiteModel, loadpoints: LoadpointsModel, prices: PriceModel) { + public init(site: SiteModel, loadpoints: LoadpointsModel, prices: PriceModel, cache: CacheReader? = nil) { live = LiveTrace(site: site) history = HistoryModel(site: site) carEnergy = CarEnergyModel(site: site) plan = PlanModel(site: site) mode = ModeModel(site: site) sources = SourcesModel(site: site) - planShare = PlanShare(site: site, plan: plan, loadpoints: loadpoints, prices: prices) + planShare = PlanShare(site: site, plan: plan, loadpoints: loadpoints, prices: prices, cache: cache) // A change the box applied is followed by its new plan. mode.applied = { [plan] in plan.follow() } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/HomeCache.swift b/appleApp/FTWKit/Sources/FTWKit/State/HomeCache.swift new file mode 100644 index 0000000..d5bb1e7 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/HomeCache.swift @@ -0,0 +1,68 @@ +import Foundation +import FTWShared + +/// What the app keeps of a home between launches beside its readings +/// (`snapshot-`): what the box said at hello, the planner's +/// preferences, the plan, the forecast, the prices, the chargers and which +/// nodes Now draws. Sealed like the readings, in `home-`. +/// +/// Every screen opens on it in its final shape, dated, and the box corrects +/// it. A household's habits live here, in the sealed app cache, and never in +/// the App Group; removing the phone erases them with the rest. +public struct HomeCache: Codable, Equatable, Sendable { + static let version = 1 + + /// What the box said at hello. Applied at start without `heardFromBox`: + /// until the box has spoken, none of it unlocks a control, and hello + /// replaces all of it. + public struct Hello: Codable, Equatable, Sendable { + public var caps: [String] + public var modes: [ModeInfo] + public var box: BoxInfo? + public var role: String + public var scopes: [String] + + init(_ s: SessionState) { + caps = s.caps.sorted() + modes = s.modes + box = s.box + role = s.role + scopes = s.scopes.sorted() + } + + func apply(to s: inout SessionState) { + s.caps = Set(caps) + s.modes = modes + s.box = box + s.role = role + s.scopes = Set(scopes) + } + } + + public var version = HomeCache.version + public var siteId: String + public var hello: Hello? + public var prefs: Stamped? + public var plan: Stamped? + public var forecast: Stamped<[PlanModel.Forecast]>? + public var prices: Stamped? + public var chargers: Stamped? + /// The nodes Now drew, in its order. + public var nodes: [EnergyNode]? + + public init(siteId: String) { + self.siteId = siteId + } + + /// As a later launch reads it: every stamp from the cache. Two files + /// that read back the same say the same thing. + var asRead: HomeCache { + var c = self + c.prefs = prefs?.cached + c.plan = plan?.cached + c.forecast = forecast?.cached + c.prices = prices?.cached + c.chargers = chargers?.cached + return c + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index 4152543..d9ca80b 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -39,7 +39,10 @@ public final class LoadpointsModel: Activatable { case failed(String) } - public private(set) var points: [Loadpoint] = [] + /// The chargers as the box last described them, and when and how that + /// reached this phone. The same chargers again change nothing. + public private(set) var stamp: Stamped<[Loadpoint]>? + public var points: [Loadpoint] { stamp?.value ?? [] } public private(set) var windows: [String: [Loadpoint.Window]] = [:] public private(set) var planMissing = false public private(set) var loading = false @@ -63,11 +66,19 @@ public final class LoadpointsModel: Activatable { public private(set) var levelSet: [String: EVText.LevelSet] = [:] /// What is on screen came from the phone's own cache, not the box. True /// from a cold start until the first read. - public private(set) var fromCache = false + public var fromCache: Bool { stamp?.fromCache ?? false } + + /// The chargers and their plan, as the home file keeps them. + public struct Chargers: Codable, Equatable, Sendable { + public var points: [Loadpoint] + public var windows: [String: [Loadpoint.Window]] + } @ObservationIgnored private unowned let site: SiteModel @ObservationIgnored private var ask: LiveAsk? @ObservationIgnored private var activityAsk: LiveAsk? + /// FTW Cloud's chargers as last taken, so they are taken once. + @ObservationIgnored private var cloudTaken: Stamped<[CarEntry.Car]>? /// Charge now per charger as the last read found it, so the Control is /// told only when it changed. @ObservationIgnored private var lastHolding: [String: Bool] = [:] @@ -93,11 +104,23 @@ public final class LoadpointsModel: Activatable { public init(site: SiteModel, charging _: ChargingWatch?, cache: CacheReader? = CacheReader.shared(), chargeNowHost: (any ChargeNowHost)? = nil) { self.site = site self.chargeNowHost = chargeNowHost - if let siteId = site.siteId, let read = cache?.read(CarEntry.self, siteId: siteId) { - points = read.value.cars.map { Self.loadpoint($0, readAtMs: read.record.readAtMs) } + // A cold start paints the chargers as last read: from the sealed + // home file, with their goals and windows, or else from the record + // left for the widgets, whichever is newer. + let shared = site.siteId.flatMap { cache?.read(CarEntry.self, siteId: $0) } + if let kept = site.kept?.chargers, kept.asOfMs >= shared?.record.readAtMs ?? 0 { + stamp = Stamped(kept.value.points, asOfMs: kept.asOfMs, origin: .cache) + windows = kept.value.windows + readAtMs = kept.asOfMs + } else if let read = shared { + stamp = Stamped(read.value.cars.map { Self.loadpoint($0, readAtMs: read.record.readAtMs) }, asOfMs: read.record.readAtMs, origin: .cache) readAtMs = read.record.readAtMs - fromCache = true } + site.keep { [weak self] home in + guard let self, let stamp = self.stamp else { return } + home.chargers = Stamped(Chargers(points: stamp.value, windows: self.windows), asOfMs: stamp.asOfMs, origin: stamp.origin) + } + site.observe { [weak self] in self?.takeCloud() } // A Live Activity from another home, or from the demo, has no // business on this phone once this home is open. activityHost?.endAll(except: activityHome) @@ -111,6 +134,30 @@ public final class LoadpointsModel: Activatable { }, ask: { [weak self] in try await self?.loadChargers() }) } + /// FTW Cloud's chargers, when they are newer than the box's last answer + /// here. They carry the cable, the level and the power; the goal and + /// the plan stay as the box last gave them. + private func takeCloud() { + guard let cloud = site.cloudCars, cloud != cloudTaken else { return } + cloudTaken = cloud + if let stamp, !stamp.fromCache, stamp.asOfMs >= cloud.asOfMs { return } + let known = Dictionary(points.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) + let next = cloud.value.map { car -> Loadpoint in + let fresh = Self.loadpoint(car, readAtMs: cloud.asOfMs) + guard var lp = known[car.loadpointID] else { return fresh } + lp.pluggedIn = fresh.pluggedIn + lp.powerW = fresh.powerW + lp.socPct = fresh.socPct + lp.socSource = fresh.socSource + lp.vehicleSocAgeS = fresh.vehicleSocAgeS + if let target = fresh.targetSocPct { lp.targetSocPct = target } + return lp + } + guard let taken = Stamped.taking(Stamped(next, asOfMs: cloud.asOfMs, origin: .cloud), over: stamp) else { return } + stamp = taken + readAtMs = taken.asOfMs + } + /// A charger as the cache kept it: enough for the level, its source and /// age, the cable and the power. The goal and the plan wait for the box. static func loadpoint(_ car: CarEntry.Car, readAtMs: Double) -> Loadpoint { @@ -184,6 +231,14 @@ public final class LoadpointsModel: Activatable { fromCache || error != nil || site.session.phase != .streaming } + /// How fresh the chargers' figures are, as a node says it: live while + /// this session's read stands, else stale and as old as the read. + public var freshness: NodeFreshness { + let age = readAtMs.map { max(0, site.nowMs - $0) } + guard stale else { return NodeFreshness(carrier: site.carrier, srcState: .live, ageMs: age, sourceName: nil) } + return NodeFreshness(carrier: fromCache ? .cache : site.carrier, srcState: .stale, ageMs: age, sourceName: nil) + } + /// Ask again now: pull to refresh, or the demo's cable. public func reread() { visits += 1 @@ -213,7 +268,8 @@ public final class LoadpointsModel: Activatable { guard mine == token else { return } let list = wire?["loadpoints"]?.array ?? [] let before = Dictionary(points.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) - points = list.map(Loadpoint.init) + let now = site.scheduler.nowMs + if let taken = Stamped.taking(Stamped(list.map(Loadpoint.init), asOfMs: now, origin: .relay), over: stamp) { stamp = taken } for lp in points { // A level set here belongs to one plug-in. let was = before[lp.id] @@ -221,13 +277,13 @@ public final class LoadpointsModel: Activatable { } inlinePlan = !points.isEmpty && points.allSatisfy(\.inlinePlan) if inlinePlan { - windows = Dictionary(uniqueKeysWithValues: points.map { ($0.id, $0.planWindows) }) - planMissing = false + let next = Dictionary(uniqueKeysWithValues: points.map { ($0.id, $0.planWindows) }) + if next != windows { windows = next } + if planMissing { planMissing = false } } - loaded = true - fromCache = false - readAtMs = site.scheduler.nowMs - error = nil + if !loaded { loaded = true } + readAtMs = now + if error != nil { error = nil } site.shareCars(points, fresh: true) readOutside() planAsk?.evaluate() @@ -257,9 +313,9 @@ public final class LoadpointsModel: Activatable { for lp in points { out[lp.id] = pending || lp.planPending || lp.planOutdated ? [] : Self.chargeWindows(actions, loadpointID: lp.id) } - windows = out + if out != windows { windows = out } takeShortfall(wire, pending: pending) - planMissing = false + if planMissing { planMissing = false } } catch { guard mine == token else { return } planMissing = true @@ -515,9 +571,10 @@ public final class LoadpointsModel: Activatable { do { _ = try await site.callBox(.delete, "/api/loadpoints/\(Self.escape(lp.id))/schedule") // The DELETE confirms the removal even if the reread fails. - if let i = points.firstIndex(where: { $0.id == lp.id }) { - points[i].schedule = nil - points[i].targetSocPct = nil + if var s = stamp, let i = s.value.firstIndex(where: { $0.id == lp.id }) { + s.value[i].schedule = nil + s.value[i].targetSocPct = nil + stamp = s } goalSave[lp.id] = .removed } catch let e as BoxAPIError { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift index 0b1f130..699f9e6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift @@ -44,7 +44,7 @@ public final class ModeModel { public nonisolated static let planFallback = Contract.modes.first { $0.tier == "primary" && $0.key.hasPrefix("planner_") }?.key ?? Contract.modes[0].key /// The planner preferences as the box answered them. - public struct Prefs: Equatable, Sendable { + public struct Prefs: Equatable, Sendable, Codable { public var safetyK: Double /// "allowed", "not_allowed" or "unknown". public var batteryExport: String @@ -87,10 +87,14 @@ public final class ModeModel { nonisolated static let doneShownMs: Double = 8_000 public private(set) var change: Change? - public private(set) var prefs: Prefs? + /// The planner preferences on screen: the box's answer, or what an + /// earlier launch kept until the box answers. The same again changes + /// nothing. + public private(set) var prefsStamp: Stamped? + public var prefs: Prefs? { prefsStamp?.value } /// When the prefs were last read, so an applied style or sale stands /// until the box has been asked again. - private var prefsReadAtMs: Double? + @ObservationIgnored private var prefsReadAtMs: Double? /// Runs after the box applied a change, so the plan can be read again. @ObservationIgnored public var applied: (@MainActor () -> Void)? @@ -102,6 +106,8 @@ public final class ModeModel { public init(site: SiteModel) { self.site = site + prefsStamp = site.kept?.prefs?.cached + site.keep { [weak self] home in home.prefs = self?.prefsStamp } } public func activate() { @@ -122,7 +128,8 @@ public final class ModeModel { func loadPrefs() async throws { let wire = try await site.callBox(.get, "/api/planner/prefs") - prefs = Self.prefs(wire) + let next = Stamped(Self.prefs(wire), asOfMs: site.scheduler.nowMs, origin: .relay) + if let taken = Stamped.taking(next, over: prefsStamp) { prefsStamp = taken } prefsReadAtMs = site.scheduler.nowMs } @@ -223,11 +230,16 @@ public final class ModeModel { /// Both must hold: the box offers dispatch, and this phone's grant /// carries the scope the box checks. A control a viewer would be refused - /// is not drawn at all. - public var canControl: Bool { + /// is not drawn at all. From caps kept at an earlier launch this draws + /// the control in its place; `canControl` says whether it works yet. + public var mayControl: Bool { site.session.caps.contains(Contract.capPlanDispatch) && site.session.scopes.contains(Contract.scopeModeWrite) } + /// A control works only once the box has spoken in this session: caps + /// kept from an earlier launch never send a command. + public var canControl: Bool { site.heardFromBox && mayControl } + public var sending: Bool { change.map { !$0.settled } ?? false } // MARK: Changing it diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift index 6b93eaa..b378dc3 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift @@ -26,19 +26,27 @@ public struct NodeReading: Equatable, Sendable, Identifiable { } public enum NowFlow { - /// One reading per node the home has. The grid is always there, because + /// The nodes Now draws, in its order. The grid is always there, because /// a home that cannot see its own meter should see that gap; any other - /// node the box sends no field for is left out rather than drawn at zero. + /// node the box sends no field for is left out rather than drawn at zero, + /// unless the home is `known` to have it. + public static func nodes(fields: [Int: Double], hasCar: Bool, known: [EnergyNode] = []) -> [EnergyNode] { + EnergyNode.allCases.filter { node in + node == .grid || fields[node.fid] != nil || (node == .car && hasCar) || known.contains(node) + } + } + + /// One reading per node the home has. A node with no reading says so. public static func readings( fields: [Int: Double], freshness: (Int) -> NodeFreshness, carLevel: (pct: Double, assumed: Bool)? = nil, - hasCar: Bool = false + hasCar: Bool = false, + known: [EnergyNode] = [] ) -> [NodeReading] { var out = [NodeReading]() - for node in EnergyNode.allCases { + for node in nodes(fields: fields, hasCar: hasCar, known: known) { let raw = fields[node.fid] - if raw == nil, node != .grid, !(node == .car && hasCar) { continue } let w = raw ?? 0 let shown = abs(node.shown(w)) let idle = abs(w) <= Flow.idleW @@ -74,7 +82,7 @@ public enum NowFlow { out.append(NodeReading( node: node, watts: raw == nil ? nil : shown, - word: word, + word: raw == nil ? NodeWords.text("no reading") : word, tone: tone, towardHome: toward, levelPct: level, @@ -85,3 +93,33 @@ public enum NowFlow { return out } } + +extension NowFlow { + /// Now's nodes from the stream, with the car's charger watts where the + /// stream leaves them out: the last the box said, dated by its read and + /// never an invented 0 W. Before this run's first reading, the nodes the + /// last launch drew keep their places. + @MainActor + public static func readings(site: SiteModel, loadpoints: LoadpointsModel) -> [NodeReading] { + let points = loadpoints.points + let chargeW = Flow.loadpointChargeW(points) + var fields = site.session.fields + let wire = fields[Contract.FID.evW] + // The car comes off the house only when both are as old: a fresh + // read beside the stream, or both from the cache. An old read never + // changes a live figure. + if !loadpoints.stale || site.lastFrameAtMs == nil { + fields = Flow.withLoadpointEV(fields, evW: chargeW) + } + if fields[Contract.FID.evW] == nil, !points.isEmpty { fields[Contract.FID.evW] = chargeW } + let fromCharger = fields[Contract.FID.evW] != wire + let car = points.first.flatMap { loadpoints.level($0) } + return readings( + fields: fields, + freshness: { fid in fid == Contract.FID.evW && fromCharger ? loadpoints.freshness : site.freshness(fid: fid) }, + carLevel: car.map { ($0.pct, $0.origin == .assumed || $0.origin == .unconfirmed) }, + hasCar: !points.isEmpty, + known: site.lastFrameAtMs == nil ? site.kept?.nodes ?? [] : [] + ) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift index ee71249..0e7ec5a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift @@ -183,24 +183,50 @@ public final class ChargingWatch: Activatable { /// Prices across today and tomorrow, from local midnight, asked for again /// when the day turns and when tomorrow's rates publish. +/// +/// It opens on the last window this phone had, from the sealed home file or +/// the widgets' record, and keeps it over midnight: yesterday's tomorrow is +/// today. The box's answer, or FTW Cloud's copy, replaces it only when it +/// differs. @Observable @MainActor public final class PriceModel: Activatable { static let horizonMs: Double = 48 * 3_600_000 static let publishHour = 14 - public private(set) var prices: Prices? - public private(set) var fromMs: Double = 0 + /// The window on screen, with when and where it came from. + public private(set) var window: Stamped? @ObservationIgnored private unowned let site: SiteModel @ObservationIgnored private var ask: LiveAsk? @ObservationIgnored private var active = 0 @ObservationIgnored private var generation = 0 @ObservationIgnored private let calendar: Calendar + /// FTW Cloud's window as last taken, so it is taken once. + @ObservationIgnored private var cloudTaken: Stamped? - public init(site: SiteModel, calendar: Calendar = .current) { + /// `cache` is what the app left for its widgets: the window a launch + /// opens on when the sealed home file has none. + public init(site: SiteModel, calendar: Calendar = .current, cache: CacheReader? = nil) { self.site = site self.calendar = calendar + let shared = site.siteId.flatMap { cache?.read(PriceEntry.self, siteId: $0) }.map { Stamped($0.value, asOfMs: $0.record.readAtMs, origin: .cache) } + window = [site.kept?.prices?.cached, shared].compactMap { $0 }.max { $0.asOfMs < $1.asOfMs } + site.keep { [weak self] home in home.prices = self?.window } + site.observe { [weak self] in self?.takeCloud() } + } + + public var prices: Prices? { window?.value.prices } + public var fromMs: Double { window?.value.fromMs ?? 0 } + + private func take(_ next: Stamped) { + if let taken = Stamped.taking(next, over: window) { window = taken } + } + + private func takeCloud() { + guard let cloud = site.cloudPrices, cloud != cloudTaken else { return } + cloudTaken = cloud + take(cloud) } public var currency: String { prices?.currency ?? "SEK" } @@ -223,10 +249,10 @@ public final class PriceModel: Activatable { if ask == nil { ask = LiveAsk(site: site, want: { [weak self] in guard let self else { return nil } - // A box that stopped offering prices shows none; a window - // from yesterday is not today's. - if !self.site.session.caps.contains(Contract.capPriceSpot) { self.prices = nil } - if self.prices != nil, self.midnight(self.site.nowMs) != self.fromMs { self.prices = nil } + // A box that says it no longer offers prices shows none. A + // window from yesterday still holds today's prices, so it + // stays until the new one lands. + if self.site.heardFromBox, !self.site.session.caps.contains(Contract.capPriceSpot), self.window != nil { self.window = nil } return self.wanted }, ask: { [weak self] in try await self?.load() }) } @@ -245,8 +271,7 @@ public final class PriceModel: Activatable { do { let wire = try await site.prices(fromMs: from, toMs: from + Self.horizonMs) guard mine == generation else { return } - prices = wire - fromMs = from + take(Stamped(PriceEntry(fromMs: from, prices: wire, timeZone: site.session.box?.tz), asOfMs: site.nowMs, origin: .relay)) site.sharePrices(wire, fromMs: from) } catch { // A window already drawn stays drawn; today's prices are still diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift index fc8ccff..15bd196 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift @@ -15,7 +15,7 @@ import Observation public final class PlanModel { /// One slot of the planner's forecast: what it expects the sun to make /// and the home to use. - public struct Forecast: Equatable, Sendable, Identifiable { + public struct Forecast: Equatable, Sendable, Identifiable, Codable { public let startMs: Double public let durationMs: Double /// Watts the sun is expected to make, never negative. @@ -30,10 +30,20 @@ public final class PlanModel { nonisolated static let followMs: Double = 3_000 nonisolated static let followTries = 6 - public private(set) var forecast: [Forecast] = [] + /// The plan on screen: the box's latest word, or the plan an earlier + /// launch kept, dated, until the box answers. The same plan again + /// changes nothing. + public private(set) var stamped: Stamped? + public private(set) var forecastStamp: Stamped<[Forecast]>? + /// Whether the box has answered for the forecast in this session. Until + /// then an empty forecast is unknown, not empty. public private(set) var forecastLoaded = false + /// Whether the box has answered `plan.get` in this session. Until then + /// no plan is unknown, never "no plan". public private(set) var loaded = false - /// When the box last answered `plan.get`. + /// When the box last sent its plan: an answer to `plan.get`, which + /// confirms the plan on screen even when it is the same, or a plan it + /// pushed. public private(set) var receivedAtMs: Double? public private(set) var problem: String? @@ -44,12 +54,39 @@ public final class PlanModel { @ObservationIgnored private var forecastActive = 0 @ObservationIgnored private var want = 0 @ObservationIgnored private var following: Cancellable? + /// The plan on the session as last taken, so a pushed one is taken once. + @ObservationIgnored private var sessionPlan: Plan? + /// Runs each time the box has sent its plan, the same or a new one, so + /// the widget's record is dated by it. + @ObservationIgnored var received: (@MainActor () -> Void)? public init(site: SiteModel) { self.site = site + stamped = site.kept?.plan?.cached + forecastStamp = site.kept?.forecast?.cached + site.keep { [weak self] home in + home.plan = self?.stamped + home.forecast = self?.forecastStamp + } + // A plan also arrives unasked, right after a command. + site.observe { [weak self] in self?.sync() } } - public var plan: Plan? { site.session.plan } + public var plan: Plan? { stamped?.value } + public var forecast: [Forecast] { forecastStamp?.value ?? [] } + + /// Take the session's plan when the box sent a new one. + func sync() { + guard let p = site.session.plan, p != sessionPlan else { return } + sessionPlan = p + receivedAtMs = site.nowMs + take(p) + received?() + } + + private func take(_ p: Plan) { + if let next = Stamped.taking(Stamped(p, asOfMs: site.nowMs, origin: .relay), over: stamped) { stamped = next } + } public func activate(forecast: Bool = false) { active += 1 @@ -80,10 +117,13 @@ public final class PlanModel { func load() async throws { do { - _ = try await site.core.plan() + let p = try await site.core.plan() + sessionPlan = p receivedAtMs = site.nowMs - loaded = true - problem = nil + take(p) + received?() + if !loaded { loaded = true } + if problem != nil { problem = nil } } catch { // The plan on screen stays; what happens now is that the app // asks again on its own, so that is what it says. @@ -94,8 +134,9 @@ public final class PlanModel { func loadForecast() async throws { let wire = try await site.callBox(.get, "/api/mpc/plan") - forecast = Self.forecast(wire?["plan"]?["actions"]?.array ?? []) - forecastLoaded = true + let next = Stamped(Self.forecast(wire?["plan"]?["actions"]?.array ?? []), asOfMs: site.nowMs, origin: .relay) + if let taken = Stamped.taking(next, over: forecastStamp) { forecastStamp = taken } + if !forecastLoaded { forecastLoaded = true } } static func forecast(_ actions: [JSON]) -> [Forecast] { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift index c9fdd84..62247e6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift @@ -35,7 +35,8 @@ extension PlanEntry { /// Leaves the plan for the plan widget whenever it changes: a new plan from /// the box, the same plan confirmed by a fresh `plan.get`, a car's windows -/// or goal, or the prices under them. +/// or goal, or the prices under them. A plan kept from an earlier launch is +/// never written back: the widget already holds it, dated. /// /// The record carries when the box last sent its plan, not when the app /// wrote the file, so the widget's age is the plan's age. @@ -45,52 +46,52 @@ public final class PlanShare { private unowned let plan: PlanModel private unowned let loadpoints: LoadpointsModel private unowned let prices: PriceModel + /// The plan the app left for its widgets at an earlier launch, for Now + /// to open on when the sealed home file holds none. + private let kept: CacheRead? private var token: Int? private var lastKey: String? - private var lastPlanKey: String? private var lastSentAtMs: Double? - /// When the plan on the session last changed, for one the box pushed. - private var changedAtMs: Double? - public init(site: SiteModel, plan: PlanModel, loadpoints: LoadpointsModel, prices: PriceModel) { + public init(site: SiteModel, plan: PlanModel, loadpoints: LoadpointsModel, prices: PriceModel, cache: CacheReader? = nil) { self.site = site self.plan = plan self.loadpoints = loadpoints self.prices = prices + kept = site.siteId.flatMap { cache?.read(PlanEntry.self, siteId: $0) } token = site.observe { [weak self] in self?.check() } + plan.received = { [weak self] in self?.check() } } /// The plan as Now draws it. public var entry: PlanEntry { - PlanEntry.live(plan: site.session.plan, points: loadpoints.points, windows: loadpoints.windows, prices: prices.prices, timeZone: loadpoints.zone.identifier) + PlanEntry.live(plan: plan.plan, points: loadpoints.points, windows: loadpoints.windows, prices: prices.prices, timeZone: loadpoints.zone.identifier) + } + + /// The plan at `nowMs` as Now and the plan view show it: the box's own + /// once it has answered; else the plan kept from an earlier launch, + /// dated; else the card's shape until the box answers. "No plan" is + /// said only after the box has answered without one. + public func moment(nowMs: Double) -> PlanGlance.Moment { + if let s = plan.stamped { + return s.fromCache ? PlanGlance.kept(entry, readAtMs: s.asOfMs, nowMs: nowMs) : PlanGlance.live(entry, nowMs: nowMs) + } + if let kept { return PlanGlance.kept(kept.value, readAtMs: kept.record.readAtMs, nowMs: nowMs) } + return PlanGlance.waiting(entry, nowMs: nowMs, note: plan.problem) } /// What would make the record different. Cheap enough for every tick. var key: String { let w = loadpoints.points.map { lp in "\(lp.id):\((loadpoints.windows[lp.id] ?? lp.planWindows).map { "\($0.fromMs)-\($0.toMs)" }.joined(separator: ",")):\(lp.targetSocPct ?? lp.schedule?.socPct ?? -1)" } let p = prices.prices.map { "\($0.slots.count)@\($0.slots.first?.startMs ?? 0)" } ?? "-" - return "\(planKey)|\(w.joined(separator: ";"))|\(p)" - } - - private var planKey: String { - let p = site.session.plan - return "\(p?.rev ?? 0)|\(p?.slots.first?.startMs ?? 0)|\(p?.slots.count ?? 0)|\(p?.stale ?? false)" - } - - /// When the box last sent the plan: the latest answer to `plan.get`, or - /// the moment a plan it pushed landed. - var sentAtMs: Double? { - [plan.receivedAtMs, changedAtMs].compactMap { $0 }.max() + let plan = self.plan.plan + return "\(plan?.rev ?? 0)|\(plan?.slots.first?.startMs ?? 0)|\(plan?.slots.count ?? 0)|\(plan?.stale ?? false)|\(w.joined(separator: ";"))|\(p)" } func check() { - let planKey = self.planKey - if planKey != lastPlanKey { - lastPlanKey = planKey - if site.session.plan != nil { changedAtMs = site.nowMs } - } - // Nothing to say until the box has sent a plan. - guard site.session.plan != nil, let sent = sentAtMs else { return } + plan.sync() + // Nothing to say until the box has sent a plan in this session. + guard let s = plan.stamped, !s.fromCache, let sent = plan.receivedAtMs else { return } let key = self.key guard key != lastKey || sent != lastSentAtMs else { return } lastKey = key diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 8279e12..e78dee3 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -38,6 +38,10 @@ public final class SiteModel { public private(set) var session = SessionState() /// Wall clock the cached view was captured. Nil when live. public private(set) var cachedAtMs: Double? + /// The box's uptime when the cached view was captured. Its sources' + /// ages count against this until the box streams: hello names the + /// uptime now, which is not the clock those readings were taken on. + @ObservationIgnored private var cachedUptimeMs: Double? public private(set) var lastFrameAtMs: Double? public private(set) var documentVisible = true /// The clock screens read for ages. It moves with every reading, and @@ -87,6 +91,18 @@ public final class SiteModel { /// The last snapshot written, without its time, so the same home is not /// written twice. @ObservationIgnored private var lastSnapshot: CachedSnapshot? + /// What the last launch kept of this home beside its readings, read + /// once when the model is made, so every model of the home opens on it. + public let kept: HomeCache? + /// Each model's part of the home file, filled in at every write. + @ObservationIgnored private var keepers: [@MainActor (inout HomeCache) -> Void] = [] + /// The home file as last read or written, so the same home is not + /// written twice. + @ObservationIgnored private var lastHome: HomeCache? + /// FTW Cloud's price window and chargers as last accepted, for the + /// models that show them. Each change comes with a `notify`. + @ObservationIgnored public private(set) var cloudPrices: Stamped? + @ObservationIgnored public private(set) var cloudCars: Stamped<[CarEntry.Car]>? @ObservationIgnored private var destroyed = false /// The box session's own state, as the last frame left it. @ObservationIgnored private var relay = SessionState() @@ -164,6 +180,9 @@ public final class SiteModel { nowMs = scheduler.nowMs minuteMs = (scheduler.nowMs / 60_000).rounded(.down) * 60_000 attemptStartedAtMs = scheduler.nowMs + let home = siteId.flatMap { id in files?.readJSON(HomeCache.self, "home-\(id)") } + kept = home.flatMap { $0.version == HomeCache.version && $0.siteId == siteId ? $0 : nil } + lastHome = kept?.asRead core = Session(build: build, ua: ua, scheduler: scheduler) core.onChange = { [weak self] s in self?.onSession(s) } } @@ -175,11 +194,13 @@ public final class SiteModel { public func start() { startedAtMs = scheduler.nowMs beginLiveSpan("cold start to live") - if let siteId, let cached = files?.readJSON(CachedSnapshot.self, "snapshot-\(siteId)"), cached.siteId == siteId { - core.restore(cached) + let cached = siteId.flatMap { id in files?.readJSON(CachedSnapshot.self, "snapshot-\(id)") }.flatMap { $0.siteId == siteId ? $0 : nil } + if let cached { cachedAtMs = cached.savedAtMs + cachedUptimeMs = cached.uptimeMs lastSnapshot = cached.timeless } + if cached != nil || kept?.hello != nil { core.restore(cached, hello: kept?.hello) } if let siteId, let kept = files?.readJSON(CloudCache.self, "cloud-\(siteId)") { adoptCloud(kept.identity, offset: kept.offset) } @@ -474,6 +495,7 @@ public final class SiteModel { files.writeJSON("cloud-\(siteId)", CloudCache(identity: identity, offset: ruleU), wait: wait) shareCloudHome() } + writeHome(siteId: siteId, files: files, wait: wait) guard session.phase == .streaming || !session.fields.isEmpty, cachedAtMs == nil else { return } // The cloud's ages move with the clock; take them as of now. let state = source == .cloud ? composed() : session @@ -484,6 +506,23 @@ public final class SiteModel { files.writeJSON("snapshot-\(siteId)", snapshot, wait: wait) } + /// A model's part of the home file: `fill` adds it at every write. + public func keep(_ fill: @escaping @MainActor (inout HomeCache) -> Void) { + keepers.append(fill) + } + + /// Write the home file when it says something new. Hello is the box's + /// own once it has spoken, else what the last launch kept. + private func writeHome(siteId: String, files: SealedFiles, wait: Bool) { + var home = HomeCache(siteId: siteId) + home.hello = relay.heardFromBox ? HomeCache.Hello(relay) : kept?.hello + for fill in keepers { fill(&home) } + let read = home.asRead + guard read != lastHome, read != HomeCache(siteId: siteId) else { return } + lastHome = read + files.writeJSON("home-\(siteId)", home, wait: wait) + } + // MARK: Time to live private func beginLiveSpan(_ name: StaticString) { @@ -632,7 +671,7 @@ public final class SiteModel { if carrier == .cache, let at = cachedAtMs { return max(age, nowMs - at) } return age } - let r = shown.relay + let r = asCached(shown.relay) let ages = nowSourceIDs.compactMap { id -> Double? in guard let src = r.sources[id] else { return nil } let age = r.uptimeMs - src.lastOkMs @@ -646,6 +685,15 @@ public final class SiteModel { return oldest + sinceLastFrameMs } + /// The state with its clock as the cache left it, while what is on + /// screen is the cache. + func asCached(_ s: SessionState) -> SessionState { + guard carrier == .cache, let cachedUptimeMs else { return s } + var c = s + c.uptimeMs = cachedUptimeMs + return c + } + /// Live is three claims at once: streaming, not the cache, and every /// source answering. public var isLive: Bool { @@ -1090,8 +1138,8 @@ extension SiteModel { guard var view = cloudView, (try? view.accept(env, arrivalMs: Int64(at))) != nil else { return false } cloudView = view switch env.t { - case CloudWire.msgLP: shareCloudCars() - case CloudWire.msgPrices: shareCloudPrices() + case CloudWire.msgLP: takeCloudCars(at) + case CloudWire.msgPrices: takeCloudPrices(at) default: break } return true @@ -1193,12 +1241,13 @@ extension SiteModel { return Double(max(0, oldest) + t) } - /// The cars as the cloud's lp has them, for the widgets. - private func shareCloudCars() { + /// The cars as the cloud's lp has them, for the car's models and the + /// widgets, dated when the box sent them. + private func takeCloudCars(_ at: Double) { guard let view = cloudView, let lp = view.lp, let identity = cloudIdentity else { return } let now = scheduler.nowMs let g = view.grade(nowMs: Int64(now)) - let entry = CarEntry(cars: lp.loadpoints.map { l in + let cars = lp.loadpoints.map { l in CarEntry.Car( loadpointID: identity.loadpoints[l.id] ?? l.id, pluggedIn: l.plugged, @@ -1208,13 +1257,15 @@ extension SiteModel { powerW: Double(l.powerW), targetSocPct: l.targetPermille.map { (Double($0) / 10).rounded() } ) - }) - shareCarEntry(entry, answering: lp.loadpoints.allSatisfy { g.loadpoints[$0.id] == .live }, via: .cloud) + } + cloudCars = Stamped(cars, asOfMs: view.sentMs(lp.header, arrivalMs: Int64(at)).map(Double.init) ?? at, origin: .cloud) + shareCarEntry(CarEntry(cars: cars), answering: lp.loadpoints.allSatisfy { g.loadpoints[$0.id] == .live }, via: .cloud) } - /// The cloud's price window, for the widgets, in the box session's shape. - private func shareCloudPrices() { - guard let p = cloudView?.prices, p.slotMs > 0 else { return } + /// The cloud's price window in the box session's shape, for the price + /// model and the widgets. + private func takeCloudPrices(_ at: Double) { + guard let view = cloudView, let p = view.prices, p.slotMs > 0 else { return } var slots = [PriceSlot]() for i in p.spotMinor.indices { guard let spot = p.spotMinor[i], i < p.totalMinor.count, let total = p.totalMinor[i] else { continue } @@ -1222,7 +1273,10 @@ extension SiteModel { spotMinor: Double(spot), totalMinor: Double(total))) } guard !slots.isEmpty else { return } - sharePrices(Prices(zone: p.zone, currency: p.currency, slots: slots, stale: false), fromMs: Double(p.t0Ms), via: .cloud) + let prices = Prices(zone: p.zone, currency: p.currency, slots: slots, stale: false) + cloudPrices = Stamped(PriceEntry(fromMs: Double(p.t0Ms), prices: prices, timeZone: session.box?.tz), + asOfMs: view.sentMs(p.header, arrivalMs: Int64(at)).map(Double.init) ?? at, origin: .cloud) + sharePrices(prices, fromMs: Double(p.t0Ms), via: .cloud) } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/Stamped.swift b/appleApp/FTWKit/Sources/FTWKit/State/Stamped.swift new file mode 100644 index 0000000..1655131 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/Stamped.swift @@ -0,0 +1,63 @@ +import Foundation + +/// A value with when it was true and where it came from. Screens branch on +/// whether a value exists, never on whether it is loaded: a value from the +/// cache is drawn in its final place, dated, and replaced when the box +/// speaks. +public struct Stamped: Sendable { + public typealias Origin = StampOrigin + + public var value: Value + /// When the box said it, by the phone clock. + public var asOfMs: Double + public var origin: Origin + + public init(_ value: Value, asOfMs: Double, origin: Origin) { + self.value = value + self.asOfMs = asOfMs + self.origin = origin + } + + /// The same value, read back from the disk. + public var cached: Stamped { + var s = self + s.origin = .cache + return s + } + + public var fromCache: Bool { origin == .cache } +} + +extension Stamped: Equatable where Value: Equatable { + /// What to show once `next` arrives, or nil when nothing on screen + /// should change. The same value again changes nothing, unless it is + /// the box confirming what the cache said: then only its origin and age + /// move. A copy older than what the box already said never replaces it. + public func taking(_ next: Stamped) -> Stamped? { + if next.value == value { + return origin == .cache && next.origin != .cache ? next : nil + } + if origin != .cache, next.asOfMs < asOfMs { return nil } + return next + } + + /// `next` on top of what may be shown, by the rule above. + public static func taking(_ next: Stamped, over shown: Stamped?) -> Stamped? { + guard let shown else { return next } + return shown.taking(next) + } +} + +extension Stamped: Codable where Value: Codable {} + +/// Where a stamped value came from. One type for every value, so a stamp +/// can move from one value to another. +public enum StampOrigin: String, Codable, Sendable { + /// Kept on this phone from an earlier launch. True when written, never + /// live. + case cache + /// From the box's own session, over the relay or WebRTC. + case relay + /// From FTW Cloud, signed by the box. + case cloud +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift index d18c804..95ed7b9 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift @@ -169,6 +169,9 @@ public enum PlanGlance { case old /// The box itself could not plan. case boxStale + /// The box has not answered yet and this phone kept no plan: the + /// shape of the card, and no sentence about a plan that may exist. + case waiting } public struct Moment: Equatable, Sendable { @@ -201,6 +204,8 @@ public enum PlanGlance { public let timeZone: TimeZone public var isGrey: Bool { state != .plan } + /// Waiting with nothing to say yet: the line is drawn as its shape. + public var placeholder: Bool { state == .waiting && summary == PlanWord.reading.text } } /// The widget's timeline: one moment now, one at every start and end @@ -234,7 +239,23 @@ public enum PlanGlance { moment(entry, state: entry.boxStale ? .boxStale : .plan, readAtMs: nil, atMs: nowMs, calendar: calendar ?? .household(entry.timeZone)) } - private static func moment(_ entry: PlanEntry, state: State, readAtMs: Double?, atMs t: Double, calendar: Calendar) -> Moment { + /// A plan this phone kept from an earlier launch, inside the app until + /// the box answers: dated "Planned 14:05", and grey once old. + public static func kept(_ entry: PlanEntry, readAtMs: Double, nowMs: Double, calendar: Calendar? = nil) -> Moment { + let state: State = entry.boxStale ? .boxStale : (nowMs - readAtMs >= staleAfterMs ? .old : .plan) + return moment(entry, state: state, readAtMs: readAtMs, atMs: nowMs, calendar: calendar ?? .household(entry.timeZone), inApp: true) + } + + /// Before the box's first answer, with no plan kept. `note` says what + /// happens instead, when something does. + public static func waiting(_ entry: PlanEntry, nowMs: Double, note: String? = nil, calendar: Calendar? = nil) -> Moment { + let m = moment(entry, state: .waiting, readAtMs: nil, atMs: nowMs, calendar: calendar ?? .household(entry.timeZone)) + guard let note else { return m } + return Moment(atMs: m.atMs, state: m.state, readAtMs: nil, events: m.events, backdrop: m.backdrop, currency: m.currency, summary: note, + next: nil, nextWhen: nil, nextWhat: nil, nextUntil: nil, cheapText: nil, laterText: nil, ageText: nil, timeZone: m.timeZone) + } + + private static func moment(_ entry: PlanEntry, state: State, readAtMs: Double?, atMs t: Double, calendar: Calendar, inApp: Bool = false) -> Moment { let clock = Clock(calendar: calendar) let list = events(entry, fromMs: t, toMs: t + horizonMs) let actions = list.filter { $0.kind != .cheap } @@ -243,7 +264,8 @@ public enum PlanGlance { var age: String? if let readAtMs { - age = state == .old ? PlanWord.planFrom.format(clock.at(readAtMs, asEnd: false, from: t)) : PlanWord.planned.format(clock.at(readAtMs, asEnd: false, from: t)) + // Only a widget sends the reader to the app for a newer plan. + age = state == .old && !inApp ? PlanWord.planFrom.format(clock.at(readAtMs, asEnd: false, from: t)) : PlanWord.planned.format(clock.at(readAtMs, asEnd: false, from: t)) } let summary: String @@ -252,6 +274,8 @@ public enum PlanGlance { summary = PlanWord.openToSee.text case .boxStale: summary = PlanWord.boxStale.text + case .waiting: + summary = PlanWord.reading.text case .plan, .old: if entry.battery == nil, entry.charges.isEmpty { summary = PlanWord.noPlanYet.text @@ -274,7 +298,7 @@ public enum PlanGlance { nextWhen: next.map { $0.startMs <= t + graceMs ? PlanWord.now.text : ahead($0.startMs, asEnd: false, from: t, clock: clock) }, nextWhat: next.map(what), nextUntil: next.map { PlanWord.until.format(ahead($0.endMs, asEnd: true, from: t, clock: clock)) }, - cheapText: state == .nothing ? nil : list.first { $0.kind == .cheap }.map { PlanWord.cheapest.format(range($0, from: t, clock: clock, ongoingIsNow: state == .plan)) }, + cheapText: state == .nothing || state == .waiting ? nil : list.first { $0.kind == .cheap }.map { PlanWord.cheapest.format(range($0, from: t, clock: clock, ongoingIsNow: state == .plan)) }, laterText: next.flatMap { n in let rest = list.filter { $0 != n } return rest.isEmpty ? nil : line(rest, from: t, clock: clock, ongoingIsNow: true, actions: 1) @@ -362,6 +386,7 @@ enum PlanWord: String, CaseIterable { case summary = "What FTW plans to do over the next hours, and when power is cheapest." case openToSee = "Open FTW to see the plan" case noPlanYet = "No plan from your box yet" + case reading = "Reading the plan from your box…" case boxStale = "Your box couldn't plan ahead · it runs on safe defaults" case nothingPlanned = "Nothing planned for 12 hours" case carChargesTo = "%@ car charges to %@" diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings index 0d5a7ae..4c2a321 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings @@ -3,6 +3,7 @@ "What FTW plans to do over the next hours, and when power is cheapest." = "What FTW plans to do over the next hours, and when power is cheapest."; "Open FTW to see the plan" = "Open FTW to see the plan"; "No plan from your box yet" = "No plan from your box yet"; +"Reading the plan from your box…" = "Reading the plan from your box…"; "Your box couldn't plan ahead · it runs on safe defaults" = "Your box couldn't plan ahead · it runs on safe defaults"; "Nothing planned for 12 hours" = "Nothing planned for 12 hours"; "%@ car charges to %@" = "%@ car charges to %@"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings index ad12ac7..c8c5c13 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings @@ -3,6 +3,7 @@ "What FTW plans to do over the next hours, and when power is cheapest." = "Vad FTW planerar de närmaste timmarna, och när elen är billigast."; "Open FTW to see the plan" = "Öppna FTW för att se planen"; "No plan from your box yet" = "Ingen plan från boxen än"; +"Reading the plan from your box…" = "Läser planen från boxen …"; "Your box couldn't plan ahead · it runs on safe defaults" = "Boxen kunde inte planera · kör på säkra standardval"; "Nothing planned for 12 hours" = "Inget planerat på 12 timmar"; "%@ car charges to %@" = "%@ laddar bilen till %@"; diff --git a/appleApp/FTWKit/Tests/FTWKitTests/HomeCacheTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/HomeCacheTests.swift new file mode 100644 index 0000000..fb4092c --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/HomeCacheTests.swift @@ -0,0 +1,333 @@ +import Foundation +import Observation +import Synchronization +import Testing +@testable import FTWKit +import FTWDemo +@testable import FTWShared + +/// Open on the last truth: after the app is killed, the next launch has the +/// plan, the prices, the mode label, Now's nodes and the car's watts before +/// the box has said hello; nothing kept unlocks a control; and the box +/// saying the same again changes nothing on screen. + +// MARK: The stamp + +@Suite struct StampedTests { + @Test func theSameValueAgainChangesNothing() { + let shown = Stamped(42, asOfMs: 1_000, origin: .relay) + #expect(shown.taking(Stamped(42, asOfMs: 2_000, origin: .relay)) == nil) + #expect(shown.taking(Stamped(42, asOfMs: 2_000, origin: .cloud)) == nil) + // The box confirming what the cache said moves its origin and age. + let kept = Stamped(42, asOfMs: 1_000, origin: .cache) + #expect(kept.taking(Stamped(42, asOfMs: 2_000, origin: .relay)) == Stamped(42, asOfMs: 2_000, origin: .relay)) + } + + @Test func anOlderCopyNeverReplacesWhatTheBoxSaid() { + let shown = Stamped(42, asOfMs: 2_000, origin: .relay) + #expect(shown.taking(Stamped(41, asOfMs: 1_000, origin: .cloud)) == nil) + #expect(shown.taking(Stamped(43, asOfMs: 3_000, origin: .cloud)) == Stamped(43, asOfMs: 3_000, origin: .cloud)) + // Anything the box says replaces the cache, however old the cache. + let kept = Stamped(42, asOfMs: 5_000, origin: .cache) + #expect(kept.taking(Stamped(40, asOfMs: 1_000, origin: .relay))?.value == 40) + #expect(Stamped.taking(Stamped(1, asOfMs: 0, origin: .relay), over: nil)?.value == 1) + } + + @Test func aStampReadBackIsTheCache() throws { + let s = Stamped(PriceEntry(fromMs: 0, prices: Prices(zone: "SE3", currency: "SEK", slots: [], stale: false)), asOfMs: 7, origin: .cloud) + let back = try JSONDecoder().decode(Stamped.self, from: JSONEncoder().encode(s)) + #expect(back == s) + #expect(back.cached.origin == .cache) + #expect(back.cached.asOfMs == 7) + } +} + +// MARK: A home after a kill + +/// One home on one phone across launches: the sealed files, the widgets' +/// files and a clock, with the demo box behind it. +@MainActor +final class KeptHome { + let scheduler = ManualScheduler(nowMs: CarTextTests.t) + let files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-home-\(UUID().uuidString)"), store: MemoryStore()) + let shared = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-home-shared-\(UUID().uuidString)")) + + /// A launch: the models of one home, started, not connected. + func launch() -> HomeModels { + let site = SiteModel(siteId: "home", build: "test", ua: "test", scheduler: scheduler, files: files, shared: shared) + let home = HomeModels(site: site, cache: CacheReader(sources: [shared])) + site.start() + return home + } + + /// What Now shows: the prices, the mode, the plan and the chargers. + func look(_ home: HomeModels) { + home.prices.activate() + home.nodes.mode.activate() + home.nodes.plan.activate(forecast: true) + home.loadpoints.activate() + } + + func run(_ ms: Double, step: Double = 50) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + scheduler.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + + /// A first launch that streams until everything Now shows has arrived, + /// then goes to the background, where the app writes what it holds, + /// and is killed. + func liveThenKilled() async throws -> (home: HomeModels, nodes: [EnergyNode], car: NodeReading) { + let home = launch() + home.site.connect(SimulatedBox.loopback(scheduler)) + look(home) + await run(3_000) + #expect(home.site.heardFromBox) + #expect(home.nodes.plan.stamped?.origin == .relay) + let readings = NowFlow.readings(site: home.site, loadpoints: home.loadpoints) + let car = try #require(readings.first { $0.node == .car }) + home.site.suspend() + home.site.destroy() + return (home, readings.map(\.node), car) + } +} + +@MainActor +@Suite struct HomeCacheTests { + @Test func afterAKillEverythingNowShowsIsThereBeforeHello() async throws { + let w = KeptHome() + let (live, nodes, liveCar) = try await w.liveThenKilled() + let label = try #require(live.nodes.mode.label) + let plan = try #require(live.nodes.plan.plan) + let prices = try #require(live.prices.prices) + + // Ten minutes later the app opens again, and nothing has connected. + await w.run(10 * 60_000, step: 5_000) + let cold = w.launch() + #expect(!cold.site.heardFromBox) + #expect(cold.site.session.phase == .idle) + + // The plan, dated, and never "no plan". + #expect(cold.nodes.plan.plan == plan) + #expect(cold.nodes.plan.stamped?.origin == .cache) + let moment = cold.nodes.planShare.moment(nowMs: w.scheduler.nowMs) + #expect(moment.state == .plan) + #expect(moment.ageText != nil) + #expect(!moment.events.isEmpty) + #expect(moment.summary != PlanWord.noPlanYet.text) + #expect(!moment.placeholder) + + // The prices, the forecast and the planner's style behind the label. + #expect(cold.prices.prices == prices) + #expect(cold.prices.window?.origin == .cache) + #expect(!cold.nodes.plan.forecast.isEmpty) + #expect(cold.nodes.mode.label == label) + + // Now's nodes, in their places, and the car at its last watts with + // their age, never 0 W from nowhere. + let readings = NowFlow.readings(site: cold.site, loadpoints: cold.loadpoints) + #expect(readings.map(\.node) == nodes) + let car = try #require(readings.first { $0.node == .car }) + #expect(car.watts == liveCar.watts) + #expect(car.watts != nil) + #expect(car.freshness.carrier == .cache) + #expect(!car.freshness.live) + #expect((car.freshness.ageMs ?? 0) >= 10 * 60_000) + #expect(cold.loadpoints.points.first?.schedule != nil) + } + + @Test func nothingKeptUnlocksAControlUntilTheBoxHasSpoken() async throws { + let w = KeptHome() + _ = try await w.liveThenKilled() + let cold = w.launch() + // The caps and the role are there, so the controls are drawn... + #expect(cold.site.session.caps.contains(Contract.capPlanDispatch)) + #expect(cold.nodes.mode.mayControl) + #expect(cold.site.canConfigure) + // ...and none of them works. + #expect(!cold.nodes.mode.canControl) + #expect(!cold.site.heardFromBox) + + // A carrier that opens is not the box speaking. + let box = ScriptedBox() + cold.site.connect(box) + box.open(w.scheduler.nowMs) + await w.run(100) + #expect(cold.site.session.phase == .handshaking) + #expect(!cold.nodes.mode.canControl) + #expect(cold.nodes.mode.label != nil) + + // Hello replaces what was kept, and only then does a control work. + box.helloOk(uptimeMs: 60_000, cloud: nil, caps: [Contract.capPlanDispatch, Contract.capAPIPassthrough]) + await w.run(100) + #expect(cold.site.heardFromBox) + #expect(cold.nodes.mode.canControl) + } + + /// Hello names the box's uptime now. The cached readings were taken + /// on the clock the cache kept, so their age does not move when hello + /// lands before the first frame. + @Test func helloDoesNotAgeTheCachedReadingsAgain() async throws { + let w = KeptHome() + _ = try await w.liveThenKilled() + let keptUptime = try #require(w.files.readJSON(CachedSnapshot.self, "snapshot-home")?.uptimeMs) + await w.run(10 * 60_000, step: 5_000) + let cold = w.launch() + let age = try #require(cold.site.ageMs) + let battery = try #require(cold.site.freshness(.battery).ageMs) + #expect(age >= 10 * 60_000) + let box = ScriptedBox() + cold.site.connect(box) + box.open(w.scheduler.nowMs) + // The box kept running while the app was away. + box.helloOk(uptimeMs: Int64(keptUptime + 10 * 60_000 + 2_000), cloud: nil) + await w.run(100) + #expect(cold.site.heardFromBox) + #expect(cold.site.carrier == .cache) + #expect(abs((cold.site.ageMs ?? 0) - age) < 1_000) + #expect(abs((cold.site.freshness(.battery).ageMs ?? 0) - battery) < 1_000) + } + + @Test func anArrivalEqualToWhatIsShownChangesNothing() async throws { + let w = KeptHome() + let home = w.launch() + home.site.connect(SimulatedBox.loopback(w.scheduler)) + w.look(home) + await w.run(3_000) + #expect(home.nodes.plan.stamped?.origin == .relay) + #expect(home.prices.window?.origin == .relay) + #expect(home.nodes.mode.prefsStamp?.origin == .relay) + + let confirmed = home.nodes.plan.receivedAtMs + let changed = Mutex(false) + withObservationTracking { + _ = home.nodes.plan.stamped + _ = home.prices.window + _ = home.nodes.mode.prefsStamp + } onChange: { + changed.withLock { $0 = true } + } + // The box says it all again, the same. + let asks = Task { + try await home.nodes.plan.load() + try await home.prices.load() + try await home.nodes.mode.loadPrefs() + } + await w.run(1_000) + try await asks.value + #expect(!changed.withLock { $0 }) + // It still counts as the box confirming its plan, for the widget. + #expect((home.nodes.plan.receivedAtMs ?? 0) > (confirmed ?? .infinity)) + } + + @Test func pricesAreKeptOverMidnight() async throws { + let w = KeptHome() + let (live, _, _) = try await w.liveThenKilled() + let prices = try #require(live.prices.prices) + // The next launch is after midnight. The box streams and has not + // answered for the new day's prices yet. + let midnight = Calendar.current.startOfDay(for: Date(timeIntervalSince1970: w.scheduler.nowMs / 1000)).addingTimeInterval(86_400).timeIntervalSince1970 * 1000 + await w.run(midnight - w.scheduler.nowMs + 30 * 60_000, step: 60_000) + let cold = w.launch() + cold.prices.activate() + let box = ScriptedBox() + cold.site.connect(box) + box.open(w.scheduler.nowMs) + box.helloOk(uptimeMs: 60_000, cloud: nil, caps: [Contract.capPriceSpot, Contract.capAPIPassthrough]) + box.snap(uptimeMs: 60_000, gridW: 500) + await w.run(1_000) + #expect(cold.site.session.phase == .streaming) + #expect(box.sent.contains("price.get")) + // Yesterday's tomorrow is today: the window stays until a new one + // lands. + #expect(cold.prices.prices == prices) + #expect(cold.prices.window?.origin == .cache) + } + + @Test func theWidgetsRecordsAreReadBackWhenNothingIsSealed() async throws { + let w = KeptHome() + _ = try await w.liveThenKilled() + // A launch from before the home file: only the App Group records. + w.files.remove("home-home") + for _ in 0..<200 where w.shared.read(PlanEntry.self, siteId: "home") == nil { + try await Task.sleep(nanoseconds: 2_000_000) + } + let cold = w.launch() + #expect(cold.site.kept == nil) + #expect(cold.prices.prices != nil) + #expect(!cold.loadpoints.points.isEmpty) + let moment = cold.nodes.planShare.moment(nowMs: w.scheduler.nowMs) + #expect(moment.ageText != nil) + #expect(moment.summary != PlanWord.noPlanYet.text) + } + + @Test func withNothingKeptThePlanWaitsForAnAnswerBeforeSayingNone() async throws { + let w = KeptHome() + let home = w.launch() + let waiting = home.nodes.planShare.moment(nowMs: w.scheduler.nowMs) + #expect(waiting.state == .waiting) + #expect(waiting.placeholder) + #expect(waiting.summary != PlanWord.noPlanYet.text) + home.site.connect(SimulatedBox.loopback(w.scheduler)) + w.look(home) + await w.run(3_000) + let answered = home.nodes.planShare.moment(nowMs: w.scheduler.nowMs) + #expect(answered.state == .plan) + #expect(answered.ageText == nil) + } + + @Test func theHomeFileIsWrittenOnlyWhenItSaysSomethingNew() async throws { + let w = KeptHome() + let home = w.launch() + home.site.connect(SimulatedBox.loopback(w.scheduler)) + w.look(home) + await w.run(3_000) + home.site.persistNow(wait: true) + let first = try #require(w.files.read("home-home")) + home.site.persistNow(wait: true) + // Sealed with a fresh nonce each time: the same bytes mean no write. + #expect(w.files.read("home-home") == first) + let kept = try #require(w.files.readJSON(HomeCache.self, "home-home")) + #expect(kept.hello?.caps.contains(Contract.capPlanDispatch) == true) + #expect(kept.nodes?.contains(.grid) == true) + #expect(kept.chargers?.value.points.isEmpty == false) + } +} + +@MainActor +@Suite struct CloudStampTests { + /// FTW Cloud's prices and chargers reach the models as the cloud's, + /// before the box session has said anything. + @Test func theCloudsPricesAndCarsAreStampedAsTheClouds() async throws { + let w = try CloudSite() + w.keepFromEarlierLaunch(gridW: nil) + let t0 = w.at - w.at % 86_400_000 + let prices = try w.box.seal(CloudWire.msgPrices, w.box.header(w.at) + [ + ("zone", .text("SE3")), ("currency", .text("SEK")), ("t0Ms", .int(t0)), ("slotMs", .int(900_000)), + ("spotMinor", .array([.int(42), .int(40)])), ("totalMinor", .array([.int(118), .int(115)])), + ]) + let lp = try w.box.seal(CloudWire.msgLP, w.box.header(w.at) + [ + ("controlRev", .int(3)), + ("loadpoints", .array([.map([("id", .text("j3HZ")), ("src", .text("m")), ("plugged", .bool(true)), + ("socPermille", .int(430)), ("socSource", .text("vehicle")), ("socAgeS", .int(60)), + ("targetPermille", .int(800)), ("powerW", .int(2100))])])), + ("feedback", .array([])), + ]) + w.service.held = [try w.live(gridW: 1000), prices, lp] + w.open() + let priceModel = PriceModel(site: w.site) + let loadpoints = LoadpointsModel(site: w.site, charging: nil, cache: nil) + w.connectCloud() + await w.run(60) + #expect(priceModel.window?.origin == .cloud) + #expect(priceModel.prices?.slots.first?.totalMinor == 118) + #expect(loadpoints.stamp?.origin == .cloud) + #expect(loadpoints.points.first?.socPct == 43) + #expect(loadpoints.points.first?.powerW == 2100) + #expect(!w.site.heardFromBox) + } +}