From 777d98dbac0631dfecd44304971cfed95b739264 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 07:36:20 +0200 Subject: [PATCH 01/11] iPhone: count request frames per stay in front Session counts each request frame it sends (history, plan, prices, API reads) and logs it under category "asks". SiteModel logs how many a stay in front cost when the app hides. The demo box lists every request frame, so tests can count them. Co-Authored-By: Claude Opus 5.5 --- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 5 +++++ .../Sources/FTWKit/Protocol/Session.swift | 19 +++++++++++++++++++ .../Sources/FTWKit/State/SiteModel.swift | 12 ++++++++++++ 3 files changed, 36 insertions(+) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 282cce2..4aa0896 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -54,6 +54,9 @@ public final class SimulatedBox { public private(set) var receivedCommands: [String] = [] /// Every API request, as "METHOD /path", so a test can count reads. public private(set) var receivedRequests: [String] = [] + /// Every request frame in the order it came: `hist.query`, `plan.get`, + /// `price.get`, and each API request as "METHOD /path". + public private(set) var asks: [String] = [] public private(set) var stepUpsSeen = 0 public nonisolated static let modes: [ModeInfo] = [ @@ -101,6 +104,7 @@ public final class SimulatedBox { guard let frame = try? Frame.decode(bytes) else { return } let env = frame.envelope let body = env.b ?? CBOR.emptyMap + if ["price.get", "hist.query", "plan.get"].contains(env.t) { asks.append(env.t) } switch env.t { case "hello": onHello(body) case "sub": @@ -426,6 +430,7 @@ public final class SimulatedBox { for e in body["query"]?.entries ?? [] { if let k = e.key.string, let v = e.value.string { query[k] = v } } let requestBody = body["body"]?.byteString.flatMap { try? JSON(parsing: $0) } receivedRequests.append("\(method) \(path)") + asks.append("\(method) \(path)") guard path.hasPrefix("/api/"), let route = Self.match(method, path) else { refuse(id, "E_UNKNOWN_OP", []) diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift index c0ff575..7bb133e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift @@ -1,5 +1,11 @@ import Foundation import FTWShared +import os + +/// Every request frame the session puts on the wire, so a log can count +/// what a return from the background costs. Read with +/// `log show --predicate 'subsystem == "energy.ftw.app" AND category == "asks"'`. +private let askLog = Logger(subsystem: "energy.ftw.app", category: "asks") /// The session: handshake, subscription and the field register. /// @@ -113,6 +119,10 @@ public final class Session { didSet { onChange?(state) } } + /// Request frames sent since the session was made: history, the plan, + /// prices and API reads. Commands are not counted; a person asked. + public private(set) var requestsSent = 0 + /// One listener: the site model. Called after every change. public var onChange: (@MainActor (SessionState) -> Void)? @@ -289,6 +299,7 @@ public final class Session { p.continuation.resume(throwing: SessionError.timedOut("prices")) } pendingPrices[id] = PendingSingle(continuation: cont, timer: timer) + sent("price.get") carrier?.send(frame) } } @@ -302,6 +313,7 @@ public final class Session { return try await withCheckedThrowingContinuation { cont in pendingHistory[id] = PendingHistory(onChunk: onChunk, continuation: cont, timer: nil) armHistory(id) + sent("hist.query") carrier?.send(frame) } } @@ -317,6 +329,7 @@ public final class Session { p.continuation.resume(throwing: SessionError.timedOut("plan")) } pendingPlan[id] = PendingSingle(continuation: cont, timer: timer) + sent("plan.get") carrier?.send(frame) } } @@ -388,6 +401,7 @@ public final class Session { return try await withCheckedThrowingContinuation { cont in pendingAPI[id] = PendingAPI(continuation: cont) armAPI(id) + sent("api.req \(req.method.rawValue) \(req.path)") carrier.send(frame) } } @@ -454,6 +468,11 @@ public final class Session { return "\(String(c[0..<8]))-\(String(c[8..<12]))-\(String(c[12..<16]))-\(String(c[16..<20]))-\(String(c[20...]))" } + private func sent(_ what: String) { + requestsSent += 1 + askLog.notice("ask \(what, privacy: .public)") + } + // MARK: Frames in private func onCarrierStatus(_ status: CarrierStatus, kind: CarrierKind) { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index e78dee3..e074c2f 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -9,6 +9,8 @@ private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") /// `log show --predicate 'subsystem == "energy.ftw.app" AND category == "reading"'`. private let readingLog = Logger(subsystem: "energy.ftw.app", category: "reading") private let signposter = OSSignposter(subsystem: "energy.ftw.app", category: "reading") +/// How many requests each stay in front cost, logged when the app hides. +private let askLog = Logger(subsystem: "energy.ftw.app", category: "asks") /// The bridge between the session and the screens. /// @@ -158,6 +160,9 @@ public final class SiteModel { /// Where the readings on screen came from at the last change. @ObservationIgnored private(set) var source: ReadingSource = .relay @ObservationIgnored private var startedAtMs: Double = 0 + /// Which stay in front this is, and the session's request count when it + /// began. + @ObservationIgnored private var visit = (number: 1, fromRequests: 0) @ObservationIgnored private var firsts: Set = [] /// The cloud session, for the About screen. Nil while there is none. public private(set) var cloudStatus: CloudCarrier.Status? @@ -268,6 +273,7 @@ public final class SiteModel { core.setTelemetryHz(1) cloud?.setLooking(true) if !was { + visit = (visit.number + 1, core.requestsSent) // A start passes through inactive on its way to the front; its // own interval keeps running. if liveSpan == nil { beginLiveSpan("foreground to live") } @@ -277,6 +283,9 @@ public final class SiteModel { } private func hide() { + if documentVisible { + askLog.notice("visit \(self.visit.number, privacy: .public): \(self.requestsThisVisit, privacy: .public) requests") + } documentVisible = false core.setTelemetryHz(0.2) cloud?.setLooking(false) @@ -284,6 +293,9 @@ public final class SiteModel { scheduleClock() } + /// Request frames sent since the app last came to the front. + public var requestsThisVisit: Int { core.requestsSent - visit.fromRequests } + /// A new network path should replace the old one without waiting. public func networkOnline() { guard documentVisible else { return } From 2c0310230a02f864f74d714bc7cfc8367395362b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 07:59:51 +0200 Subject: [PATCH 02/11] iPhone: ask the box by the data's age, in order LiveAsk now names what it wants by how old the data is and keeps an answer while that name holds. A view appearing or a redial asks again only once the answer is older than its time to live: a minute for the chargers, a quarter of an hour for the planner's prefs. A return from the background within a minute sends no request. Reads wait for the box's one API slot in order: chargers, prefs, plan, status, the rest. An idle slot waits one display frame for reads, so the order holds when they are asked in the same moment. A person's request never waits. Prices are asked again each quarter after 14:00 while tomorrow's rates are late, instead of on every redial. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 14 +- .../Sources/FTWKit/Protocol/Session.swift | 57 +++++-- .../FTWKit/Sources/FTWKit/State/BoxAPI.swift | 85 +++++++--- .../FTWKit/State/LoadpointsModel.swift | 26 +-- .../Sources/FTWKit/State/ModeModel.swift | 5 +- .../Sources/FTWKit/State/NowModels.swift | 15 +- .../Sources/FTWKit/State/PlanModel.swift | 4 +- .../Sources/FTWKit/State/SourcesModel.swift | 2 +- .../FTWKit/Tests/FTWKitTests/CarTests.swift | 16 +- .../Tests/FTWKitTests/SecondOpenTests.swift | 154 ++++++++++++++++++ 10 files changed, 313 insertions(+), 65 deletions(-) create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index abf14bb..1f3b8d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,10 +76,16 @@ native, and smooth enough to impress. quote the numbers in any PR that touches launch. - **The handshake runs beside the first frame, never ahead of it.** - **No timer loops.** Lane 0 drives Now. Until the box streams charger - state (M5, or FTW Cloud C3), the chargers are read when a screen that - shows them appears, after a command, and when the stream says the car - started or stopped drawing. The plan's shortfall is read when the car - view opens and when the car's plan changes. + state (M5, or FTW Cloud C3), the chargers are read when someone starts + looking and the last read is over a minute old, after a command, and + when the stream says the car started or stopped drawing. The plan's + shortfall is read when the car view opens and when the car's plan + changes. +- **Ask by the data's age.** `LiveAsk` names what it wants by how old the + data is (the five minutes, the quarter, the day) and keeps an answer + while that name holds. A view appearing or a redial asks only when the + answer is older than its time to live. Reads wait for the box's one API + slot in `ReadOrder`: chargers, prefs, plan, status, the rest. - **A frame redraws what moved.** Small views that read only what they show, value inputs, and `Equatable` with `.equatable()` where a frame would otherwise redraw a whole section. A clock on screen ticks by the diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift index 7bb133e..26447c4 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift @@ -96,6 +96,18 @@ public struct CommandError: Error, Equatable, HelpfulError { public let help: String } +/// Which read goes first when several wait for the box's one API slot: what +/// Now's car needs, then the mode, then the plan, then the device status, +/// then the rest. Anything a person asked for goes before all of them. +/// `LiveAsk` sets it for the reads it makes. +public enum ReadOrder: Int, Sendable, Comparable { + case person, loadpoints, prefs, plan, status, later + + @TaskLocal public static var current: ReadOrder = .person + + public static func < (a: ReadOrder, b: ReadOrder) -> Bool { a.rawValue < b.rawValue } +} + @MainActor public final class Session { public static let apiTimeoutMs: Double = 20_000 @@ -170,11 +182,16 @@ public final class Session { private var pendingCommands: [String: PendingCommand] = [:] // The api queue: one call on the wire at a time, because that is how - // many the box serves. + // many the box serves. Waiting calls go in `ReadOrder`, then in turn. private var apiBusy = false - private var apiWaiters: [(id: Int, continuation: CheckedContinuation, timer: Cancellable)] = [] + private var apiWaiters: [(id: Int, order: ReadOrder, continuation: CheckedContinuation, timer: Cancellable)] = [] private var apiWaiterSeq = 0 private var apiGeneration = 0 + private var apiGrant: Cancellable? + /// An idle slot waits one display frame before a read gets it, so reads + /// asked for in the same moment go in their order, not in the order + /// their tasks happened to start. What a person asked for never waits. + static let apiGatherMs: Double = 16 public init(build: String, ua: String = "native", locales: [String] = ["en"], subscription: Subscription = Subscription(), scheduler: Scheduler) { self.build = build @@ -341,7 +358,7 @@ public final class Session { /// success, and a call never crosses a disconnect into a new session. public func api(_ req: APIRequest) async throws -> APIResponse { let generation = apiGeneration - try await acquireAPISlot() + try await acquireAPISlot(ReadOrder.current) defer { releaseAPISlot() } for attempt in 0... { if generation != apiGeneration { throw SessionError.carrierClosed } @@ -366,11 +383,7 @@ public final class Session { } } - private func acquireAPISlot() async throws { - if !apiBusy { - apiBusy = true - return - } + private func acquireAPISlot(_ order: ReadOrder) async throws { apiWaiterSeq += 1 let mine = apiWaiterSeq try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in @@ -379,18 +392,30 @@ public final class Session { self.apiWaiters.remove(at: i) cont.resume(throwing: SessionError.queueTimedOut) } - apiWaiters.append((mine, cont, timer)) + apiWaiters.append((mine, order, cont, timer)) + if order == .person { + grantAPISlot() + } else if !apiBusy, apiGrant == nil { + apiGrant = scheduler.after(Self.apiGatherMs) { [weak self] in + self?.apiGrant = nil + self?.grantAPISlot() + } + } } } private func releaseAPISlot() { - if apiWaiters.isEmpty { - apiBusy = false - return - } - let next = apiWaiters.removeFirst() - next.timer.cancel() - next.continuation.resume() + apiBusy = false + grantAPISlot() + } + + /// The slot to the first waiting call in order. + private func grantAPISlot() { + guard !apiBusy, let next = apiWaiters.indices.min(by: { (apiWaiters[$0].order, apiWaiters[$0].id) < (apiWaiters[$1].order, apiWaiters[$1].id) }) else { return } + let waiter = apiWaiters.remove(at: next) + waiter.timer.cancel() + apiBusy = true + waiter.continuation.resume() } private func dispatchAPI(_ req: APIRequest) async throws -> APIResponse { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift b/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift index be6c6bd..314a168 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift @@ -87,29 +87,47 @@ extension SiteModel { } } -/// One rule for everything a screen asks the box for: ask while the session -/// streams, again every time it comes back, and again after a failure, with -/// a wait that doubles up to a quarter of an hour. A carrier that drops -/// settles every request at once, and nothing else would ever ask again. +/// One rule for everything a screen asks the box for. `want` names the +/// data by its age: the five minutes for history, the quarter for the +/// plan, the day for prices. An answer stands while that name holds. When +/// someone starts looking again (a view appears, or the stream comes back) +/// an answer older than `ttlMs` no longer stands. Neither a view appearing +/// nor a redial asks by itself, and no timer does: an answer from before +/// the app went to the background still stands when it comes back. +/// +/// An answer a dropped carrier never gave is asked for at the next live +/// moment. A failure is asked again after a wait that doubles up to a +/// quarter of an hour. Reads that wait for the box's one API slot go in +/// `order`. @MainActor public final class LiveAsk { static let retryMs: Double = 30_000 static let ceilingMs: Double = 15 * 60_000 private unowned let site: SiteModel + private let order: ReadOrder + private let ttlMs: Double private let want: @MainActor () -> String? private let ask: @MainActor () async throws -> Void - private var asked: String? - private var tries = 0 + /// The last answer, by name, and when it came. + private var answered: (name: String, atMs: Double)? + /// What is on the wire now. + private var asking: String? + /// What failed, waiting out its backoff. + private var failed: String? + /// Whether someone looked at the last evaluation, while it streamed. + private var looking = false private var waitMs = LiveAsk.retryMs private var timer: Cancellable? private var generation = 0 private var token: Int? - /// `want` names what to ask for; the same name is not asked twice, and - /// nil means there is nothing to ask. `ask` rejects to be asked again. - public init(site: SiteModel, want: @escaping @MainActor () -> String?, ask: @escaping @MainActor () async throws -> Void) { + /// `want` names what to ask for, or nil when nobody looks. `ask` + /// rejects to be asked again. + public init(site: SiteModel, order: ReadOrder = .later, ttlMs: Double = .infinity, want: @escaping @MainActor () -> String?, ask: @escaping @MainActor () async throws -> Void) { self.site = site + self.order = order + self.ttlMs = ttlMs self.want = want self.ask = ask token = site.observe { [weak self] in self?.evaluate() } @@ -122,33 +140,58 @@ public final class LiveAsk { token = nil } - /// Check whether to ask now. Called on every session change and tick, - /// and by the owner when what it wants changes. + /// Check whether to ask now. Called on every session change and clock + /// tick, and by the owner when what it wants changes. public func evaluate() { guard token != nil else { return } - guard site.session.phase == .streaming, let name = want() else { - // Whatever was asked is unanswered now; the next live moment asks - // again rather than waiting out a backoff. - asked = nil + guard site.session.phase == .streaming else { + // What was on the wire is settled with the carrier. The next + // live moment asks again at once rather than waiting out a + // backoff; an answer already given still stands. + generation += 1 + asking = nil + failed = nil waitMs = Self.retryMs timer?.cancel() + looking = false return } - let key = "\(name) \(tries)" - if asked == key { return } - asked = key + guard let name = want() else { + // Nobody looks: no retry. What is on the wire may still land. + failed = nil + timer?.cancel() + looking = false + return + } + if !looking { + looking = true + if let answered, site.scheduler.nowMs - answered.atMs >= ttlMs { self.answered = nil } + } + if asking == name || failed == name || answered?.name == name { return } + start(name) + } + + private func start(_ name: String) { timer?.cancel() + failed = nil + asking = name generation += 1 let mine = generation + let order = order Task { @MainActor [weak self] in guard let self else { return } do { - try await self.ask() - if mine == self.generation { self.waitMs = Self.retryMs } + try await ReadOrder.$current.withValue(order) { try await self.ask() } + guard mine == self.generation else { return } + self.asking = nil + self.answered = (name, self.site.scheduler.nowMs) + self.waitMs = Self.retryMs } catch { guard mine == self.generation else { return } + self.asking = nil + self.failed = name self.timer = self.site.scheduler.after(self.waitMs) { [weak self] in - self?.tries += 1 + self?.failed = nil self?.evaluate() } self.waitMs = min(self.waitMs * 2, Self.ceilingMs) diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index d9ca80b..8f3490d 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -90,8 +90,12 @@ public final class LoadpointsModel: Activatable { @ObservationIgnored private var reading: Task? @ObservationIgnored private var levelSending: Set = [] @ObservationIgnored private var clearing: [String: Cancellable] = [:] - /// Bumped by each appearance and by `reread`: a new reason to ask. - @ObservationIgnored private var visits = 0 + /// Bumped by `reread`: a person asked to look again. + @ObservationIgnored private var rereads = 0 + /// How long a read of the chargers stands when someone starts looking + /// again. The stream says when a car starts or stops drawing; this + /// covers a cable plugged in while nobody looked. + nonisolated static let readStandsMs: Double = 60_000 /// The Live Activity for Charge now. A test sets its own; otherwise the /// app's, which it installs at launch. @@ -124,10 +128,11 @@ public final class LoadpointsModel: Activatable { // 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) - // A Live Activity is only as good as the last read: every time the - // stream comes back while someone looks, read the chargers once, so - // the Live Activity is confirmed again or ended. - activityAsk = LiveAsk(site: site, want: { [weak self] in + // A Live Activity is only as good as the last read: when the stream + // comes back while someone looks and the last read is over a + // minute old, read the chargers once, so the Live Activity is + // confirmed again or ended. + activityAsk = LiveAsk(site: site, order: .loadpoints, ttlMs: Self.readStandsMs, want: { [weak self] in guard let self, self.site.documentVisible, self.site.hasPassthrough, let host = self.activityHost, !host.loadpointIDs(home: self.activityHome).isEmpty else { return nil } return "live activity" @@ -176,11 +181,10 @@ public final class LoadpointsModel: Activatable { public func activate() { active += 1 - visits += 1 if ask == nil { - ask = LiveAsk(site: site, want: { [weak self] in + ask = LiveAsk(site: site, order: .loadpoints, ttlMs: Self.readStandsMs, want: { [weak self] in guard let self, self.active > 0, self.site.documentVisible, self.site.hasPassthrough else { return nil } - return "loadpoints \(self.visits) \(self.drawing)" + return "loadpoints \(self.rereads) \(self.drawing)" }, ask: { [weak self] in try await self?.load() }) } ask?.evaluate() @@ -203,7 +207,7 @@ public final class LoadpointsModel: Activatable { public func watchPlan() { planWatchers += 1 if planAsk == nil { - planAsk = LiveAsk(site: site, want: { [weak self] in + planAsk = LiveAsk(site: site, order: .plan, want: { [weak self] in guard let self, self.planWatchers > 0, self.site.documentVisible, self.site.hasPassthrough, self.inlinePlan else { return nil } let goals = self.points.filter { $0.pluggedIn && $0.schedule != nil && !$0.planPending && !$0.planOutdated } guard !goals.isEmpty else { return nil } @@ -241,7 +245,7 @@ public final class LoadpointsModel: Activatable { /// Ask again now: pull to refresh, or the demo's cable. public func reread() { - visits += 1 + rereads += 1 ask?.evaluate() } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift index 699f9e6..98dba7d 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift @@ -103,6 +103,9 @@ public final class ModeModel { @ObservationIgnored private var ask: LiveAsk? @ObservationIgnored private var active = 0 @ObservationIgnored private var want = 0 + /// How long the box's preferences stand when someone starts looking + /// again. They change on the box's page or another phone, never often. + nonisolated static let prefsStandMs: Double = 15 * 60_000 public init(site: SiteModel) { self.site = site @@ -113,7 +116,7 @@ public final class ModeModel { public func activate() { active += 1 if ask == nil { - ask = LiveAsk(site: site, want: { [weak self] in + ask = LiveAsk(site: site, order: .prefs, ttlMs: Self.prefsStandMs, want: { [weak self] in guard let self, self.active > 0, self.site.hasPassthrough else { return nil } return "prefs \(self.want)" }, ask: { [weak self] in try await self?.loadPrefs() }) diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift index 0e7ec5a..932b7f4 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift @@ -236,12 +236,23 @@ public final class PriceModel: Activatable { calendar.startOfDay(for: Date(timeIntervalSince1970: ms / 1000)).timeIntervalSince1970 * 1000 } - /// The window, named by what would make it out of date. + /// The window, named by what would make it out of date: a new day, the + /// hour tomorrow's rates publish, and, while they are late, each + /// quarter hour until they come. var wanted: String? { guard active > 0, site.session.caps.contains(Contract.capPriceSpot) else { return nil } let now = Date(timeIntervalSince1970: site.nowMs / 1000) let hour = calendar.component(.hour, from: now) - return "\(midnight(site.nowMs))/\(hour >= Self.publishHour ? "published" : "pending")" + let day = midnight(site.nowMs) + guard hour >= Self.publishHour else { return "\(day)/pending" } + if hasTomorrow(after: day) { return "\(day)/published" } + return "\(day)/late \(Int(site.nowMs / 900_000))" + } + + /// Whether the window on screen holds tomorrow's rates. + func hasTomorrow(after day: Double) -> Bool { + let tomorrow = midnight(day + 36 * 3_600_000) + return prices?.slots.contains { $0.startMs >= tomorrow } ?? false } public func activate() { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift index 15bd196..46f785f 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift @@ -91,7 +91,7 @@ public final class PlanModel { public func activate(forecast: Bool = false) { active += 1 if ask == nil { - ask = LiveAsk(site: site, want: { [weak self] in + ask = LiveAsk(site: site, order: .plan, want: { [weak self] in guard let self, self.active > 0, self.site.documentVisible else { return nil } return "plan \(self.want) \(Int(self.site.nowMs / Self.reaskMs))" }, ask: { [weak self] in try await self?.load() }) @@ -100,7 +100,7 @@ public final class PlanModel { guard forecast else { return } forecastActive += 1 if forecastAsk == nil { - forecastAsk = LiveAsk(site: site, want: { [weak self] in + forecastAsk = LiveAsk(site: site, order: .plan, want: { [weak self] in guard let self, self.forecastActive > 0, self.site.documentVisible, self.site.hasPassthrough else { return nil } return "forecast \(self.want) \(Int(self.site.nowMs / Self.reaskMs))" }, ask: { [weak self] in try await self?.loadForecast() }) diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SourcesModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SourcesModel.swift index e7af2d6..80bd64e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SourcesModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SourcesModel.swift @@ -91,7 +91,7 @@ public final class SourcesModel { defer { inFlight = false } let fields = site.session.fields do { - let status = try await site.callBox(.get, "/api/status") + let status = try await ReadOrder.$current.withValue(.status) { try await site.callBox(.get, "/api/status") } devices = Self.devices(status) readAtMs = site.nowMs failedAtMs = nil diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift index 5c106a8..83673f2 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift @@ -479,8 +479,9 @@ import FTWDemo #expect(try #require(rig.box).receivedRequests.isEmpty) } - /// No timer reads the chargers. A screen appearing does, a command - /// does, and the stream saying the car started drawing does. + /// No timer reads the chargers, and no screen appearing while the + /// last read stands. A command does, and the stream saying the car + /// started drawing does. @Test func theChargersAreReadOnEventsNotOnATimer() async throws { let rig = Rig() rig.app.startDemo() @@ -493,10 +494,11 @@ import FTWDemo await rig.run(60_000, step: 500) #expect(reads() == 1) - // A second screen appearing is a reason to look again. + // A second screen appearing is no reason to look again: what the + // first one shows is the box's answer. home.loadpoints.activate() await rig.run(1_000) - #expect(reads() == 2) + #expect(reads() == 1) home.loadpoints.deactivate() // Charge now: one read after the box's answer, one when the stream @@ -505,13 +507,13 @@ import FTWDemo let charge = Task { await home.loadpoints.chargeNow(car) } await rig.run(2_000) await charge.value - #expect(reads() == 3) + #expect(reads() == 2) #expect(home.loadpoints.points.first?.powerW == 0) await rig.run(5_000, step: 250) - #expect(reads() == 4) + #expect(reads() == 3) #expect(home.loadpoints.points.first?.powerW == 11_000) await rig.run(60_000, step: 500) - #expect(reads() == 4) + #expect(reads() == 3) // The plan's shortfall: read when the car view opens and when the // car's plan changes, not once a minute. diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift new file mode 100644 index 0000000..fb102ee --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift @@ -0,0 +1,154 @@ +import Foundation +import Testing +@testable import FTWDemo +@testable import FTWKit +import FTWShared + +/// One home that goes to the background and comes back, the way the app +/// does it: `suspend` closes the session, and the return dials a new +/// carrier to the same box. +@MainActor +final class ReturnRig { + let scheduler = ManualScheduler(nowMs: CarTextTests.t) + let box: SimulatedBox + let site: SiteModel + let home: HomeModels + + init(files: SealedFiles? = nil) { + box = SimulatedBox(scheduler: scheduler, house: SimulatedHouse(timeZone: CarTextTests.stockholm)) + site = SiteModel(siteId: "home", build: "test", ua: "test", scheduler: scheduler, files: files) + home = HomeModels(site: site, cache: nil) + site.start() + } + + func dial() { + site.connect(LoopbackCarrier(box: box, scheduler: scheduler)) + } + + /// What Now shows asks for: the chargers, the mode and the plan. + func lookAtNow() { + home.loadpoints.activate() + home.nodes.mode.activate() + home.nodes.plan.activate() + home.prices.activate() + } + + /// The app in the background for `ms`, then in front again. + func away(_ ms: Double) async { + site.suspend() + await run(ms, step: 1_000) + site.setVisible(true) + dial() + } + + 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() } + } + + /// What the box was asked since `from`. + func asks(since from: Int) -> [String] { Array(box.asks[from...]) } +} + +// MARK: Asks keyed on the data's age + +@MainActor +@Suite struct LiveAskTests { + /// A return within a minute asks for nothing: every answer still + /// stands. After two minutes only the chargers are read again, the one + /// thing that can change unseen. + @Test func aReturnFromTheBackgroundAsksOnlyForWhatAged() async throws { + let w = ReturnRig() + w.dial() + w.lookAtNow() + await w.run(3_000) + #expect(w.site.isLive) + #expect(w.box.asks.contains("GET /api/loadpoints")) + #expect(w.box.asks.contains("GET /api/planner/prefs")) + #expect(w.box.asks.contains("plan.get")) + #expect(w.box.asks.contains("price.get")) + + var before = w.box.asks.count + await w.away(30_000) + await w.run(3_000) + #expect(w.site.isLive) + #expect(w.asks(since: before).isEmpty) + #expect(w.site.requestsThisVisit == 0) + + before = w.box.asks.count + await w.away(2 * 60_000) + await w.run(3_000) + let asked = w.asks(since: before) + #expect(asked.contains("GET /api/loadpoints")) + #expect(!asked.contains("GET /api/planner/prefs")) + #expect(!asked.contains("plan.get")) + #expect(!asked.contains("price.get")) + #expect(w.site.requestsThisVisit == asked.count) + } + + /// A view that opens again asks nothing while its answer stands. Once + /// the answer is older than its time to live, opening the view asks + /// once; staying open on a minute's clock asks nothing more. + @Test func aViewOpenedAgainAsksOnlyOnceItsAnswerIsOld() async throws { + let w = ReturnRig() + w.dial() + await w.run(1_000) + func prefs() -> Int { w.box.asks.filter { $0 == "GET /api/planner/prefs" }.count } + let mode = w.home.nodes.mode + mode.activate() + await w.run(1_000) + #expect(prefs() == 1) + mode.deactivate() + + await w.run(5 * 60_000, step: 1_000) + mode.activate() + await w.run(1_000) + #expect(prefs() == 1) + mode.deactivate() + + await w.run(ModeModel.prefsStandMs, step: 5_000) + mode.activate() + await w.run(1_000) + #expect(prefs() == 2) + await w.run(ModeModel.prefsStandMs * 2, step: 5_000) + #expect(prefs() == 2) + } + + /// Reads wanted in the same moment reach the box in order: the + /// chargers, the planner's style, the plan, then the devices. + @Test func readsGoInOrder() async throws { + let w = ReturnRig() + // Asked for in the opposite order, before the stream is up. + w.home.nodes.sources.activate(.solar) + w.home.nodes.plan.activate(forecast: true) + w.home.nodes.mode.activate() + w.home.loadpoints.activate() + w.dial() + await w.run(3_000) + let reads = w.box.asks.filter { $0.hasPrefix("GET ") } + func first(_ path: String) -> Int { reads.firstIndex(of: "GET \(path)") ?? .max } + #expect(first("/api/loadpoints") < first("/api/planner/prefs")) + #expect(first("/api/planner/prefs") < first("/api/mpc/plan")) + #expect(first("/api/mpc/plan") < first("/api/status")) + #expect(first("/api/status") < .max) + } + + /// What a person asks for never waits behind the reads. + @Test func aPersonsRequestGoesFirst() async throws { + let w = ReturnRig() + w.dial() + await w.run(1_000) + w.home.nodes.sources.activate(.solar) + w.home.nodes.mode.activate() + let mine = Task { try await w.site.callBox(.get, "/api/loadpoints") } + await w.run(1_000) + _ = try await mine.value + let reads = w.box.asks.filter { $0.hasPrefix("GET ") } + #expect(reads.first == "GET /api/loadpoints") + } +} From afd0aff0cc4661dc80d9ca793c3309006113d939 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 08:00:47 +0200 Subject: [PATCH 03/11] iPhone: dial at inactive on the way back, and keep 1 Hz there iOS passes through inactive before active when the app comes back. The new carrier now dials there, so the handshake runs beside the app switch. A glance at Control Center or the app switcher keeps the session at 1 Hz instead of dropping to 0.2 Hz; only the background closes it. Inactive still writes what is on screen, in case the app is closed from the switcher. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/FTWApp.swift | 14 +++++--- .../Sources/FTWKit/State/AppModel.swift | 10 ++++++ .../Tests/FTWKitTests/AppModelTests.swift | 35 +++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index 9d65ab4..73d42b9 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -63,13 +63,19 @@ struct FTWApp: App { } .onChange(of: scenePhase, initial: true) { _, phase in // Inactive is a glance at Control Center or the app - // switcher: slow the stream, keep the session. Only the - // background closes it. - app.setVisible(phase == .active) + // switcher, or the way back from the background: the + // stream keeps 1 Hz, and a return dials here, not at + // active. Only the background closes the session. PricesInBackground.inBackground = phase == .background - if phase == .background { + switch phase { + case .background: + app.setVisible(false) app.suspend() Task { await PricesInBackground.schedule(app) } + case .inactive: + app.glance() + default: + app.setVisible(true) } } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index 865485b..8a6d727 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -230,6 +230,16 @@ public final class AppModel { } } + /// Inactive: a glance at Control Center or the app switcher, or the app + /// on its way back to the front. The stream keeps its rate, a return + /// from the background dials at once, so the handshake runs beside the + /// app switch, and what is on screen is written in case the app is + /// closed from the switcher. + public func glance() { + setVisible(true) + site?.persistNow() + } + /// The app went to the background: close the session and keep what is /// on screen. The demo has no socket to free and keeps running. public func suspend() { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift index e87b163..f3e07af 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift @@ -287,6 +287,41 @@ import FTWDemo #expect(world.passkeys.assertions.isEmpty) } + /// Coming back, iOS passes through inactive before active. The dial + /// starts there, so the handshake runs beside the app switch, and a + /// glance at Control Center keeps the session and its rate. + @Test func theReturnDialsAtInactive() async throws { + let world = World() + let app = world.app() + let pair = PairModel(app: app) + let t = Task { await pair.pair(world.pairingURL) } + await world.run(500) + await t.value + let site = try #require(app.site) + + // A glance from the front: nothing closes, nothing slows. + app.glance() + await world.run(100) + #expect(site.documentVisible) + #expect(site.core.hasCarrier) + #expect(world.relay.dialled.count == 1) + + app.setVisible(false) + app.suspend() + await world.run(100) + #expect(!site.core.hasCarrier) + + // Inactive on the way back: the new carrier dials now. + app.glance() + await world.run(1_000) + #expect(world.relay.dialled.count == 2) + #expect(site.session.phase == .streaming) + #expect(site.documentVisible) + app.setVisible(true) + await world.run(100) + #expect(world.relay.dialled.count == 2) + } + /// Widgets read what the app leaves in the App Group, never the relay. @Test func widgetsGetThePricesAndTheCar() async throws { let world = World() From 8d87204e4c6ce6e1ba7a38782d2bdec4ce34dafe Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 08:04:24 +0200 Subject: [PATCH 04/11] iPhone: the live trace records from any live source LiveTrace recorded only the box's own stream, so through FTW Cloud the node's curve stayed empty. It now records whatever the screen reads, from the relay or from FTW Cloud, and only fields whose own reading is live: a device that stopped answering draws nothing. Each point is stamped with when the box took it: the box's uptime placed by the smallest clock gap seen this connection, or FTW Cloud's send time by rule U. A frame a slow network held back lands where it was taken. Each source has its own gap rule: three and a half beats of the box's stream (1 Hz in front, 0.2 Hz hidden), and twice FTW Cloud's heartbeat and a margin, since the cloud sends on change. Runs are decided when a point arrives. SiteModel.recent, a second copy of the last two minutes, is gone. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/NodeView.swift | 2 +- .../Sources/FTWKit/State/LiveTrace.swift | 108 +++++++++++------- .../Sources/FTWKit/State/SiteModel.swift | 56 ++++++--- .../Tests/FTWKitTests/AppModelTests.swift | 2 +- .../Tests/FTWKitTests/SecondOpenTests.swift | 83 ++++++++++++++ 5 files changed, 193 insertions(+), 58 deletions(-) diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index 401cccf..c34f935 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -330,7 +330,7 @@ private struct LiveSection: View { var body: some View { let now = site.nowMs - let points = trace.points(node.fid, sinceMs: now - LiveTrace.windowMs).map { LiveTrace.Point(t: $0.t, v: node.shown($0.v)) } + let points = trace.points(node.fid, sinceMs: now - LiveTrace.windowMs).map { $0.with(v: node.shown($0.v)) } let live = site.freshness(node).live VStack(alignment: .leading, spacing: 4) { // The header gives way to the place under the finger. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift b/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift index 524c45e..63c27d4 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift @@ -42,24 +42,43 @@ public struct RingBuffer: Sendable { } } -/// The last minutes of every reading lane 0 carries, kept in memory as the -/// frames arrive. No request and no timer: a frame lands, its values go in. +/// The last minutes of every live reading, kept in memory as they arrive. +/// No request and no timer: a reading lands, its values go in. /// -/// It records from the moment the home opens, so a node's live curve opens -/// already drawn. A frame that changed nothing (`tick`) still records, because -/// the values it stands for are still true; a stream that stops records -/// nothing, and the curve breaks there rather than drawing a line across. +/// It records from the moment the home opens, from whichever source is +/// live: the box's own stream or FTW Cloud. A field goes in only while its +/// own reading is live, so a device that stopped answering draws nothing +/// rather than its last value over and over. Each point is stamped with +/// when the box took it, not when it reached the phone, so a slow network +/// or a switch between sources never bends the line. +/// +/// A frame that changed nothing (`tick`) still records, because the values +/// it stands for are still true; a source that falls silent past its gap +/// rule records nothing, and the curve breaks there rather than drawing a +/// line across. @Observable @MainActor public final class LiveTrace { public struct Point: Equatable, Sendable { - /// The phone's clock when the frame arrived, in ms. + /// When the box took the reading, by the phone clock, in ms. public let t: Double public let v: Double + /// Points with the same run join. A silence past the source's gap + /// starts a new one. + public let run: Int + /// Kept from before this launch. Drawn in ink, never as live. + public let restored: Bool - public init(t: Double, v: Double) { + public init(t: Double, v: Double, run: Int = 0, restored: Bool = false) { self.t = t self.v = v + self.run = run + self.restored = restored + } + + /// The same point with another value: a node's view of it. + public func with(v: Double) -> Point { + Point(t: t, v: v, run: run, restored: restored) } } @@ -68,15 +87,20 @@ public final class LiveTrace { /// Ten minutes at 1 Hz. Hidden, at 0.2 Hz, the same room lasts longer, /// and the window still shows only the last five minutes. nonisolated static let capacity = 600 - /// Past this a silence is a gap: the 1 Hz stream misses a beat now and - /// then, and three are a stream that stopped. + /// Past this a silence in a 1 Hz stream is a gap: it misses a beat now + /// and then, and three are a stream that stopped. Each source says its + /// own (`SiteModel.readingGapMs`); this is the box's stream in front. public nonisolated static let gapMs: Double = 3_500 - /// Moves once per recorded frame, so a curve redraws once a second and - /// nothing else does. + /// Moves once per recorded reading, so a curve redraws once a second + /// and nothing else does. public private(set) var revision = 0 @ObservationIgnored private var rings: [Int: RingBuffer] = [:] + /// Per field, how long its newest point may stand before a silence is + /// a gap, as its source said when it was taken. + @ObservationIgnored private var holds: [Int: Double] = [:] + @ObservationIgnored private var nextRun = 0 @ObservationIgnored private var lastFrameAtMs: Double? @ObservationIgnored private weak var site: SiteModel? @ObservationIgnored private var token: Int? @@ -96,18 +120,39 @@ public final class LiveTrace { } private func take() { - guard let site, site.session.phase == .streaming, let at = site.lastFrameAtMs, at != lastFrameAtMs else { return } + guard let site, let at = site.lastFrameAtMs, at != lastFrameAtMs else { return } lastFrameAtMs = at - record(site.session.fields, atMs: at) + guard let stamp = site.readingStampMs else { return } + var live = [Int: Double]() + for (fid, v) in site.session.fields where !Self.notReadings.contains(fid) && site.freshness(fid: fid).live { + live[fid] = v + } + guard !live.isEmpty else { return } + record(live, atMs: stamp, gapMs: site.readingGapMs) } - /// One frame's values. The mode and the source references are not - /// readings and are not kept. - func record(_ fields: [Int: Double], atMs: Double) { + /// One reading's values, taken by the box at `atMs`. The mode and the + /// source references are not readings and are not kept. A value no + /// newer than what the ring holds is not a new reading. + func record(_ fields: [Int: Double], atMs: Double, gapMs: Double = LiveTrace.gapMs) { + var any = false for (fid, v) in fields where !Self.notReadings.contains(fid) { - rings[fid, default: RingBuffer(capacity: Self.capacity)].append(Point(t: atMs, v: v)) + var ring = rings[fid] ?? RingBuffer(capacity: Self.capacity) + let last = ring.last + if let last, atMs <= last.t { continue } + let run: Int + if let last, !last.restored, atMs - last.t <= holds[fid, default: gapMs] { + run = last.run + } else { + nextRun += 1 + run = nextRun + } + ring.append(Point(t: atMs, v: v, run: run)) + rings[fid] = ring + holds[fid] = gapMs + any = true } - revision += 1 + if any { revision += 1 } } nonisolated static let notReadings: Set = [Contract.FID.mode, Contract.FID.srcGrid, Contract.FID.srcPV, Contract.FID.srcBattery] @@ -118,32 +163,17 @@ public final class LiveTrace { return (rings[fid]?.elements ?? []).filter { $0.t >= sinceMs } } - /// Several fields summed sample by sample, for a node that stands for - /// more than one field. A sample missing any of them is left out. - public func sum(_ fids: [Int], sinceMs: Double) -> [Point] { - guard let first = fids.first else { return [] } - if fids.count == 1 { return points(first, sinceMs: sinceMs) } - var byTime = [Double: (n: Int, v: Double)]() - for fid in fids { - for p in points(fid, sinceMs: sinceMs) { - let e = byTime[p.t, default: (0, 0)] - byTime[p.t] = (e.n + 1, e.v + p.v) - } - } - return byTime.filter { $0.value.n == fids.count }.map { Point(t: $0.key, v: $0.value.v) }.sorted { $0.t < $1.t } - } - - /// Where the curve breaks: each run of samples no further apart than - /// `gapMs`, numbered, so a chart draws one line per run. + /// Where the curve breaks: each run numbered from zero in order, so a + /// chart draws one line per run. public nonisolated static func runs(_ points: [Point]) -> [(run: Int, point: Point)] { var out = [(run: Int, point: Point)]() out.reserveCapacity(points.count) var run = 0 - var previous: Double? + var previous: Point? for p in points { - if let previous, p.t - previous > gapMs { run += 1 } + if let previous, previous.run != p.run || previous.restored != p.restored { run += 1 } out.append((run, p)) - previous = p.t + previous = p } return out } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index e074c2f..8f40c56 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -34,7 +34,6 @@ public final class SiteModel { static let streamQuietAfterMs: Double = 3_000 /// How long a foreground stream gets to prove its socket still works. public static let foregroundFrameDeadlineMs: Double = 2_500 - static let recentWindowMs: Double = 130_000 static let snapshotIntervalMs: Double = 15_000 public private(set) var session = SessionState() @@ -45,6 +44,17 @@ public final class SiteModel { /// uptime now, which is not the clock those readings were taken on. @ObservationIgnored private var cachedUptimeMs: Double? public private(set) var lastFrameAtMs: Double? + /// When the box took the newest reading on screen, by the phone clock. + /// From the box's own stream: its uptime, placed by the smallest gap + /// seen this connection between the phone's clock and the box's, so a + /// frame a slow network held back lands where it was taken. Through + /// FTW Cloud: the send time rule U gives. The live trace stamps by it. + @ObservationIgnored public private(set) var readingStampMs: Double? + /// How long the newest reading stands before a silence is a gap. The + /// box streams at 1 Hz in front and 0.2 Hz hidden, and misses a beat + /// now and then; FTW Cloud sends on change and a heartbeat at least + /// every H. + @ObservationIgnored public private(set) var readingGapMs: Double = LiveTrace.gapMs public private(set) var documentVisible = true /// The clock screens read for ages. It moves with every reading, and /// between readings only when something on screen would change: the @@ -87,7 +97,6 @@ public final class SiteModel { @ObservationIgnored private var clock: Cancellable? @ObservationIgnored private var resumeTimer: Cancellable? @ObservationIgnored private var resumeWaiting = false - @ObservationIgnored private var recent: [(t: Double, v: [Int: Double])] = [] @ObservationIgnored private var lastSnapshotMs: Double = 0 @ObservationIgnored private var snapshotTimer: Cancellable? /// The last snapshot written, without its time, so the same home is not @@ -111,6 +120,9 @@ public final class SiteModel { /// When the box session last delivered a reading. Waking after sleep /// waits for this one, whatever the cloud does meanwhile. @ObservationIgnored private var relayFrameAtMs: Double? + /// The phone's clock minus the box's uptime, the smallest seen since + /// this carrier's hello: where a frame's uptime sits on the phone clock. + @ObservationIgnored private var relayOffsetMs: Double? @ObservationIgnored private var listeners: [Int: @MainActor () -> Void] = [:] /// A burst of messages within one display frame reaches the screens as /// one change: the first goes at once, the rest together after it. @@ -338,7 +350,11 @@ public final class SiteModel { // Only a streaming session is a reading arriving. Restoring the cache // and answering hello both move the clock and carry no reading. let reading = s.phase == .streaming && (previous.phase != .streaming || s.uptimeMs != previous.uptimeMs) + // A new hello, or a box that restarted, starts the clock over. + if s.phase == .handshaking, previous.phase != .handshaking { relayOffsetMs = nil } if reading { + let offset = now - s.uptimeMs + if let held = relayOffsetMs, s.uptimeMs >= previous.uptimeMs { relayOffsetMs = min(held, offset) } else { relayOffsetMs = offset } relayFrameAtMs = now relayDelivered = true finishResume() @@ -403,7 +419,8 @@ public final class SiteModel { for fid in times.keys where s.fields[fid] == nil { times[fid] = nil } if delivered { lastFrameAtMs = now - recordRecent(s.fields, now) + readingStampMs = readingStamp(source, state: s) ?? at + readingGapMs = readingGap(source) } if times != fieldAtMs { fieldAtMs = times } if delivered || readingAtMs == nil || s.fields.isEmpty != session.fields.isEmpty { readingAtMs = s.fields.isEmpty ? nil : at } @@ -438,6 +455,25 @@ public final class SiteModel { } } + /// When the box took the newest reading from a source, by the phone + /// clock. + private func readingStamp(_ from: ReadingSource, state s: SessionState) -> Double? { + switch from { + case .relay: return relayOffsetMs.map { s.uptimeMs + $0 } + case .cloud: return readingTime(.cloud) + } + } + + /// How long a reading from a source stands before a silence is a gap: + /// three and a half of the box's beats, or twice FTW Cloud's heartbeat + /// and a margin. + private func readingGap(_ from: ReadingSource) -> Double { + switch from { + case .relay: return LiveTrace.gapMs * (documentVisible ? 1 : 5) + case .cloud: return max(LiveTrace.gapMs, 2 * Double(cloudView?.heartbeatMs ?? 15_000) + 500) + } + } + // MARK: The clock /// Arms the one timer the clock needs: the next moment something on @@ -721,20 +757,6 @@ public final class SiteModel { session.fields[Contract.FID.batterySoc].map { ($0 / 10).rounded() } } - private func recordRecent(_ fields: [Int: Double], _ now: Double) { - var v = [Int: Double]() - for fid in Self.nowFids { if let x = fields[fid] { v[fid] = x } } - recent.append((now, v)) - let cutoff = now - Self.recentWindowMs - recent.removeAll { $0.t < cutoff } - } - - /// The last two minutes of one field, oldest first, so a live line opens - /// already drawn. - public func recentField(_ fid: Int) -> [(t: Double, v: Double)] { - recent.compactMap { r in r.v[fid].map { (r.t, $0) } } - } - // MARK: Requests, through this layer public func prices(fromMs: Double, toMs: Double) async throws -> Prices { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift index f3e07af..94bfe7c 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift @@ -34,7 +34,7 @@ import FTWDemo #expect(home.site.carrier == .relay) #expect(home.site.isLive) #expect(home.site.explanation.situation != .noData) - #expect(home.site.recentField(Contract.FID.gridW).count >= 2) + #expect(home.nodes.live.points(Contract.FID.gridW, sinceMs: 0).count >= 2) } @Test func pricesSavingsAndStatusFill() async { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift index fb102ee..11110a0 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift @@ -152,3 +152,86 @@ final class ReturnRig { #expect(reads.first == "GET /api/loadpoints") } } + +// MARK: The live trace, from any live source + +@MainActor +@Suite struct LiveTraceSourceTests { + /// Through FTW Cloud the trace records too, with the cloud's own gap + /// rule: the box sends on change and a heartbeat at least every H, so + /// four quiet seconds are no gap there, though they would be on the + /// box's 1 Hz stream. A silence past twice H is. + @Test func itRecordsFromFTWCloudWithTheCloudsGapRule() async throws { + let w = try CloudSite() + w.keepFromEarlierLaunch(gridW: 111) + w.service.held = [try w.live(gridW: 1000)] + w.open() + let trace = LiveTrace(site: w.site) + // The cache is not a reading. + #expect(trace.points(Contract.FID.gridW, sinceMs: 0).isEmpty) + w.connectCloud() + await w.run(60) + #expect(w.site.carrier == .cloud) + #expect(trace.points(Contract.FID.gridW, sinceMs: 0).map(\.v) == [1000]) + + await w.run(4_000) + w.service.push(try w.live(gridW: 1200)) + await w.run(50) + await w.run(20_000) + w.service.push(try w.live(gridW: 900)) + await w.run(50) + let points = trace.points(Contract.FID.gridW, sinceMs: 0) + #expect(points.map(\.v) == [1000, 1200, 900]) + #expect(LiveTrace.runs(points).map(\.run) == [0, 0, 1]) + #expect(points.allSatisfy { !$0.restored }) + } + + /// Frames from the box's own stream are stamped by its uptime: one a + /// slow network held back lands where the box took it, a second after + /// the one before, not where it arrived. + @Test func boxFramesAreStampedByUptime() async throws { + let scheduler = ManualScheduler() + let site = SiteModel(siteId: nil, build: "test", ua: "test", scheduler: scheduler, files: nil) + let trace = LiveTrace(site: site) + let relay = ScriptedBox() + site.start() + site.connect(relay) + let t0 = scheduler.nowMs + relay.open(t0) + relay.helloOk(uptimeMs: 60_000, cloud: nil) + relay.snap(uptimeMs: 60_000, gridW: 500) + func delta(_ uptime: Int64, _ w: Int) { + relay.say("delta", .map([("seq", .int(uptime)), ("uptimeMs", .int(uptime)), ("fields", .map(["2": .int(Int64(w))]))])) + } + // One second on, but 700 ms late; then on time. + scheduler.advance(1_700) + delta(61_000, 600) + scheduler.advance(300) + delta(62_000, 700) + for _ in 0..<5 { await Task.yield() } + let points = trace.points(Contract.FID.gridW, sinceMs: 0) + #expect(points.map(\.v) == [500, 600, 700]) + #expect(points.map { $0.t - t0 } == [0, 1_000, 2_000]) + #expect(Set(LiveTrace.runs(points).map(\.run)).count == 1) + } + + /// A device that stops answering draws nothing, though the stream + /// goes on: its last value is not a live reading. + @Test func aQuietDeviceRecordsNothing() async throws { + let scheduler = ManualScheduler() + let site = SiteModel(siteId: nil, build: "test", ua: "test", scheduler: scheduler, files: nil) + let trace = LiveTrace(site: site) + let relay = ScriptedBox() + site.start() + site.connect(relay) + relay.open(scheduler.nowMs) + relay.helloOk(uptimeMs: 60_000, cloud: nil) + relay.snap(uptimeMs: 60_000, gridW: 500) + scheduler.advance(1_000) + relay.say("delta", .map([("seq", .int(1)), ("uptimeMs", .int(61_000)), ("fields", .map(["2": .int(510)])), + ("sources", .map(["p1meter": .map([("kind", .text("driver")), ("name", .text("P1 meter")), ("lastOkMs", .int(40_000)), + ("staleAfterMs", .int(10_000)), ("state", .text("stale"))])]))])) + for _ in 0..<5 { await Task.yield() } + #expect(trace.points(Contract.FID.gridW, sinceMs: 0).map(\.v) == [500]) + } +} From a44e4c6abe781db6908a016d1932eed4c6330c42 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 08:11:30 +0200 Subject: [PATCH 05/11] iPhone: seal the live trace, and restore it in ink after a kill LiveTrace keeps the last five minutes of each ring in a sealed ring- file (about 16 KB), written with the home's other files: every 15 s while streaming, at inactive and at background. A model adds its own file with SiteModel.keepFile and reads it back with keptFile. After a kill the node's curve opens with those points before anything connects. Each is marked restored: the live chart draws it in ink, with no head dot, and a band covers the time the app missed, tagged RESUMED and the time the first live reading came. Restored and live points never share a run. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/NodeCharts.swift | 25 ++++-- appleApp/FTW/UI/NodeView.swift | 3 +- .../Sources/FTWKit/State/LiveTrace.swift | 87 ++++++++++++++++++- .../Sources/FTWKit/State/NodeWords.swift | 1 + .../Sources/FTWKit/State/SiteModel.swift | 36 ++++++++ .../Tests/FTWKitTests/SecondOpenTests.swift | 64 ++++++++++++++ 6 files changed, 209 insertions(+), 7 deletions(-) diff --git a/appleApp/FTW/UI/NodeCharts.swift b/appleApp/FTW/UI/NodeCharts.swift index a7c39f9..7c329de 100644 --- a/appleApp/FTW/UI/NodeCharts.swift +++ b/appleApp/FTW/UI/NodeCharts.swift @@ -120,32 +120,47 @@ private func signedRun(_ t: Double, _ v: Double, run: Int, look: ChartLook, opac .interpolationMethod(.monotone) } -/// The last five minutes, from the ring buffer lane 0 fills. A break in -/// the stream is a break in the line. +/// The last five minutes, from the live trace. A break in the stream is a +/// break in the line. Points kept from before this launch draw in ink, +/// with a band over the time the app missed. struct LiveChart: View, Equatable { let node: EnergyNode let points: [LiveTrace.Point] let nowMs: Double let look: ChartLook + var resumed: LiveTrace.Resumed? var onScrub: (ChartReadout?) -> Void = { _ in } @State private var selected: Date? @State private var range: ClosedRange? nonisolated static func == (a: Self, b: Self) -> Bool { - a.node == b.node && a.points == b.points && a.nowMs == b.nowMs && a.look == b.look + a.node == b.node && a.points == b.points && a.nowMs == b.nowMs && a.look == b.look && a.resumed == b.resumed } var body: some View { let runs = LiveTrace.runs(points) + let ink = NodeLook.look(node, live: false) Chart { + if let resumed { + RectangleMark(xStart: .value("Missed", date(resumed.fromMs)), xEnd: .value("Resumed", date(resumed.toMs ?? nowMs))) + .foregroundStyle(Theme.ink3.opacity(0.14)) + .annotation(position: .overlay, alignment: .topLeading) { + if let at = resumed.toMs { + Text(NodeWords.format("RESUMED %@", AppWords.clock(at, zone: .current))) + .font(Theme.mono(.caption2)) + .foregroundStyle(Theme.ink3) + .fixedSize() + } + } + } ForEach(runs.indices, id: \.self) { i in let r = runs[i] - signedRun(r.point.t, r.point.v, run: r.run, look: look, opacity: (0.32, 0.04), lineWidth: 2) + signedRun(r.point.t, r.point.v, run: r.run, look: r.point.restored ? ink : look, opacity: (0.32, 0.04), lineWidth: 2) } if look.signed { RuleMark(y: .value("Zero", 0)).foregroundStyle(Theme.line).lineStyle(StrokeStyle(lineWidth: 1)) } - if let last = runs.last, selected == nil { + if let last = runs.last, !last.point.restored, selected == nil { PointMark(x: .value("Time", date(last.point.t)), y: .value("Power", last.point.v)) .foregroundStyle(last.point.v < 0 ? look.down : look.line) .symbolSize(28) diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index c34f935..8bfbb06 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -332,6 +332,7 @@ private struct LiveSection: View { let now = site.nowMs let points = trace.points(node.fid, sinceMs: now - LiveTrace.windowMs).map { $0.with(v: node.shown($0.v)) } let live = site.freshness(node).live + let resumed = trace.missed(nowMs: now) VStack(alignment: .leading, spacing: 4) { // The header gives way to the place under the finger. ZStack(alignment: .leading) { @@ -350,7 +351,7 @@ private struct LiveSection: View { .frame(maxWidth: .infinity) .frame(minHeight: fill ? 52 : 110) } else { - LiveChart(node: node, points: points, nowMs: now, look: NodeLook.look(node, live: live), onScrub: { readout = $0 }) + LiveChart(node: node, points: points, nowMs: now, look: NodeLook.look(node, live: live), resumed: resumed, onScrub: { readout = $0 }) .equatable() // Each second's step glides over that second, so the // curve moves on at an even pace instead of jumping; diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift b/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift index 63c27d4..07430da 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift @@ -56,6 +56,11 @@ public struct RingBuffer: Sendable { /// it stands for are still true; a source that falls silent past its gap /// rule records nothing, and the curve breaks there rather than drawing a /// line across. +/// +/// The last five minutes are sealed as `ring-` with the home's other +/// files, so after a kill a node's curve opens with what the app saw +/// before. Those points are marked `restored`: drawn in ink, never as live, +/// and `resumed` dates the time the app missed. @Observable @MainActor public final class LiveTrace { @@ -92,9 +97,18 @@ public final class LiveTrace { /// own (`SiteModel.readingGapMs`); this is the box's stream in front. public nonisolated static let gapMs: Double = 3_500 + /// The time between the newest restored point and the first live one + /// after it. `toMs` is nil until a live reading comes. + public struct Resumed: Equatable, Sendable { + public let fromMs: Double + public var toMs: Double? + } + /// Moves once per recorded reading, so a curve redraws once a second /// and nothing else does. public private(set) var revision = 0 + /// Nil when nothing was restored at launch. + public private(set) var resumed: Resumed? @ObservationIgnored private var rings: [Int: RingBuffer] = [:] /// Per field, how long its newest point may stand before a silence is @@ -107,6 +121,8 @@ public final class LiveTrace { public init(site: SiteModel) { self.site = site + if let file = site.keptFile(RingFile.self, "ring"), file.version == RingFile.version { restore(file) } + site.keepFile("ring") { [weak self] in self?.file } token = site.observe { [weak self] in self?.take() } take() } @@ -152,11 +168,60 @@ public final class LiveTrace { holds[fid] = gapMs any = true } - if any { revision += 1 } + guard any else { return } + if resumed != nil, resumed?.toMs == nil { resumed?.toMs = atMs } + revision += 1 + } + + // MARK: The sealed ring + + /// The last five minutes of every ring, as the file keeps them. + var file: RingFile? { + guard let newest = rings.values.compactMap({ $0.last?.t }).max() else { return nil } + var lanes = [String: RingFile.Lane]() + for (fid, ring) in rings { + let kept = ring.elements.filter { $0.t >= newest - Self.windowMs } + guard let first = kept.first else { continue } + var lane = RingFile.Lane(t0: first.t.rounded(), dt: [], v: [], starts: []) + var previous: Point? + for (i, p) in kept.enumerated() { + lane.dt.append(Int(p.t.rounded() - (previous?.t.rounded() ?? lane.t0))) + lane.v.append(p.v) + if previous.map({ $0.run != p.run || $0.restored != p.restored }) ?? true { lane.starts.append(i) } + previous = p + } + lanes[String(fid)] = lane + } + return RingFile(lanes: lanes) + } + + /// What the last launch kept, as points that are no longer live. + func restore(_ file: RingFile) { + var newest: Double? + for (key, lane) in file.lanes { + guard let fid = Int(key), !Self.notReadings.contains(fid), lane.dt.count == lane.v.count, !lane.v.isEmpty else { continue } + var ring = RingBuffer(capacity: Self.capacity) + var t = lane.t0 + for i in lane.v.indices { + t += Double(lane.dt[i]) + if i == 0 || lane.starts.contains(i) { nextRun += 1 } + ring.append(Point(t: t, v: lane.v[i], run: nextRun, restored: true)) + } + rings[fid] = ring + newest = max(newest ?? t, t) + } + if let newest { resumed = Resumed(fromMs: newest, toMs: nil) } } nonisolated static let notReadings: Set = [Contract.FID.mode, Contract.FID.srcGrid, Contract.FID.srcPV, Contract.FID.srcBattery] + /// The time the app missed, while restored points still show in the + /// window that ends at `nowMs`. + public func missed(nowMs: Double) -> Resumed? { + guard let resumed, resumed.fromMs >= nowMs - Self.windowMs else { return nil } + return resumed + } + /// One field from `sinceMs` on, oldest first. public func points(_ fid: Int, sinceMs: Double) -> [Point] { _ = revision @@ -178,3 +243,23 @@ public final class LiveTrace { return out } } + +/// The live rings as `ring-` keeps them: per field, times as steps in +/// whole ms after the first, the values, and where each run starts. About +/// 15 KB for five minutes of Now's fields. +struct RingFile: Codable, Equatable, Sendable { + static let version = 1 + + struct Lane: Codable, Equatable, Sendable { + var t0: Double + /// Each point's time after the one before; the first is 0. + var dt: [Int] + var v: [Double] + /// The indexes where a run starts. + var starts: [Int] + } + + var version = RingFile.version + /// By field id. + var lanes: [String: Lane] +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 3ca0991..10cad76 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -45,6 +45,7 @@ public enum NodeWords { "Live · last 5 min": "Live · senaste 5 min", "Live, the last five minutes": "Live, de senaste fem minuterna", "Drawing as readings arrive…": "Ritas när mätningarna kommer …", + "RESUMED %@": "ÅTERUPPTAGEN %@", "No live readings while your box is out of reach.": "Inga livevärden medan boxen inte går att nå.", "No reading yet": "Ingen mätning än", "Not live, %@": "Inte live, %@", diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 8f40c56..352b989 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -110,6 +110,8 @@ public final class SiteModel { /// The home file as last read or written, so the same home is not /// written twice. @ObservationIgnored private var lastHome: HomeCache? + /// The models' own sealed files beside the home file. + @ObservationIgnored private var fileKeepers: [FileKeeper] = [] /// 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? @@ -544,6 +546,12 @@ public final class SiteModel { shareCloudHome() } writeHome(siteId: siteId, files: files, wait: wait) + for keeper in fileKeepers { + guard let data = keeper.encode(), data != keeper.last else { continue } + keeper.last = data + let name = "\(keeper.prefix)-\(siteId)" + if wait { files.write(name, data.byteArray) } else { files.writeLater(name, data.byteArray) } + } 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 @@ -559,6 +567,21 @@ public final class SiteModel { keepers.append(fill) } + /// A model's own sealed file beside the home file, `-`, + /// written on the same triggers when what `value` gives differs from + /// what was written last. Nil writes nothing. + public func keepFile(_ prefix: String, _ value: @escaping @MainActor () -> T?) { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + fileKeepers.append(FileKeeper(prefix: prefix) { value().flatMap { try? encoder.encode($0) } }) + } + + /// The file `keepFile` wrote for this home, as the last launch left it. + public func keptFile(_ type: T.Type, _ prefix: String) -> T? { + guard let siteId else { return nil } + return files?.readJSON(T.self, "\(prefix)-\(siteId)") + } + /// 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) { @@ -943,6 +966,19 @@ public final class SiteModel { } } +/// One model's sealed file, and what was written to it last. +@MainActor +private final class FileKeeper { + let prefix: String + let encode: @MainActor () -> Data? + var last: Data? + + init(prefix: String, encode: @escaping @MainActor () -> Data?) { + self.prefix = prefix + self.encode = encode + } +} + /// One wait for the stream, settled once. The timer settles it even if the /// model is gone, so the background task always ends. @MainActor diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift index 11110a0..201338d 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift @@ -235,3 +235,67 @@ final class ReturnRig { #expect(trace.points(Contract.FID.gridW, sinceMs: 0).map(\.v) == [500]) } } + +// MARK: The sealed ring + +@MainActor +@Suite struct RingFileTests { + /// After a kill, a node's curve opens with the last five minutes the + /// app saw, before anything connects: every point marked restored, so + /// it draws in ink, and the time the app missed starts at the newest. + /// The first live reading closes that band and starts a run of its own. + @Test func afterAKillTheTraceOpensWithWhatTheAppSaw() async throws { + let w = KeptHome() + let first = w.launch() + first.site.connect(SimulatedBox.loopback(w.scheduler)) + await w.run(6 * 60_000, step: 500) + let seen = first.nodes.live.points(Contract.FID.gridW, sinceMs: 0) + let newest = try #require(seen.last).t + let kept = seen.filter { $0.t >= newest - LiveTrace.windowMs } + let file = try #require(first.nodes.live.file) + let bytes = try JSONEncoder().encode(file).count + #expect(bytes < 20_000, "ring file \(bytes) bytes") + first.site.suspend() + first.site.destroy() + + await w.run(60_000, step: 5_000) + let cold = w.launch() + #expect(cold.site.session.phase == .idle) + let restored = cold.nodes.live.points(Contract.FID.gridW, sinceMs: 0) + #expect(restored.map(\.t) == kept.map { $0.t.rounded() }) + #expect(restored.map(\.v) == kept.map(\.v)) + #expect(restored.allSatisfy { $0.restored }) + #expect(cold.nodes.live.resumed == LiveTrace.Resumed(fromMs: newest.rounded(), toMs: nil)) + // Nothing restored is live. + #expect(!cold.site.freshness(.grid).live) + #expect(cold.nodes.live.missed(nowMs: w.scheduler.nowMs) != nil) + #expect(cold.nodes.live.missed(nowMs: newest + LiveTrace.windowMs + 1) == nil) + + cold.site.connect(SimulatedBox.loopback(w.scheduler)) + await w.run(3_000) + let after = cold.nodes.live.points(Contract.FID.gridW, sinceMs: 0) + let fresh = after.filter { !$0.restored } + #expect(fresh.count >= 2) + let resumedAt = try #require(cold.nodes.live.resumed?.toMs) + #expect(resumedAt == fresh.first?.t) + let runs = LiveTrace.runs(after) + let restoredRuns = Set(runs.filter { $0.point.restored }.map(\.run)) + let liveRuns = Set(runs.filter { !$0.point.restored }.map(\.run)) + #expect(restoredRuns.isDisjoint(with: liveRuns)) + #expect(liveRuns.count == 1) + } + + /// A break the stream had before the kill is still a break after it. + @Test func runsSurviveTheFile() throws { + let trace = LiveTrace() + let t0 = 1_000_000.0 + for i in 0..<5 { trace.record([Contract.FID.gridW: Double(i)], atMs: t0 + Double(i) * 1000) } + for i in 0..<5 { trace.record([Contract.FID.gridW: Double(10 + i)], atMs: t0 + 20_000 + Double(i) * 1000) } + let file = try #require(trace.file) + let back = LiveTrace() + back.restore(file) + let points = back.points(Contract.FID.gridW, sinceMs: 0) + #expect(points.count == 10) + #expect(LiveTrace.runs(points).map(\.run) == [0, 0, 0, 0, 0, 1, 1, 1, 1, 1]) + } +} From 0caca4f1d255947b5819b0bb8ccf4111a908d611 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 08:17:53 +0200 Subject: [PATCH 06/11] iPhone: seal the history tiles, and prime them once HistoryModel keeps its closed tiles and the tile still filling in a sealed tiles- file (about 11.5 KB for 48 hours). After a kill a node's history draws in frame one. Past the newest value in the tile that was still filling, the steps read as on their way, not as a hole the box confirms. The first live frame primes the window once, with no node open: the tile still filling first when older tiles are missing too, then the rest. After that the window is asked for again only when the five minutes turn while a node's view is open, so reopening a node within the same five minutes sends no hist.query. Only closed tiles are offered to the box as held. The open tile stays on screen until its replacement lands, so the right edge never pulls back. Tiles that land within one display frame draw as one change. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/State/HistoryModel.swift | 172 ++++++++++++++---- .../Tests/FTWKitTests/SecondOpenTests.swift | 146 +++++++++++++++ 2 files changed, 285 insertions(+), 33 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift index 721be36..f0e1788 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift @@ -6,10 +6,15 @@ import Observation /// at five minutes serves every node, so opening the battery after the sun /// asks nothing new. /// -/// Closed tiles never change, so they are kept for the session and the box -/// is told which it already sent; only the tile still filling crosses the -/// wire again. The window is asked for again when the five minutes turn, -/// and only while a node's view is open. +/// Closed tiles never change. They are sealed as `tiles-` with the +/// tile still filling, so a node's history draws in frame one after a +/// kill, and the box is told which it already sent: only the tile still +/// filling crosses the wire again. That open tile stays on screen until +/// its replacement lands, so the right edge never pulls back. +/// +/// The window is primed once after the first live frame, newest tile +/// first, and asked for again when the five minutes turn while a node's +/// view is open. @Observable @MainActor public final class HistoryModel { @@ -22,7 +27,7 @@ public final class HistoryModel { public private(set) var frame: HistoryGeometry.Frame? public private(set) var gaps: [HistGap] = [] - /// The box answered at least once for this home. + /// The box answered at least once for this home since launch. public private(set) var loaded = false public private(set) var loading = false /// A sentence about the wire, never about the home. @@ -34,11 +39,32 @@ public final class HistoryModel { @ObservationIgnored private var token = 0 /// Closed tiles, by id. Immutable once closed. @ObservationIgnored private var tiles: [String: HistChunk] = [:] + /// The tile still filling, as last sent: drawn until its replacement + /// lands, never offered to the box as held. + @ObservationIgnored private var open: HistChunk? + /// The window has been asked for once since launch. + @ObservationIgnored private var primed = false + /// Tiles that land within one display frame are drawn as one change. + @ObservationIgnored private var drawing: Cancellable? @ObservationIgnored let calendar: Calendar public init(site: SiteModel, calendar: Calendar = .current) { self.site = site self.calendar = calendar + if let kept = site.keptFile(TileFile.self, "tiles"), kept.version == TileFile.version { + for tile in kept.tiles.map(\.chunk) { + if tile.partial { open = tile } else { tiles[tile.tileId] = tile } + } + let (plan, from, to) = window() + show(plan, from, to) + } + site.keepFile("tiles") { [weak self] in self?.file } + ask = LiveAsk(site: site, want: { [weak self] in + guard let self, self.site.documentVisible, self.available, self.active > 0 || !self.primed else { return nil } + // The window ends now, so which five minutes "now" is belongs + // in the name; otherwise the right edge says "now" all day. + return "history \(Int(self.site.nowMs / Self.stepMs))" + }, ask: { [weak self] in try await self?.load() }) } /// Whether this box keeps history at all. Unknown until its hello. @@ -48,15 +74,6 @@ public final class HistoryModel { public func activate() { active += 1 - if ask == nil { - ask = LiveAsk(site: site, want: { [weak self] in - guard let self, self.active > 0, self.site.documentVisible, self.available else { return nil } - // The window ends now, so which five minutes "now" is - // belongs in the name; otherwise the right edge says "now" - // all day. - return "history \(Int(self.site.nowMs / Self.stepMs))" - }, ask: { [weak self] in try await self?.load() }) - } ask?.evaluate() } @@ -65,33 +82,40 @@ public final class HistoryModel { ask?.evaluate() } + /// The 48 hours that end now, as tiles. + private func window() -> (HistoryGeometry.TilePlan, Double, Double) { + let to = site.nowMs + let from = to - Self.spanMs + return (HistoryGeometry.plan(.fiveMinutes, fromMs: from, toMs: to, maxPoints: Self.maxPoints), from, to) + } + func load() async throws { token += 1 let mine = token - let to = site.nowMs - let from = to - Self.spanMs - let plan = HistoryGeometry.plan(.fiveMinutes, fromMs: from, toMs: to, maxPoints: Self.maxPoints) - var have = tiles.filter { id, _ in plan.tiles.contains { $0.tileId == id } } - if frame == nil, !have.isEmpty { show(plan, have, from, to) } + let (plan, from, to) = window() + if frame == nil, !tiles.isEmpty || open != nil { show(plan, from, to) } loading = true defer { if mine == token { loading = false } } do { - let query = HistQuery(series: Self.series, res: .fiveMinutes, fromMs: from, toMs: to, have: have.values.map { ($0.tileId, $0.etag) }, maxPoints: Self.maxPoints) - let end = try await site.core.history(query) { [weak self] chunk in - guard let self, mine == self.token else { return } - have[chunk.tileId] = chunk - if !chunk.partial { self.tiles[chunk.tileId] = chunk } - // Drawn as the tiles arrive: a tile still on its way is a - // bridge the next one grows into. - self.show(plan, have, from, to) + // With older tiles missing too, the tile still filling comes + // first, so the right edge draws before the rest. + if let newest = plan.tiles.last, plan.tiles.dropLast().contains(where: { tiles[$0.tileId] == nil }) { + _ = try await fetch(HistQuery(series: Self.series, res: .fiveMinutes, fromMs: newest.startMs, toMs: to, maxPoints: Self.maxPoints), plan, from, to, mine) + guard mine == token else { return } } + let held = plan.tiles.compactMap { tiles[$0.tileId] }.map { ($0.tileId, $0.etag) } + let end = try await fetch(HistQuery(series: Self.series, res: .fiveMinutes, fromMs: from, toMs: to, have: held, maxPoints: Self.maxPoints), plan, from, to, mine) guard mine == token else { return } - show(plan, have, from, to) + drawing?.cancel() + drawing = nil + // Tiles that fell out of the window are not kept. + tiles = tiles.filter { id, _ in plan.tiles.contains { $0.tileId == id } } + if let o = open, !plan.tiles.contains(where: { $0.tileId == o.tileId }) { open = nil } + show(plan, from, to) gaps = end.gaps loaded = true + primed = true problem = nil - // Tiles that fell out of the window are not asked for again. - tiles = tiles.filter { id, _ in plan.tiles.contains { $0.tileId == id } } } catch { guard mine == token else { return } problem = Self.failure(error, hadSome: frame != nil) @@ -99,8 +123,51 @@ public final class HistoryModel { } } - private func show(_ plan: HistoryGeometry.TilePlan, _ have: [String: HistChunk], _ from: Double, _ to: Double) { - frame = HistoryGeometry.clip(HistoryGeometry.assemble(plan, names: Self.series, tiles: have), fromMs: from, toMs: to) + private func fetch(_ query: HistQuery, _ plan: HistoryGeometry.TilePlan, _ from: Double, _ to: Double, _ mine: Int) async throws -> HistEnd { + try await site.core.history(query) { [weak self] chunk in + guard let self, mine == self.token else { return } + if chunk.partial { + self.open = chunk + } else { + self.tiles[chunk.tileId] = chunk + if self.open?.tileId == chunk.tileId { self.open = nil } + } + // Drawn as the tiles arrive, a display frame at a time: a tile + // still on its way is a bridge the next one grows into. + guard self.drawing == nil else { return } + self.drawing = self.site.scheduler.after(SiteModel.coalesceMs) { [weak self] in + guard let self else { return } + self.drawing = nil + self.show(plan, from, to) + } + } + } + + /// The closed tiles and the open one, laid on the window. Past the open + /// tile's newest value nothing is known yet: those steps have not + /// arrived, so they read as on their way, not as a hole the box has. + private func show(_ plan: HistoryGeometry.TilePlan, _ from: Double, _ to: Double) { + var have = tiles.filter { id, _ in plan.tiles.contains { $0.tileId == id } } + if let open, have[open.tileId] == nil, plan.tiles.contains(where: { $0.tileId == open.tileId }) { have[open.tileId] = open } + var assembled = HistoryGeometry.assemble(plan, names: Self.series, tiles: have) + if let open, have[open.tileId] == open, let index = plan.tiles.firstIndex(where: { $0.tileId == open.tileId }) { + let perTile = plan.tiles.first?.points ?? 0 + let base = index * perTile + let filled = assembled.columns.map { column in + (base..` keeps: the closed tiles in the window and the + /// open one. + var file: TileFile? { + let kept = tiles.values.sorted { $0.startMs < $1.startMs } + (open.map { [$0] } ?? []) + return kept.isEmpty ? nil : TileFile(tiles: kept.map(TileFile.Tile.init)) } static func failure(_ error: Error, hadSome: Bool) -> String { @@ -116,9 +183,13 @@ public final class HistoryModel { /// with a hole where the box recorded nothing. public func samples(_ node: EnergyNode, fromMs: Double) -> [HistorySample] { guard let frame else { return [] } - return Self.samples(frame, node: node, fromMs: fromMs, fetching: loading) + return Self.samples(frame, node: node, fromMs: fromMs, fetching: pending) } + /// A tile not here yet is on its way: while the box is asked, or + /// before it has answered once and nothing has failed. + var pending: Bool { loading || (!loaded && problem == nil) } + /// `fetching`: a hole whose tile has not arrived is on its way, not /// known to be empty. nonisolated static func samples(_ frame: HistoryGeometry.Frame, node: EnergyNode, fromMs: Double, fetching: Bool = false) -> [HistorySample] { @@ -349,3 +420,38 @@ public final class CarEnergyModel { } } } + +/// The history tiles as `tiles-` keeps them: about 11.5 KB of closed +/// tiles for 48 hours, and the tile still filling, marked partial. +struct TileFile: Codable, Equatable, Sendable { + static let version = 1 + + struct Tile: Codable, Equatable, Sendable { + var tileId: String + var etag: String + var res: String + var startMs: Double + var stepMs: Double + var series: [String] + var data: Data + var partial: Bool + + init(_ c: HistChunk) { + tileId = c.tileId + etag = c.etag + res = c.res.rawValue + startMs = c.startMs + stepMs = c.stepMs + series = c.series + data = Data(c.data) + partial = c.partial + } + + var chunk: HistChunk { + HistChunk(tileId: tileId, etag: etag, res: Resolution(rawValue: res) ?? .fiveMinutes, startMs: startMs, stepMs: stepMs, series: series, data: data.byteArray, partial: partial) + } + } + + var version = TileFile.version + var tiles: [Tile] +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift index 201338d..2f56bd2 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift @@ -299,3 +299,149 @@ final class ReturnRig { #expect(LiveTrace.runs(points).map(\.run) == [0, 0, 0, 0, 0, 1, 1, 1, 1, 1]) } } + +// MARK: The sealed tiles + +/// Counts the changes an observable property makes. +@MainActor +final class Changes { + var count = 0 + private let read: @MainActor () -> Void + + init(_ read: @escaping @MainActor () -> Void) { + self.read = read + watch() + } + + private func watch() { + withObservationTracking { read() } onChange: { [weak self] in + Task { @MainActor in + self?.count += 1 + self?.watch() + } + } + } +} + +@MainActor +@Suite struct TileCacheTests { + /// The newest step with a value, across the series. + static func rightEdge(_ frame: HistoryGeometry.Frame?) -> Double? { + guard let frame else { return nil } + let last = (0.. 500) + #expect(changes.count <= 2) + await w.run(20 * 60_000, step: 1_000) + #expect(w.box.asks.filter { $0 == "hist.query" }.count == 2) + } + + /// A node opened again within the same five minutes asks nothing, and + /// neither does a return from the background in between. + @Test func reopeningANodeWithinFiveMinutesSendsNoHistQuery() async throws { + let w = ReturnRig() + let history = w.home.nodes.history + w.dial() + await w.run(3_000) + func queries() -> Int { w.box.asks.filter { $0 == "hist.query" }.count } + let primed = queries() + + history.activate() + await w.run(30_000, step: 500) + history.deactivate() + await w.away(60_000) + await w.run(3_000) + history.activate() + await w.run(30_000, step: 500) + history.deactivate() + #expect(queries() == primed) + + // A new five minutes while a node is open is new data: one query, + // which fetches the tile still filling and nothing else. + history.activate() + await w.run(3 * 60_000, step: 1_000) + #expect(queries() == primed + 1) + history.deactivate() + } + + /// After a kill, a node's history and its live curve are there before + /// anything connects. Past the newest value in the tile that was still + /// filling, the steps read as on their way, never as a hole the box + /// has. The box is then asked only for that tile, and the right edge + /// never pulls back while it comes. + @Test func afterAKillHistoryAndTheTraceDrawInFrameOne() async throws { + let w = KeptHome() + let first = w.launch() + first.site.connect(SimulatedBox.loopback(w.scheduler)) + await w.run(3 * 60_000, step: 500) + let before = try #require(first.nodes.history.frame) + let edge = try #require(Self.rightEdge(before)) + let file = try #require(first.nodes.history.file) + #expect(file.tiles.filter(\.partial).count == 1) + first.site.suspend() + first.site.destroy() + + await w.run(20 * 60_000, step: 5_000) + let cold = w.launch() + #expect(cold.site.session.phase == .idle) + let frame = try #require(cold.nodes.history.frame) + #expect(Self.rightEdge(frame) == edge) + let today = cold.nodes.history.samples(.battery, fromMs: cold.nodes.history.midnightMs) + #expect(today.filter { $0.watts != nil }.count > 100) + // The twenty minutes since are on their way, not missing. + let since = today.filter { $0.startMs > edge } + #expect(!since.isEmpty) + #expect(since.allSatisfy { $0.watts == nil && $0.fetching }) + #expect(!cold.nodes.live.points(Contract.FID.batteryW, sinceMs: 0).isEmpty) + + cold.site.connect(SimulatedBox.loopback(w.scheduler)) + var shown = edge + for _ in 0..<60 { + await w.run(50) + let now = try #require(Self.rightEdge(cold.nodes.history.frame)) + #expect(now >= shown) + shown = now + } + #expect(cold.nodes.history.loaded) + #expect(shown > edge) + } + + /// Only closed tiles are offered to the box as held, and a file that + /// is read back says the same. + @Test func theFileKeepsClosedTilesAndTheOpenOne() async throws { + let w = ReturnRig() + w.dial() + await w.run(3_000) + let file = try #require(w.home.nodes.history.file) + #expect(file.tiles.filter(\.partial).count == 1) + #expect(file.tiles.last?.partial == true) + let bytes = try JSONEncoder().encode(file) + let back = try JSONDecoder().decode(TileFile.self, from: bytes) + #expect(back == file) + #expect(back.tiles.map(\.chunk.etag) == file.tiles.map(\.etag)) + #expect(back.tiles.allSatisfy { HistoryGeometry.etag($0.chunk.data) == $0.etag }) + } +} From 9b5af5c65177145da078bd64cf9528d0d41acd2d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 08:21:44 +0200 Subject: [PATCH 07/11] iPhone: a chart opened again starts settled MovingPoints grew every chart from zero each time its view was made, so opening a node a second time replayed the grow-in. What a chart already has when it appears now starts settled; only what changes while it is on screen moves. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/ChartMotion.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/appleApp/FTW/UI/ChartMotion.swift b/appleApp/FTW/UI/ChartMotion.swift index 3e67a7b..70c0fa5 100644 --- a/appleApp/FTW/UI/ChartMotion.swift +++ b/appleApp/FTW/UI/ChartMotion.swift @@ -100,7 +100,9 @@ struct ChartChange: Equatable { /// Holds a chart's points and moves between them: the chart draws what /// `content` is handed, at the display's rate while a change moves and not -/// at all otherwise. +/// at all otherwise. What the chart already has when it appears starts +/// settled, so a view opened again never grows from zero; only what +/// changes while it is on screen moves. struct MovingPoints: View { let points: [HistoryPlot.Point] @ViewBuilder let content: (_ drawn: [HistoryPlot.Point], _ fading: [HistoryPlot.Point], _ fadeLeft: Double) -> Content @@ -114,10 +116,16 @@ struct MovingPoints: View { let now = moving ? context.date : .distantFuture content(change.drawn(shown, at: now), change.fading, change.fading.isEmpty ? 0 : change.fadeLeft(at: now)) } - .onAppear { begin(points) } + .onAppear { settle(points) } .onChange(of: points) { _, new in begin(new) } } + private func settle(_ new: [HistoryPlot.Point]) { + shown = new + change = ChartChange() + moving = false + } + private func begin(_ new: [HistoryPlot.Point]) { let now = Date() // Interrupted mid-move: start from where the points are drawn now. From d3a14ad8dd6c76f72be70071a2a2ba4f60d3dc7a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 08:23:36 +0200 Subject: [PATCH 08/11] iPhone: a second history read waits for the one on the wire The grid widget's quarter-hourly read asked for history itself, so the first live frame sent the priming query and another beside it. A second caller now waits for the load already on the wire. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/State/HistoryModel.swift | 12 ++++++++++++ .../Tests/FTWKitTests/SecondOpenTests.swift | 17 +++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift index f0e1788..ea5bfc2 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift @@ -46,6 +46,9 @@ public final class HistoryModel { @ObservationIgnored private var primed = false /// Tiles that land within one display frame are drawn as one change. @ObservationIgnored private var drawing: Cancellable? + /// The load on the wire. A second caller waits for it rather than + /// asking again. + @ObservationIgnored private var running: Task? @ObservationIgnored let calendar: Calendar public init(site: SiteModel, calendar: Calendar = .current) { @@ -89,7 +92,16 @@ public final class HistoryModel { return (HistoryGeometry.plan(.fiveMinutes, fromMs: from, toMs: to, maxPoints: Self.maxPoints), from, to) } + /// Ask for the window, or wait for the ask already on the wire. func load() async throws { + if let running { return try await running.value } + let task = Task { @MainActor in try await self.fetchWindow() } + running = task + defer { running = nil } + try await task.value + } + + private func fetchWindow() async throws { token += 1 let mine = token let (plan, from, to) = window() diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift index 2f56bd2..cfdea41 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift @@ -14,9 +14,9 @@ final class ReturnRig { let site: SiteModel let home: HomeModels - init(files: SealedFiles? = nil) { + init(files: SealedFiles? = nil, shared: AppGroupFiles? = nil) { box = SimulatedBox(scheduler: scheduler, house: SimulatedHouse(timeZone: CarTextTests.stockholm)) - site = SiteModel(siteId: "home", build: "test", ua: "test", scheduler: scheduler, files: files) + site = SiteModel(siteId: "home", build: "test", ua: "test", scheduler: scheduler, files: files, shared: shared) home = HomeModels(site: site, cache: nil) site.start() } @@ -359,6 +359,19 @@ final class Changes { #expect(w.box.asks.filter { $0 == "hist.query" }.count == 2) } + /// A home that leaves today's grid for its widget reads the same + /// history: its read waits for the priming rather than asking again. + @Test func theGridWidgetSharesThePriming() async throws { + let shared = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-wp5-\(UUID().uuidString)")) + let w = ReturnRig(shared: shared) + #expect(w.site.sharesWidgets) + w.dial() + await w.run(3_000) + #expect(w.box.asks.contains("GET /api/savings/daily")) + #expect(w.box.asks.filter { $0 == "hist.query" }.count == 2) + #expect(shared.siteIDs(GridTodayEntry.self) == ["home"]) + } + /// A node opened again within the same five minutes asks nothing, and /// neither does a return from the background in between. @Test func reopeningANodeWithinFiveMinutesSendsNoHistQuery() async throws { From 9d92c690e12e7a6ae2e178ab4d1e4981a7b7c868 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 14:16:46 +0200 Subject: [PATCH 09/11] iPhone: a UI tour for recording the second open of a node Opens the battery's Today twice, then kills the demo and opens it again straight into the battery's live trace. Skipped unless TEST_RUNNER_FTW_SECOND=1, like ChartTour. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWUITests/SecondOpenTour.swift | 54 ++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 appleApp/FTWUITests/SecondOpenTour.swift diff --git a/appleApp/FTWUITests/SecondOpenTour.swift b/appleApp/FTWUITests/SecondOpenTour.swift new file mode 100644 index 0000000..4622156 --- /dev/null +++ b/appleApp/FTWUITests/SecondOpenTour.swift @@ -0,0 +1,54 @@ +import XCTest + +/// Opens the battery's Today twice, then kills the app and opens it again +/// straight into the battery's live trace, for screen recordings of the +/// second open. The demo keeps its sealed cache like a paired home. +/// Skipped unless asked for: +/// +/// TEST_RUNNER_FTW_SECOND=1 xcodebuild test … -only-testing:FTWUITests/SecondOpenTour +final class SecondOpenTour: XCTestCase { + @MainActor + func testOpenANodeTwiceThenAfterAKill() throws { + let env = ProcessInfo.processInfo.environment + try XCTSkipUnless(env["FTW_SECOND"] == "1", "Set TEST_RUNNER_FTW_SECOND=1 to open the nodes.") + let lang = env["FTW_LANG"] ?? "sv" + let base = ["-FTWDemo", "YES", "-FTWDemoCache", "YES", "-AppleLanguages", "(\(lang))", "-AppleLocale", lang == "sv" ? "sv_SE" : "en_US"] + let app = XCUIApplication() + app.launchArguments = base + ["-FTWPeriod", "today"] + app.launch() + let battery = app.descendants(matching: .any)["now.node.battery"].firstMatch + XCTAssertTrue(battery.waitForExistence(timeout: 15)) + Thread.sleep(forTimeInterval: 6) + + // First open, back, second open. + for open in ["first", "second"] { + battery.tap() + let chart = app.descendants(matching: .any)["node.period.chart"].firstMatch + XCTAssertTrue(chart.waitForExistence(timeout: 5)) + shoot(app, "\(open)-open") + Thread.sleep(forTimeInterval: 3) + app.navigationBars.buttons.element(boundBy: 0).tap() + Thread.sleep(forTimeInterval: 2) + } + + // Into the background, where the app seals what it holds; killed; + // opened again straight into the battery. + XCUIDevice.shared.press(.home) + Thread.sleep(forTimeInterval: 3) + app.terminate() + Thread.sleep(forTimeInterval: 2) + app.launchArguments = base + ["-FTWNode", "battery", "-FTWPeriod", "live"] + app.launch() + XCTAssertTrue(app.descendants(matching: .any)["node.live.chart"].firstMatch.waitForExistence(timeout: 5)) + shoot(app, "after-kill") + Thread.sleep(forTimeInterval: 6) + } + + @MainActor + private func shoot(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +} From 4ddf6c692d15391b1f8e227d7787031aa3a611f3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 14:26:48 +0200 Subject: [PATCH 10/11] iPhone: the second-open tour can stay longer on Now, and shows Today after the kill FTW_DWELL sets how long Now stays up before the first open, so the live ring has more to keep when the demo is killed. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWUITests/SecondOpenTour.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/appleApp/FTWUITests/SecondOpenTour.swift b/appleApp/FTWUITests/SecondOpenTour.swift index 4622156..7913304 100644 --- a/appleApp/FTWUITests/SecondOpenTour.swift +++ b/appleApp/FTWUITests/SecondOpenTour.swift @@ -1,9 +1,10 @@ import XCTest /// Opens the battery's Today twice, then kills the app and opens it again -/// straight into the battery's live trace, for screen recordings of the -/// second open. The demo keeps its sealed cache like a paired home. -/// Skipped unless asked for: +/// straight into the battery's live trace, then its Today, for screen +/// recordings of the second open. The demo keeps its sealed cache like a +/// paired home. `FTW_DWELL` is how many seconds Now stays up first, so the +/// live ring has more to keep. Skipped unless asked for: /// /// TEST_RUNNER_FTW_SECOND=1 xcodebuild test … -only-testing:FTWUITests/SecondOpenTour final class SecondOpenTour: XCTestCase { @@ -18,7 +19,7 @@ final class SecondOpenTour: XCTestCase { app.launch() let battery = app.descendants(matching: .any)["now.node.battery"].firstMatch XCTAssertTrue(battery.waitForExistence(timeout: 15)) - Thread.sleep(forTimeInterval: 6) + Thread.sleep(forTimeInterval: Double(env["FTW_DWELL"] ?? "") ?? 6) // First open, back, second open. for open in ["first", "second"] { @@ -41,7 +42,10 @@ final class SecondOpenTour: XCTestCase { app.launch() XCTAssertTrue(app.descendants(matching: .any)["node.live.chart"].firstMatch.waitForExistence(timeout: 5)) shoot(app, "after-kill") - Thread.sleep(forTimeInterval: 6) + Thread.sleep(forTimeInterval: 4) + app.segmentedControls["node.period"].buttons.element(boundBy: 1).tap() + shoot(app, "after-kill-today") + Thread.sleep(forTimeInterval: 3) } @MainActor From 135ee723d80647e015ec2b4addfdbe3d49086d53 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 14:51:09 +0200 Subject: [PATCH 11/11] iPhone: tomorrow's prices arriving are no reason to ask again After 14:00 the price window's name changed when the answer itself brought tomorrow's rates, so the first stay sent price.get twice. The name asked under now stays once tomorrow is here; a window still without it is asked again the next quarter hour. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/State/NowModels.swift | 11 ++++++++-- .../Tests/FTWKitTests/SecondOpenTests.swift | 20 +++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift index 932b7f4..a93da7a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift @@ -204,6 +204,10 @@ public final class PriceModel: Activatable { @ObservationIgnored private let calendar: Calendar /// FTW Cloud's window as last taken, so it is taken once. @ObservationIgnored private var cloudTaken: Stamped? + /// The name asked under after the publish hour. It stays once + /// tomorrow's rates are here, so their arrival is no reason to ask + /// again. + @ObservationIgnored private var publishedName: String? /// `cache` is what the app left for its widgets: the window a launch /// opens on when the sealed home file has none. @@ -245,8 +249,11 @@ public final class PriceModel: Activatable { let hour = calendar.component(.hour, from: now) let day = midnight(site.nowMs) guard hour >= Self.publishHour else { return "\(day)/pending" } - if hasTomorrow(after: day) { return "\(day)/published" } - return "\(day)/late \(Int(site.nowMs / 900_000))" + let here = hasTomorrow(after: day) + if here, let publishedName, publishedName.hasPrefix("\(day)/") { return publishedName } + let name = here ? "\(day)/published" : "\(day)/late \(Int(site.nowMs / 900_000))" + publishedName = name + return name } /// Whether the window on screen holds tomorrow's rates. diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift index 52df5a2..94836b1 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift @@ -9,12 +9,13 @@ import FTWShared /// carrier to the same box. @MainActor final class ReturnRig { - let scheduler = ManualScheduler(nowMs: CarTextTests.t) + let scheduler: ManualScheduler let box: SimulatedBox let site: SiteModel let home: HomeModels - init(files: SealedFiles? = nil, shared: AppGroupFiles? = nil) { + init(files: SealedFiles? = nil, shared: AppGroupFiles? = nil, nowMs: Double = CarTextTests.t) { + scheduler = ManualScheduler(nowMs: nowMs) box = SimulatedBox(scheduler: scheduler, house: SimulatedHouse(timeZone: CarTextTests.stockholm)) site = SiteModel(siteId: "home", build: "test", ua: "test", scheduler: scheduler, files: files, shared: shared) home = HomeModels(site: site, cache: nil) @@ -138,6 +139,21 @@ final class ReturnRig { #expect(first("/api/status") < .max) } + /// After tomorrow's rates publish, the window is asked for once. The + /// answer bringing tomorrow is no reason to ask again; a window still + /// without it is asked again the next quarter hour. + @Test func pricesAreAskedOnceAfterThePublishHour() async throws { + let w = ReturnRig(nowMs: CarTextTests.t + 3 * 3_600_000) + w.dial() + await w.run(1_000) + w.home.prices.activate() + await w.run(3_000) + #expect(w.home.prices.hasTomorrow(after: w.home.prices.midnight(w.scheduler.nowMs))) + #expect(w.box.asks.filter { $0 == "price.get" }.count == 1) + await w.run(30 * 60_000, step: 1_000) + #expect(w.box.asks.filter { $0 == "price.get" }.count == 1) + } + /// What a person asks for never waits behind the reads. @Test func aPersonsRequestGoesFirst() async throws { let w = ReturnRig()