From f98cbcb01543c89916585250665dd48e987e3520 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:36:35 +0200 Subject: [PATCH 1/3] iPhone: the plan reaches the car the moment the box replans The app read the car's plan from /api/loadpoints only when the car started or stopped drawing, so a car plugged in for a night charge kept the old plan on the phone while the box's page showed the new one. srcfl/ftw#1643 pushes the session plan when the box replans, with each car's windows, cable, pending/outdated and shortfall. The app takes them at once: Now's strip, the plan view, the car view and PlanShare (the widgets and the watch) move with the push, and a cable that moved is read once for the rest of the car. A box whose plan comes without cars is read each quarter while a car is plugged in, and every 15 s while it says the plan is being made or for 30 s after FTW Cloud says a cable moved. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 16 +- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 44 ++++- .../Sources/FTWDemo/SimulatedHistory.swift | 3 +- .../FTWKit/Sources/FTWKit/Format/EV.swift | 7 + .../Sources/FTWKit/Protocol/HistoryPlan.swift | 58 ++++++- .../FTWKit/State/LoadpointsModel.swift | 107 +++++++++++- .../Sources/FTWKit/State/PlanShare.swift | 3 + .../Sources/FTWKit/State/SiteModel.swift | 2 +- .../Tests/FTWKitTests/PlanPushTests.swift | 156 ++++++++++++++++++ 9 files changed, 376 insertions(+), 20 deletions(-) create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/PlanPushTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 4335b00..88fcb98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,12 +87,16 @@ native, and smooth enough to impress. the time from process start to the first frame and the first content; 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 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. +- **No timer loops.** Lane 0 drives Now. The box pushes its plan, with + each car's windows, cable and shortfall, whenever it replans, and Now, + the plan, the car view and the watch redraw from that push. The chargers + are read when someone starts looking and the last read is over a minute + old, after a command, when the stream says the car started or stopped + drawing, and when FTW Cloud or a pushed plan says a cable went in or out. + A box whose plan comes without cars pushes none: its chargers are also + read each quarter while a car is plugged in and every 15 s while its plan + is being made or for half a minute after a cable moved, and its shortfall + 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 diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index a220be6..bdde180 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -49,6 +49,14 @@ public final class SimulatedBox { /// `time_of_day_min` on the household clock. Off, it is a Core that /// knows only `time_of_day_min_utc`. public var readsHouseholdClock = true + /// A Core with srcfl/ftw#1643 puts each car's share on the plan and + /// pushes the plan when it replans. Off, it is a Core that does neither. + public var pushesPlan = true + /// How long the box takes to replan after a cable, a goal, a level or + /// solar-only charging: its checks, the solve and the push's settle. The + /// plan is pending meanwhile. A hold replans nothing, as in Core. + public var replanMs: Double = 300 + private var replanning: Cancellable? /// The last schedule body the app sent, as sent. public private(set) var lastScheduleBody: JSON? /// The box the demo is showing, for the demo's own controls (plug the @@ -398,9 +406,11 @@ public final class SimulatedBox { house.carSocSource = "inferred" house.carReportedAtMs = house.carReportedAtMs.map { min($0, scheduler.nowMs - 1) } result(cmdId, "applied", observed: house.carSocFraction) + replan() case Contract.opLoadpointSurplusOnlySet: house.surplusOnly = args["surplus_only"]?.bool ?? false result(cmdId, "applied", observed: house.surplusOnly ? 1 : 0) + replan() default: result(cmdId, "rejected", error: ("E_UNKNOWN_OP", [])) } @@ -607,9 +617,11 @@ public final class SimulatedBox { Int(body?["days"]?.number ?? 0), body?["surplus_unlock_bat_soc"]?.number ?? 0 ) + replan() return (200, ["ok": true]) case ("DELETE", "/api/loadpoints/{id}/schedule"): house.schedule = nil + replan() return (200, ["ok": true]) default: return (404, [:]) @@ -642,8 +654,8 @@ public final class SimulatedBox { "manual_charge_w": h.manualHoldW.map { .number($0) } ?? .null, "surplus_only": .bool(h.surplusOnly), "charger": ["known": true, "available": true, "updated_at_ms": .number(scheduler.nowMs - 2_000)], - "plan_pending": false, - "plan_outdated": false, + "plan_pending": .bool(replanning != nil), + "plan_outdated": .bool(replanning != nil), "commanded_known": true, "commanded_w": .number(h.evWatts), "commanded_reason": "", @@ -695,7 +707,7 @@ public final class SimulatedBox { lp = lp.setting("target_time", .string(ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: deadline / 1000)))) } } - if let p = h.carPlan(now), p.endMs > p.startMs, p.endMs > now { + if replanning == nil, let p = h.carPlan(now), p.endMs > p.startMs, p.endMs > now { lp = lp.setting("plan_windows", [["start_ms": .number(p.startMs.rounded()), "end_ms": .number(p.endMs.rounded()), "wh": .number(p.wh)]]) lp = lp.setting("plan_next_start_ms", .number(p.startMs.rounded())) lp = lp.setting("plan_next_end_ms", .number(p.endMs.rounded())) @@ -854,6 +866,32 @@ extension SimulatedBox { /// Plug the demo car in or out. public func setCarPluggedIn(_ on: Bool) { if on { house.plugIn() } else { house.unplug() } + replan() + } + + /// The car's share of the plan, as Core puts it on the plan it sends. + func planCar(nowMs: Double) -> PlanCar { + let quiet = replanning != nil + var car = PlanCar(id: "garage", pluggedIn: house.carPluggedIn, pending: quiet, outdated: quiet, windows: [], totalWh: 0) + if !quiet, let p = house.carPlan(nowMs), p.endMs > p.startMs, p.endMs > nowMs { + car.windows = [Loadpoint.Window(fromMs: p.startMs.rounded(), toMs: p.endMs.rounded(), peakW: nil, energyWh: p.wh)] + car.totalWh = p.wh + car.shortfallWh = p.shortfallWh + } + return car + } + + /// Something the plan depends on changed: the box replans, saying so + /// meanwhile, and a box that pushes sends the new plan to the stream. + public func replan() { + replanning?.cancel() + replanning = scheduler.after(replanMs) { [weak self] in + guard let self else { return } + self.replanning = nil + self.house.planRev += 1 + guard self.pushesPlan, self.subscription != nil else { return } + self.sendBulk(Envelope(t: "plan", b: self.sessionPlan().wire)) + } } } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift index 7bbdf34..2e52bfb 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift @@ -223,7 +223,8 @@ extension SimulatedBox { uptimeMs: uptimeMs, slots: steps.map { PlanSlot(startMs: $0.startMs.rounded(), durationMs: $0.durationMs.rounded(), batteryW: $0.batteryW, gridW: $0.gridW, priceMinor: $0.priceMinor, reason: $0.reason, socPermille: ($0.soc * 1000).rounded()) }, stale: false, - ceilingW: nil + ceilingW: nil, + cars: pushesPlan ? [planCar(nowMs: scheduler.nowMs)] : nil ) madePlan = (quarter, house.planRev, plan) return plan diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift index d3e703d..c829d81 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -54,6 +54,13 @@ public struct Loadpoint: Equatable, Sendable, Identifiable, Codable { public var peakW: Double? public var energyWh: Double? public var id: Double { fromMs } + + public init(fromMs: Double, toMs: Double, peakW: Double?, energyWh: Double?) { + self.fromMs = fromMs + self.toMs = toMs + self.peakW = peakW + self.energyWh = energyWh + } } public var id: String diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift index ddeea0d..8aa66dc 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift @@ -164,13 +164,17 @@ public struct Plan: Equatable, Sendable, Codable { /// different sentences. public var stale: Bool public var ceilingW: Double? + /// Each charger's share of the plan. Nil from a box that sends none, + /// which also pushes no plan when it replans. + public var cars: [PlanCar]? - public init(rev: UInt64, uptimeMs: Double, slots: [PlanSlot], stale: Bool, ceilingW: Double?) { + public init(rev: UInt64, uptimeMs: Double, slots: [PlanSlot], stale: Bool, ceilingW: Double?, cars: [PlanCar]? = nil) { self.rev = rev self.uptimeMs = uptimeMs self.slots = slots self.stale = stale self.ceilingW = ceilingW + self.cars = cars } init(_ c: CBOR) { @@ -190,6 +194,7 @@ public struct Plan: Equatable, Sendable, Codable { } stale = c["stale"]?.bool ?? false ceilingW = c["ceilingW"]?.double + cars = c["cars"]?.array?.compactMap { PlanCar($0) } } var cbor: CBOR { @@ -207,6 +212,57 @@ public struct Plan: Equatable, Sendable, Codable { ("stale", .bool(stale)), ] pairs.append(("ceilingW", ceilingW.map { .ms($0) } ?? .null)) + if let cars { pairs.append(("cars", .array(cars.map(\.cbor)))) } + return .map(pairs) + } +} + +/// One charger's share of the plan, as `GET /api/loadpoints` gives it, so +/// a plan the box pushes redraws the car without a read. +public struct PlanCar: Equatable, Sendable, Codable { + public var id: String + public var pluggedIn: Bool + /// A newer plan is being made, or the plan is not one the box acts on. + /// The box sends no windows then. + public var pending: Bool + public var outdated: Bool + public var windows: [Loadpoint.Window] + public var totalWh: Double + /// How far short of the goal the plan leaves the car, in battery + /// watt-hours. Nil when the plan has no goal for it. + public var shortfallWh: Double? + + public init(id: String, pluggedIn: Bool, pending: Bool = false, outdated: Bool = false, windows: [Loadpoint.Window], totalWh: Double, shortfallWh: Double? = nil) { + self.id = id + self.pluggedIn = pluggedIn + self.pending = pending + self.outdated = outdated + self.windows = windows + self.totalWh = totalWh + self.shortfallWh = shortfallWh + } + + init?(_ c: CBOR) { + guard let id = c["id"]?.string else { return nil } + self.id = id + pluggedIn = c["pluggedIn"]?.bool ?? false + pending = c["pending"]?.bool ?? false + outdated = c["outdated"]?.bool ?? false + windows = (c["windows"]?.array ?? []).compactMap { w in + guard let s = w["startMs"]?.double, let e = w["endMs"]?.double, e > s, let wh = w["wh"]?.double, wh >= 0 else { return nil } + return Loadpoint.Window(fromMs: s, toMs: e, peakW: nil, energyWh: wh) + } + totalWh = c["totalWh"]?.double ?? 0 + shortfallWh = c["shortfallWh"]?.double.map { max(0, $0) } + } + + var cbor: CBOR { + var pairs: [(String, CBOR)] = [ + ("id", .text(id)), ("pluggedIn", .bool(pluggedIn)), ("pending", .bool(pending)), ("outdated", .bool(outdated)), + ("windows", .array(windows.map { .map([("startMs", .ms($0.fromMs)), ("endMs", .ms($0.toMs)), ("wh", .ms($0.energyWh ?? 0))]) })), + ("totalWh", .ms(totalWh)), + ] + if let shortfallWh { pairs.append(("shortfallWh", .ms(shortfallWh))) } return .map(pairs) } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index e7f7054..baaa43e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -27,8 +27,11 @@ public protocol ChargeNowHost: AnyObject { /// /// No timer asks the box. The chargers are read when a screen that shows /// them appears, after a command, when the stream says the car started or -/// stopped drawing power, and when the stream comes back. Until the box -/// streams charger state (M5), that is all. +/// stopped drawing power, when FTW Cloud or a pushed plan says a cable went +/// in or out, and when the stream comes back. Each car's plan comes with +/// the plan the box pushes when it replans. A box whose plan comes without +/// cars pushes none: it is read each quarter while a car is plugged in, and +/// every 15 s while it says its plan is being made or a cable just moved. @Observable @MainActor public final class LoadpointsModel: Activatable { @@ -92,6 +95,18 @@ public final class LoadpointsModel: Activatable { @ObservationIgnored private var clearing: [String: Cancellable] = [:] /// Bumped by `reread`: a person asked to look again. @ObservationIgnored private var rereads = 0 + /// Bumped when FTW Cloud or a pushed plan says a cable went in or out, + /// or names a car this phone has not read: a reason to read the + /// chargers once. A read itself never bumps it. + @ObservationIgnored private var plugNews = 0 { + didSet { plugNewsAtMs = site.scheduler.nowMs } + } + @ObservationIgnored private var plugNewsAtMs: Double? + /// The plan whose cars were last taken, so a pushed plan is taken once. + @ObservationIgnored private var planTaken: Plan? + /// Bumped each time a plan's cars are taken. A read on the wire while + /// one arrived does not put its older plan back. + @ObservationIgnored private var planTakes = 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. @@ -126,6 +141,7 @@ public final class LoadpointsModel: Activatable { } site.observe { [weak self] in self?.takeCloud() + self?.takePlan() self?.noticeDrawing() } // A Live Activity from another home, or from the demo, has no @@ -150,6 +166,7 @@ public final class LoadpointsModel: Activatable { cloudTaken = cloud if let stamp, !stamp.fromCache, stamp.asOfMs >= cloud.asOfMs { return } let known = Dictionary(points.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) + if cloud.value.contains(where: { known[$0.loadpointID]?.pluggedIn != $0.pluggedIn }) { plugNews += 1 } let next = cloud.value.map { car -> Loadpoint in let fresh = Self.loadpoint(car, readAtMs: cloud.asOfMs) guard var lp = known[car.loadpointID] else { return fresh } @@ -164,8 +181,54 @@ public final class LoadpointsModel: Activatable { guard let taken = Stamped.taking(Stamped(next, asOfMs: cloud.asOfMs, origin: .cloud), over: stamp) else { return } stamp = taken readAtMs = taken.asOfMs + ask?.evaluate() + } + + /// A plan the box sent carries each car's share: its windows, its + /// cable, whether a newer plan is being made, and its shortfall. Taken + /// at once, without a read. A cable that moved, or a car this phone + /// has not read, is read once for the rest of the car. + func takePlan() { + guard let plan = site.session.plan, plan != planTaken, let cars = plan.cars else { return } + planTaken = plan + planTakes += 1 + var nextWindows = windows + var nextShortfall = shortfallWh + var lps = points + for car in cars { + let quiet = car.pending || car.outdated + nextWindows[car.id] = quiet ? [] : car.windows + nextShortfall[car.id] = quiet ? nil : car.shortfallWh + guard let i = lps.firstIndex(where: { $0.id == car.id }) else { + plugNews += 1 + continue + } + if lps[i].pluggedIn != car.pluggedIn { plugNews += 1 } + lps[i].pluggedIn = car.pluggedIn + Self.plan(&lps[i], from: car) + } + if nextWindows != windows { windows = nextWindows } + if nextShortfall != shortfallWh { shortfallWh = nextShortfall } + shortfallReadAtMs = site.scheduler.nowMs + if let stamp, lps != stamp.value { self.stamp = Stamped(lps, asOfMs: stamp.asOfMs, origin: stamp.origin) } + ask?.evaluate() } + /// A car's plan as the box pushed it. + static func plan(_ lp: inout Loadpoint, from car: PlanCar) { + let quiet = car.pending || car.outdated + lp.planPending = car.pending + lp.planOutdated = car.outdated + lp.planWindows = quiet ? [] : car.windows + lp.planTotalWh = quiet ? nil : car.totalWh + lp.planStartMs = lp.planWindows.first?.fromMs + lp.planEndMs = lp.planWindows.first?.toMs + } + + /// Whether this box pushes its plan, cars and all, each time it + /// replans: a plan with cars has come. + var boxPushesPlan: Bool { site.session.plan?.cars != nil } + /// A charger as the cache kept it: enough for the level, its source and /// age, the cable and the power. The goal and the plan wait for the box. static func loadpoint(_ car: CarEntry.Car, readAtMs: Double) -> Loadpoint { @@ -187,12 +250,30 @@ public final class LoadpointsModel: Activatable { if ask == nil { 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.rereads) \(self.drawing)" + return "loadpoints \(self.rereads) \(self.drawing) \(self.plugNews)\(self.unpushedPlan)" }, ask: { [weak self] in try await self?.load() }) } ask?.evaluate() } + /// For a box known to push no plan, because its plan came without + /// cars: the quarter while a car is plugged in, since the box replans + /// each quarter, and every 15 s while it says the plan is being made or + /// for half a minute after a cable moved, which is how long the box + /// takes to notice the cable and replan. Nothing until a plan has come, + /// so a box that pushes never pays for the fallback. + var unpushedPlan: String { + guard let plan = site.session.plan, plan.cars == nil else { return "" } + let nowMs = site.nowMs + let settling = plugNewsAtMs.map { nowMs - $0 < Self.plugSettleMs } ?? false + if settling || points.contains(where: \.planPending) { return " pending \(Int(nowMs / Self.pendingAskMs))" } + if points.contains(where: \.pluggedIn) { return " quarter \(Int(nowMs / 900_000))" } + return "" + } + + nonisolated static let pendingAskMs: Double = 15_000 + nonisolated static let plugSettleMs: Double = 30_000 + /// Whether the stream says a car is drawing power. A change is a reason /// to read the chargers; nothing else on lane 0 is. var drawing: String { @@ -205,13 +286,14 @@ public final class LoadpointsModel: Activatable { ask?.evaluate() } - /// The car view is open: read the plan's shortfall too, once a minute - /// and whenever the car's plan changes. + /// The car view is open: read the plan's shortfall too, whenever the + /// car's plan changes. A box that pushes its plan sends the shortfall + /// with it. public func watchPlan() { planWatchers += 1 if planAsk == nil { 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 } + guard let self, self.planWatchers > 0, self.site.documentVisible, self.site.hasPassthrough, self.inlinePlan, !self.boxPushesPlan else { return nil } let goals = self.points.filter { $0.pluggedIn && $0.schedule != nil && !$0.planPending && !$0.planOutdated } guard !goals.isEmpty else { return nil } return "plan \(goals.map(Self.planKey).joined(separator: ","))" @@ -268,6 +350,7 @@ public final class LoadpointsModel: Activatable { private func readChargers() async throws { token += 1 let mine = token + let takes = planTakes loading = true defer { if mine == token { loading = false } } do { @@ -276,14 +359,22 @@ public final class LoadpointsModel: Activatable { let list = wire?["loadpoints"]?.array ?? [] let before = Dictionary(points.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) let now = site.scheduler.nowMs - if let taken = Stamped.taking(Stamped(list.map(Loadpoint.init), asOfMs: now, origin: .relay), over: stamp) { stamp = taken } + // A plan pushed while this read was on the wire is at least as + // new as the read's: its share of each car stays. + let pushed = planTakes != takes + let read = list.map { json -> Loadpoint in + var lp = Loadpoint(json) + if pushed, let car = planTaken?.cars?.first(where: { $0.id == lp.id }) { Self.plan(&lp, from: car) } + return lp + } + if let taken = Stamped.taking(Stamped(read, asOfMs: now, origin: .relay), over: stamp) { stamp = taken } for lp in points { // A level set here belongs to one plug-in. let was = before[lp.id] if !lp.pluggedIn || (was.map { lp.sessionWh + 100 < $0.sessionWh } ?? false) { levelSet[lp.id] = nil } } inlinePlan = !points.isEmpty && points.allSatisfy(\.inlinePlan) - if inlinePlan { + if inlinePlan, !pushed { let next = Dictionary(uniqueKeysWithValues: points.map { ($0.id, $0.planWindows) }) if next != windows { windows = next } if planMissing { planMissing = false } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift index 445b07f..aca3b87 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift @@ -135,7 +135,10 @@ public final class PlanShare { } func check() { + // A pushed plan moves the battery and each car's windows in one go, + // so the record never holds one without the other. plan.sync() + loadpoints.takePlan() // Nothing to say until the box has sent a plan in this session. guard let s = plan.stamped, !s.fromCache, let sent = plan.receivedAtMs else { return } let key = self.key diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 52c8624..ee734a4 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -119,7 +119,7 @@ public final class SiteModel { /// FTW Cloud's price window and chargers as last accepted, for the /// models that show them. Each change comes with a `notify`. @ObservationIgnored public private(set) var cloudPrices: Stamped? - @ObservationIgnored public private(set) var cloudCars: Stamped<[CarEntry.Car]>? + @ObservationIgnored public internal(set) var cloudCars: Stamped<[CarEntry.Car]>? @ObservationIgnored private var destroyed = false /// The box session's own state, as the last frame left it. @ObservationIgnored private var relay = SessionState() diff --git a/appleApp/FTWKit/Tests/FTWKitTests/PlanPushTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/PlanPushTests.swift new file mode 100644 index 0000000..5e95cbc --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/PlanPushTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWDemo + +/// The plan reaches the car the moment the box replans. A box that pushes +/// its plan redraws each car from the push; an older box is read once when +/// FTW Cloud says a cable went in, each quarter while a car is plugged in, +/// and every 15 s while it says the plan is being made. +@MainActor +@Suite struct PlanPushTests { + typealias Rig = CarFlowTests.Rig + + func reads(_ box: SimulatedBox) -> Int { box.receivedRequests.filter { $0 == "GET /api/loadpoints" }.count } + + /// The bytes srcfl/ftw pins in TestThePlanWithCarsVector. + @Test func thePlanWithCarsDecodesFromTheBoxsBytes() throws { + let hex = "a66372657607646361727382a76269646565617365656770656e64696e67f467746f74616c5768191ce86777696e646f777381a3627768191ce865656e644d731b000001a1325825806773746172744d731b000001a131ea4880686f75746461746564f469706c7567676564496ef56b73686f727466616c6c57681904b0a6626964666761726167656770656e64696e67f567746f74616c5768006777696e646f777380686f75746461746564f569706c7567676564496ef465736c6f747381a765677269645719012c66726561736f6e70657870656e736976655f696d706f72746773746172744d731b000001a131ea48806862617474657279573904af6a6475726174696f6e4d731a000dbba06a70726963654d696e6f7218576b736f635065726d696c6c65190280657374616c65f4686365696c696e6757f668757074696d654d731a0036ee80" + let plan = Plan(try decodeCBOR(Bytes(hex: hex))) + #expect(plan.rev == 7) + #expect(plan.slots.first?.socPermille == 640) + let cars = try #require(plan.cars) + #expect(cars == [ + PlanCar(id: "easee", pluggedIn: true, windows: [Loadpoint.Window(fromMs: 1_791_838_800_000, toMs: 1_791_846_000_000, peakW: nil, energyWh: 7_400)], totalWh: 7_400, shortfallWh: 1_200), + PlanCar(id: "garage", pluggedIn: false, pending: true, outdated: true, windows: [], totalWh: 0), + ]) + // A plan from a box without cars says so with nil, not an empty list. + #expect(Plan(Plan(rev: 1, uptimeMs: 0, slots: [], stale: false, ceilingW: nil).wire).cars == nil) + #expect(Plan(plan.wire) == plan) + } + + @Test func aPushedPlanMovesTheCarsWindowsWithoutARead() async throws { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + home.loadpoints.activate() + await rig.run(1_500) + #expect(reads(box) == 1) + let before = try #require(home.loadpoints.windows["garage"]?.first) + + // The goal moves on the box: it replans and pushes the plan. + box.house.schedule = (0.95, 7 * 60, true, 0b0011111, 0) + box.replan() + await rig.run(1_000) + let after = try #require(home.loadpoints.windows["garage"]?.first) + #expect(after.toMs > before.toMs) + #expect(reads(box) == 1) + let car = try #require(home.loadpoints.points.first) + #expect(car.planWindows == [after]) + #expect(!car.planPending) + #expect(home.loadpoints.shortfallWh["garage"] != nil) + // Now's strip, the plan view and the watch draw from PlanShare. + #expect(home.nodes.planShare.entry.charges.first?.toMs == after.toMs) + #expect(home.nodes.plan.plan?.rev == home.site.session.plan?.rev) + + // Nothing reads the car again while the plan holds. + await rig.run(60_000, step: 500) + #expect(reads(box) == 1) + } + + /// A cable plugged in arrives on the pushed plan: the windows at once, + /// and one read for the rest of the car. + @Test func aPlugInOnThePushIsReadOnceForTheRestOfTheCar() async throws { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + box.setCarPluggedIn(false) + await rig.run(1_000) + home.loadpoints.activate() + await rig.run(1_500) + #expect(reads(box) == 1) + #expect(home.loadpoints.points.first?.pluggedIn == false) + + box.setCarPluggedIn(true) + // The replan takes 300 ms and the loopback 120 ms: the push has + // landed, and the read it asks for has not reached the box yet. + await rig.run(450) + #expect(reads(box) == 1) + #expect(home.loadpoints.points.first?.pluggedIn == true) + #expect(home.loadpoints.windows["garage"]?.isEmpty == false) + await rig.run(1_500) + #expect(reads(box) == 2) + await rig.run(60_000, step: 500) + #expect(reads(box) == 2) + } + + /// An older box pushes nothing. FTW Cloud's copy says the cable went in, + /// and that is one read, which brings the plan. + @Test func anOldBoxIsReadOnceWhenFTWCloudSaysTheCableWentIn() async throws { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + box.pushesPlan = false + box.setCarPluggedIn(false) + await rig.run(1_000) + home.loadpoints.activate() + await rig.run(1_500) + #expect(reads(box) == 1) + + box.setCarPluggedIn(true) + await rig.run(1_000) + #expect(reads(box) == 1) + #expect(home.loadpoints.points.first?.pluggedIn == false) + + let car = CarEntry.Car(loadpointID: "garage", pluggedIn: true, socPct: 20, socSource: "assumed", socReadAtMs: nil, powerW: 0, targetSocPct: 80) + home.site.cloudCars = Stamped([car], asOfMs: rig.scheduler.nowMs, origin: .cloud) + await rig.run(1_500) + #expect(reads(box) == 2) + #expect(home.loadpoints.points.first?.pluggedIn == true) + #expect(home.loadpoints.windows["garage"]?.isEmpty == false) + #expect(home.nodes.planShare.entry.charges.isEmpty == false) + + // The same cable again is no news. + home.site.cloudCars = Stamped([car], asOfMs: rig.scheduler.nowMs, origin: .cloud) + await rig.run(60_000, step: 500) + #expect(reads(box) == 2) + } + + /// An older box that says its plan is being made is asked again about + /// every 15 s, and no longer once the plan is there. + @Test func anOldBoxMakingItsPlanIsAskedEvery15s() async throws { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + box.pushesPlan = false + box.replanMs = 40_000 + // Now asks for the plan, which comes without cars: an older box. + home.nodes.plan.activate() + home.loadpoints.activate() + await rig.run(2_000) + #expect(home.site.session.plan != nil) + #expect(home.site.session.plan?.cars == nil) + + box.house.schedule = (0.95, 7 * 60, true, 0b0011111, 0) + box.replan() + home.loadpoints.reread() + await rig.run(1_000) + #expect(home.loadpoints.points.first?.planPending == true) + let pending = reads(box) + await rig.run(30_000, step: 250) + #expect(reads(box) - pending >= 2) + #expect(reads(box) - pending <= 3) + + // The plan is made at 40 s: the next ask finds it, and that is all. + await rig.run(16_000, step: 250) + #expect(home.loadpoints.points.first?.planPending == false) + #expect(home.loadpoints.windows["garage"]?.isEmpty == false) + let done = reads(box) + await rig.run(60_000, step: 500) + #expect(reads(box) <= done + 1) + } +} From 98a1f96d99d14461c19083a89506a2f07b4dd64b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 19:58:36 +0200 Subject: [PATCH 2/3] iPhone: read the plan's share by charger, with the session's car beside it srcfl/ftw#1643 now sends chargers/PlanCharger keyed by the loadpoint id, with an optional carId (the session's car, empty until Core keeps cars), since in the car contract (srcfl/ftw#1647) cars means real cars. The vector Core pins carries carId. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +-- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 9 ++++--- .../Sources/FTWDemo/SimulatedHistory.swift | 2 +- .../Sources/FTWKit/Protocol/HistoryPlan.swift | 24 ++++++++++++------ .../FTWKit/State/LoadpointsModel.swift | 25 ++++++++++--------- .../Tests/FTWKitTests/PlanPushTests.swift | 23 +++++++++-------- 6 files changed, 49 insertions(+), 38 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 88fcb98..cd73e55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,12 +88,12 @@ 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. The box pushes its plan, with - each car's windows, cable and shortfall, whenever it replans, and Now, + each charger's windows, cable and shortfall, whenever it replans, and Now, the plan, the car view and the watch redraw from that push. The chargers are read when someone starts looking and the last read is over a minute old, after a command, when the stream says the car started or stopped drawing, and when FTW Cloud or a pushed plan says a cable went in or out. - A box whose plan comes without cars pushes none: its chargers are also + A box whose plan comes without chargers pushes none: its chargers are also read each quarter while a car is plugged in and every 15 s while its plan is being made or for half a minute after a cable moved, and its shortfall when the car view opens and when the car's plan changes. diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index bdde180..cbfe1bf 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -49,7 +49,7 @@ public final class SimulatedBox { /// `time_of_day_min` on the household clock. Off, it is a Core that /// knows only `time_of_day_min_utc`. public var readsHouseholdClock = true - /// A Core with srcfl/ftw#1643 puts each car's share on the plan and + /// A Core with srcfl/ftw#1643 puts each charger's share on the plan and /// pushes the plan when it replans. Off, it is a Core that does neither. public var pushesPlan = true /// How long the box takes to replan after a cable, a goal, a level or @@ -869,10 +869,11 @@ extension SimulatedBox { replan() } - /// The car's share of the plan, as Core puts it on the plan it sends. - func planCar(nowMs: Double) -> PlanCar { + /// The charger's share of the plan, as Core puts it on the plan it + /// sends. A box that keeps no cars names none. + func planCharger(nowMs: Double) -> PlanCharger { let quiet = replanning != nil - var car = PlanCar(id: "garage", pluggedIn: house.carPluggedIn, pending: quiet, outdated: quiet, windows: [], totalWh: 0) + var car = PlanCharger(id: "garage", pluggedIn: house.carPluggedIn, pending: quiet, outdated: quiet, windows: [], totalWh: 0) if !quiet, let p = house.carPlan(nowMs), p.endMs > p.startMs, p.endMs > nowMs { car.windows = [Loadpoint.Window(fromMs: p.startMs.rounded(), toMs: p.endMs.rounded(), peakW: nil, energyWh: p.wh)] car.totalWh = p.wh diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift index 2e52bfb..11595bb 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift @@ -224,7 +224,7 @@ extension SimulatedBox { slots: steps.map { PlanSlot(startMs: $0.startMs.rounded(), durationMs: $0.durationMs.rounded(), batteryW: $0.batteryW, gridW: $0.gridW, priceMinor: $0.priceMinor, reason: $0.reason, socPermille: ($0.soc * 1000).rounded()) }, stale: false, ceilingW: nil, - cars: pushesPlan ? [planCar(nowMs: scheduler.nowMs)] : nil + chargers: pushesPlan ? [planCharger(nowMs: scheduler.nowMs)] : nil ) madePlan = (quarter, house.planRev, plan) return plan diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift index 8aa66dc..eca8905 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift @@ -166,15 +166,15 @@ public struct Plan: Equatable, Sendable, Codable { public var ceilingW: Double? /// Each charger's share of the plan. Nil from a box that sends none, /// which also pushes no plan when it replans. - public var cars: [PlanCar]? + public var chargers: [PlanCharger]? - public init(rev: UInt64, uptimeMs: Double, slots: [PlanSlot], stale: Bool, ceilingW: Double?, cars: [PlanCar]? = nil) { + public init(rev: UInt64, uptimeMs: Double, slots: [PlanSlot], stale: Bool, ceilingW: Double?, chargers: [PlanCharger]? = nil) { self.rev = rev self.uptimeMs = uptimeMs self.slots = slots self.stale = stale self.ceilingW = ceilingW - self.cars = cars + self.chargers = chargers } init(_ c: CBOR) { @@ -194,7 +194,7 @@ public struct Plan: Equatable, Sendable, Codable { } stale = c["stale"]?.bool ?? false ceilingW = c["ceilingW"]?.double - cars = c["cars"]?.array?.compactMap { PlanCar($0) } + chargers = c["chargers"]?.array?.compactMap { PlanCharger($0) } } var cbor: CBOR { @@ -212,15 +212,20 @@ public struct Plan: Equatable, Sendable, Codable { ("stale", .bool(stale)), ] pairs.append(("ceilingW", ceilingW.map { .ms($0) } ?? .null)) - if let cars { pairs.append(("cars", .array(cars.map(\.cbor)))) } + if let chargers { pairs.append(("chargers", .array(chargers.map(\.cbor)))) } return .map(pairs) } } /// One charger's share of the plan, as `GET /api/loadpoints` gives it, so -/// a plan the box pushes redraws the car without a read. -public struct PlanCar: Equatable, Sendable, Codable { +/// a plan the box pushes redraws the charger without a read. Keyed by the +/// charger: a car visits chargers. +public struct PlanCharger: Equatable, Sendable, Codable { + /// The charger's loadpoint id. public var id: String + /// The session's car, nil while the box does not know which, and from + /// a box that keeps no cars. + public var carId: String? public var pluggedIn: Bool /// A newer plan is being made, or the plan is not one the box acts on. /// The box sends no windows then. @@ -232,8 +237,9 @@ public struct PlanCar: Equatable, Sendable, Codable { /// watt-hours. Nil when the plan has no goal for it. public var shortfallWh: Double? - public init(id: String, pluggedIn: Bool, pending: Bool = false, outdated: Bool = false, windows: [Loadpoint.Window], totalWh: Double, shortfallWh: Double? = nil) { + public init(id: String, carId: String? = nil, pluggedIn: Bool, pending: Bool = false, outdated: Bool = false, windows: [Loadpoint.Window], totalWh: Double, shortfallWh: Double? = nil) { self.id = id + self.carId = carId self.pluggedIn = pluggedIn self.pending = pending self.outdated = outdated @@ -245,6 +251,7 @@ public struct PlanCar: Equatable, Sendable, Codable { init?(_ c: CBOR) { guard let id = c["id"]?.string else { return nil } self.id = id + carId = c["carId"]?.string.flatMap { $0.isEmpty ? nil : $0 } pluggedIn = c["pluggedIn"]?.bool ?? false pending = c["pending"]?.bool ?? false outdated = c["outdated"]?.bool ?? false @@ -262,6 +269,7 @@ public struct PlanCar: Equatable, Sendable, Codable { ("windows", .array(windows.map { .map([("startMs", .ms($0.fromMs)), ("endMs", .ms($0.toMs)), ("wh", .ms($0.energyWh ?? 0))]) })), ("totalWh", .ms(totalWh)), ] + if let carId { pairs.append(("carId", .text(carId))) } if let shortfallWh { pairs.append(("shortfallWh", .ms(shortfallWh))) } return .map(pairs) } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index baaa43e..d7d1026 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -30,7 +30,7 @@ public protocol ChargeNowHost: AnyObject { /// stopped drawing power, when FTW Cloud or a pushed plan says a cable went /// in or out, and when the stream comes back. Each car's plan comes with /// the plan the box pushes when it replans. A box whose plan comes without -/// cars pushes none: it is read each quarter while a car is plugged in, and +/// chargers pushes none: it is read each quarter while a car is plugged in, and /// every 15 s while it says its plan is being made or a cable just moved. @Observable @MainActor @@ -102,9 +102,10 @@ public final class LoadpointsModel: Activatable { didSet { plugNewsAtMs = site.scheduler.nowMs } } @ObservationIgnored private var plugNewsAtMs: Double? - /// The plan whose cars were last taken, so a pushed plan is taken once. + /// The plan whose chargers were last taken, so a pushed plan is taken + /// once. @ObservationIgnored private var planTaken: Plan? - /// Bumped each time a plan's cars are taken. A read on the wire while + /// Bumped each time a plan's chargers are taken. A read on the wire while /// one arrived does not put its older plan back. @ObservationIgnored private var planTakes = 0 /// How long a read of the chargers stands when someone starts looking @@ -189,13 +190,13 @@ public final class LoadpointsModel: Activatable { /// at once, without a read. A cable that moved, or a car this phone /// has not read, is read once for the rest of the car. func takePlan() { - guard let plan = site.session.plan, plan != planTaken, let cars = plan.cars else { return } + guard let plan = site.session.plan, plan != planTaken, let chargers = plan.chargers else { return } planTaken = plan planTakes += 1 var nextWindows = windows var nextShortfall = shortfallWh var lps = points - for car in cars { + for car in chargers { let quiet = car.pending || car.outdated nextWindows[car.id] = quiet ? [] : car.windows nextShortfall[car.id] = quiet ? nil : car.shortfallWh @@ -215,7 +216,7 @@ public final class LoadpointsModel: Activatable { } /// A car's plan as the box pushed it. - static func plan(_ lp: inout Loadpoint, from car: PlanCar) { + static func plan(_ lp: inout Loadpoint, from car: PlanCharger) { let quiet = car.pending || car.outdated lp.planPending = car.pending lp.planOutdated = car.outdated @@ -225,9 +226,9 @@ public final class LoadpointsModel: Activatable { lp.planEndMs = lp.planWindows.first?.toMs } - /// Whether this box pushes its plan, cars and all, each time it - /// replans: a plan with cars has come. - var boxPushesPlan: Bool { site.session.plan?.cars != nil } + /// Whether this box pushes its plan, chargers and all, each time it + /// replans: a plan with chargers has come. + var boxPushesPlan: Bool { site.session.plan?.chargers != nil } /// A charger as the cache kept it: enough for the level, its source and /// age, the cable and the power. The goal and the plan wait for the box. @@ -257,13 +258,13 @@ public final class LoadpointsModel: Activatable { } /// For a box known to push no plan, because its plan came without - /// cars: the quarter while a car is plugged in, since the box replans + /// chargers: the quarter while a car is plugged in, since the box replans /// each quarter, and every 15 s while it says the plan is being made or /// for half a minute after a cable moved, which is how long the box /// takes to notice the cable and replan. Nothing until a plan has come, /// so a box that pushes never pays for the fallback. var unpushedPlan: String { - guard let plan = site.session.plan, plan.cars == nil else { return "" } + guard let plan = site.session.plan, plan.chargers == nil else { return "" } let nowMs = site.nowMs let settling = plugNewsAtMs.map { nowMs - $0 < Self.plugSettleMs } ?? false if settling || points.contains(where: \.planPending) { return " pending \(Int(nowMs / Self.pendingAskMs))" } @@ -364,7 +365,7 @@ public final class LoadpointsModel: Activatable { let pushed = planTakes != takes let read = list.map { json -> Loadpoint in var lp = Loadpoint(json) - if pushed, let car = planTaken?.cars?.first(where: { $0.id == lp.id }) { Self.plan(&lp, from: car) } + if pushed, let car = planTaken?.chargers?.first(where: { $0.id == lp.id }) { Self.plan(&lp, from: car) } return lp } if let taken = Stamped.taking(Stamped(read, asOfMs: now, origin: .relay), over: stamp) { stamp = taken } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/PlanPushTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/PlanPushTests.swift index 5e95cbc..5f5c472 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/PlanPushTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/PlanPushTests.swift @@ -13,19 +13,20 @@ import FTWDemo func reads(_ box: SimulatedBox) -> Int { box.receivedRequests.filter { $0 == "GET /api/loadpoints" }.count } - /// The bytes srcfl/ftw pins in TestThePlanWithCarsVector. - @Test func thePlanWithCarsDecodesFromTheBoxsBytes() throws { - let hex = "a66372657607646361727382a76269646565617365656770656e64696e67f467746f74616c5768191ce86777696e646f777381a3627768191ce865656e644d731b000001a1325825806773746172744d731b000001a131ea4880686f75746461746564f469706c7567676564496ef56b73686f727466616c6c57681904b0a6626964666761726167656770656e64696e67f567746f74616c5768006777696e646f777380686f75746461746564f569706c7567676564496ef465736c6f747381a765677269645719012c66726561736f6e70657870656e736976655f696d706f72746773746172744d731b000001a131ea48806862617474657279573904af6a6475726174696f6e4d731a000dbba06a70726963654d696e6f7218576b736f635065726d696c6c65190280657374616c65f4686365696c696e6757f668757074696d654d731a0036ee80" + /// The bytes srcfl/ftw pins in TestThePlanWithChargersVector. + @Test func thePlanWithChargersDecodesFromTheBoxsBytes() throws { + let hex = "a6637265760765736c6f747381a765677269645719012c66726561736f6e70657870656e736976655f696d706f72746773746172744d731b000001a131ea48806862617474657279573904af6a6475726174696f6e4d731a000dbba06a70726963654d696e6f7218576b736f635065726d696c6c65190280657374616c65f4686365696c696e6757f668636861726765727382a76269646565617365656770656e64696e67f467746f74616c5768191ce86777696e646f777381a3627768191ce865656e644d731b000001a1325825806773746172744d731b000001a131ea4880686f75746461746564f469706c7567676564496ef56b73686f727466616c6c57681904b0a762696466676172616765656361724964782030313233343536373839616263646566303132333435363738396162636465666770656e64696e67f567746f74616c5768006777696e646f777380686f75746461746564f569706c7567676564496ef468757074696d654d731a0036ee80" let plan = Plan(try decodeCBOR(Bytes(hex: hex))) #expect(plan.rev == 7) #expect(plan.slots.first?.socPermille == 640) - let cars = try #require(plan.cars) - #expect(cars == [ - PlanCar(id: "easee", pluggedIn: true, windows: [Loadpoint.Window(fromMs: 1_791_838_800_000, toMs: 1_791_846_000_000, peakW: nil, energyWh: 7_400)], totalWh: 7_400, shortfallWh: 1_200), - PlanCar(id: "garage", pluggedIn: false, pending: true, outdated: true, windows: [], totalWh: 0), + let chargers = try #require(plan.chargers) + #expect(chargers == [ + PlanCharger(id: "easee", pluggedIn: true, windows: [Loadpoint.Window(fromMs: 1_791_838_800_000, toMs: 1_791_846_000_000, peakW: nil, energyWh: 7_400)], totalWh: 7_400, shortfallWh: 1_200), + PlanCharger(id: "garage", carId: "0123456789abcdef0123456789abcdef", pluggedIn: false, pending: true, outdated: true, windows: [], totalWh: 0), ]) - // A plan from a box without cars says so with nil, not an empty list. - #expect(Plan(Plan(rev: 1, uptimeMs: 0, slots: [], stale: false, ceilingW: nil).wire).cars == nil) + // A plan from a box without chargers says so with nil, not an empty + // list. + #expect(Plan(Plan(rev: 1, uptimeMs: 0, slots: [], stale: false, ceilingW: nil).wire).chargers == nil) #expect(Plan(plan.wire) == plan) } @@ -128,12 +129,12 @@ import FTWDemo let box = try #require(rig.box) box.pushesPlan = false box.replanMs = 40_000 - // Now asks for the plan, which comes without cars: an older box. + // Now asks for the plan, which comes without chargers: an older box. home.nodes.plan.activate() home.loadpoints.activate() await rig.run(2_000) #expect(home.site.session.plan != nil) - #expect(home.site.session.plan?.cars == nil) + #expect(home.site.session.plan?.chargers == nil) box.house.schedule = (0.95, 7 * 60, true, 0b0011111, 0) box.replan() From 24af4aaf1f022935ff0bd15a72006329f7250e3b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 22:52:46 +0200 Subject: [PATCH 3/3] iPhone: the push is enough for the car view; a stale read keeps the cable Review of app#73 (S1, N13) and the paired Core changes in srcfl/ftw#1643: - Each charger on the plan now carries gridDeferred and goalRev. The car view takes gridDeferred from the push; a goal revision that moves (a goal changed on the box's page or another phone) reads the car once. - While a new plan is made the push says pending and keeps the windows the box goes on acting on, so Now's strip does not blink. - A read that was on the wire while a newer push came keeps the push's cable as well as its plan. - An older box is asked every 15 s for a minute after a cable moved: it notices a cable within 10 s, or 40 s after its own replan. - The demo box says pending when its replan starts, as Core now does, and can hold an answer back (answerDelayMs) to test a stale read. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 36 +++++++-- .../Sources/FTWDemo/SimulatedHistory.swift | 28 ++++--- .../Sources/FTWKit/Protocol/HistoryPlan.swift | 19 ++++- .../FTWKit/State/LoadpointsModel.swift | 42 +++++++---- .../Tests/FTWKitTests/PlanPushTests.swift | 74 ++++++++++++++++--- 6 files changed, 156 insertions(+), 48 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cd73e55..40e5965 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,10 +92,11 @@ native, and smooth enough to impress. the plan, the car view and the watch redraw from that push. The chargers are read when someone starts looking and the last read is over a minute old, after a command, when the stream says the car started or stopped - drawing, and when FTW Cloud or a pushed plan says a cable went in or out. + drawing, when FTW Cloud or a pushed plan says a cable went in or out, and + when a pushed plan says a goal changed elsewhere. A box whose plan comes without chargers pushes none: its chargers are also read each quarter while a car is plugged in and every 15 s while its plan - is being made or for half a minute after a cable moved, and its shortfall + is being made or for a minute after a cable moved, and its shortfall 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 diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index cbfe1bf..3530879 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -57,6 +57,9 @@ public final class SimulatedBox { /// plan is pending meanwhile. A hold replans nothing, as in Core. public var replanMs: Double = 300 private var replanning: Cancellable? + /// How long an API answer waits to leave after the box made it: a slow + /// box, whose answer can land after a newer push. + public var answerDelayMs: Double = 0 /// The last schedule body the app sent, as sent. public private(set) var lastScheduleBody: JSON? /// The box the demo is showing, for the demo's own controls (plug the @@ -492,7 +495,11 @@ public final class SimulatedBox { break } let (status, json) = answer(method, route.pattern, route.id, query, requestBody) - respond(id, status: status, json: json) + guard answerDelayMs > 0 else { + respond(id, status: status, json: json) + return + } + scheduler.after(answerDelayMs) { [weak self] in self?.respond(id, status: status, json: json) } } /// Core's answer to "Are we in control?" for the demo's battery and @@ -872,27 +879,40 @@ extension SimulatedBox { /// The charger's share of the plan, as Core puts it on the plan it /// sends. A box that keeps no cars names none. func planCharger(nowMs: Double) -> PlanCharger { - let quiet = replanning != nil - var car = PlanCharger(id: "garage", pluggedIn: house.carPluggedIn, pending: quiet, outdated: quiet, windows: [], totalWh: 0) - if !quiet, let p = house.carPlan(nowMs), p.endMs > p.startMs, p.endMs > nowMs { + let pending = replanning != nil + var car = PlanCharger(id: "garage", pluggedIn: house.carPluggedIn, pending: pending, outdated: pending, goalRev: goalRev, windows: [], totalWh: 0) + if let p = house.carPlan(nowMs), p.endMs > p.startMs, p.endMs > nowMs { car.windows = [Loadpoint.Window(fromMs: p.startMs.rounded(), toMs: p.endMs.rounded(), peakW: nil, energyWh: p.wh)] car.totalWh = p.wh - car.shortfallWh = p.shortfallWh + car.shortfallWh = pending ? nil : p.shortfallWh } return car } + /// Moves when the goal or solar-only choice does, as Core's does. + var goalRev: UInt32 { + let s = house.schedule + let text = "\(s.map { "\($0.socFraction) \($0.clockMin) \($0.recurring) \($0.days)" } ?? "-") \(house.surplusOnly)" + return text.utf8.reduce(2_166_136_261) { ($0 ^ UInt32($1)) &* 16_777_619 } + } + /// Something the plan depends on changed: the box replans, saying so - /// meanwhile, and a box that pushes sends the new plan to the stream. + /// meanwhile. A box that pushes sends the plan when the replan starts, + /// pending, and again when the new one is made. public func replan() { replanning?.cancel() replanning = scheduler.after(replanMs) { [weak self] in guard let self else { return } self.replanning = nil self.house.planRev += 1 - guard self.pushesPlan, self.subscription != nil else { return } - self.sendBulk(Envelope(t: "plan", b: self.sessionPlan().wire)) + self.pushPlan() } + pushPlan() + } + + private func pushPlan() { + guard pushesPlan, subscription != nil else { return } + sendBulk(Envelope(t: "plan", b: sessionPlan().wire)) } } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift index 11595bb..cd47789 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift @@ -216,17 +216,23 @@ extension SimulatedBox { /// or the preferences change, and the same plan until then. func sessionPlan() -> Plan { let quarter = (scheduler.nowMs / 900_000).rounded(.down) - if let made = madePlan, made.quarter == quarter, made.rev == house.planRev { return made.plan } - let steps = house.plan(nowMs: scheduler.nowMs) - let plan = Plan( - rev: house.planRev, - uptimeMs: uptimeMs, - slots: steps.map { PlanSlot(startMs: $0.startMs.rounded(), durationMs: $0.durationMs.rounded(), batteryW: $0.batteryW, gridW: $0.gridW, priceMinor: $0.priceMinor, reason: $0.reason, socPermille: ($0.soc * 1000).rounded()) }, - stale: false, - ceilingW: nil, - chargers: pushesPlan ? [planCharger(nowMs: scheduler.nowMs)] : nil - ) - madePlan = (quarter, house.planRev, plan) + var plan: Plan + if let made = madePlan, made.quarter == quarter, made.rev == house.planRev { + plan = made.plan + } else { + let steps = house.plan(nowMs: scheduler.nowMs) + plan = Plan( + rev: house.planRev, + uptimeMs: uptimeMs, + slots: steps.map { PlanSlot(startMs: $0.startMs.rounded(), durationMs: $0.durationMs.rounded(), batteryW: $0.batteryW, gridW: $0.gridW, priceMinor: $0.priceMinor, reason: $0.reason, socPermille: ($0.soc * 1000).rounded()) }, + stale: false, + ceilingW: nil + ) + madePlan = (quarter, house.planRev, plan) + } + // The charger's share as it stands, pending or not, as Core reads it + // each time it sends. + plan.chargers = pushesPlan ? [planCharger(nowMs: scheduler.nowMs)] : nil return plan } diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift index eca8905..da599a1 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift @@ -227,22 +227,30 @@ public struct PlanCharger: Equatable, Sendable, Codable { /// a box that keeps no cars. public var carId: String? public var pluggedIn: Bool - /// A newer plan is being made, or the plan is not one the box acts on. - /// The box sends no windows then. + /// A newer plan is being made: the windows are the plan the box goes on + /// acting on until the new one lands. Outdated comes with it, and alone + /// when the plan is not one the box acts on; then there are no windows. public var pending: Bool public var outdated: Bool + /// Grid-funded charging waits for prices that reach the deadline. + public var gridDeferred: Bool + /// Moves when the charger's goal, ready time, schedule or solar-only + /// choice changes, wherever that was done. Nil from a box without it. + public var goalRev: UInt32? public var windows: [Loadpoint.Window] public var totalWh: Double /// How far short of the goal the plan leaves the car, in battery /// watt-hours. Nil when the plan has no goal for it. public var shortfallWh: Double? - public init(id: String, carId: String? = nil, pluggedIn: Bool, pending: Bool = false, outdated: Bool = false, windows: [Loadpoint.Window], totalWh: Double, shortfallWh: Double? = nil) { + public init(id: String, carId: String? = nil, pluggedIn: Bool, pending: Bool = false, outdated: Bool = false, gridDeferred: Bool = false, goalRev: UInt32? = nil, windows: [Loadpoint.Window], totalWh: Double, shortfallWh: Double? = nil) { self.id = id self.carId = carId self.pluggedIn = pluggedIn self.pending = pending self.outdated = outdated + self.gridDeferred = gridDeferred + self.goalRev = goalRev self.windows = windows self.totalWh = totalWh self.shortfallWh = shortfallWh @@ -255,6 +263,8 @@ public struct PlanCharger: Equatable, Sendable, Codable { pluggedIn = c["pluggedIn"]?.bool ?? false pending = c["pending"]?.bool ?? false outdated = c["outdated"]?.bool ?? false + gridDeferred = c["gridDeferred"]?.bool ?? false + goalRev = c["goalRev"]?.int64.flatMap { UInt32(exactly: $0) } windows = (c["windows"]?.array ?? []).compactMap { w in guard let s = w["startMs"]?.double, let e = w["endMs"]?.double, e > s, let wh = w["wh"]?.double, wh >= 0 else { return nil } return Loadpoint.Window(fromMs: s, toMs: e, peakW: nil, energyWh: wh) @@ -265,11 +275,12 @@ public struct PlanCharger: Equatable, Sendable, Codable { var cbor: CBOR { var pairs: [(String, CBOR)] = [ - ("id", .text(id)), ("pluggedIn", .bool(pluggedIn)), ("pending", .bool(pending)), ("outdated", .bool(outdated)), + ("id", .text(id)), ("pluggedIn", .bool(pluggedIn)), ("pending", .bool(pending)), ("outdated", .bool(outdated)), ("gridDeferred", .bool(gridDeferred)), ("windows", .array(windows.map { .map([("startMs", .ms($0.fromMs)), ("endMs", .ms($0.toMs)), ("wh", .ms($0.energyWh ?? 0))]) })), ("totalWh", .ms(totalWh)), ] if let carId { pairs.append(("carId", .text(carId))) } + if let goalRev { pairs.append(("goalRev", .unsigned(UInt64(goalRev)))) } if let shortfallWh { pairs.append(("shortfallWh", .ms(shortfallWh))) } return .map(pairs) } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index d7d1026..baa3326 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -28,7 +28,8 @@ public protocol ChargeNowHost: AnyObject { /// No timer asks the box. The chargers are read when a screen that shows /// them appears, after a command, when the stream says the car started or /// stopped drawing power, when FTW Cloud or a pushed plan says a cable went -/// in or out, and when the stream comes back. Each car's plan comes with +/// in or out, when a pushed plan says the goal changed elsewhere, and when +/// the stream comes back. Each car's plan comes with /// the plan the box pushes when it replans. A box whose plan comes without /// chargers pushes none: it is read each quarter while a car is plugged in, and /// every 15 s while it says its plan is being made or a cable just moved. @@ -102,6 +103,11 @@ public final class LoadpointsModel: Activatable { didSet { plugNewsAtMs = site.scheduler.nowMs } } @ObservationIgnored private var plugNewsAtMs: Double? + /// Bumped when a pushed plan says a charger's goal changed, wherever + /// that was done: the schedule and target are read once. + @ObservationIgnored private var goalNews = 0 + /// Each charger's goal revision as the last pushed plan gave it. + @ObservationIgnored private var goalRevs: [String: UInt32] = [:] /// The plan whose chargers were last taken, so a pushed plan is taken /// once. @ObservationIgnored private var planTaken: Plan? @@ -197,9 +203,13 @@ public final class LoadpointsModel: Activatable { var nextShortfall = shortfallWh var lps = points for car in chargers { - let quiet = car.pending || car.outdated - nextWindows[car.id] = quiet ? [] : car.windows - nextShortfall[car.id] = quiet ? nil : car.shortfallWh + // While the next plan is made, the box goes on with this one. + nextWindows[car.id] = car.outdated && !car.pending ? [] : car.windows + nextShortfall[car.id] = car.pending || car.outdated ? nil : car.shortfallWh + if let rev = car.goalRev { + if let seen = goalRevs[car.id], seen != rev { goalNews += 1 } + goalRevs[car.id] = rev + } guard let i = lps.firstIndex(where: { $0.id == car.id }) else { plugNews += 1 continue @@ -215,15 +225,16 @@ public final class LoadpointsModel: Activatable { ask?.evaluate() } - /// A car's plan as the box pushed it. + /// A charger's plan as the box pushed it. static func plan(_ lp: inout Loadpoint, from car: PlanCharger) { - let quiet = car.pending || car.outdated + let gone = car.outdated && !car.pending lp.planPending = car.pending lp.planOutdated = car.outdated - lp.planWindows = quiet ? [] : car.windows - lp.planTotalWh = quiet ? nil : car.totalWh + lp.planWindows = gone ? [] : car.windows + lp.planTotalWh = gone ? nil : car.totalWh lp.planStartMs = lp.planWindows.first?.fromMs lp.planEndMs = lp.planWindows.first?.toMs + lp.gridDeferred = car.gridDeferred } /// Whether this box pushes its plan, chargers and all, each time it @@ -251,7 +262,7 @@ public final class LoadpointsModel: Activatable { if ask == nil { 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.rereads) \(self.drawing) \(self.plugNews)\(self.unpushedPlan)" + return "loadpoints \(self.rereads) \(self.drawing) \(self.plugNews) \(self.goalNews)\(self.unpushedPlan)" }, ask: { [weak self] in try await self?.load() }) } ask?.evaluate() @@ -260,8 +271,8 @@ public final class LoadpointsModel: Activatable { /// For a box known to push no plan, because its plan came without /// chargers: the quarter while a car is plugged in, since the box replans /// each quarter, and every 15 s while it says the plan is being made or - /// for half a minute after a cable moved, which is how long the box - /// takes to notice the cable and replan. Nothing until a plan has come, + /// for a minute after a cable moved: such a box notices a cable within + /// 10 s, or 40 s after a replan of its own, and then solves. Nothing until a plan has come, /// so a box that pushes never pays for the fallback. var unpushedPlan: String { guard let plan = site.session.plan, plan.chargers == nil else { return "" } @@ -273,7 +284,7 @@ public final class LoadpointsModel: Activatable { } nonisolated static let pendingAskMs: Double = 15_000 - nonisolated static let plugSettleMs: Double = 30_000 + nonisolated static let plugSettleMs: Double = 60_000 /// Whether the stream says a car is drawing power. A change is a reason /// to read the chargers; nothing else on lane 0 is. @@ -361,11 +372,14 @@ public final class LoadpointsModel: Activatable { let before = Dictionary(points.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) let now = site.scheduler.nowMs // A plan pushed while this read was on the wire is at least as - // new as the read's: its share of each car stays. + // new as the read's: its cable and its share of each charger stay. let pushed = planTakes != takes let read = list.map { json -> Loadpoint in var lp = Loadpoint(json) - if pushed, let car = planTaken?.chargers?.first(where: { $0.id == lp.id }) { Self.plan(&lp, from: car) } + if pushed, let car = planTaken?.chargers?.first(where: { $0.id == lp.id }) { + lp.pluggedIn = car.pluggedIn + Self.plan(&lp, from: car) + } return lp } if let taken = Stamped.taking(Stamped(read, asOfMs: now, origin: .relay), over: stamp) { stamp = taken } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/PlanPushTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/PlanPushTests.swift index 5f5c472..a2b7acd 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/PlanPushTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/PlanPushTests.swift @@ -15,14 +15,14 @@ import FTWDemo /// The bytes srcfl/ftw pins in TestThePlanWithChargersVector. @Test func thePlanWithChargersDecodesFromTheBoxsBytes() throws { - let hex = "a6637265760765736c6f747381a765677269645719012c66726561736f6e70657870656e736976655f696d706f72746773746172744d731b000001a131ea48806862617474657279573904af6a6475726174696f6e4d731a000dbba06a70726963654d696e6f7218576b736f635065726d696c6c65190280657374616c65f4686365696c696e6757f668636861726765727382a76269646565617365656770656e64696e67f467746f74616c5768191ce86777696e646f777381a3627768191ce865656e644d731b000001a1325825806773746172744d731b000001a131ea4880686f75746461746564f469706c7567676564496ef56b73686f727466616c6c57681904b0a762696466676172616765656361724964782030313233343536373839616263646566303132333435363738396162636465666770656e64696e67f567746f74616c5768006777696e646f777380686f75746461746564f569706c7567676564496ef468757074696d654d731a0036ee80" + let hex = "a6637265760765736c6f747381a765677269645719012c66726561736f6e70657870656e736976655f696d706f72746773746172744d731b000001a131ea48806862617474657279573904af6a6475726174696f6e4d731a000dbba06a70726963654d696e6f7218576b736f635065726d696c6c65190280657374616c65f4686365696c696e6757f668636861726765727382a962696465656173656567676f616c5265761acafebabe6770656e64696e67f467746f74616c5768191ce86777696e646f777381a3627768191ce865656e644d731b000001a1325825806773746172744d731b000001a131ea4880686f75746461746564f469706c7567676564496ef56b73686f727466616c6c57681904b06c677269644465666572726564f5a9626964666761726167656563617249647820303132333435363738396162636465663031323334353637383961626364656667676f616c526576076770656e64696e67f567746f74616c5768006777696e646f777380686f75746461746564f569706c7567676564496ef46c677269644465666572726564f468757074696d654d731a0036ee80" let plan = Plan(try decodeCBOR(Bytes(hex: hex))) #expect(plan.rev == 7) #expect(plan.slots.first?.socPermille == 640) let chargers = try #require(plan.chargers) #expect(chargers == [ - PlanCharger(id: "easee", pluggedIn: true, windows: [Loadpoint.Window(fromMs: 1_791_838_800_000, toMs: 1_791_846_000_000, peakW: nil, energyWh: 7_400)], totalWh: 7_400, shortfallWh: 1_200), - PlanCharger(id: "garage", carId: "0123456789abcdef0123456789abcdef", pluggedIn: false, pending: true, outdated: true, windows: [], totalWh: 0), + PlanCharger(id: "easee", pluggedIn: true, gridDeferred: true, goalRev: 3_405_691_582, windows: [Loadpoint.Window(fromMs: 1_791_838_800_000, toMs: 1_791_846_000_000, peakW: nil, energyWh: 7_400)], totalWh: 7_400, shortfallWh: 1_200), + PlanCharger(id: "garage", carId: "0123456789abcdef0123456789abcdef", pluggedIn: false, pending: true, outdated: true, goalRev: 7, windows: [], totalWh: 0), ]) // A plan from a box without chargers says so with nil, not an empty // list. @@ -40,10 +40,15 @@ import FTWDemo #expect(reads(box) == 1) let before = try #require(home.loadpoints.windows["garage"]?.first) - // The goal moves on the box: it replans and pushes the plan. - box.house.schedule = (0.95, 7 * 60, true, 0b0011111, 0) + // The car's level moves on the box, its goal stays: the box replans + // and pushes, pending at once and then the new plan. + box.house.carSocFraction = 0.05 box.replan() - await rig.run(1_000) + await rig.run(150) + #expect(home.loadpoints.points.first?.planPending == true) + // The box goes on with the plan it has while it makes the next. + #expect(home.loadpoints.windows["garage"]?.isEmpty == false) + await rig.run(850) let after = try #require(home.loadpoints.windows["garage"]?.first) #expect(after.toMs > before.toMs) #expect(reads(box) == 1) @@ -75,18 +80,69 @@ import FTWDemo #expect(home.loadpoints.points.first?.pluggedIn == false) box.setCarPluggedIn(true) - // The replan takes 300 ms and the loopback 120 ms: the push has - // landed, and the read it asks for has not reached the box yet. - await rig.run(450) + // The box says pending as the replan starts; the loopback takes + // 120 ms. The cable has landed, and the read it asks for has not + // reached the box yet. + await rig.run(150) #expect(reads(box) == 1) #expect(home.loadpoints.points.first?.pluggedIn == true) + #expect(home.loadpoints.points.first?.planPending == true) + await rig.run(1_500) + #expect(reads(box) == 2) + #expect(home.loadpoints.points.first?.planPending == false) #expect(home.loadpoints.windows["garage"]?.isEmpty == false) + await rig.run(60_000, step: 500) + #expect(reads(box) == 2) + } + + /// A goal changed elsewhere (the box's page, another phone) moves the + /// goal revision on the push: the car is read once for its new goal. + @Test func aGoalChangedElsewhereIsReadOnce() async throws { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + home.loadpoints.activate() + await rig.run(1_500) + // A first push gives the goal's revision. + box.replan() + await rig.run(1_000) + #expect(reads(box) == 1) + + box.house.schedule = (0.95, 6 * 60, true, 0b0011111, 0) + box.replan() await rig.run(1_500) #expect(reads(box) == 2) + #expect(home.loadpoints.points.first?.schedule?.socPct == 95) await rig.run(60_000, step: 500) #expect(reads(box) == 2) } + /// A read the box answered before the cable went in, landing after the + /// push that says it is in, leaves the cable in. + @Test func aReadOnTheWireKeepsTheCableANewerPushGave() async throws { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + box.setCarPluggedIn(false) + await rig.run(1_000) + home.loadpoints.activate() + await rig.run(1_500) + #expect(home.loadpoints.points.first?.pluggedIn == false) + + box.answerDelayMs = 2_000 + home.loadpoints.reread() + await rig.run(300) + #expect(reads(box) == 2) + box.setCarPluggedIn(true) + await rig.run(1_000) + #expect(home.loadpoints.points.first?.pluggedIn == true) + // The answer made before the cable went in lands now. + await rig.run(1_500) + #expect(home.loadpoints.points.first?.pluggedIn == true) + } + /// An older box pushes nothing. FTW Cloud's copy says the cable went in, /// and that is one read, which brings the plan. @Test func anOldBoxIsReadOnceWhenFTWCloudSaysTheCableWentIn() async throws {