diff --git a/CLAUDE.md b/CLAUDE.md index 4335b00..40e5965 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,12 +87,17 @@ 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 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, 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 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..3530879 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -49,6 +49,17 @@ 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 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 + /// 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? + /// 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 @@ -398,9 +409,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", [])) } @@ -482,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 @@ -607,9 +624,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 +661,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 +714,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 +873,46 @@ extension SimulatedBox { /// Plug the demo car in or out. public func setCarPluggedIn(_ on: Bool) { if on { house.plugIn() } else { house.unplug() } + replan() + } + + /// 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 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 = 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. 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 + 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 7bbdf34..cd47789 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift @@ -216,16 +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 - ) - 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/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..da599a1 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 chargers: [PlanCharger]? - public init(rev: UInt64, uptimeMs: Double, slots: [PlanSlot], stale: Bool, ceilingW: Double?) { + 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.chargers = chargers } init(_ c: CBOR) { @@ -190,6 +194,7 @@ public struct Plan: Equatable, Sendable, Codable { } stale = c["stale"]?.bool ?? false ceilingW = c["ceilingW"]?.double + chargers = c["chargers"]?.array?.compactMap { PlanCharger($0) } } var cbor: CBOR { @@ -207,6 +212,76 @@ public struct Plan: Equatable, Sendable, Codable { ("stale", .bool(stale)), ] pairs.append(("ceilingW", ceilingW.map { .ms($0) } ?? .null)) + 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 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: 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, 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 + } + + 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 + 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) + } + 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)), ("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 e7f7054..baa3326 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -27,8 +27,12 @@ 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, 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. @Observable @MainActor public final class LoadpointsModel: Activatable { @@ -92,6 +96,24 @@ 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? + /// 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? + /// 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 /// again. The stream says when a car starts or stops drawing; this /// covers a cable plugged in while nobody looked. @@ -126,6 +148,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 +173,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 +188,59 @@ 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 chargers = plan.chargers else { return } + planTaken = plan + planTakes += 1 + var nextWindows = windows + var nextShortfall = shortfallWh + var lps = points + for car in chargers { + // 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 + } + 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 charger's plan as the box pushed it. + static func plan(_ lp: inout Loadpoint, from car: PlanCharger) { + let gone = car.outdated && !car.pending + lp.planPending = car.pending + lp.planOutdated = car.outdated + 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 + /// 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. static func loadpoint(_ car: CarEntry.Car, readAtMs: Double) -> Loadpoint { @@ -187,12 +262,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.goalNews)\(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 + /// 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 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 "" } + 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 = 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. var drawing: String { @@ -205,13 +298,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 +362,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 +371,25 @@ 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 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 }) { + 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 } 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..a2b7acd --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/PlanPushTests.swift @@ -0,0 +1,213 @@ +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 TestThePlanWithChargersVector. + @Test func thePlanWithChargersDecodesFromTheBoxsBytes() throws { + 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, 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. + #expect(Plan(Plan(rev: 1, uptimeMs: 0, slots: [], stale: false, ceilingW: nil).wire).chargers == 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 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(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) + 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 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 { + 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 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?.chargers == 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) + } +}