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) + } +}