diff --git a/CLAUDE.md b/CLAUDE.md index 65566de..4ea3105 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,10 +88,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/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index d9b4492..35e20cc 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -70,13 +70,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/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index ebcc671..5fec267 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -224,15 +224,16 @@ final class NodeTraceModel { /// What the chart and the hero read: held still while a finger is down. var shown: TraceFrame? { held ?? frame } - func build(_ points: [LiveTrace.Point], node: EnergyNode, carrier: CarrierKind, size: CGSize) { + /// `gapMs` is the silence the source in use allows before the line + /// breaks. + func build(_ samples: [LiveSample], node: EnergyNode, gapMs: Double, size: CGSize) { guard size.width > 0, size.height > 0 else { return } let resized = size != self.size self.size = size - let samples = points.map { LiveSample(t: $0.t, v: node.shown($0.v)) } let now = Date() let series = LiveSeries.build( samples, - sourceGapMs: NodeLive.sourceGapMs(carrier), + sourceGapMs: gapMs, windowMs: LiveTrace.windowMs, nowMs: now.timeIntervalSince1970 * 1000, size: size, @@ -684,7 +685,7 @@ private struct LiveChartArea: View { let began = ContinuousClock.now defer { TraceCost.log.debug("trace built in \((ContinuousClock.now - began) / .microseconds(1), format: .fixed(precision: 0)) µs") } #endif - trace.build(live.points(node.fid, sinceMs: Date().timeIntervalSince1970 * 1000 - LiveTrace.windowMs - 10_000), node: node, carrier: site.carrier, size: plot) + trace.build(live.samples(node, sinceMs: Date().timeIntervalSince1970 * 1000 - LiveTrace.windowMs - 10_000), node: node, gapMs: site.readingGapMs, size: plot) } } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index e7a850a..3435c24 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": @@ -428,6 +432,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 3f2a364..6e755e1 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. /// @@ -90,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 @@ -113,6 +131,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)? @@ -160,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 @@ -289,6 +316,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 +330,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 +346,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) } } @@ -328,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 } @@ -353,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 @@ -366,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 { @@ -388,6 +426,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) } } @@ -456,6 +495,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/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/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/HistoryModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift index 721be36..ea5bfc2 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,35 @@ 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? + /// 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) { 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 +77,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 +85,49 @@ 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) + } + + /// 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 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 +135,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 +195,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 +432,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/Sources/FTWKit/State/LiveTrace.swift b/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift index 524c45e..49168d4 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LiveTrace.swift @@ -1,4 +1,5 @@ import Foundation +import FTWShared import Observation /// The latest `capacity` values, oldest dropped first. Appending is O(1) @@ -42,24 +43,48 @@ 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. +/// +/// 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 { 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,21 +93,37 @@ 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. + /// 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 + /// 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? 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() } @@ -96,55 +137,136 @@ 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 } + 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 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 } + /// One node's readings as its trace draws them: solar as what it makes, + /// and a point kept from before this launch not seen live. + public func samples(_ node: EnergyNode, sinceMs: Double) -> [LiveSample] { + points(node.fid, sinceMs: sinceMs).map { LiveSample(t: $0.t, v: node.shown($0.v), seen: !$0.restored) } } - /// 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 } } + +/// 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/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index 71e7744..0338acc 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 ee31d8f..447bf57 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/NodeLive.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift index 7ba7a1a..ea47b32 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift @@ -33,12 +33,6 @@ public enum NodeLive { return via + " · " + NodeWords.format("READS EVERY %@ S", String(seconds)) } - /// The longest silence the way the readings come allows before the line - /// breaks; the device's own read period can only lengthen it. - public static func sourceGapMs(_ carrier: CarrierKind) -> Double { - LiveTrace.gapMs - } - /// Energy in and out of the site over the runs, up to `untilMs`, by /// joining readings with straight lines. A silence that broke the line /// counts for nothing. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift index 0e7ec5a..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. @@ -236,12 +240,26 @@ 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" } + 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. + 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/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index e78dee3..352b989 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. /// @@ -32,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() @@ -43,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 @@ -85,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 @@ -99,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? @@ -109,6 +122,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. @@ -158,6 +174,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 +287,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 +297,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 +307,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 } @@ -326,7 +352,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() @@ -391,7 +421,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 } @@ -426,6 +457,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 @@ -496,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 @@ -511,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) { @@ -709,20 +780,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 { @@ -909,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/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/AppModelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift index e87b163..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 { @@ -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() 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/NodeLiveTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeLiveTests.swift index a66b794..be16590 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeLiveTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeLiveTests.swift @@ -33,8 +33,6 @@ import Testing #expect(relay.split(separator: "·").dropFirst() == cloud.split(separator: "·").dropFirst()) #expect(NodeLive.footer(.relay, periodMs: 5_200) == "VIA RELAY · READS EVERY 5 S") #expect(NodeLive.footer(.cache, periodMs: 1_000) == "SAVED ON THIS PHONE") - // The gap rule does not hang on the path's name either. - #expect(NodeLive.sourceGapMs(.relay) == NodeLive.sourceGapMs(.webrtc)) } AppWords.$language.withValue(.sv) { #expect(NodeLive.footer(.cloud, periodMs: 60_000) == "VIA FTW CLOUD · LÄSER VAR 60 S") diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift new file mode 100644 index 0000000..94836b1 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/SecondOpenTests.swift @@ -0,0 +1,480 @@ +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 + let box: SimulatedBox + let site: SiteModel + let home: HomeModels + + 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) + 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) + } + + /// 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() + 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") + } +} + +// 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]) + } +} + +// 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) + // The trace draws what the app did not see live in ink. + let samples = cold.nodes.live.samples(.grid, sinceMs: 0) + #expect(samples.map(\.seen) == after.map { !$0.restored }) + #expect(samples.contains { !$0.seen } && samples.contains { $0.seen }) + 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]) + } +} + +// 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 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 { + 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 }) + } +} diff --git a/appleApp/FTWUITests/SecondOpenTour.swift b/appleApp/FTWUITests/SecondOpenTour.swift new file mode 100644 index 0000000..7913304 --- /dev/null +++ b/appleApp/FTWUITests/SecondOpenTour.swift @@ -0,0 +1,58 @@ +import XCTest + +/// Opens the battery's Today twice, then kills the app and opens it again +/// 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 { + @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: Double(env["FTW_DWELL"] ?? "") ?? 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: 4) + app.segmentedControls["node.period"].buttons.element(boundBy: 1).tap() + shoot(app, "after-kill-today") + Thread.sleep(forTimeInterval: 3) + } + + @MainActor + private func shoot(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +}