From 53ca882645a69fb10379824771366cddc8838972 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:01:20 +0200 Subject: [PATCH 1/3] Follow car commands to their outcome and read the box's clock The car view's rules, in FTWKit so they are tested: - Where the level comes from: from the car, from the car but older than five minutes ("last reported HH:MM"), set from this phone, FTW's estimate, or FTW's assumption of 20 % at plug-in. - A receipt for every command: sent, received (cmd.ack), done (cmd.result), or why not. Session.command gains onAck for this. - The ready time on the household clock, worked out the way Core does, so 07:00 stays 07:00 across 25 October and a box that kept the UTC minute shows the 06:00 it really uses. Goals go out as both time_of_day_min and time_of_day_min_utc. - Whether the car makes its goal, from the plan's own shortfall. - "Is FTW in control?" from Core's control feedback, never green from readings that are not live. - English and Swedish words for all of it. LoadpointsModel drops boost, solar-only and battery size from the daily path. Charge now holds the charger's full current with no time limit and no level gate. The demo box plugs in and out, has a car that reports its level, a plan with a shortfall, and a charger that takes a moment. Co-Authored-By: Claude Opus 5.5 --- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 140 ++- .../Sources/FTWDemo/SimulatedHouse.swift | 98 +- .../FTWKit/Sources/FTWKit/Format/EV.swift | 982 +++++++++++++++--- .../Sources/FTWKit/Protocol/Session.swift | 10 +- .../FTWKit/Sources/FTWKit/State/BoxAPI.swift | 36 +- .../FTWKit/State/LoadpointsModel.swift | 395 ++++--- .../Sources/FTWKit/State/NowModels.swift | 82 ++ .../Tests/FTWKitTests/AppModelTests.swift | 29 - .../FTWKit/Tests/FTWKitTests/CarTests.swift | 494 +++++++++ .../Tests/FTWKitTests/FormatTests.swift | 45 +- 10 files changed, 1880 insertions(+), 431 deletions(-) create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 1b3ecb5..2aa7a1f 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -27,6 +27,12 @@ public final class SimulatedBox { public var mute = false /// Ack commands but never report a result. public var neverConfirm = false + /// How long the box takes from ack to result: the demo waits as a + /// charger write does, so the receipt has a moment to show. + public var resultDelayMs: Double = 0 + /// The box the demo is showing, for the demo's own controls (plug the + /// car in or out). Nil outside the demo. + public private(set) static weak var current: SimulatedBox? public private(set) var receivedCommands: [String] = [] public private(set) var stepUpsSeen = 0 @@ -242,7 +248,14 @@ public final class SimulatedBox { } sendControl(Envelope(t: "cmd.ack", b: .map([("cmdId", .text(cmdId)), ("leaseId", .text("lease-\(cmdId.prefix(8))")), ("expiresAtMs", .number(uptimeMs + 60_000))]))) if neverConfirm { return } + if resultDelayMs > 0 { + scheduler.after(resultDelayMs) { [weak self] in self?.act(cmdId, op, args) } + } else { + act(cmdId, op, args) + } + } + private func act(_ cmdId: String, _ op: String, _ args: CBOR) { switch op { case Contract.opSetMode: guard let mode = args["mode"]?.string, Self.modes.contains(where: { $0.key == mode }) else { @@ -278,6 +291,7 @@ public final class SimulatedBox { } house.carSocFraction = max(0, min(1, args["soc"]?.double ?? house.carSocFraction)) house.carSocSource = "inferred" + house.carReportedAtMs = house.carReportedAtMs.map { min($0, scheduler.nowMs - 1) } result(cmdId, "applied", observed: house.carSocFraction) case Contract.opLoadpointSurplusOnlySet: house.surplusOnly = args["surplus_only"]?.bool ?? false @@ -402,14 +416,21 @@ public final class SimulatedBox { case ("GET", "/api/loadpoints"): return (200, ["enabled": true, "loadpoints": [loadpointJSON()]]) case ("GET", "/api/mpc/plan"): - return (200, ["plan": ["actions": []], "meta": ["replanning": false, "outdated": false]]) + var plan: JSON = ["actions": []] + if let p = house.carPlan(now) { plan = plan.setting("loadpoint_shortfall_wh", ["garage": .number(p.shortfallWh)]) } + return (200, ["plan": plan, "meta": ["replanning": false, "outdated": false]]) case ("POST", "/api/loadpoints/{id}/vehicle"): if let wh = body?["capacity_wh"]?.number, wh >= 1_000 { house.carCapacityWh = wh } return (200, ["ok": true]) case ("PUT", "/api/loadpoints/{id}/schedule"): + // The household clock when sent, else the UTC minute read on + // today's date: what Core does. + let today = Date(timeIntervalSince1970: now / 1000) + let clock = body?["time_of_day_min"]?.number.map { Int($0) } + ?? EVText.clockFromUTC(Int(body?["time_of_day_min_utc"]?.number ?? 300), at: today, zone: house.timeZone) house.schedule = ( body?["soc"]?.number ?? 0.8, - Int(body?["time_of_day_min_utc"]?.number ?? 300), + clock, body?["recurring"]?.bool ?? false, Int(body?["days"]?.number ?? 0), body?["surplus_unlock_bat_soc"]?.number ?? 0 @@ -425,6 +446,11 @@ public final class SimulatedBox { func loadpointJSON() -> JSON { let h = house + let now = scheduler.nowMs + // A car that reports counts as the car's word for five minutes, as + // in Core; after that its report is shown, dated, and FTW estimates. + var socSource = h.carSocSource + if let at = h.carReportedAtMs, socSource == "vehicle", now - at > 5 * 60_000 { socSource = "inferred" } var lp: JSON = [ "id": "garage", "driver_name": "easee", @@ -433,7 +459,7 @@ public final class SimulatedBox { "capacity_source": "user", "soc_retention": "session", "current_soc": .number(h.carSocFraction), - "soc_source": .string(h.carSocSource), + "soc_source": .string(h.carPluggedIn ? socSource : ""), "current_power_w": .number(h.evWatts), "delivered_wh_session": .number(h.sessionWh), "min_charge_w": 4_140, @@ -456,25 +482,50 @@ public final class SimulatedBox { "stop_reason": .string(h.lastBoostStop ?? ""), ], ] + if let at = h.carReportedAtMs, h.carPluggedIn { + let age = max(0, (now - at) / 1000).rounded() + lp = lp.setting("vehicle_driver", "demo_car") + lp = lp.setting("vehicle_soc", .number(h.carSocFraction)) + lp = lp.setting("vehicle_soc_age_s", .number(age)) + lp = lp.setting("vehicle_stale", .bool(age > 15 * 60)) + } if let manual = h.manualHoldW { + let since = h.manualSinceMs ?? now + let state: String + if manual == 0 { + state = "paused" + } else if h.evWatts > 100 { + state = "charging" + } else if !h.holdActing { + state = now - since < h.chargerRampMs / 2 ? "sent" : "accepted" + } else { + state = "not_drawing" + } lp = lp.setting("manual", [ "active": true, - "state": manual == 0 ? "paused" : (h.evWatts > 100 ? "charging" : "sent"), + "state": .string(state), "requested_w": .number(manual), "requested_a": .number((manual / 690).rounded()), + "started_at_ms": .number(since), ]) } if let s = h.schedule { lp = lp.setting("schedule", [ - "soc": .number(s.socFraction), "time_of_day_min_utc": .number(Double(s.minuteUTC)), + "soc": .number(s.socFraction), + // Today's UTC minute of the household clock, as Core answers. + "time_of_day_min_utc": .number(Double(EVText.minuteUTC(clock: s.clockMin, at: Date(timeIntervalSince1970: now / 1000), zone: h.timeZone))), "recurring": .bool(s.recurring), "days": .number(Double(s.days)), "surplus_unlock_bat_soc": .number(s.surplusUnlock), ]) - // The next cheap night, as one window. - let midnight = scheduler.nowMs - h.hourOfDay(scheduler.nowMs) * 3_600_000 - let start = midnight + (h.hourOfDay(scheduler.nowMs) >= 5 ? 25 : 1) * 3_600_000 - let needWh = max(0, (s.socFraction - h.carSocFraction) * h.carCapacityWh) - lp = lp.setting("plan_windows", needWh > 0 ? [["start_ms": .number(start), "end_ms": .number(start + needWh / 11_000 * 3_600_000), "wh": .number(needWh.rounded())]] : []) lp = lp.setting("target_soc", .number(s.socFraction)) + if let deadline = h.deadlineMs(now) { + 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 { + 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())) + lp = lp.setting("plan_total_wh", .number(p.wh)) } else { lp = lp.setting("plan_windows", []) } @@ -572,8 +623,73 @@ public final class LoopbackCarrier: Carrier { extension SimulatedBox { /// The demo as `AppModel` takes it: a fresh simulated home behind a - /// loopback carrier. + /// loopback carrier, a car just plugged in, and a box and charger that + /// take a moment to answer, as real ones do. public static func loopback(_ scheduler: Scheduler) -> Carrier { - LoopbackCarrier(box: SimulatedBox(scheduler: scheduler), scheduler: scheduler) + let box = SimulatedBox(scheduler: scheduler) + box.resultDelayMs = 900 + box.house.chargerRampMs = 4_000 + #if DEBUG + // For screenshots: `-FTWDemoCar unplugged` and the like. + if let name = UserDefaults.standard.string(forKey: "FTWDemoCar"), let car = DemoCar(rawValue: name) { + car.apply(to: box.house, nowMs: scheduler.nowMs) + } + #endif + current = box + return LoopbackCarrier(box: box, scheduler: scheduler) + } + + /// Plug the demo car in or out. + public func setCarPluggedIn(_ on: Bool) { + if on { house.plugIn() } else { house.unplug() } + } +} + +/// The car situations the demo can open in, so each screen of the car view +/// can be shown without a box. +public enum DemoCar: String, CaseIterable, Sendable { + /// Plugged in, unread: FTW assumes 20 %. The default. + case assumed + case unplugged + /// A level someone gave FTW, charging since. + case estimated + /// A car that reports its own level, just now. + case car + /// A car that last reported 40 minutes ago. + case carOld + /// A goal the night is too short for. + case late + /// No goal yet. + case noGoal + /// Charge now running. + case charging + + @MainActor + public func apply(to h: SimulatedHouse, nowMs: Double) { + switch self { + case .assumed: break + case .unplugged: h.unplug() + case .estimated: + h.carSocFraction = 0.46 + h.carSocSource = "inferred" + case .car: + h.carReportedAtMs = nowMs - 40_000 + h.carSocFraction = 0.64 + h.carSocSource = "vehicle" + case .carOld: + h.carReportedAtMs = nowMs - 40 * 60_000 + h.carSocFraction = 0.58 + h.carSocSource = "vehicle" + case .late: + h.carSocFraction = 0.35 + h.carSocSource = "inferred" + h.schedule = (0.8, 3 * 60, true, 0, 0) + case .noGoal: + h.schedule = nil + case .charging: + h.carSocFraction = 0.46 + h.carSocSource = "inferred" + h.manualHoldW = 11_000 + } } } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift index fa576c4..8f62cc7 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift @@ -22,19 +22,88 @@ public final class SimulatedHouse { public var modeKey = "planner_passive_arbitrage" - // The car. + // The car. It arrives plugged in and unread, so the box assumes 20 %, + // which is the moment the car view is for. public var carPluggedIn = true - public var carSocFraction: Double = 0.41 - public var carSocSource = "inferred" + public var carSocFraction: Double = 0.2 + public var carSocSource = "assumed" public var carCapacityWh: Double = 64_000 + /// A car that reports its own level: when it last did. Nil for a car + /// FTW cannot read. + public var carReportedAtMs: Double? public private(set) var evW: Double = 0 - public var manualHoldW: Double? + public var manualHoldW: Double? { + didSet { if manualHoldW != oldValue { manualSinceMs = manualHoldW == nil ? nil : lastStepMs } } + } + /// When the current hold began. The demo charger takes a few seconds to + /// act on it, as a real one does. + public private(set) var manualSinceMs: Double? + public var chargerRampMs: Double = 0 public var surplusOnly = false public var boostUntilMs: Double? public var boostReserve: Double = 0.3 public var lastBoostStop: String? - public var schedule: (socFraction: Double, minuteUTC: Int, recurring: Bool, days: Int, surplusUnlock: Double)? = (0.8, 5 * 60, true, 0b0011111, 0) - public private(set) var sessionWh: Double = 5_300 + /// The goal on the household clock, as Core keeps it. + public var schedule: (socFraction: Double, clockMin: Int, recurring: Bool, days: Int, surplusUnlock: Double)? = (0.8, 7 * 60, true, 0b0011111, 0) + public private(set) var sessionWh: Double = 0 + private var lastStepMs: Double = 0 + + /// The cable goes in: a new session, nothing known about the car. + public func plugIn() { + carPluggedIn = true + carSocFraction = 0.2 + carSocSource = carReportedAtMs == nil ? "assumed" : "vehicle" + sessionWh = 0 + manualHoldW = nil + } + + /// The cable comes out. An unplug releases a hold, as in Core. + public func unplug() { + carPluggedIn = false + manualHoldW = nil + boostUntilMs = nil + evW = 0 + } + + /// Whether the charger has acted on the hold yet. + public var holdActing: Bool { + guard let since = manualSinceMs else { return false } + return lastStepMs - since >= chargerRampMs + } + + /// The next time the goal's clock comes round on an allowed day. + public func deadlineMs(_ nowMs: Double) -> Double? { + guard let s = schedule else { return nil } + var cal = Calendar(identifier: .gregorian) + cal.timeZone = timeZone + let now = Date(timeIntervalSince1970: nowMs / 1000) + let today = cal.startOfDay(for: now) + for i in 0..<15 { + guard let day = cal.date(byAdding: .day, value: i, to: today), + let t = cal.date(bySettingHour: s.clockMin / 60, minute: s.clockMin % 60, second: 0, of: day) else { continue } + let iso = (cal.component(.weekday, from: day) + 5) % 7 + if s.days != 0, s.days & (1 << iso) == 0 { continue } + if t > now { return t.timeIntervalSince1970 * 1000 } + } + return nil + } + + public static let chargerW: Double = 11_000 + public static let chargeEfficiency = 0.92 + + /// The plan for the car: the night's cheap hours from 01:00, as many as + /// the goal needs, never past the deadline. `shortfallWh` is what the + /// battery still lacks at the deadline, the way Core's plan says it. + public func carPlan(_ nowMs: Double) -> (startMs: Double, endMs: Double, wh: Double, shortfallWh: Double)? { + guard carPluggedIn, manualHoldW == nil, !surplusOnly, let s = schedule, let deadline = deadlineMs(nowMs) else { return nil } + let needWh = max(0, (s.socFraction - carSocFraction) * carCapacityWh) + guard needWh > 1 else { return (nowMs, nowMs, 0, 0) } + let night = deadline - (hourOfDay(deadline) - 1) * 3_600_000 + let start = max(nowMs, night < deadline ? night : nowMs) + let hours = max(0, min((deadline - start) / 3_600_000, needWh / (Self.chargerW * Self.chargeEfficiency))) + let gained = hours * Self.chargerW * Self.chargeEfficiency + return (start, start + hours * 3_600_000, (hours * Self.chargerW).rounded(), max(0, needWh - gained).rounded()) + } // Energy since local midnight, integrated. public private(set) var todayImportWh: Double = 6_100 @@ -117,24 +186,27 @@ public final class SimulatedHouse { boostUntilMs = nil lastBoostStop = "expired" } + lastStepMs = nowMs if !carPluggedIn || carSocFraction >= 0.999 { evW = 0 } else if let hold = manualHoldW { - evW = hold + evW = holdActing ? hold : 0 } else if boostUntilMs != nil { evW = 7_400 } else if surplusOnly { let spare = max(0, -pvW - baseLoadW - 200) evW = spare >= 1_400 ? min(spare, 11_000).rounded() : 0 - } else if let schedule, carSocFraction < schedule.socFraction, isChargeHour(nowMs) { - evW = 11_000 + } else if let plan = carPlan(nowMs), nowMs >= plan.startMs, nowMs < plan.endMs { + evW = Self.chargerW } else { evW = 0 } if evW > 0 { let wh = evW * dtMs / 3_600_000 sessionWh += wh - carSocFraction = min(1, carSocFraction + wh * 0.92 / carCapacityWh) + carSocFraction = min(1, carSocFraction + wh * Self.chargeEfficiency / carCapacityWh) + // A car that reports keeps reporting while it charges. + if carReportedAtMs != nil, carSocSource == "vehicle" { carReportedAtMs = nowMs } } // The battery, by mode. @@ -175,12 +247,6 @@ public final class SimulatedHouse { if batteryW > 0 { todayChargedWh += batteryW * h } else { todayDischargedWh += -batteryW * h } } - /// The cheap night hours before the ready time. - func isChargeHour(_ ms: Double) -> Bool { - let h = hourOfDay(ms) - return h >= 1 && h < 5 - } - /// A day of the energy ledger, made up from the same model, for days /// before today. public func ledger(dayOffset: Int, nowMs: Double) -> (load: Double, pv: Double, imp: Double, exp: Double, charged: Double, discharged: Double) { diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift index 5ce6416..24df9ed 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -8,11 +8,17 @@ import FTWShared public struct Loadpoint: Equatable, Sendable, Identifiable { public struct Schedule: Equatable, Sendable { public var socPct: Double + /// The ready time as the box sends it: the UTC minute of day of that + /// time on today's date. public var timeOfDayMinUTC: Int + /// The ready time on the household clock, from a box that sends it. + public var clockMin: Int? public var surplusUnlockPct: Double public var recurring: Bool /// 7-bit weekday mask, bit 0 = Monday. Zero means every day. public var days: Int + /// The goal is the car's own limit rather than a percentage. + public var finishAtVehicleLimit: Bool } public struct Manual: Equatable, Sendable { @@ -46,13 +52,19 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { public var socPct: Double? public var socSource: String /// How old the car's own report of its level was when the box answered. - /// Sent only while the level comes from the car. public var vehicleSocAgeS: Double? + /// The car's own report, kept apart from the box's estimate. + public var vehicleSocPct: Double? + public var vehicleDriver: String + public var vehicleStale: Bool public var vehicleCapacityWh: Double? public var capacitySource: String public var socRetention: String public var chargingDeclined: Bool public var targetSocPct: Double? + /// The deadline the box plans for, on the phone's clock. + public var targetTimeMs: Double? + public var goalComplete: Bool public var sessionWh: Double public var minChargeW: Double? public var maxChargeW: Double? @@ -70,6 +82,7 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { public var gridDeferred: Bool public var planStartMs: Double? public var planEndMs: Double? + public var planTotalWh: Double? public var planPending: Bool public var planOutdated: Bool /// Whether this box reports its plan with the charger, in one read. @@ -102,6 +115,19 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { return (f > 1 ? f : f * 100).rounded() } + /// An RFC 3339 time from Go. Its zero time means none. + static func ms(_ v: JSON?) -> Double? { + guard let s = v?.string, !s.isEmpty else { return nil } + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = f.date(from: s) ?? { + f.formatOptions = [.withInternetDateTime] + return f.date(from: s) + }() + guard let date, date.timeIntervalSince1970 > 946_684_800 else { return nil } + return date.timeIntervalSince1970 * 1000 + } + public init(_ w: JSON) { let num: (String) -> Double? = { w[$0]?.number } id = w["id"]?.string ?? "" @@ -117,11 +143,20 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { } socSource = w["soc_source"]?.string ?? "" vehicleSocAgeS = num("vehicle_soc_age_s") + vehicleDriver = w["vehicle_driver"]?.string ?? "" + vehicleStale = w["vehicle_stale"]?.bool == true + if let v = num("vehicle_soc"), v >= 0, v <= 1, !vehicleDriver.isEmpty { + vehicleSocPct = (v * 100).rounded() + } else { + vehicleSocPct = nil + } vehicleCapacityWh = num("vehicle_capacity_wh") capacitySource = w["capacity_source"]?.string ?? "" socRetention = w["soc_retention"]?.string ?? "" chargingDeclined = w["charging_declined"]?.bool == true targetSocPct = Loadpoint.pct(w["target_soc"]) ?? num("target_soc_pct") + targetTimeMs = Loadpoint.ms(w["target_time"]) + goalComplete = w["goal_complete"]?.bool == true sessionWh = max(0, (num("delivered_wh_session") ?? 0).rounded()) minChargeW = num("min_charge_w") maxChargeW = num("max_charge_w") @@ -151,6 +186,7 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { gridDeferred = w["grid_deferred"]?.bool == true planStartMs = num("plan_next_start_ms") planEndMs = num("plan_next_end_ms") + planTotalWh = num("plan_total_wh") planPending = w["plan_pending"]?.bool == true planOutdated = w["plan_outdated"]?.bool == true inlinePlan = (w["plan_pending"]?.bool != nil && w["plan_outdated"]?.bool != nil) || w["plan_windows"]?.array != nil @@ -173,19 +209,347 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { } else { boostStopReason = nil } - if let s = w["schedule"], s.object != nil, let minute = s["time_of_day_min_utc"]?.number, - let socPct = Loadpoint.pct(s["soc"]) ?? s["soc_pct"]?.number, socPct > 0 { - schedule = Schedule( - socPct: socPct, - timeOfDayMinUTC: Int(minute), - surplusUnlockPct: (s["surplus_unlock_bat_soc"]?.number ?? 0) * 100, - recurring: s["recurring"]?.bool == true, - days: Int(s["days"]?.number ?? 0) & 0x7f - ) + if let s = w["schedule"], s.object != nil, let minute = s["time_of_day_min_utc"]?.number { + let limit = s["finish_at_vehicle_limit"]?.bool == true + let socPct = Loadpoint.pct(s["soc"]) ?? s["soc_pct"]?.number + if limit || (socPct ?? 0) > 0 { + let clock = s["time_of_day_min"]?.number.map { Int($0) } + schedule = Schedule( + socPct: socPct ?? 100, + timeOfDayMinUTC: Int(minute), + clockMin: clock.flatMap { (0..<1440).contains($0) ? $0 : nil }, + surplusUnlockPct: (s["surplus_unlock_bat_soc"]?.number ?? 0) * 100, + recurring: s["recurring"]?.bool == true, + days: Int(s["days"]?.number ?? 0) & 0x7f, + finishAtVehicleLimit: limit + ) + } } } } +// MARK: - Words + +/// The app's own words in English or Swedish. The English sentence is the +/// key, so a missing translation shows English rather than a code. +/// +/// FTWShared keeps its Swedish in a strings file; FTWKit has no resources, +/// so its Swedish lives in the table below. Tests pin a language with +/// `AppWords.$language.withValue(.sv) { … }`. +public enum AppWords { + public enum Language: String, Sendable { + case en, sv + + /// The phone's choice among the two the app speaks. + public static var preferred: Language { + Bundle.preferredLocalizations(from: ["en", "sv"]).first == "sv" ? .sv : .en + } + } + + @TaskLocal public static var language: Language = .preferred + + public static func text(_ en: String) -> String { + language == .sv ? (sv[en] ?? en) : en + } + + /// `%@` in order, or `%1$@` by position, replaced with ready text. + public static func format(_ en: String, _ args: String...) -> String { + var out = text(en) + for (i, a) in args.enumerated() { out = out.replacingOccurrences(of: "%\(i + 1)$@", with: a) } + for a in args { + guard let r = out.range(of: "%@") else { break } + out.replaceSubrange(r, with: a) + } + return out + } + + /// A decimal in the language's own way: "7.2" or "7,2". + public static func decimal(_ text: String) -> String { + language == .sv ? text.replacingOccurrences(of: ".", with: ",") : text + } + + /// "80 %", kept on one line. + public static func percent(_ v: Double) -> String { "\(Int(v.rounded()))\u{00A0}%" } + + /// "7.2 kW" or "7,2 kW". + public static func power(_ watts: Double) -> String { + let p = PowerFormat.parts(watts) + return "\(decimal(p.text))\u{00A0}\(p.unit)" + } + + /// "14 kWh", one decimal below ten. + public static func energy(_ wh: Double) -> String { + let kwh = max(0, wh) / 1000 + return "\(decimal(kwh >= 10 ? String(Int(kwh.rounded())) : PowerFormat.fixed(kwh, 1)))\u{00A0}kWh" + } + + /// "07:00" in a zone, the 24-hour clock both languages use for FTW. + public static func clock(_ ms: Double, zone: TimeZone) -> String { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + let c = cal.dateComponents([.hour, .minute], from: Date(timeIntervalSince1970: ms / 1000)) + return String(format: "%02d:%02d", c.hour ?? 0, c.minute ?? 0) + } + + public static func clock(minute: Int) -> String { + String(format: "%02d:%02d", (minute / 60) % 24, minute % 60) + } + + /// "today 07:00", "tomorrow 07:00", "Tue 07:00", else "12 Oct 07:00". + public static func day(_ ms: Double, nowMs: Double, zone: TimeZone) -> String { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + let at = Date(timeIntervalSince1970: ms / 1000) + let now = Date(timeIntervalSince1970: nowMs / 1000) + let days = cal.dateComponents([.day], from: cal.startOfDay(for: now), to: cal.startOfDay(for: at)).day ?? 0 + let time = clock(ms, zone: zone) + switch days { + case 0: return format("today %@", time) + case 1: return format("tomorrow %@", time) + case 2..<7: return "\(text(EVText.dayLabels[(cal.component(.weekday, from: at) + 5) % 7])) \(time)" + default: + let c = cal.dateComponents([.day, .month], from: at) + return "\(c.day ?? 1) \(text(monthLabels[((c.month ?? 1) + 11) % 12])) \(time)" + } + } + + static let monthLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + + static let sv: [String: String] = [ + // Clock and calendar + "today %@": "i dag %@", + "tomorrow %@": "i morgon %@", + "Mon": "mån", "Tue": "tis", "Wed": "ons", "Thu": "tor", "Fri": "fre", "Sat": "lör", "Sun": "sön", + "Jan": "jan", "Feb": "feb", "Mar": "mar", "Apr": "apr", "May": "maj", "Jun": "jun", + "Jul": "jul", "Aug": "aug", "Sep": "sep", "Oct": "okt", "Nov": "nov", "Dec": "dec", + "every day": "varje dag", + "weekdays": "vardagar", + "weekends": "helger", + "once": "en gång", + "on weekdays": "vardagar", + "on weekends": "helger", + "on %@": "%@", + + // The car view + "Your car": "Bilen", + "Car": "Bilen", + "Plugged in": "Inkopplad", + "Not plugged in": "Inte inkopplad", + "How full is the car?": "Hur full är bilen?", + "Correct the level": "Rätta nivån", + "Plug in the car to tell FTW its level.": "Koppla in bilen för att ange nivån.", + "FTW takes a level only while the car is plugged in.": "FTW tar emot nivån bara när bilen är inkopplad.", + "Level unknown": "Nivån är okänd", + "From the car": "Från bilen", + "From the car, last reported %@": "Från bilen, senast rapporterad %@", + "You set %1$@ at %2$@": "Du angav %1$@ kl. %2$@", + "You set %1$@ at %2$@. FTW adds what has charged since.": "Du angav %1$@ kl. %2$@. FTW räknar in det som laddats sedan.", + "FTW's estimate from energy delivered": "FTW:s uppskattning från laddad energi", + "FTW assumes %@. Nobody has told it the real level.": "FTW antar %@. Ingen har angett den riktiga nivån.", + "FTW assumes": "FTW antar", + "from the car": "från bilen", + "reported %@": "rapporterad %@", + "you set": "du angav", + "estimated": "uppskattad", + "not confirmed": "inte bekräftad", + "The car stopped charging. Its level is not confirmed.": "Bilen slutade ladda. Nivån är inte bekräftad.", + "Only correct it if the car shows something else.": "Rätta bara om bilen visar något annat.", + + "Goal": "Mål", + "Ready by": "Klar till", + "%1$@ by %2$@ %3$@": "%1$@ till %2$@ %3$@", + "Charge to the car's limit by %1$@ %2$@": "Ladda till bilens gräns till %1$@ %2$@", + "Next: %@": "Nästa gång: %@", + "Next: %@, home time": "Nästa gång: %@, hemmatid", + "No goal yet. Without one, FTW only charges from spare solar.": "Inget mål än. Utan mål laddar FTW bara med solöverskott.", + "Use %@": "Använd %@", + "Change": "Ändra", + "Save": "Spara", + "Cancel": "Avbryt", + "Remove goal": "Ta bort målet", + "Charge to": "Ladda till", + "Repeat": "Upprepa", + "Saving the goal…": "Sparar målet…", + "Goal saved. FTW is updating the plan.": "Målet är sparat. FTW räknar om planen.", + "Goal removed.": "Målet är borttaget.", + "Choose at least one day.": "Välj minst en dag.", + + "Updating the plan…": "Planen räknas om…", + "Charging times are unavailable. Your goal is saved.": "Laddtiderna saknas just nu. Målet är sparat.", + "Waiting for tomorrow's prices. Spare solar can charge meanwhile.": "Väntar på morgondagens priser. Solöverskott kan ladda under tiden.", + "Charging %1$@–%2$@, about %3$@.": "Laddar %1$@–%2$@, cirka %3$@.", + "Charging %1$@–%2$@, about %3$@, and %4$@ more times.": "Laddar %1$@–%2$@, cirka %3$@, och %4$@ gånger till.", + "No charging planned yet.": "Ingen laddning planerad än.", + "On track: %1$@ by %2$@.": "Hinner bli klar: %1$@ till %2$@.", + "Will not make it: about %1$@ by %2$@, not %3$@.": "Hinner inte: cirka %1$@ till %2$@, inte %3$@.", + "Goal reached.": "Målet är nått.", + "Counted from FTW's guess of %@.": "Räknat från FTW:s antagande, %@.", + "Counted from the level you gave.": "Räknat från nivån du angav.", + + "Charge now": "Ladda nu", + "Pause": "Pausa", + "Back to the plan": "Tillbaka till planen", + "Charge now runs until you go back to the plan or unplug.": "Ladda nu kör tills du går tillbaka till planen eller kopplar ur.", + "Sent": "Skickat", + "Received": "Mottaget", + "Done": "Utfört", + + // What the charger does + "Charger status is out of date.": "Laddarens status är inaktuell.", + "Waiting for the charger's first report.": "Väntar på laddarens första rapport.", + "Paused by you": "Pausad av dig", + "Charging now at %@": "Laddar nu med %@", + "Charging at %@": "Laddar med %@", + "Charge now sent to the charger": "Ladda nu är skickat till laddaren", + "The charger is starting…": "Laddaren startar…", + "The car is not taking power. Check the car's charge limit.": "Bilen tar ingen ström. Kolla bilens laddgräns.", + "The charger has not responded. Check the charger.": "Laddaren har inte svarat. Kolla laddaren.", + "The main fuse limits charging right now.": "Huvudsäkringen begränsar laddningen just nu.", + "The charger limits the current.": "Laddaren begränsar strömmen.", + "Pause sent. Waiting for the charger to stop.": "Paus skickad. Väntar på att laddaren stannar.", + "The car stopped asking for power.": "Bilen vill inte ta mer ström.", + "Paused for safety: the electricity meter is not reporting.": "Pausad för säkerhets skull: elmätaren rapporterar inte.", + "Paused by the main fuse. It resumes on its own.": "Pausad av huvudsäkringen. Den fortsätter av sig själv.", + "Plugged in · charges at %@": "Inkopplad · laddar kl. %@", + "Plugged in, not charging now": "Inkopplad, laddar inte nu", + "%@ this session": "%@ den här laddningen", + + // A command's outcome + "Sending %@ to FTW…": "Skickar %@ till FTW…", + "FTW received %@.": "FTW har tagit emot %@.", + "FTW now plans from %@.": "FTW planerar nu från %@.", + "FTW kept %@. A newer level came first.": "FTW behöll %@. Ett nyare värde kom före.", + "FTW received %@ but has not confirmed it yet.": "FTW har tagit emot %@ men inte bekräftat det än.", + "Sending Charge now…": "Skickar Ladda nu…", + "Sending Pause…": "Skickar Pausa…", + "Sending Back to the plan…": "Skickar Tillbaka till planen…", + "FTW received it.": "FTW har tagit emot det.", + "FTW received it but has not confirmed the result yet.": "FTW har tagit emot det men inte bekräftat resultatet än.", + "FTW started charging. Waiting for the car to draw power.": "FTW har startat laddningen. Väntar på att bilen tar ström.", + "%@. The plan waits until you go back to it.": "%@. Planen väntar tills du går tillbaka till den.", + "Paused. The car will not charge until you choose.": "Pausad. Bilen laddar inte förrän du väljer.", + "Back to the plan. FTW decides when to charge.": "Tillbaka till planen. FTW bestämmer när bilen laddar.", + "Something else changed charging first. Try again.": "Något annat ändrade laddningen först. Försök igen.", + + // Why not + "Plug the car in first. FTW takes a level only while the car is plugged in.": "Koppla in bilen först. FTW tar emot nivån bara när bilen är inkopplad.", + "FTW cannot reach the charger right now. Try again shortly.": "FTW når inte laddaren just nu. Försök igen om en stund.", + "Something changed at home while this was sending. Try again.": "Något ändrades hemma medan det skickades. Försök igen.", + "Only the home's owner can control charging.": "Bara hemmets ägare kan styra laddningen.", + "That took too long to reach FTW. Try again.": "Det tog för lång tid att nå FTW. Försök igen.", + "FTW is starting. Give it a minute.": "FTW startar. Vänta en minut.", + "Your box cannot do this yet. It may need an update.": "Din box kan inte det här än. Den kan behöva uppdateras.", + "That did not go through. Try again.": "Det gick inte igenom. Försök igen.", + "Did not reach FTW. Try again.": "Nådde inte FTW. Försök igen.", + "Not connected to FTW right now. Try again when the connection is back.": "Inte ansluten till FTW just nu. Försök igen när anslutningen är tillbaka.", + "FTW received it but could not save it for a restart.": "FTW har tagit emot det men kunde inte spara det inför en omstart.", + + // The box's API, said for people + "Your box doesn't have that yet — it may be running older software.": "Din box har inte det här än. Den kan köra äldre programvara.", + "Your box is busy with something else. This will fill in shortly.": "Din box är upptagen med något annat. Det här fylls i snart.", + "Your box can't answer that right now. Still trying.": "Din box kan inte svara just nu. Försöker igen.", + "Only the owner of this home can change that.": "Bara hemmets ägare kan ändra det.", + "Your access to this home was withdrawn by its owner.": "Ägaren har dragit tillbaka din åtkomst till hemmet.", + "That has to be done from the controls on the home screen.": "Det görs med knapparna i appen.", + "That one is only available on your box's own page, from home.": "Det går bara att göra på boxens egen sida, hemifrån.", + "That's more than we can send over your connection — narrow the range.": "Det är mer än anslutningen klarar. Välj ett mindre intervall.", + "Your box needs more proof than this phone can give. Do it on your box.": "Din box kräver mer bevis än telefonen kan ge. Gör det på boxen.", + "That didn't work. Nothing on your box has changed.": "Det fungerade inte. Inget på boxen har ändrats.", + "Someone has to own this home, so the last owner cannot be removed.": "Någon måste äga hemmet, så den sista ägaren kan inte tas bort.", + "That's no longer on your box.": "Det finns inte längre på boxen.", + "Your box refused that from this phone.": "Din box nekade det från den här telefonen.", + "Something changed on your box first. Nothing here was applied.": "Något ändrades på boxen först. Inget härifrån har genomförts.", + "Your box can't answer that yet. Still trying.": "Din box kan inte svara på det än. Försöker igen.", + "Something went wrong on your box. Nothing has changed.": "Något gick fel på boxen. Inget har ändrats.", + "Your box couldn't do that.": "Din box kunde inte göra det.", + "Your box didn't answer. Still trying.": "Din box svarade inte. Försöker igen.", + "Your box sent something this app couldn't read.": "Din box skickade något som appen inte kunde läsa.", + "Choose a valid ready time.": "Välj en giltig klartid.", + "Choose a charge target from 10 to 100 %.": "Välj ett laddmål mellan 10 och 100 %.", + "Not up to date — your box is out of reach": "Inte aktuellt – boxen går inte att nå", + + // Is FTW in control? + "Is FTW in control?": "Styr FTW?", + "Yes, FTW is in control": "Ja, FTW styr", + "Every device follows FTW.": "Alla enheter följer FTW.", + "Not live right now": "Inte live just nu", + "Last known %@.": "Senast känt %@.", + "Waiting for your box.": "Väntar på din box.", + "FTW is not in control right now": "FTW styr inte just nu", + "A meter stopped reporting. Your home runs on grid power as usual.": "En mätare har slutat rapportera. Hemmet går på nätet som vanligt.", + "Checking…": "Kontrollerar…", + "Asking your box how its devices respond.": "Frågar boxen hur enheterna svarar.", + "Connected and live": "Ansluten och live", + "This box does not report whether devices follow it.": "Den här boxen rapporterar inte om enheterna följer den.", + "FTW controls no device right now": "FTW styr ingen enhet just nu", + "%@ needs attention now": "%@ behöver åtgärd nu", + "%@ devices need attention now": "%@ enheter behöver åtgärd nu", + "%@ needs a look": "%@ behöver en titt", + "%@ devices need a look": "%@ enheter behöver en titt", + "The battery": "Batteriet", + "The charger": "Laddaren", + "The solar inverter": "Solväxelriktaren", + "A device": "En enhet", + "%1$@: %2$@": "%1$@: %2$@", + + // Core's control reasons and statuses + "Following FTW": "Följer FTW", "Waiting": "Väntar", "Limited": "Begränsad", "Not following": "Följer inte", + "No contact": "Ingen kontakt", "Not controlled by FTW": "Styrs inte av FTW", + "Adjusting": "Justerar", "No command yet": "Inget kommando än", "No car connected": "Ingen bil inkopplad", + "Waiting for the plan": "Väntar på planen", "Waiting for spare solar": "Väntar på solöverskott", "Starting the charger": "Startar laddaren", + "Car is full": "Bilen är full", "Car reached its limit": "Bilen nådde sin gräns", "Car not taking power": "Bilen tar ingen ström", + "Battery full": "Batteriet är fullt", "Battery nearly full": "Batteriet är nästan fullt", "Battery nearly empty": "Batteriet är nästan tomt", + "Limited by FTW": "Begränsad av FTW", "Limited by the main fuse": "Begränsad av huvudsäkringen", "Waiting after a fuse limit": "Väntar efter säkringsgräns", + "Limited by FTW's charger setting": "Begränsad av FTW:s laddarinställning", "Charger limit": "Laddarens gräns", "Charger offers less current": "Laddaren ger mindre ström", + "Device fault": "Fel på enheten", "No fresh readings": "Inga färska mätvärden", "Lost control": "Tappade styrningen", + "Commands failing": "Kommandon misslyckas", "Command not confirmed": "Kommandot är inte bekräftat", "Cannot hand back control": "Kan inte lämna tillbaka styrningen", + "No power reading": "Ingen effektmätning", "Monitoring only": "Bara övervakning", "Disabled": "Avstängd", "Own control": "Egen styrning", + "Paused for safety": "Pausad för säkerhets skull", "Checking": "Kontrollerar", + + // Now + "Now": "Nu", + "Market now": "Elpris nu", + "Electricity price": "Elpris", + "incl. fees and VAT": "inkl. avgifter och moms", + "Cheapest 2 h": "Billigast 2 h", + "No 2 h window published": "Inget 2 h-fönster publicerat", + "No prices published ahead yet.": "Inga priser framåt är publicerade än.", + "Dotted line: average ahead, %@": "Streckad linje: snitt framåt, %@", + "Some hours are missing their price.": "Några timmar saknar pris.", + "Tomorrow's rates aren't published yet.": "Morgondagens priser är inte publicerade än.", + "Price to import": "Pris att köpa", + "now": "nu", + "HOME": "HEMMET", + "%@ self-powered today": "%@ egen el i dag", + "Home using %@": "Hemmet använder %@", + "GRID": "ELNÄT", "SOLAR": "SOL", "BATTERY": "BATTERI", "EV CHARGER": "LADDARE", + "balanced": "i balans", "importing": "köper", "exporting": "säljer", "no data": "ingen data", + "idle": "vilar", "charging": "laddar", "discharging": "laddar ur", "observe only": "bara övervakning", + "Device details are out of date.": "Enhetsdetaljerna är inaktuella.", + "Device details are out of date. Showing live totals.": "Enhetsdetaljerna är inaktuella. Visar live-summor.", + + // The shell + "Live demo · simulated home": "Livedemo · simulerat hem", + "Starting the demo": "Startar demon", + "Exit demo": "Avsluta demon", + "Demo": "Demo", + "Plug in the car": "Koppla in bilen", + "Unplug the car": "Koppla ur bilen", + "The demo plugs in a car FTW cannot read, so FTW assumes 20 %.": "Demon kopplar in en bil som FTW inte kan läsa av, så FTW antar 20 %.", + "Your box is starting": "Din box startar", + "This can take a few minutes after an update. Nothing is wrong. Your home appears here when it is ready.": "Det kan ta några minuter efter en uppdatering. Inget är fel. Hemmet visas här när boxen är klar.", + "Access ended": "Åtkomsten är avslutad", + "This session ended.": "Sessionen är avslutad.", + "Your box won't let this phone in?": "Släpper boxen inte in telefonen?", + "Nothing from your box yet": "Inget från din box än", + "This phone is paired to it and keeps trying on its own. Your home appears here as soon as the box answers.": "Telefonen är parkopplad och försöker själv. Hemmet visas här så fort boxen svarar.", + "Get this phone back in": "Släpp in telefonen igen", + "This app is older than your box. Some things are hidden until it updates.": "Appen är äldre än din box. En del döljs tills appen uppdateras.", + ] +} + +// MARK: - Sentences + public enum EVText { public static let dayLabels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] public static let manualSaveErrorText = "This choice is active now, but could not be saved for restart. FTW is retrying." @@ -193,65 +557,245 @@ public enum EVText { /// The weekday mask as a person says it. public static func days(_ mask: Int) -> String { let m = mask & 0x7f - if m == 0 || m == 0x7f { return "every day" } - if m == 0b0011111 { return "weekdays" } - if m == 0b1100000 { return "weekends" } - return dayLabels.enumerated().filter { m & (1 << $0.offset) != 0 }.map(\.element).joined(separator: ", ") - } - - // MARK: Clock conversions - - /// UTC minutes of the day on the local clock, today. - public static func localTime(minuteUTC: Int, at: Date = Date(), calendar: Calendar = .current) -> (hour: Int, minute: Int) { - var utc = Calendar(identifier: .gregorian) - utc.timeZone = TimeZone(identifier: "UTC")! - var comps = utc.dateComponents([.year, .month, .day], from: at) - comps.hour = minuteUTC / 60 - comps.minute = minuteUTC % 60 - let date = utc.date(from: comps) ?? at - let local = calendar.dateComponents([.hour, .minute], from: date) - return (local.hour ?? 0, local.minute ?? 0) - } - - /// A local hour and minute back to UTC minutes of the day, exact for - /// today's offset: the box's own page does the same. - public static func minuteUTC(hour: Int, minute: Int, at: Date = Date(), calendar: Calendar = .current) -> Int? { - guard (0...23).contains(hour), (0...59).contains(minute) else { return nil } - var comps = calendar.dateComponents([.year, .month, .day], from: at) - comps.hour = hour - comps.minute = minute - guard let date = calendar.date(from: comps) else { return nil } - var utc = Calendar(identifier: .gregorian) - utc.timeZone = TimeZone(identifier: "UTC")! - let u = utc.dateComponents([.hour, .minute], from: date) + if m == 0 || m == 0x7f { return AppWords.text("every day") } + if m == 0b0011111 { return AppWords.text("weekdays") } + if m == 0b1100000 { return AppWords.text("weekends") } + return dayLabels.enumerated().filter { m & (1 << $0.offset) != 0 }.map { AppWords.text($0.element) }.joined(separator: ", ") + } + + /// The days after a time: "on weekdays", "every day", "once". + static func dayPhrase(_ mask: Int, recurring: Bool) -> String { + guard recurring else { return AppWords.text("once") } + let m = mask & 0x7f + if m == 0 || m == 0x7f { return AppWords.text("every day") } + if m == 0b0011111 { return AppWords.text("on weekdays") } + if m == 0b1100000 { return AppWords.text("on weekends") } + return AppWords.format("on %@", days(m)) + } + + // MARK: The household clock + + /// The zone the box keeps its clock in, when it names one; else the + /// phone's. "Local" is what a box without TZ sends, and UTC is what a + /// container without a zone runs in: neither is anyone's home. + public static func householdZone(boxTZ: String?, phone: TimeZone = .current) -> TimeZone { + guard let id = boxTZ, !["", "Local", "UTC", "Etc/UTC", "GMT", "Etc/GMT"].contains(id), let zone = TimeZone(identifier: id) else { + return phone + } + return zone + } + + static func calendar(_ zone: TimeZone) -> Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + return cal + } + + /// The first instant on `day`'s date at which the clock shows `clock` + /// minutes or later: Core's `wallClock`. A skipped time gives the end of + /// the gap, a repeated one its first pass. + static func wallClock(_ day: Date, clock: Int, zone: TimeZone) -> Date { + let cal = calendar(zone) + let start = cal.startOfDay(for: day) + return cal.date(bySettingHour: clock / 60, minute: clock % 60, second: 0, of: start, matchingPolicy: .nextTime, repeatedTimePolicy: .first, direction: .forward) ?? start + } + + /// A UTC minute of day as the box reads it: the time on `at`'s date in + /// `zone` that has that UTC minute. Core's `clockFromUTC`. + public static func clockFromUTC(_ minuteUTC: Int, at: Date, zone: TimeZone) -> Int { + let m = ((minuteUTC % 1440) + 1440) % 1440 + let start = wallClock(at, clock: 0, zone: zone) + let utc = calendar(TimeZone(identifier: "UTC")!) + var c = utc.dateComponents([.year, .month, .day], from: start) + c.hour = m / 60 + c.minute = m % 60 + var t = utc.date(from: c) ?? start + if t < start { t = t.addingTimeInterval(86_400) } + let l = calendar(zone).dateComponents([.hour, .minute], from: t) + return (l.hour ?? 0) * 60 + (l.minute ?? 0) + } + + /// The household clock as the box's API carries it: the UTC minute of + /// that time on `at`'s date. Core's `utcMinuteToday`. + public static func minuteUTC(clock: Int, at: Date, zone: TimeZone) -> Int { + let u = calendar(TimeZone(identifier: "UTC")!).dateComponents([.hour, .minute], from: wallClock(at, clock: clock, zone: zone)) return (u.hour ?? 0) * 60 + (u.minute ?? 0) } - public static func clock(_ ms: Double, calendar: Calendar = .current) -> String { - let c = calendar.dateComponents([.hour, .minute], from: Date(timeIntervalSince1970: ms / 1000)) - return String(format: "%02d:%02d", c.hour ?? 0, c.minute ?? 0) + /// The ready time the box uses, on the household clock. + public static func goalClock(_ s: Loadpoint.Schedule, at: Date, zone: TimeZone) -> Int { + s.clockMin ?? clockFromUTC(s.timeOfDayMinUTC, at: at, zone: zone) + } + + /// The next time the household clock shows the ready time on an allowed + /// day, strictly after now: Core's `NextDeadlineUTC`. + public static func nextDeadlineMs(_ s: Loadpoint.Schedule, nowMs: Double, zone: TimeZone) -> Double { + let now = Date(timeIntervalSince1970: nowMs / 1000) + let clock = goalClock(s, at: now, zone: zone) + let cal = calendar(zone) + let today = cal.startOfDay(for: now) + for i in 0..<15 { + guard let day = cal.date(byAdding: .day, value: i, to: today) else { break } + let iso = (cal.component(.weekday, from: day) + 5) % 7 + if s.days != 0, s.days & (1 << iso) == 0 { continue } + let t = wallClock(day, clock: clock, zone: zone) + if t > now { return t.timeIntervalSince1970 * 1000 } + } + return nowMs + } + + /// "80 % by 07:00 on weekdays", on the household clock. + public static func goal(_ s: Loadpoint.Schedule, at: Date, zone: TimeZone) -> String { + let time = AppWords.clock(minute: goalClock(s, at: at, zone: zone)) + let when = dayPhrase(s.days, recurring: s.recurring) + if s.finishAtVehicleLimit { return AppWords.format("Charge to the car's limit by %1$@ %2$@", time, when) } + return AppWords.format("%1$@ by %2$@ %3$@", AppWords.percent(s.socPct), time, when) + } + + /// The deadline the box plans for: its own, or worked out the way it + /// works it out. Shown on the household clock, said so when the phone is + /// somewhere else. + public static func nextGoal(_ lp: Loadpoint, nowMs: Double, zone: TimeZone, phone: TimeZone = .current) -> String? { + guard let s = lp.schedule else { return nil } + let at = lp.targetTimeMs.flatMap { $0 > nowMs ? $0 : nil } ?? nextDeadlineMs(s, nowMs: nowMs, zone: zone) + let day = AppWords.day(at, nowMs: nowMs, zone: zone) + let away = zone.secondsFromGMT(for: Date(timeIntervalSince1970: nowMs / 1000)) != phone.secondsFromGMT(for: Date(timeIntervalSince1970: nowMs / 1000)) + return AppWords.format(away ? "Next: %@, home time" : "Next: %@", day) + } + + /// The goal VISION names, offered when there is none. + public static let suggestedGoal = (socPct: 80.0, clock: 7 * 60, days: 0b0011111) + + public static func suggestedGoalText() -> String { + AppWords.format("%1$@ by %2$@ %3$@", AppWords.percent(suggestedGoal.socPct), AppWords.clock(minute: suggestedGoal.clock), dayPhrase(suggestedGoal.days, recurring: true)) + } + + // MARK: The car's level + + /// Where the level on screen comes from, said as such. + public struct Level: Equatable, Sendable { + public enum Origin: Equatable, Sendable { + /// The car reported it within the last five minutes. + case car + /// The car reported it, longer ago than that. + case carEarlier + /// Someone set it in this app for this plug-in. + case you + /// The box's estimate from energy delivered. + case estimated + /// The box assumes it; nobody has said. + case assumed + /// The car stopped charging; the level is a guess pinned to the goal. + case unconfirmed + } + + public var pct: Double + public var origin: Origin + /// When the car reported it, or when it was set here. + public var atMs: Double? + /// What was set here, once the estimate has moved on from it. + public var setPct: Double? + } + + /// A level set in this app, kept for as long as the car stays plugged in. + public struct LevelSet: Equatable, Sendable { + public var pct: Double + public var atMs: Double + + public init(pct: Double, atMs: Double) { + self.pct = pct + self.atMs = atMs + } + } + + /// Core plans from a car's report for five minutes (VehicleMaxAge). + public static let carLevelMaxAgeMs: Double = 5 * 60_000 + + /// What the box assumes at plug-in when nothing is known. + public static let assumedPct: Double = 20 + + /// The level and where it comes from. Nil when no car is plugged in: + /// the box takes no level then, so none is offered. + public static func level(_ lp: Loadpoint, answeredAtMs: Double, nowMs: Double, set: LevelSet? = nil) -> Level? { + guard lp.pluggedIn else { return nil } + var car: (pct: Double, atMs: Double)? + if let pct = lp.vehicleSocPct { + car = (pct, answeredAtMs - max(0, lp.vehicleSocAgeS ?? 0) * 1000) + } else if lp.socSource == "vehicle", let pct = lp.socPct { + car = (pct, lp.socReadAtMs(answeredAtMs: answeredAtMs) ?? answeredAtMs) + } + if let car, set.map({ $0.atMs <= car.atMs }) ?? true { + let old = lp.vehicleStale || nowMs - car.atMs > carLevelMaxAgeMs + return Level(pct: car.pct, origin: old ? .carEarlier : .car, atMs: car.atMs, setPct: nil) + } + switch lp.socSource { + case "assumed": + return Level(pct: lp.socPct ?? assumedPct, origin: .assumed, atMs: nil, setPct: nil) + case "completed": + guard let pct = lp.socPct else { return nil } + return Level(pct: pct, origin: .unconfirmed, atMs: nil, setPct: nil) + default: + if let set { + let pct = lp.socPct ?? set.pct + return Level(pct: pct, origin: .you, atMs: set.atMs, setPct: abs(pct - set.pct) >= 1 ? set.pct : nil) + } + guard let pct = lp.socPct else { return nil } + return Level(pct: pct, origin: .estimated, atMs: nil, setPct: nil) + } } - // MARK: Sentences + /// The sentence under the level: who said it, and when if it matters. + public static func origin(_ l: Level, zone: TimeZone) -> String { + switch l.origin { + case .car: return AppWords.text("From the car") + case .carEarlier: return AppWords.format("From the car, last reported %@", l.atMs.map { AppWords.clock($0, zone: zone) } ?? "—") + case .you: + let at = l.atMs.map { AppWords.clock($0, zone: zone) } ?? "—" + if let set = l.setPct { return AppWords.format("You set %1$@ at %2$@. FTW adds what has charged since.", AppWords.percent(set), at) } + return AppWords.format("You set %1$@ at %2$@", AppWords.percent(l.pct), at) + case .estimated: return AppWords.text("FTW's estimate from energy delivered") + case .assumed: return AppWords.format("FTW assumes %@. Nobody has told it the real level.", AppWords.percent(l.pct)) + case .unconfirmed: return AppWords.text("The car stopped charging. Its level is not confirmed.") + } + } - /// What the charger is doing now. Power leads when there is any. - public static func status(_ lp: Loadpoint, canControl: Bool = true) -> String { - if lp.manualRestoreUnconfirmed { - return (canControl ? "Confirm how to continue charging." : "An owner needs to confirm how charging should continue.") + " FTW could not confirm the charger or connection." + /// Two or three words for a row: "FTW assumes", "from the car". + public static func originShort(_ l: Level, zone: TimeZone) -> String { + switch l.origin { + case .car: return AppWords.text("from the car") + case .carEarlier: return AppWords.format("reported %@", l.atMs.map { AppWords.clock($0, zone: zone) } ?? "—") + case .you: return AppWords.text("you set") + case .estimated: return AppWords.text("estimated") + case .assumed: return AppWords.text("FTW assumes") + case .unconfirmed: return AppWords.text("not confirmed") } + } + + /// The chips, and a step of five from wherever the level stands. + public static let levelPresets: [Double] = [20, 40, 60, 80] + + public static func stepped(_ pct: Double, up: Bool) -> Double { + let next = up ? (pct / 5).rounded(.down) * 5 + 5 : (pct / 5).rounded(.up) * 5 - 5 + return min(100, max(0, next)) + } + + // MARK: What the charger does + + /// What the charger is doing, in a few words. Power leads when there is any. + public static func status(_ lp: Loadpoint, zone: TimeZone = .current) -> String { if let c = lp.charger, c.available != true { - return c.known ? "Charger status is out of date. FTW cannot confirm whether the car is charging." : "Waiting for the charger’s first status report." + return AppWords.text(c.known ? "Charger status is out of date." : "Waiting for the charger's first report.") } - if !lp.pluggedIn { return "Not plugged in" } + if !lp.pluggedIn { return AppWords.text("Not plugged in") } if lp.manualActive { return manualStatus(lp) } - if lp.powerW >= 100 { return "Charging at \(PowerFormat.text(lp.powerW))" } - if lp.chargingDeclined { return "The car stopped asking for charge. Check its charge limit or schedule. This does not confirm the battery is full." } - if lp.commandedKnown, lp.commandedReason == "site_meter_stale" { return "Paused for safety: house power readings are out of date. Charging resumes when readings return." } - if lp.commandedKnown, lp.commandedW == 0, ["fuse_cooldown", "fuse_limit"].contains(lp.commandedReason) { return "Paused: main-fuse protection. Charging resumes on its own." } - if lp.commandedKnown, let w = lp.commandedW, w > 0 { - return "FTW requests \(PowerFormat.text(w)). Waiting for the car to draw power." + (lp.charger?.reason.map { " Charger reports: \($0)." } ?? "") + if lp.powerW >= 100 { return AppWords.format("Charging at %@", AppWords.power(lp.powerW)) } + if lp.chargingDeclined { return AppWords.text("The car stopped asking for power.") } + if lp.commandedKnown, lp.commandedReason == "site_meter_stale" { return AppWords.text("Paused for safety: the electricity meter is not reporting.") } + if lp.commandedKnown, lp.commandedW == 0, ["fuse_cooldown", "fuse_limit"].contains(lp.commandedReason) { return AppWords.text("Paused by the main fuse. It resumes on its own.") } + if !lp.planPending, !lp.planOutdated, let first = lp.planWindows.first ?? lp.planStartMs.map({ Loadpoint.Window(fromMs: $0, toMs: lp.planEndMs ?? $0, peakW: nil, energyWh: nil) }), first.fromMs > 0 { + return AppWords.format("Plugged in · charges at %@", AppWords.clock(first.fromMs, zone: zone)) } - return "Plugged in — not charging right now" + return AppWords.text("Plugged in, not charging now") } /// A zero hold is a pause; older boxes omit their zero setpoint. @@ -264,86 +808,244 @@ public enum EVText { /// The hold is intent; only a fresh charger reading proves charging. public static func manualStatus(_ lp: Loadpoint) -> String { - let m = lp.manual - let request = m?.requestedA.map { "\(Int($0.rounded())) A" } ?? "your charge request" - let limit = m?.commandedA.map { "\(Int($0.rounded())) A" } ?? "the requested current" - let reason = m?.chargerReason.flatMap { $0.isEmpty ? nil : " Charger reports: \($0)." } ?? "" - let flowing = lp.powerW >= 100 ? PowerFormat.text(lp.powerW) : nil - switch m?.state { - case "unavailable": return "Charger status is out of date. FTW cannot confirm whether the car is charging." - case "pausing": return "Pause requested. " + (flowing.map { "\($0) is still flowing. " } ?? "") + "Waiting for the charger to stop." - case "paused": return "Paused by you. Charging stays off until you resume the plan, choose Charge now, or unplug." - case "charging": return lp.powerW > 0 ? "Charging at \(PowerFormat.text(lp.powerW)). \(request) requested." : "The charger reports charging. Waiting for a power reading." - case "sent": return "FTW received \(request). Waiting for the charger to confirm the new limit." + (flowing.map { " Still charging at \($0)." } ?? "") - case "accepted": return "Charger reports a \(limit) limit. Waiting for the car to start drawing…\(reason)" - case "not_drawing": return "Charger offers \(limit) but the car is not drawing.\(reason.isEmpty ? " Check the car’s charge limit or schedule." : reason)" - case "stalled": - if isPaused(lp) { return "The charger has not stopped after your pause request. Check the charger’s app." } - return "The charger has not acted on \(request)." + (flowing.map { " Still charging at \($0)." } ?? "") + (reason.isEmpty ? " Check the charger and the car’s charge limit or schedule." : reason) + if isPaused(lp) { + if lp.manual?.state == "pausing" || (lp.manual?.state == "stalled" && lp.powerW >= 100) { + return AppWords.text("Pause sent. Waiting for the charger to stop.") + } + return AppWords.text("Paused by you") + } + if lp.powerW >= 100 { return AppWords.format("Charging now at %@", AppWords.power(lp.powerW)) } + switch lp.manual?.state { + case "unavailable": return AppWords.text("Charger status is out of date.") + case "accepted", "charging": return AppWords.text("The charger is starting…") + case "not_drawing": return AppWords.text("The car is not taking power. Check the car's charge limit.") + case "stalled": return AppWords.text("The charger has not responded. Check the charger.") case "limited": - switch m?.limitReason { - case "charger_limit": return "The charger limits this request to \(limit) (\(request) requested)." - case "site_meter_stale": return "Paused for safety: house power readings are out of date. Charging resumes when readings return." - case "fuse_cooldown": return "Paused: main-fuse protection. Charging resumes on its own." - default: return "Main fuse limits this charge to \(limit) right now (\(request) requested)." + switch lp.manual?.limitReason { + case "charger_limit": return AppWords.text("The charger limits the current.") + case "site_meter_stale": return AppWords.text("Paused for safety: the electricity meter is not reporting.") + case "fuse_cooldown": return AppWords.text("Paused by the main fuse. It resumes on its own.") + default: return AppWords.text("The main fuse limits charging right now.") } - default: - if lp.powerW >= 100 { return "Charging at \(PowerFormat.text(lp.powerW))" } - return "Manual charge requested. Waiting for charger status." + default: return AppWords.text("Charge now sent to the charger") } } - public static func plan(_ lp: Loadpoint, nowMs: Double, canControl: Bool = true, calendar: Calendar = .current) -> String? { - if lp.planPending { return "Updating the charging plan…" } - if lp.planOutdated { return "Charging times are unavailable. Your settings are saved." } - if !lp.pluggedIn || lp.manualActive || lp.manualRestoreUnconfirmed || lp.chargingDeclined { return nil } - if let c = lp.charger, c.available != true { return nil } - if lp.gridDeferred, lp.schedule != nil { return "Waiting for tomorrow’s electricity prices. Solar surplus can charge the car meanwhile." } - if let s = lp.planStartMs, let e = lp.planEndMs, s > 0, e > nowMs { - return "Charging planned \(clock(s, calendar: calendar))–\(clock(e, calendar: calendar))." + /// The plan for this car in one line, or why there is none. + public static func plan(_ lp: Loadpoint, windows: [Loadpoint.Window], nowMs: Double, zone: TimeZone) -> String? { + if lp.planPending { return AppWords.text("Updating the plan…") } + if lp.planOutdated { return AppWords.text("Charging times are unavailable. Your goal is saved.") } + guard lp.pluggedIn, !lp.manualActive, !lp.chargingDeclined, lp.charger?.available != false else { return nil } + if lp.gridDeferred, lp.schedule != nil { return AppWords.text("Waiting for tomorrow's prices. Spare solar can charge meanwhile.") } + let ahead = windows.filter { $0.toMs > nowMs } + guard let first = ahead.first else { + return lp.schedule == nil ? nil : AppWords.text("No charging planned yet.") } - if lp.surplusOnly { return "Solar only: charging waits for spare solar power." } - if lp.schedule == nil, lp.powerW < 100 { - return canControl ? "No charging plan yet. Set a ready time, or choose Charge now." : "No charging plan yet. Ask an owner to set a ready time or start charging." + let wh = ahead.compactMap(\.energyWh).reduce(0, +) + let from = AppWords.clock(max(first.fromMs, nowMs), zone: zone) + let to = AppWords.clock(first.toMs, zone: zone) + let total = AppWords.energy(wh > 0 ? wh : (lp.planTotalWh ?? 0)) + if ahead.count > 1 { + return AppWords.format("Charging %1$@–%2$@, about %3$@, and %4$@ more times.", from, to, total, String(ahead.count - 1)) } - if lp.schedule != nil, lp.powerW < 100 { - return canControl ? "No charge window yet for this goal. Choose Charge now if you need to charge immediately." : "No charge window yet for this goal. An owner can start charging now." + return AppWords.format("Charging %1$@–%2$@, about %3$@.", from, to, total) + } + + public static func session(_ lp: Loadpoint) -> String? { + guard lp.pluggedIn, lp.sessionWh >= 50 else { return nil } + return AppWords.format("%@ this session", AppWords.energy(lp.sessionWh)) + } + + // MARK: Will the car be ready? + + public enum Readiness: Equatable, Sendable { + case reached + case onTrack(targetPct: Double, byMs: Double) + case short(reachPct: Double, targetPct: Double, byMs: Double) + } + + /// Within this much of the goal the plan counts as making it: a few + /// hundred watt-hours is rounding inside the plan, not a missed morning. + static func onTrackWh(_ capacityWh: Double) -> Double { max(300, capacityWh * 0.005) } + + /// Whether the car reaches its goal, from the plan's own shortfall for + /// it. Nil when the box has not said: no goal, a charge-now hold that + /// sets the plan aside, a plan being remade, or no shortfall read yet. + public static func readiness(_ lp: Loadpoint, level: Level?, shortfallWh: Double?, nowMs: Double, zone: TimeZone) -> Readiness? { + guard lp.pluggedIn, let s = lp.schedule, !lp.manualActive, !lp.planPending, !lp.planOutdated else { return nil } + if lp.goalComplete { return .reached } + guard let shortfallWh, shortfallWh.isFinite else { return nil } + let target = s.finishAtVehicleLimit ? 100 : s.socPct + let by = lp.targetTimeMs.flatMap { $0 > nowMs ? $0 : nil } ?? nextDeadlineMs(s, nowMs: nowMs, zone: zone) + if let pct = level?.pct, pct >= target { return .reached } + let capacity = (lp.vehicleCapacityWh ?? 0) > 0 ? lp.vehicleCapacityWh! : 60_000 + if shortfallWh <= onTrackWh(capacity) { return .onTrack(targetPct: target, byMs: by) } + let reach = max(level?.pct ?? 0, (target - shortfallWh / capacity * 100).rounded(.down)) + return .short(reachPct: reach, targetPct: target, byMs: by) + } + + public static func readiness(_ r: Readiness, level: Level?, nowMs: Double, zone: TimeZone) -> String { + let main: String + switch r { + case .reached: return AppWords.text("Goal reached.") + case .onTrack(let target, let by): + main = AppWords.format("On track: %1$@ by %2$@.", AppWords.percent(target), AppWords.day(by, nowMs: nowMs, zone: zone)) + case .short(let reach, let target, let by): + main = AppWords.format("Will not make it: about %1$@ by %2$@, not %3$@.", AppWords.percent(reach), AppWords.day(by, nowMs: nowMs, zone: zone), AppWords.percent(target)) + } + switch level?.origin { + case .assumed?, .unconfirmed?: return main + " " + AppWords.format("Counted from FTW's guess of %@.", AppWords.percent(level!.pct)) + default: return main } - return nil } - /// "85 % Ready by 07:00 · weekdays". Nil when the box has no schedule: - /// an app that cannot read one cannot claim its absence. - public static func schedule(_ lp: Loadpoint, at: Date = Date(), calendar: Calendar = .current) -> String? { - guard let s = lp.schedule else { return nil } - let t = localTime(minuteUTC: s.timeOfDayMinUTC, at: at, calendar: calendar) - let when = String(format: "%02d:%02d", t.hour, t.minute) - return "\(Int(s.socPct.rounded())) % Ready by \(when) · \(s.recurring ? days(s.days) : "once")" + // MARK: A command's outcome + + /// One command from the car view, followed from tap to outcome. + public struct Step: Equatable, Sendable { + public enum Ask: Equatable, Sendable { + case level(Double) + case chargeNow, pause, resume + } + + public enum Result: Equatable, Sendable { + /// The box did it, and read back what it now holds. + case applied(observed: Double?) + /// Something else changed it first; the box holds `observed`. + case superseded(observed: Double?) + /// Taken, not confirmed. Not a failure and not a success; the + /// note says more when the box did. + case unconfirmed(note: String?) + /// Refused, or never arrived. The reason is ready to show. + case refused(String) + } + + public var ask: Ask + /// The box said it has the intent (`cmd.ack`). + public var acked: Bool + /// What the box said happened (`cmd.result`), or why not. + public var result: Result? + + public init(ask: Ask, acked: Bool = false, result: Result? = nil) { + self.ask = ask + self.acked = acked + self.result = result + } + + public var settled: Bool { result != nil } } - public static func session(_ lp: Loadpoint) -> String? { - guard lp.pluggedIn, lp.sessionWh >= 50 else { return nil } - let kwh = lp.sessionWh / 1000 - return "\(kwh >= 10 ? String(Int(kwh.rounded())) : PowerFormat.fixed(kwh, 1)) kWh this session" + /// The receipt under a control: three marks and one sentence. + public struct Outcome: Equatable, Sendable { + public enum Mark: Equatable, Sendable { case done, active, waiting, failed } + public enum Tone: Equatable, Sendable { case working, done, warning, failed } + + public var sent: Mark + public var received: Mark + public var done: Mark + public var text: String + public var tone: Tone } - // MARK: The car's level + public static func outcome(_ step: Step, _ lp: Loadpoint?) -> Outcome { + let received: Outcome.Mark + let done: Outcome.Mark + let tone: Outcome.Tone + switch step.result { + case nil: + received = step.acked ? .done : .active + done = step.acked ? .active : .waiting + tone = .working + case .applied?: + received = .done + done = .done + tone = .done + case .superseded?: + received = .done + done = .failed + tone = .warning + case .unconfirmed?: + received = .done + done = .waiting + tone = .warning + case .refused?: + received = step.acked ? .done : .failed + done = .failed + tone = .failed + } + return Outcome(sent: .done, received: received, done: done, text: outcomeText(step, lp), tone: tone) + } - public static let socDefaultPct: Double = 50 + static func outcomeText(_ step: Step, _ lp: Loadpoint?) -> String { + if case .refused(let why)? = step.result { return why } + switch step.ask { + case .level(let asked): + let pct = AppWords.percent(asked) + switch step.result { + case nil: return AppWords.format(step.acked ? "FTW received %@." : "Sending %@ to FTW…", pct) + case .applied(let seen)?: return AppWords.format("FTW now plans from %@.", AppWords.percent(seen.map { $0 * 100 } ?? asked)) + case .superseded(let seen)?: return AppWords.format("FTW kept %@. A newer level came first.", seen.map { AppWords.percent($0 * 100) } ?? "—") + case .unconfirmed(let note)?: return note ?? AppWords.format("FTW received %@ but has not confirmed it yet.", pct) + case .refused?: return "" + } + case .chargeNow, .pause, .resume: + switch step.result { + case nil: + if step.acked { return AppWords.text("FTW received it.") } + switch step.ask { + case .pause: return AppWords.text("Sending Pause…") + case .resume: return AppWords.text("Sending Back to the plan…") + default: return AppWords.text("Sending Charge now…") + } + case .superseded?: return AppWords.text("Something else changed charging first. Try again.") + case .unconfirmed(let note)?: return note ?? AppWords.text("FTW received it but has not confirmed the result yet.") + case .applied?: + switch step.ask { + case .resume: return AppWords.text("Back to the plan. FTW decides when to charge.") + case .pause: + if let lp, lp.manualActive, lp.powerW >= 100 { return AppWords.text("Pause sent. Waiting for the charger to stop.") } + return AppWords.text("Paused. The car will not charge until you choose.") + default: + // The box holds the charge now; the charger's own account + // says whether the car takes it. + guard let lp, lp.manualActive, !isPaused(lp) else { return AppWords.text("FTW started charging. Waiting for the car to draw power.") } + if lp.powerW >= 100 { return AppWords.format("%@. The plan waits until you go back to it.", AppWords.format("Charging at %@", AppWords.power(lp.powerW))) } + switch lp.manual?.state { + case "not_drawing", "stalled", "limited", "unavailable": return manualStatus(lp) + default: return AppWords.text("FTW started charging. Waiting for the car to draw power.") + } + } + case .refused?: return "" + } + } + } - public static func socSource(_ lp: Loadpoint) -> String { - let retention: String - switch lp.socRetention { - case "session": retention = " FTW keeps this level for the same charging session, including after a box restart." - case "error": retention = " This level could not be saved for a box restart. Enter it again before relying on the plan after restarting." - default: retention = " This level must be entered again after a box restart." + /// Why a command did not happen, from the box's stable code. + public static func refusal(_ r: CmdResult) -> String { + if r.state == .expired { return AppWords.text("That took too long to reach FTW. Try again.") } + switch r.errorCode { + case "E_UNAVAILABLE": + switch r.errorArgs["reason"]?.string { + case "unplugged": return AppWords.text("Plug the car in first. FTW takes a level only while the car is plugged in.") + case "persistence_unconfirmed": return AppWords.text("FTW received it but could not save it for a restart.") + default: return AppWords.text("FTW cannot reach the charger right now. Try again shortly.") + } + case "E_PRECONDITION", "E_CONFLICT": return AppWords.text("Something changed at home while this was sending. Try again.") + case "E_SCOPE_DENIED", "E_GRANT_REVOKED": return AppWords.text("Only the home's owner can control charging.") + case "E_CMD_EXPIRED": return AppWords.text("That took too long to reach FTW. Try again.") + case "E_BOOTING": return AppWords.text("FTW is starting. Give it a minute.") + case "E_UNKNOWN_OP": return AppWords.text("Your box cannot do this yet. It may need an update.") + default: return AppWords.text("That did not go through. Try again.") } - switch lp.socSource { - case "assumed": return "Battery level needs confirmation. The plan currently assumes \(Int(lp.socPct ?? socDefaultPct)) %. Drag to match the car." + retention - case "vehicle": return "Reported by the car. Drag only to correct drift." - case "completed": return "The car stopped asking for charge. Its actual battery level is not confirmed. Drag to match the car." - default: return "Estimated from energy delivered. Drag to the real value and the plan follows." + retention + } + + /// Why a command never reached an answer. + public static func failure(_ error: Error) -> String { + if let e = error as? CommandError { + return e.code == "E_NO_ACK" ? AppWords.text("Did not reach FTW. Try again.") : AppWords.text("That did not go through. Try again.") } + if error is SessionError { return AppWords.text("Not connected to FTW right now. Try again when the connection is back.") } + return AppWords.text("That did not go through. Try again.") } // MARK: Charge now, in amps @@ -355,8 +1057,8 @@ public enum EVText { public let phases: Int } - /// The slider's range, with the box page's fallbacks: three phases at - /// 230 V, 6 to 16 A when no floor or ceiling was reported. + /// The range, with the box page's fallbacks: three phases at 230 V, 6 to + /// 16 A when no floor or ceiling was reported. public static func current(_ lp: Loadpoint) -> Current { let phases = (lp.phases ?? 0) > 0 ? lp.phases! : 3 let volts = (lp.voltageV ?? 0) > 0 ? lp.voltageV! : 230 @@ -383,40 +1085,4 @@ public enum EVText { public static func readout(_ lp: Loadpoint, amps: Int) -> String { "\(amps) A · \(PowerFormat.fixed(watts(lp, amps: amps) / 1000, 1)) kW" } - - // MARK: Battery boost - - public static let boostReserveDefaultPct = 30 - public static let boostReserveMinPct = 5 - public static let boostDurations: [(seconds: Int, label: String)] = [(1800, "30 min"), (3600, "1 h"), (7200, "2 h"), (14400, "4 h")] - public static let boostDurationDefault = 3600 - - static let boostStop: [String: String] = [ - "cancelled": "it was stopped by hand", - "expired": "its time ran out", - "vehicle_unplugged": "the car was unplugged", - "ev_target_reached": "the car reached its target", - "departure_reached": "the departure time came", - "operator_hold": "a manual charge took over", - "surplus_only": "the charger went back to spare solar only", - "site_safety_block": "the site meter went quiet", - "loadpoint_driver_unavailable": "your box lost touch with the charger", - "battery_unavailable": "your box lost touch with the house battery", - "battery_reserve_reached": "the house battery reached its reserve", - "battery_hold": "the house battery was held for something else", - "core_mode": "the site mode does not allow it", - "fuse_safety_block": "the fuse limit stepped in", - "restart_lease_invalid": "your box restarted and would not resume it", - ] - - public static func boostActive(_ lp: Loadpoint, calendar: Calendar = .current) -> String { - let reserve = lp.boostReservePct.map { " down to \(Int($0)) %" } ?? "" - let until = lp.boostExpiresAtMs.map { " until \(clock($0, calendar: calendar))" } ?? "" - return "Battery boost is on — the house battery is helping the car\(reserve)\(until)." - } - - public static func boostStopped(_ lp: Loadpoint) -> String? { - guard !lp.boostActive, let reason = lp.boostStopReason else { return nil } - return "The last boost ended because \(boostStop[reason] ?? "your box stopped it")." - } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift index 7380ae2..63b9edf 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift @@ -133,6 +133,7 @@ public final class Session { var ackTimer: Cancellable var confirmTimer: Cancellable var acked = false + var onAck: (@MainActor () -> Void)? } private var pendingPrices: [UInt32: PendingSingle] = [:] @@ -342,8 +343,9 @@ public final class Session { /// Express an intent and follow it to its outcome. Three deadlines, /// because they are three events: no ack means it never reached the box; /// ack but no result means the hardware has not confirmed; a result is - /// what actually happened. - public func command(_ op: String, args: [(String, CBOR)], guards: [Guard] = []) async throws -> CmdResult { + /// what actually happened. `onAck` runs when the box takes the intent, + /// so a screen can say so before the result. + public func command(_ op: String, args: [(String, CBOR)], guards: [Guard] = [], onAck: (@MainActor () -> Void)? = nil) async throws -> CmdResult { guard let carrier else { throw SessionError.noCarrier } let cmdId = Self.uuidv7(nowMs: scheduler.nowMs) let body = CBOR.map([ @@ -372,7 +374,7 @@ public final class Session { // and the screen has to be able to say so. p.continuation.resume(returning: CmdResult(cmdId: cmdId, state: .unconfirmed)) } - pendingCommands[cmdId] = PendingCommand(continuation: cont, ackTimer: ackTimer, confirmTimer: confirmTimer) + pendingCommands[cmdId] = PendingCommand(continuation: cont, ackTimer: ackTimer, confirmTimer: confirmTimer, onAck: onAck) carrier.send(frame) } } @@ -442,8 +444,10 @@ public final class Session { case "cmd.ack": guard let cmdId = body["cmdId"]?.string, var p = pendingCommands[cmdId] else { return } p.ackTimer.cancel() + let first = !p.acked p.acked = true pendingCommands[cmdId] = p + if first { p.onAck?() } case "cmd.result": let result = CmdResult(body) guard let p = pendingCommands.removeValue(forKey: result.cmdId) else { return } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift b/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift index 2b34642..be6c6bd 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/BoxAPI.swift @@ -26,7 +26,7 @@ extension SiteModel { do { return try JSON(parsing: res.body) } catch { - throw BoxAPIError(code: "E_BAD_BODY", help: "Your box sent something this app couldn't read.", status: res.status) + throw BoxAPIError(code: "E_BAD_BODY", help: AppWords.text("Your box sent something this app couldn't read."), status: res.status) } } @@ -48,7 +48,7 @@ extension SiteModel { } catch { // No code and no status: the wire went away or the deadline // passed. Both heal on their own. - throw BoxAPIError(code: "E_NO_ANSWER", help: "Your box didn't answer. Still trying.", status: nil) + throw BoxAPIError(code: "E_NO_ANSWER", help: AppWords.text("Your box didn't answer. Still trying."), status: nil) } } @@ -60,29 +60,29 @@ extension SiteModel { /// What happens now, for a refusal that never reached a handler. public static func refusalHelp(_ code: String, _ args: [String: CBOR] = [:]) -> String { switch code { - case "E_UNKNOWN_OP": return "Your box doesn't have that yet — it may be running older software." + case "E_UNKNOWN_OP": return AppWords.text("Your box doesn't have that yet — it may be running older software.") case "E_UNAVAILABLE": - return args["reason"]?.string == "busy" ? "Your box is busy with something else. This will fill in shortly." : "Your box can't answer that right now. Still trying." - case "E_SCOPE_DENIED": return "Only the owner of this home can change that." - case "E_GRANT_REVOKED": return "Your access to this home was withdrawn by its owner." - case "E_USE_CMD": return "That has to be done from the controls on the home screen." - case "E_UNSUPPORTED_MEDIA", "E_WHOLE_DOCUMENT", "E_LOCAL_ONLY": return "That one is only available on your box's own page, from home." - case "E_RESPONSE_TOO_LARGE": return "That's more than we can send over your connection — narrow the range." - case "E_NEEDS_STEP_UP": return "Your box needs more proof than this phone can give. Do it on your box." - default: return "That didn't work. Nothing on your box has changed." + return AppWords.text(args["reason"]?.string == "busy" ? "Your box is busy with something else. This will fill in shortly." : "Your box can't answer that right now. Still trying.") + case "E_SCOPE_DENIED": return AppWords.text("Only the owner of this home can change that.") + case "E_GRANT_REVOKED": return AppWords.text("Your access to this home was withdrawn by its owner.") + case "E_USE_CMD": return AppWords.text("That has to be done from the controls on the home screen.") + case "E_UNSUPPORTED_MEDIA", "E_WHOLE_DOCUMENT", "E_LOCAL_ONLY": return AppWords.text("That one is only available on your box's own page, from home.") + case "E_RESPONSE_TOO_LARGE": return AppWords.text("That's more than we can send over your connection — narrow the range.") + case "E_NEEDS_STEP_UP": return AppWords.text("Your box needs more proof than this phone can give. Do it on your box.") + default: return AppWords.text("That didn't work. Nothing on your box has changed.") } } /// What happens now, for a status a handler answered with. public static func statusHelp(_ status: Int, _ code: String?) -> String { - if code == "E_LAST_OWNER_PROTECTED" { return "Someone has to own this home, so the last owner cannot be removed." } + if code == "E_LAST_OWNER_PROTECTED" { return AppWords.text("Someone has to own this home, so the last owner cannot be removed.") } switch status { - case 404: return "That's no longer on your box." - case 403: return "Your box refused that from this phone." - case 409: return "Something changed on your box first. Nothing here was applied." - case 503: return "Your box can't answer that yet. Still trying." - case 500...: return "Something went wrong on your box. Nothing has changed." - default: return "Your box couldn't do that." + case 404: return AppWords.text("That's no longer on your box.") + case 403: return AppWords.text("Your box refused that from this phone.") + case 409: return AppWords.text("Something changed on your box first. Nothing here was applied.") + case 503: return AppWords.text("Your box can't answer that yet. Still trying.") + case 500...: return AppWords.text("Something went wrong on your box. Nothing has changed.") + default: return AppWords.text("Your box couldn't do that.") } } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index 5e29750..0ba7887 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -2,56 +2,55 @@ import Foundation import FTWShared import Observation -/// The charger sheet. Everything on it is a fact the box served; the -/// controls express intent with an expiry, and the box decides. After any -/// outcome the charger is read again, because the box's account is the -/// truth to repaint from. +/// The car view. Everything on it is a fact the box served; the controls +/// express intent and the box decides. Each command is followed from the +/// tap to the box's answer and stays on screen, and after any outcome the +/// charger is read again, because the box's account is the truth to +/// repaint from. @Observable @MainActor public final class LoadpointsModel: Activatable { - public enum Control: Sendable { case hold, boost, soc, surplus } - public enum Outcome: Sendable { case hold, release, pause, boost, unboost, soc, surplusOn, surplusOff } - - public enum Command: Sendable { - case idle - case sending(Control) - case applied(Control, Outcome) - case unconfirmed(Control) - case failed(Control, String) - - public var isSending: Bool { if case .sending = self { return true }; return false } + public enum GoalSave: Equatable, Sendable { + case saving + case saved + case removed + case failed(String) } public private(set) var points: [Loadpoint] = [] public private(set) var windows: [String: [Loadpoint.Window]] = [:] public private(set) var planMissing = false - public private(set) var planPending = false - public private(set) var planOutdated = false public private(set) var loading = false /// Whether the box has ever answered. No answer is not an empty bay. public private(set) var loaded = false public private(set) var readAtMs: Double? public private(set) var error: String? - public private(set) var command: Command = .idle - public private(set) var commandLoadpointID: String? - /// Applied choices stay on screen until a later read confirms them. - public private(set) var acceptedSoc: [String: Double] = [:] - public private(set) var acceptedSurplus: [String: Bool] = [:] - - // The sheet's drafts, held here so a reread never snaps a control from - // under a finger. - public var ampsDraft: [String: Int] = [:] - public var socDraft: [String: Double] = [:] - public var surplusDraft: [String: Bool] = [:] + /// The plan's shortfall per car, in battery watt-hours, read while the + /// car view is open. Missing means the plan has no goal for that car. + public private(set) var shortfallWh: [String: Double] = [:] + public private(set) var shortfallReadAtMs: Double? + /// The last level and the last charge command per charger, from the + /// tap to the box's answer. + public private(set) var levelStep: [String: EVText.Step] = [:] + public private(set) var holdStep: [String: EVText.Step] = [:] + public private(set) var goalSave: [String: GoalSave] = [:] + /// A level tapped and not yet answered: what the view shows meanwhile. + public private(set) var levelWanted: [String: Double] = [:] + /// Levels set from this phone, kept while the car stays plugged in, so + /// the view can say "you set it". + public private(set) var levelSet: [String: EVText.LevelSet] = [:] @ObservationIgnored private unowned let site: SiteModel @ObservationIgnored private let charging: ChargingWatch? @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var planAsk: LiveAsk? @ObservationIgnored private var active = 0 + @ObservationIgnored private var planWatchers = 0 @ObservationIgnored private var token = 0 - @ObservationIgnored private var settle: Cancellable? @ObservationIgnored private var inlinePlan = false @ObservationIgnored private var reading: Task? + @ObservationIgnored private var levelSending: Set = [] + @ObservationIgnored private var clearing: [String: Cancellable] = [:] public init(site: SiteModel, charging: ChargingWatch?) { self.site = site @@ -74,11 +73,42 @@ 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. + public func watchPlan() { + planWatchers += 1 + if planAsk == nil { + planAsk = LiveAsk(site: site, want: { [weak self] in + guard let self, self.planWatchers > 0, self.site.documentVisible, self.site.hasPassthrough, self.inlinePlan else { return nil } + let goals = self.points.filter { $0.pluggedIn && $0.schedule != nil && !$0.planPending && !$0.planOutdated } + guard !goals.isEmpty else { return nil } + return "plan \(goals.map(Self.planKey).joined(separator: ",")) \(Int(self.site.nowMs / 60_000))" + }, ask: { [weak self] in try await self?.readShortfall() }) + } + planAsk?.evaluate() + } + + public func unwatchPlan() { + planWatchers = max(0, planWatchers - 1) + planAsk?.evaluate() + } + + /// What would make a shortfall read out of date for one car. + static func planKey(_ lp: Loadpoint) -> String { + let s = lp.schedule + return "\(lp.id):\(lp.socPct ?? -1):\(s?.socPct ?? -1):\(s?.timeOfDayMinUTC ?? -1):\(lp.planWindows.count):\(Int(lp.planWindows.first?.fromMs ?? 0)):\(Int(lp.planTotalWh ?? 0))" + } + /// Out of date: the read failed, the stream stopped, or it is old. public var stale: Bool { error != nil || site.session.phase != .streaming || (readAtMs.map { site.nowMs - $0 >= 15_000 } ?? false) } + /// Read now rather than at the next tick. + public func reread() { + Task { try? await self.loadChargers() } + } + // MARK: Reads public func loadChargers() async throws { @@ -95,35 +125,35 @@ public final class LoadpointsModel: Activatable { private func readChargers() async throws { token += 1 let mine = token - let socChoices = acceptedSoc - let surplusChoices = acceptedSurplus loading = true defer { if mine == token { loading = false } } do { let wire = try await site.callBox(.get, "/api/loadpoints") guard mine == token else { return } let list = wire?["loadpoints"]?.array ?? [] + let before = Dictionary(points.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) points = list.map(Loadpoint.init) + 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 { - planPending = points.contains { $0.planPending } - planOutdated = points.contains { $0.planOutdated } windows = Dictionary(uniqueKeysWithValues: points.map { ($0.id, $0.planWindows) }) planMissing = false } - // A read that started before a command cannot confirm its choice. - acceptedSoc = acceptedSoc.filter { socChoices[$0.key] != $0.value } - acceptedSurplus = acceptedSurplus.filter { surplusChoices[$0.key] != $0.value } loaded = true readAtMs = site.scheduler.nowMs error = nil site.shareCars(points, fresh: true) + planAsk?.evaluate() } catch { guard mine == token else { return } if let e = error as? BoxAPIError, points.isEmpty { self.error = e.help } else { - self.error = "Not up to date — your box is out of reach" + self.error = AppWords.text("Not up to date — your box is out of reach") } throw error } @@ -138,14 +168,14 @@ public final class LoadpointsModel: Activatable { do { let wire = try await site.callBox(.get, "/api/mpc/plan") guard mine == token else { return } - planPending = wire?["meta"]?["replanning"]?.bool == true - planOutdated = wire?["meta"]?["outdated"]?.bool == true + let pending = wire?["meta"]?["replanning"]?.bool == true || wire?["meta"]?["outdated"]?.bool == true let actions = wire?["plan"]?["actions"]?.array ?? [] var out = [String: [Loadpoint.Window]]() for lp in points { - out[lp.id] = planPending || planOutdated || lp.planPending || lp.planOutdated ? [] : Self.chargeWindows(actions, loadpointID: lp.id) + out[lp.id] = pending || lp.planPending || lp.planOutdated ? [] : Self.chargeWindows(actions, loadpointID: lp.id) } windows = out + takeShortfall(wire, pending: pending) planMissing = false } catch { guard mine == token else { return } @@ -153,6 +183,23 @@ public final class LoadpointsModel: Activatable { } } + private func readShortfall() async throws { + let wire = try await site.callBox(.get, "/api/mpc/plan") + takeShortfall(wire, pending: wire?["meta"]?["replanning"]?.bool == true || wire?["meta"]?["outdated"]?.bool == true) + } + + /// A plan being remade says nothing about whether the car makes it. + private func takeShortfall(_ wire: JSON?, pending: Bool) { + var out = [String: Double]() + if !pending { + for (id, wh) in wire?["plan"]?["loadpoint_shortfall_wh"]?.object ?? [] { + if let v = wh.number, v.isFinite { out[id] = max(0, v) } + } + } + shortfallWh = out + shortfallReadAtMs = site.scheduler.nowMs + } + /// Adjacent charging slots fold into one window; a person asks when it /// will charge, not when the reason changes. static func chargeWindows(_ actions: [JSON], loadpointID: String) -> [Loadpoint.Window] { @@ -161,189 +208,201 @@ public final class LoadpointsModel: Activatable { guard let start = a["slot_start_ms"]?.number, let len = a["slot_len_min"]?.number, let w = a["loadpoint_power_w"]?[loadpointID]?.number, w > 0 else { continue } let end = start + len * 60_000 + let wh = w * len / 60 if var last = out.last, start <= last.toMs { last.toMs = end last.peakW = max(last.peakW ?? 0, w) + last.energyWh = (last.energyWh ?? 0) + wh out[out.count - 1] = last } else { - out.append(Loadpoint.Window(fromMs: start, toMs: end, peakW: w, energyWh: nil)) + out.append(Loadpoint.Window(fromMs: start, toMs: end, peakW: w, energyWh: wh)) } } return out } - // MARK: Commands - - /// Charge now: a hold at a chosen current until Stop or an unplug. - public func chargeNow(_ lp: Loadpoint, amps: Int) async { - await send(Contract.opLoadpointHold, lp.id, [("id", .text(lp.id)), ("power_w", .number(EVText.watts(lp, amps: amps))), ("hold_s", .int(0)), ("phase_mode", .text(EVText.current(lp).phases == 1 ? "1p" : "3p"))], .hold, .hold, CommandText.help) + // MARK: The level + + /// The level a person tapped. It goes to the box at once; taps made + /// while one is on its way are folded into the next send, so ±5 pressed + /// three times sends the last value, not three. + public func setLevel(_ lp: Loadpoint, pct: Double) async { + let id = lp.id + levelWanted[id] = min(100, max(0, pct.rounded())) + guard !levelSending.contains(id) else { return } + levelSending.insert(id) + defer { levelSending.remove(id) } + var sent: Double? + while let want = levelWanted[id], want != sent { + sent = want + await follow(id, .level(want), op: Contract.opLoadpointSocSet, args: [("id", .text(id)), ("soc", .number(want / 100))], into: \.levelStep, tag: "level") + if case .applied(let seen)? = levelStep[id]?.result { + levelSet[id] = EVText.LevelSet(pct: seen.map { ($0 * 100).rounded() } ?? want, atMs: site.scheduler.nowMs) + } + if levelWanted[id] == want { + // Settled and nothing newer: the box's own reading takes over. + try? await loadChargers() + if levelWanted[id] == want { levelWanted[id] = nil } + } + } } - public func pauseCharging(_ lp: Loadpoint) async { - await send(Contract.opLoadpointHold, lp.id, [("id", .text(lp.id)), ("power_w", .int(0)), ("hold_s", .int(0))], .hold, .pause, CommandText.help) + /// The level to show: a tap on its way, else where the car stands. + public func level(_ lp: Loadpoint) -> EVText.Level? { + guard var level = EVText.level(lp, answeredAtMs: readAtMs ?? site.nowMs, nowMs: site.nowMs, set: levelSet[lp.id]) else { return nil } + if let want = levelWanted[lp.id] { + level.pct = want + level.origin = .you + level.atMs = site.nowMs + level.setPct = nil + } + return level } - /// Release the hold; the plan takes back over. - public func stopCharging(_ lp: Loadpoint) async { - await send(Contract.opLoadpointHold, lp.id, [("id", .text(lp.id)), ("clear", .bool(true))], .hold, .release, CommandText.help) - } + // MARK: Charge now, Pause, Back to the plan - /// Let the house battery push the car for a bounded while. - public func boost(_ lp: Loadpoint, reservePct: Int, durationS: Int) async { - await send(Contract.opLoadpointBoost, lp.id, [("id", .text(lp.id)), ("min_battery_soc_pct", .int(reservePct)), ("duration_s", .int(durationS))], .boost, .boost, CommandText.boostHelp) + /// Charge now: the charger's full current, held until Back to the plan + /// or an unplug. No time limit and no level gate: the level is often a + /// guess, and a guess must not lock the owner out (Fredrik, 4 Sep 2026). + public func chargeNow(_ lp: Loadpoint) async { + let range = EVText.current(lp) + await hold(lp, .chargeNow, [("id", .text(lp.id)), ("power_w", .number(EVText.watts(lp, amps: range.maxA))), ("hold_s", .int(0)), ("phase_mode", .text(range.phases == 1 ? "1p" : "3p"))]) } - public func stopBoost(_ lp: Loadpoint) async { - await send(Contract.opLoadpointBoost, lp.id, [("id", .text(lp.id)), ("cancel", .bool(true))], .boost, .unboost, CommandText.boostHelp) + /// A zero hold: the charger stays off until the owner chooses. + public func pause(_ lp: Loadpoint) async { + await hold(lp, .pause, [("id", .text(lp.id)), ("power_w", .int(0)), ("hold_s", .int(0))]) } - /// Whole percent from the slider, a fraction to the box. - public func setSoc(_ lp: Loadpoint, pct: Double) async { - await send(Contract.opLoadpointSocSet, lp.id, [("id", .text(lp.id)), ("soc", .number(pct / 100))], .soc, .soc, CommandText.socHelp) + /// Release the hold; the plan takes back over. + public func backToPlan(_ lp: Loadpoint) async { + await hold(lp, .resume, [("id", .text(lp.id)), ("clear", .bool(true))]) } - public func setSurplusOnly(_ lp: Loadpoint, _ on: Bool) async { - await send(Contract.opLoadpointSurplusOnlySet, lp.id, [("id", .text(lp.id)), ("surplus_only", .bool(on))], .surplus, on ? .surplusOn : .surplusOff, CommandText.help) + private func hold(_ lp: Loadpoint, _ ask: EVText.Step.Ask, _ args: [(String, CBOR)]) async { + if let step = holdStep[lp.id], !step.settled { return } + await follow(lp.id, ask, op: Contract.opLoadpointHold, args: args, into: \.holdStep, tag: "hold") + try? await loadChargers() + charging?.refresh() } - private func send(_ op: String, _ id: String, _ args: [(String, CBOR)], _ of: Control, _ did: Outcome, _ help: (CmdResult) -> String) async { - if command.isSending { return } - settle?.cancel() - commandLoadpointID = id - command = .sending(of) + /// One command, written into `into` at every stage: sent, received, + /// and what the box did or why not. + private func follow(_ id: String, _ ask: EVText.Step.Ask, op: String, args: [(String, CBOR)], into path: ReferenceWritableKeyPath, tag: String) async { + let key = "\(tag) \(id)" + clearing[key]?.cancel() + self[keyPath: path][id] = EVText.Step(ask: ask) + let result: EVText.Step.Result do { - let result = try await site.command(op, args: args) - switch result.state { - case .applied: - command = .applied(of, did) - if of == .soc, let soc = args.first(where: { $0.0 == "soc" })?.1.double { acceptedSoc[id] = (soc * 100).rounded() } - if of == .surplus, let on = args.first(where: { $0.0 == "surplus_only" })?.1.bool { acceptedSurplus[id] = on } - case .unconfirmed: - command = .unconfirmed(of) - default: - command = .failed(of, help(result)) + let r = try await site.core.command(op, args: args, onAck: { [weak self] in + guard let self, self[keyPath: path][id]?.ask == ask else { return } + self[keyPath: path][id]?.acked = true + }) + switch r.state { + case .applied: result = .applied(observed: r.observedValue) + case .superseded: result = .superseded(observed: r.observedValue) + case .unconfirmed: result = .unconfirmed(note: r.errorCode == nil ? nil : EVText.refusal(r)) + case .rejected, .expired: result = .refused(EVText.refusal(r)) } - } catch let e as CommandError { - command = .failed(of, e.help) + self[keyPath: path][id]?.acked = true } catch { - command = .failed(of, "FTW did not confirm the request. Reading its current state…") + result = .refused(EVText.failure(error)) } - if case .applied = command { - settle = site.scheduler.after(6_000) { [weak self] in self?.command = .idle } + guard self[keyPath: path][id]?.ask == ask else { return } + self[keyPath: path][id]?.result = result + // A success fades once the screen shows its effect; a refusal stays + // until the next tap. + if case .applied = result { + clearing[key] = site.scheduler.after(Self.doneShownMs) { [weak self] in + guard let self, self[keyPath: path][id]?.result == result else { return } + self[keyPath: path][id] = nil + } } - try? await loadChargers() - charging?.refresh() } - // MARK: Configure routes: schedule, vehicle + static let doneShownMs: Double = 20_000 - /// Save a goal in one PUT, so it costs one ceremony at most. - public func saveSchedule(_ lp: Loadpoint, socPct: Double, hour: Int, minute: Int, recurring: Bool, days: Int, surplusUnlockPct: Double, calendar: Calendar = .current) async throws { - guard let minUTC = EVText.minuteUTC(hour: hour, minute: minute, calendar: calendar) else { - throw BoxAPIError(code: "E_BAD_TIME", help: "Choose a valid ready time.", status: nil) - } - guard !recurring || days != 0 else { throw BoxAPIError(code: "E_NO_DAYS", help: "Choose at least one day.", status: nil) } - guard (10...100).contains(socPct) else { throw BoxAPIError(code: "E_BAD_SOC", help: "Choose a charge target from 10 to 100 %.", status: nil) } - guard (0...100).contains(surplusUnlockPct) else { throw BoxAPIError(code: "E_BAD_SOC", help: "Choose a home battery level from 1 to 100 %.", status: nil) } - _ = try await site.callBox(.put, "/api/loadpoints/\(Self.escape(lp.id))/schedule", body: [ - "soc": .number(socPct / 100), - "time_of_day_min_utc": .number(Double(minUTC)), - "recurring": .bool(recurring), - "days": .number(Double(!recurring || days == 0x7f ? 0 : days & 0x7f)), - "surplus_unlock_bat_soc": .number(surplusUnlockPct / 100), - ]) - try? await loadChargers() - charging?.refresh() - } + // MARK: The goal - public func removeSchedule(_ lp: Loadpoint) async throws { - _ = try await site.callBox(.delete, "/api/loadpoints/\(Self.escape(lp.id))/schedule") - // The DELETE confirms the removal even if the reread fails. - if let i = points.firstIndex(where: { $0.id == lp.id }) { - points[i].schedule = nil - points[i].targetSocPct = nil + /// Save a goal in one PUT. The ready time goes as the household clock + /// and as today's UTC minute: a box that reads the clock takes it, an + /// older one reads the minute on today's date, as Core does. + public func saveGoal(_ lp: Loadpoint, socPct: Double, clock: Int, recurring: Bool, days: Int, zone: TimeZone) async { + guard (0..<1440).contains(clock) else { goalSave[lp.id] = .failed(AppWords.text("Choose a valid ready time.")); return } + guard (10...100).contains(socPct) else { goalSave[lp.id] = .failed(AppWords.text("Choose a charge target from 10 to 100 %.")); return } + guard !recurring || days & 0x7f != 0 else { goalSave[lp.id] = .failed(AppWords.text("Choose at least one day.")); return } + let minuteUTC = EVText.minuteUTC(clock: clock, at: Date(timeIntervalSince1970: site.nowMs / 1000), zone: zone) + let mask = !recurring || days & 0x7f == 0x7f ? 0 : days & 0x7f + goalSave[lp.id] = .saving + do { + _ = try await site.callBox(.put, "/api/loadpoints/\(Self.escape(lp.id))/schedule", body: [ + "soc": .number(socPct / 100), + "time_of_day_min": .number(Double(clock)), + "time_of_day_min_utc": .number(Double(minuteUTC)), + "recurring": .bool(recurring), + "days": .number(Double(mask)), + "surplus_unlock_bat_soc": .number((lp.schedule?.surplusUnlockPct ?? 0) / 100), + ]) + goalSave[lp.id] = .saved + } catch let e as BoxAPIError { + goalSave[lp.id] = .failed(e.help) + } catch { + goalSave[lp.id] = .failed(AppWords.text("That did not go through. Try again.")) } try? await loadChargers() charging?.refresh() } - /// The car's usable battery size, 1 to 300 kWh. - public func setCapacity(_ lp: Loadpoint, kwh: Double) async throws -> String { - guard kwh.isFinite, (1...300).contains(kwh) else { - throw BoxAPIError(code: "E_BAD_CAPACITY", help: "Enter the usable battery size from 1 to 300 kWh.", status: nil) - } - let wh = (kwh * 1000).rounded() - _ = try await site.callBox(.post, "/api/loadpoints/\(Self.escape(lp.id))/vehicle", body: ["capacity_wh": .number(wh)]) + public func removeGoal(_ lp: Loadpoint) async { + goalSave[lp.id] = .saving do { - try await load() + _ = try await site.callBox(.delete, "/api/loadpoints/\(Self.escape(lp.id))/schedule") + // The DELETE confirms the removal even if the reread fails. + if let i = points.firstIndex(where: { $0.id == lp.id }) { + points[i].schedule = nil + points[i].targetSocPct = nil + } + goalSave[lp.id] = .removed + } catch let e as BoxAPIError { + goalSave[lp.id] = .failed(e.help) } catch { - return "Battery size saved. Current charging status is unavailable." + goalSave[lp.id] = .failed(AppWords.text("That did not go through. Try again.")) } + try? await loadChargers() charging?.refresh() - if let active = points.first(where: { $0.id == lp.id })?.vehicleCapacityWh, active != wh { - return "Saved as the usual battery size. This session uses \(EnergyFormat.short(active / 1000)) kWh." + } + + public static func goalText(_ save: GoalSave) -> String { + switch save { + case .saving: return AppWords.text("Saving the goal…") + case .saved: return AppWords.text("Goal saved. FTW is updating the plan.") + case .removed: return AppWords.text("Goal removed.") + case .failed(let help): return help } - return "Battery size saved. The plan uses this size for its estimates." } static func escape(_ id: String) -> String { id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))) ?? id } - // MARK: What the sheet shows + // MARK: What the view shows - public func socShown(_ lp: Loadpoint) -> Double { - socDraft[lp.id] ?? acceptedSoc[lp.id] ?? lp.socPct ?? EVText.socDefaultPct - } + /// The household zone, from the box's hello. + public var zone: TimeZone { EVText.householdZone(boxTZ: site.session.box?.tz) } - public func surplusShown(_ lp: Loadpoint) -> Bool { - surplusDraft[lp.id] ?? acceptedSurplus[lp.id] ?? lp.surplusOnly + public func readiness(_ lp: Loadpoint) -> EVText.Readiness? { + EVText.readiness(lp, level: level(lp), shortfallWh: shortfallWh[lp.id], nowMs: site.nowMs, zone: zone) } - /// The slider's amps: the draft, else the running hold, else the ceiling. - public func ampsShown(_ lp: Loadpoint) -> Int { - let range = EVText.current(lp) - let held: Int? = lp.manualActive && !lp.manualRestoreUnconfirmed && !EVText.isPaused(lp) ? lp.manualChargeW.map { EVText.amps(lp, watts: $0) } : nil - return min(range.maxA, max(range.minA, ampsDraft[lp.id] ?? held ?? range.maxA)) + public func outcome(level lp: Loadpoint) -> EVText.Outcome? { + levelStep[lp.id].map { EVText.outcome($0, lp) } } - /// One sentence per thing the box did, under the control that asked. - public func outcomeSentence(_ did: Outcome, _ lp: Loadpoint) -> String { - switch did { - case .hold: return stale ? "Waiting for current charger status." : lp.manual != nil ? EVText.status(lp) : "FTW received your charge request. Waiting for charger status." - case .release: return "The plan decides when to charge." - case .pause: return EVText.status(lp) - case .boost: return "Battery boost selected. The power readings show what the house battery supplies." - case .unboost: return "Boost stopped — the plan decides again." - case .soc: - if let v = acceptedSoc[lp.id] { return "Charge level accepted: \(Int(v)) %. Waiting for updated charging status." } - let pct = Int(lp.socPct ?? socShown(lp)) - let base = lp.socRetention == "error" ? "Charge level updated: \(pct) %. It could not be saved for a box restart." : "Charge level saved: \(pct) %." - let tail = lp.schedule == nil && !lp.manualActive && !lp.surplusOnly ? " Set a ready time, or choose Charge now." : planMissing ? " Charging times are not available yet." : "" - return base + tail - case .surplusOn: return acceptedSurplus[lp.id] != nil ? "Solar rule accepted. Waiting for updated charging status." : "Solar rule saved. The plan uses spare solar only." - case .surplusOff: return acceptedSurplus[lp.id] != nil ? "Solar rule accepted. Waiting for updated charging status." : "Solar rule saved. The plan may use grid power again." - } + public func outcome(hold lp: Loadpoint) -> EVText.Outcome? { + holdStep[lp.id].map { EVText.outcome($0, lp) } } - /// The outcome line under one control, or nil. - public func outcome(for control: Control, _ lp: Loadpoint) -> String? { - guard commandLoadpointID == lp.id else { - if control == .soc, acceptedSoc[lp.id] != nil { return outcomeSentence(.soc, lp) } - if control == .surplus, acceptedSurplus[lp.id] != nil { return "Solar rule accepted. Waiting for updated charging status." } - return nil - } - switch command { - case .applied(let of, let did) where of == control && (of != .hold || did == .release): - return outcomeSentence(did, lp) - case .unconfirmed(let of) where of == control: - return "FTW received the request. Its result is not confirmed yet." - case .failed(let of, let help) where of == control: - return help - default: - if control == .soc, acceptedSoc[lp.id] != nil { return outcomeSentence(.soc, lp) } - if control == .surplus, acceptedSurplus[lp.id] != nil { return "Solar rule accepted. Waiting for updated charging status." } - return nil - } + public func holdSending(_ lp: Loadpoint) -> Bool { + holdStep[lp.id].map { !$0.settled } ?? false } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift index 897f1b1..5828d29 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift @@ -296,3 +296,85 @@ public final class SavingsModel: Activatable { periods = next.today.available || next.week.available ? next : nil } } + +/// "Is FTW in control?" in one calm line. Core owns every verdict: the +/// stream's dispatch block and each device's control feedback with its +/// severity. This only picks words, and never says yes from readings that +/// are not live: a cache shows when it was last known, in grey. +public enum InControl { + public enum Tone: Equatable, Sendable { case ok, warning, alarm, neutral, stale } + + public struct Answer: Equatable, Sendable { + public let tone: Tone + public let title: String + public let detail: String? + } + + /// - live: the stream is streaming, not the cache, every source live. + /// - status: GET /api/status, nil until it has answered. + /// - statusFresh: that answer is current. + public static func answer(live: Bool, lastKnownMs: Double?, dispatchBlockedBy: [String], status: JSON?, statusFresh: Bool, zone: TimeZone = .current) -> Answer { + guard live else { + return Answer(tone: .stale, title: AppWords.text("Not live right now"), detail: lastKnownMs.map { AppWords.format("Last known %@.", AppWords.clock($0, zone: zone)) } ?? AppWords.text("Waiting for your box.")) + } + if !dispatchBlockedBy.isEmpty { + return Answer(tone: .warning, title: AppWords.text("FTW is not in control right now"), detail: AppWords.text("A meter stopped reporting. Your home runs on grid power as usual.")) + } + guard let status, statusFresh else { + return Answer(tone: .neutral, title: AppWords.text("Checking…"), detail: AppWords.text("Asking your box how its devices respond.")) + } + guard let rows = status["control_feedback"]?.array else { + // A box from before control feedback: live and not blocked is + // all it says, so that is all this says. + return Answer(tone: .neutral, title: AppWords.text("Connected and live"), detail: AppWords.text("This box does not report whether devices follow it.")) + } + let valid = rows.filter { $0["driver"]?.string != nil && $0["status"]?.string != nil } + let alarms = valid.filter { $0["severity"]?.string == "alarm" } + let warnings = valid.filter { $0["severity"]?.string == "warning" } + if let first = alarms.first { + let title = alarms.count == 1 ? AppWords.format("%@ needs attention now", noun(first)) : AppWords.format("%@ devices need attention now", String(alarms.count)) + return Answer(tone: .alarm, title: title, detail: reasonTitle(first)) + } + if let first = warnings.first { + let title = warnings.count == 1 ? AppWords.format("%@ needs a look", noun(first)) : AppWords.format("%@ devices need a look", String(warnings.count)) + return Answer(tone: .warning, title: title, detail: reasonTitle(first)) + } + if !valid.contains(where: { $0["status"]?.string != "not_controlled" }) { + return Answer(tone: .neutral, title: AppWords.text("FTW controls no device right now"), detail: nil) + } + return Answer(tone: .ok, title: AppWords.text("Yes, FTW is in control"), detail: AppWords.text("Every device follows FTW.")) + } + + static func noun(_ row: JSON) -> String { + switch row["kind"]?.string { + case "battery": return AppWords.text("The battery") + case "ev", "v2x_charger": return AppWords.text("The charger") + case "pv": return AppWords.text("The solar inverter") + default: return AppWords.text("A device") + } + } + + /// Core's reason titles, the box page's words (web/control-feedback.js). + static let reasonTitles: [String: String] = [ + "waiting_response": "Adjusting", "no_command": "No command yet", "not_connected": "No car connected", + "no_plan_budget": "Waiting for the plan", "pv_surplus_pause": "Waiting for spare solar", "wake_kick": "Starting the charger", + "vehicle_complete": "Car is full", "vehicle_limit_completion": "Car reached its limit", "vehicle_not_requesting": "Car not taking power", + "battery_full": "Battery full", "battery_nearly_full": "Battery nearly full", "battery_nearly_empty": "Battery nearly empty", + "core_limit": "Limited by FTW", "fuse_limit": "Limited by the main fuse", "fuse_cooldown": "Waiting after a fuse limit", + "charger_limit": "Limited by FTW's charger setting", "device_limit": "Charger limit", "offered_current_lower": "Charger offers less current", + "device_fault": "Device fault", "telemetry_stale": "No fresh readings", "readings_lost": "Lost control", + "command_failed": "Commands failing", "command_unconfirmed": "Command not confirmed", "default_failed": "Cannot hand back control", + "response_unknown": "No power reading", "observe_only": "Monitoring only", "disabled": "Disabled", "device_control": "Own control", + "site_meter_stale": "Paused for safety", "site_phase_currents_stale": "Paused for safety", + ] + + static let statusTitles: [String: String] = [ + "following": "Following FTW", "waiting": "Waiting", "limited": "Limited", "not_following": "Not following", + "no_contact": "No contact", "not_controlled": "Not controlled by FTW", + ] + + static func reasonTitle(_ row: JSON) -> String { + let title = reasonTitles[row["reason"]?.string ?? ""] ?? statusTitles[row["status"]?.string ?? ""] ?? "Checking" + return AppWords.text(title) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift index 5d177e7..2ed611c 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift @@ -49,35 +49,6 @@ import FTWDemo #expect(Flow.fuse(status: home.status.status!) != nil) } - @Test func theChargerSheetSendsIntentAndRereads() async throws { - let (_, home, s) = await demo() - home.loadpoints.activate() - await run(s, 1_000) - let lp = try #require(home.loadpoints.points.first) - #expect(lp.pluggedIn) - #expect(home.loadpoints.windows[lp.id] != nil) - - let hold = Task { await home.loadpoints.chargeNow(lp, amps: 10) } - await run(s, 1_000) - await hold.value - #expect(home.loadpoints.points.first?.manualActive == true) - - let release = Task { await home.loadpoints.stopCharging(lp) } - await run(s, 1_000) - await release.value - #expect(home.loadpoints.outcome(for: .hold, lp) == "The plan decides when to charge.") - - let soc = Task { await home.loadpoints.setSoc(lp, pct: 55) } - await run(s, 1_000) - await soc.value - #expect(home.loadpoints.points.first?.socPct == 55) - - let save = Task { try await home.loadpoints.saveSchedule(lp, socPct: 90, hour: 6, minute: 30, recurring: true, days: 0b0011111, surplusUnlockPct: 0) } - await run(s, 1_000) - try await save.value - #expect(home.loadpoints.points.first?.schedule?.socPct == 90) - } - @Test func leavingTheDemoTouchesNoSavedHome() async { let (app, _, _) = await demo() app.exitDemo() diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift new file mode 100644 index 0000000..41ba87a --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift @@ -0,0 +1,494 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWDemo + +/// The car view's rules: where a level comes from and how old it is, the +/// receipt under every command, the ready time the box really uses, and +/// whether the car makes it. In English unless a test says otherwise. +@Suite struct CarTextTests { + static let utc = TimeZone(identifier: "UTC")! + static let stockholm = TimeZone(identifier: "Europe/Stockholm")! + + func lp(_ json: String) throws -> Loadpoint { + Loadpoint(try JSON(parsing: Array(json.utf8))) + } + + /// 2026-10-12 10:00:00 UTC. + static let t: Double = 1_791_799_200_000 + + // MARK: The level's origin and age + + @Test func theLevelSaysWhoSaidIt() throws { + try AppWords.$language.withValue(.en) { + let t = Self.t + let assumed = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.2,"soc_source":"assumed"}"#) + let a = try #require(EVText.level(assumed, answeredAtMs: t, nowMs: t)) + #expect(a.origin == .assumed) + #expect(a.pct == 20) + #expect(EVText.origin(a, zone: Self.utc) == "FTW assumes 20\u{00A0}%. Nobody has told it the real level.") + #expect(EVText.originShort(a, zone: Self.utc) == "FTW assumes") + + let estimated = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.46,"soc_source":"inferred"}"#) + #expect(EVText.level(estimated, answeredAtMs: t, nowMs: t)?.origin == .estimated) + + // Set from this phone: said as such, and once the box has added + // what charged since, both numbers are honest. + let set = EVText.LevelSet(pct: 40, atMs: t - 600_000) + let you = try #require(EVText.level(estimated, answeredAtMs: t, nowMs: t, set: set)) + #expect(you.origin == .you) + #expect(you.pct == 46) + #expect(EVText.origin(you, zone: Self.utc) == "You set 40\u{00A0}% at 09:50. FTW adds what has charged since.") + let same = try #require(EVText.level(try lp(#"{"id":"a","plugged_in":true,"current_soc":0.4,"soc_source":"inferred"}"#), answeredAtMs: t, nowMs: t, set: set)) + #expect(EVText.origin(same, zone: Self.utc) == "You set 40\u{00A0}% at 09:50") + + let completed = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.8,"soc_source":"completed"}"#) + #expect(EVText.level(completed, answeredAtMs: t, nowMs: t)?.origin == .unconfirmed) + + // No car on the cable: no level, and none offered. + #expect(EVText.level(try lp(#"{"id":"a","plugged_in":false,"current_soc":0.6}"#), answeredAtMs: t, nowMs: t) == nil) + } + } + + /// Core plans from a car's report for five minutes. Older than that the + /// view still shows it, dated "last reported HH:MM". + @Test func aCarsLevelOlderThanFiveMinutesIsDated() throws { + try AppWords.$language.withValue(.en) { + let t = Self.t + let fresh = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.6,"soc_source":"vehicle","vehicle_driver":"vag","vehicle_soc":0.64,"vehicle_soc_age_s":300}"#) + let f = try #require(EVText.level(fresh, answeredAtMs: t, nowMs: t)) + #expect(f.origin == .car) + #expect(f.pct == 64) + #expect(EVText.origin(f, zone: Self.utc) == "From the car") + + let old = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.6,"soc_source":"inferred","vehicle_driver":"vag","vehicle_soc":0.64,"vehicle_soc_age_s":301}"#) + let o = try #require(EVText.level(old, answeredAtMs: t, nowMs: t)) + #expect(o.origin == .carEarlier) + #expect(o.atMs == t - 301_000) + #expect(EVText.origin(o, zone: Self.utc) == "From the car, last reported 09:54") + #expect(EVText.originShort(o, zone: Self.utc) == "reported 09:54") + + // The same answer ages on screen: two minutes later it is dated. + #expect(EVText.level(fresh, answeredAtMs: t, nowMs: t + 120_000)?.origin == .carEarlier) + + // The box's own stale flag dates it whatever the age. + let flagged = try lp(#"{"id":"a","plugged_in":true,"soc_source":"inferred","vehicle_driver":"vag","vehicle_soc":0.5,"vehicle_soc_age_s":10,"vehicle_stale":true}"#) + #expect(EVText.level(flagged, answeredAtMs: t, nowMs: t)?.origin == .carEarlier) + + // A box without vehicle_soc still dates a vehicle level by its age. + let legacy = try lp(#"{"id":"a","plugged_in":true,"current_soc":0.6,"soc_source":"vehicle","vehicle_soc_age_s":900}"#) + #expect(EVText.level(legacy, answeredAtMs: t, nowMs: t)?.origin == .carEarlier) + + // A level set here after the car's last report wins. + let later = EVText.LevelSet(pct: 70, atMs: t - 60_000) + #expect(EVText.level(old, answeredAtMs: t, nowMs: t, set: later)?.origin == .you) + let earlier = EVText.LevelSet(pct: 70, atMs: t - 900_000) + #expect(EVText.level(old, answeredAtMs: t, nowMs: t, set: earlier)?.origin == .carEarlier) + } + } + + @Test func theLevelInSwedish() throws { + try AppWords.$language.withValue(.sv) { + let t = Self.t + let assumed = try #require(EVText.level(try lp(#"{"id":"a","plugged_in":true,"current_soc":0.2,"soc_source":"assumed"}"#), answeredAtMs: t, nowMs: t)) + #expect(EVText.origin(assumed, zone: Self.utc) == "FTW antar 20\u{00A0}%. Ingen har angett den riktiga nivån.") + let old = try #require(EVText.level(try lp(#"{"id":"a","plugged_in":true,"soc_source":"inferred","vehicle_driver":"vag","vehicle_soc":0.64,"vehicle_soc_age_s":2400}"#), answeredAtMs: t, nowMs: t)) + #expect(EVText.origin(old, zone: Self.stockholm) == "Från bilen, senast rapporterad 11:20") + let you = EVText.Level(pct: 40, origin: .you, atMs: t, setPct: nil) + #expect(EVText.origin(you, zone: Self.stockholm) == "Du angav 40\u{00A0}% kl. 12:00") + #expect(EVText.status(try lp(#"{"id":"a","plugged_in":true,"current_power_w":7200}"#)) == "Laddar med 7,2\u{00A0}kW") + } + } + + @Test func plusAndMinusMoveByFive() { + #expect(EVText.stepped(41, up: true) == 45) + #expect(EVText.stepped(41, up: false) == 40) + #expect(EVText.stepped(40, up: true) == 45) + #expect(EVText.stepped(40, up: false) == 35) + #expect(EVText.stepped(100, up: true) == 100) + #expect(EVText.stepped(0, up: false) == 0) + } + + // MARK: The outcome row + + @Test func theReceiptFollowsAckAndResult() throws { + try AppWords.$language.withValue(.en) { + let sending = EVText.outcome(EVText.Step(ask: .level(40)), nil) + #expect(sending.sent == .done && sending.received == .active && sending.done == .waiting) + #expect(sending.tone == .working) + #expect(sending.text == "Sending 40\u{00A0}% to FTW…") + + let received = EVText.outcome(EVText.Step(ask: .level(40), acked: true), nil) + #expect(received.received == .done && received.done == .active) + #expect(received.text == "FTW received 40\u{00A0}%.") + + let done = EVText.outcome(EVText.Step(ask: .level(40), acked: true, result: .applied(observed: 0.4)), nil) + #expect(done.received == .done && done.done == .done && done.tone == .done) + #expect(done.text == "FTW now plans from 40\u{00A0}%.") + + let superseded = EVText.outcome(EVText.Step(ask: .level(40), acked: true, result: .superseded(observed: 0.45)), nil) + #expect(superseded.done == .failed && superseded.tone == .warning) + #expect(superseded.text == "FTW kept 45\u{00A0}%. A newer level came first.") + + let unconfirmed = EVText.outcome(EVText.Step(ask: .chargeNow, acked: true, result: .unconfirmed(note: nil)), nil) + #expect(unconfirmed.done == .waiting && unconfirmed.tone == .warning) + #expect(unconfirmed.text == "FTW received it but has not confirmed the result yet.") + + // Refused after the box took it, and never reaching it at all. + let why = EVText.refusal(CmdResult(cmdId: "c", state: .rejected, errorCode: "E_UNAVAILABLE", errorArgs: ["reason": .text("unplugged")])) + #expect(why == "Plug the car in first. FTW takes a level only while the car is plugged in.") + let refused = EVText.outcome(EVText.Step(ask: .level(40), acked: true, result: .refused(why)), nil) + #expect(refused.received == .done && refused.done == .failed && refused.tone == .failed) + #expect(refused.text == why) + let lost = EVText.outcome(EVText.Step(ask: .chargeNow, result: .refused(EVText.failure(CommandError(code: "E_NO_ACK", help: "")))), nil) + #expect(lost.received == .failed && lost.done == .failed) + #expect(lost.text == "Did not reach FTW. Try again.") + #expect(EVText.failure(SessionError.noCarrier) == "Not connected to FTW right now. Try again when the connection is back.") + + // Done for Charge now means the box holds it; the charger's own + // account says whether the car takes it. + let started = EVText.Step(ask: .chargeNow, acked: true, result: .applied(observed: 11_000)) + #expect(EVText.outcome(started, nil).text == "FTW started charging. Waiting for the car to draw power.") + let drawing = try lp(#"{"id":"a","plugged_in":true,"manual_active":true,"manual_charge_w":11000,"current_power_w":11000,"manual":{"state":"charging"}}"#) + #expect(EVText.outcome(started, drawing).text == "Charging at 11\u{00A0}kW. The plan waits until you go back to it.") + let refusing = try lp(#"{"id":"a","plugged_in":true,"manual_active":true,"manual_charge_w":11000,"manual":{"state":"not_drawing"}}"#) + #expect(EVText.outcome(started, refusing).text == "The car is not taking power. Check the car's charge limit.") + #expect(EVText.outcome(EVText.Step(ask: .resume, acked: true, result: .applied(observed: 0)), nil).text == "Back to the plan. FTW decides when to charge.") + } + } + + @Test func theReceiptInSwedish() { + AppWords.$language.withValue(.sv) { + let r = EVText.outcome(EVText.Step(ask: .chargeNow, acked: true), nil) + #expect(r.text == "FTW har tagit emot det.") + #expect(EVText.outcome(EVText.Step(ask: .pause, acked: true, result: .applied(observed: 0)), nil).text == "Pausad. Bilen laddar inte förrän du väljer.") + #expect(AppWords.text("Sent") == "Skickat") + #expect(AppWords.text("Received") == "Mottaget") + #expect(AppWords.text("Done") == "Utfört") + } + } + + // MARK: The ready time over 25 October 2026 + + static func noon(_ day: Int, _ month: Int = 10) -> Date { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = stockholm + return cal.date(from: DateComponents(year: 2026, month: month, day: day, hour: 12))! + } + + static func utcDate(_ s: String) -> Date { ISO8601DateFormatter().date(from: s)! } + + /// Summer time ends on 25 October 2026. 07:00 is 05:00 UTC before and + /// 06:00 UTC after; the box keeps 07:00 and answers with today's minute. + @Test func theReadyTimeIsTheHouseholdClockAcrossOctober25() throws { + let z = Self.stockholm + #expect(EVText.minuteUTC(clock: 420, at: Self.noon(24), zone: z) == 300) + #expect(EVText.minuteUTC(clock: 420, at: Self.noon(25), zone: z) == 360) + #expect(EVText.minuteUTC(clock: 420, at: Self.noon(26), zone: z) == 360) + #expect(EVText.clockFromUTC(300, at: Self.noon(24), zone: z) == 420) + #expect(EVText.clockFromUTC(360, at: Self.noon(25), zone: z) == 420) + #expect(EVText.clockFromUTC(360, at: Self.noon(26), zone: z) == 420) + // A box that kept the UTC minute really charges by 06:00 after the + // change, and the view says 06:00 rather than the 07:00 once asked. + #expect(EVText.clockFromUTC(300, at: Self.noon(26), zone: z) == 360) + + // The hour that happens twice counts its first pass; the hour that + // never happens (29 March) gives the end of the gap. Core does both. + #expect(EVText.minuteUTC(clock: 150, at: Self.noon(25), zone: z) == 30) + #expect(EVText.minuteUTC(clock: 150, at: Self.noon(29, 3), zone: z) == 60) + } + + @Test func theGoalShowsTheWallTimeTheBoxUses() throws { + try AppWords.$language.withValue(.en) { + let z = Self.stockholm + let before = try lp(#"{"id":"a","plugged_in":true,"schedule":{"soc":0.8,"time_of_day_min_utc":300,"recurring":true,"days":31}}"#) + let after = try lp(#"{"id":"a","plugged_in":true,"schedule":{"soc":0.8,"time_of_day_min_utc":360,"recurring":true,"days":31}}"#) + let clocked = try lp(#"{"id":"a","plugged_in":true,"schedule":{"soc":0.8,"time_of_day_min_utc":300,"time_of_day_min":420,"recurring":true,"days":31}}"#) + #expect(EVText.goal(try #require(before.schedule), at: Self.noon(24), zone: z) == "80\u{00A0}% by 07:00 on weekdays") + #expect(EVText.goal(try #require(after.schedule), at: Self.noon(26), zone: z) == "80\u{00A0}% by 07:00 on weekdays") + // A box that sends the household clock is read as it says. + #expect(EVText.goal(try #require(clocked.schedule), at: Self.noon(26), zone: z) == "80\u{00A0}% by 07:00 on weekdays") + + // From Saturday noon the next weekday 07:00 is Monday, in winter time. + let next = EVText.nextDeadlineMs(try #require(before.schedule), nowMs: Self.noon(24).timeIntervalSince1970 * 1000, zone: z) + #expect(next == Self.utcDate("2026-10-26T06:00:00Z").timeIntervalSince1970 * 1000) + var daily = try #require(before.schedule) + daily.days = 0 + let sunday = EVText.nextDeadlineMs(daily, nowMs: Self.noon(24).timeIntervalSince1970 * 1000, zone: z) + #expect(sunday == Self.utcDate("2026-10-25T06:00:00Z").timeIntervalSince1970 * 1000) + } + AppWords.$language.withValue(.sv) { + let s = Loadpoint.Schedule(socPct: 80, timeOfDayMinUTC: 300, clockMin: nil, surplusUnlockPct: 0, recurring: true, days: 0b0011111, finishAtVehicleLimit: false) + #expect(EVText.goal(s, at: Self.noon(24), zone: Self.stockholm) == "80\u{00A0}% till 07:00 vardagar") + } + } + + @Test func theHouseholdZoneIsTheBoxsWhenItNamesOne() throws { + let phone = TimeZone(identifier: "America/New_York")! + #expect(EVText.householdZone(boxTZ: "Europe/Stockholm", phone: phone) == Self.stockholm) + #expect(EVText.householdZone(boxTZ: "Local", phone: phone) == phone) + #expect(EVText.householdZone(boxTZ: "UTC", phone: phone) == phone) + #expect(EVText.householdZone(boxTZ: "", phone: phone) == phone) + #expect(EVText.householdZone(boxTZ: "Not/AZone", phone: phone) == phone) + try AppWords.$language.withValue(.en) { + let away = try lp(#"{"id":"a","plugged_in":true,"schedule":{"soc":0.8,"time_of_day_min_utc":300,"recurring":true,"days":0}}"#) + let now = Self.noon(24).timeIntervalSince1970 * 1000 + #expect(EVText.nextGoal(away, nowMs: now, zone: Self.stockholm, phone: phone) == "Next: tomorrow 07:00, home time") + #expect(EVText.nextGoal(away, nowMs: now, zone: Self.stockholm, phone: Self.stockholm) == "Next: tomorrow 07:00") + } + } + + // MARK: Will the car be ready? + + @Test func readinessComesFromThePlansShortfall() throws { + try AppWords.$language.withValue(.en) { + let z = Self.stockholm + let now = Self.noon(24).timeIntervalSince1970 * 1000 + let json = #"{"id":"a","plugged_in":true,"current_soc":0.35,"soc_source":"inferred","vehicle_capacity_wh":64000,"target_time":"2026-10-25T06:00:00Z","schedule":{"soc":0.8,"time_of_day_min_utc":360,"recurring":true,"days":0}}"# + let car = try lp(json) + let level = EVText.level(car, answeredAtMs: now, nowMs: now) + #expect(EVText.readiness(car, level: level, shortfallWh: 0, nowMs: now, zone: z) == .onTrack(targetPct: 80, byMs: Self.utcDate("2026-10-25T06:00:00Z").timeIntervalSince1970 * 1000)) + let short = try #require(EVText.readiness(car, level: level, shortfallWh: 18_200, nowMs: now, zone: z)) + #expect(short == .short(reachPct: 51, targetPct: 80, byMs: Self.utcDate("2026-10-25T06:00:00Z").timeIntervalSince1970 * 1000)) + #expect(EVText.readiness(short, level: level, nowMs: now, zone: z) == "Will not make it: about 51\u{00A0}% by tomorrow 07:00, not 80\u{00A0}%.") + #expect(EVText.readiness(.onTrack(targetPct: 80, byMs: now + 3_600_000), level: level, nowMs: now, zone: z) == "On track: 80\u{00A0}% by today 13:00.") + + // From a guess, it says so. + let guessed = EVText.Level(pct: 20, origin: .assumed, atMs: nil, setPct: nil) + #expect(EVText.readiness(.onTrack(targetPct: 80, byMs: now + 3_600_000), level: guessed, nowMs: now, zone: z).hasSuffix("Counted from FTW's guess of 20\u{00A0}%.")) + + // Nothing claimed without the box's word. + #expect(EVText.readiness(car, level: level, shortfallWh: nil, nowMs: now, zone: z) == nil) + let pending = try lp(json.replacingOccurrences(of: #""id":"a","#, with: #""id":"a","plan_pending":true,"#)) + #expect(EVText.readiness(pending, level: level, shortfallWh: 0, nowMs: now, zone: z) == nil) + let holding = try lp(json.replacingOccurrences(of: #""id":"a","#, with: #""id":"a","manual_active":true,"#)) + #expect(EVText.readiness(holding, level: level, shortfallWh: 0, nowMs: now, zone: z) == nil) + let done = try lp(json.replacingOccurrences(of: #""id":"a","#, with: #""id":"a","goal_complete":true,"#)) + #expect(EVText.readiness(done, level: level, shortfallWh: nil, nowMs: now, zone: z) == .reached) + } + } + + // MARK: Is FTW in control? + + @Test func inControlIsNeverGreenFromACache() throws { + try AppWords.$language.withValue(.en) { + let following = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"following","reason":"plan","severity":"info"}]}"#.utf8)) + let ok = InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: following, statusFresh: true) + #expect(ok.tone == .ok) + #expect(ok.title == "Yes, FTW is in control") + + // The same verdict from a cache, or from an old status read, is + // not a yes. + let cached = InControl.answer(live: false, lastKnownMs: Self.t, dispatchBlockedBy: [], status: following, statusFresh: true, zone: Self.utc) + #expect(cached.tone == .stale) + #expect(cached.detail == "Last known 10:00.") + #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: following, statusFresh: false).tone == .neutral) + + #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: ["meter"], status: following, statusFresh: true).tone == .warning) + let old = try JSON(parsing: Array(#"{"grid_w":100}"#.utf8)) + #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: old, statusFresh: true).title == "Connected and live") + + let alarm = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"no_contact","reason":"telemetry_stale","severity":"alarm"},{"driver":"sungrow","kind":"battery","status":"following","reason":"plan","severity":"info"}]}"#.utf8)) + let a = InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: alarm, statusFresh: true) + #expect(a.tone == .alarm) + #expect(a.title == "The charger needs attention now") + #expect(a.detail == "No fresh readings") + + let warnings = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"limited","reason":"fuse_limit","severity":"warning"},{"driver":"sungrow","kind":"battery","status":"limited","reason":"battery_full","severity":"warning"}]}"#.utf8)) + #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: warnings, statusFresh: true).title == "2 devices need a look") + } + try AppWords.$language.withValue(.sv) { + let following = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"following","reason":"plan","severity":"info"}]}"#.utf8)) + #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: following, statusFresh: true).title == "Ja, FTW styr") + #expect(AppWords.text("Is FTW in control?") == "Styr FTW?") + } + } +} + +/// The car view against the simulated box: plug in, give a level, set a +/// goal, Charge now and back to the plan, and every receipt on the way. +@MainActor +@Suite struct CarFlowTests { + @MainActor + final class Rig { + let scheduler = ManualScheduler(nowMs: CarTextTests.t) + var box: SimulatedBox? + var app: AppModel! + + init() { + app = AppModel(store: MemoryStore(), files: nil, passkeys: nil, scheduler: scheduler, build: "test", ua: "test", demo: { [unowned self] s in + let box = SimulatedBox(scheduler: s, house: SimulatedHouse(timeZone: CarTextTests.stockholm)) + box.resultDelayMs = 900 + box.house.chargerRampMs = 4_000 + self.box = box + return LoopbackCarrier(box: box, scheduler: s) + }) + } + + func run(_ ms: Double, step: Double = 50) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + scheduler.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + } + + @Test func plugInGiveALevelSetAGoalAndChargeNow() async throws { + try await AppWords.$language.withValue(.en) { + 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) + let car = try #require(home.loadpoints.points.first) + #expect(car.pluggedIn) + #expect(home.loadpoints.zone == CarTextTests.stockholm) + #expect(home.loadpoints.level(car)?.origin == .assumed) + #expect(home.loadpoints.level(car)?.pct == 20) + + // The level: sent, received, done, and then "you set". + let level = Task { await home.loadpoints.setLevel(car, pct: 40) } + await rig.run(100) + #expect(home.loadpoints.levelStep[car.id] == EVText.Step(ask: .level(40))) + #expect(home.loadpoints.level(car)?.pct == 40) + await rig.run(300) + #expect(home.loadpoints.levelStep[car.id]?.acked == true) + #expect(home.loadpoints.levelStep[car.id]?.result == nil) + await rig.run(1_500) + await level.value + #expect(home.loadpoints.levelStep[car.id]?.result == .applied(observed: 0.4)) + #expect(box.receivedCommands.last == Contract.opLoadpointSocSet) + let after = try #require(home.loadpoints.points.first) + #expect(home.loadpoints.level(after)?.origin == .you) + #expect(home.loadpoints.level(after)?.pct == 40) + + // Three quick taps send the last one, not three. + let sent = box.receivedCommands.count + let a = Task { await home.loadpoints.setLevel(after, pct: 45) } + let b = Task { await home.loadpoints.setLevel(after, pct: 50) } + let c = Task { await home.loadpoints.setLevel(after, pct: 55) } + await rig.run(5_000) + await a.value; await b.value; await c.value + #expect(box.receivedCommands.count - sent == 2) + #expect(box.house.carSocFraction == 0.55) + + // The goal, saved as the household clock, read back on it. + await home.loadpoints.saveGoalAndWait(car, rig, socPct: 90, clock: 6 * 60 + 30, recurring: true, days: 0b0011111) + #expect(box.house.schedule?.clockMin == 390) + #expect(home.loadpoints.goalSave[car.id] == .saved) + let goal = try #require(home.loadpoints.points.first?.schedule) + #expect(EVText.goal(goal, at: Date(timeIntervalSince1970: CarTextTests.t / 1000), zone: home.loadpoints.zone) == "90\u{00A0}% by 06:30 on weekdays") + + // Whether it makes it comes from the plan's shortfall. + home.loadpoints.watchPlan() + await rig.run(6_000, step: 100) + #expect(home.loadpoints.shortfallWh[car.id] != nil) + let withGoal = try #require(home.loadpoints.points.first) + #expect(home.loadpoints.readiness(withGoal) != nil) + home.loadpoints.unwatchPlan() + + // Charge now: no time limit, the charger's full current. + let charge = Task { await home.loadpoints.chargeNow(withGoal) } + await rig.run(400) + #expect(home.loadpoints.holdStep[car.id]?.acked == true) + #expect(home.loadpoints.holdSending(withGoal)) + await rig.run(1_500) + await charge.value + #expect(home.loadpoints.holdStep[car.id]?.result == .applied(observed: 11_000)) + #expect(box.house.manualHoldW == 11_000) + await rig.run(5_000, step: 100) + home.loadpoints.reread() + await rig.run(500) + let charging = try #require(home.loadpoints.points.first) + #expect(charging.manualActive) + #expect(charging.powerW == 11_000) + #expect(home.loadpoints.outcome(hold: charging)?.text == "Charging at 11\u{00A0}kW. The plan waits until you go back to it.") + // A plan set aside says nothing about the goal. + #expect(home.loadpoints.readiness(charging) == nil) + + let back = Task { await home.loadpoints.backToPlan(charging) } + await rig.run(2_000) + await back.value + #expect(home.loadpoints.outcome(hold: charging)?.text == "Back to the plan. FTW decides when to charge.") + #expect(home.loadpoints.points.first?.manualActive == false) + // A success fades once its effect is on screen. + await rig.run(LoadpointsModel.doneShownMs + 1_000, step: 500) + #expect(home.loadpoints.holdStep[car.id] == nil) + } + } + + @Test func unpluggedTakesNoLevelAndSaysWhy() async throws { + try await AppWords.$language.withValue(.en) { + 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) + let car = try #require(home.loadpoints.points.first) + let set = Task { await home.loadpoints.setLevel(car, pct: 60) } + await rig.run(2_000) + await set.value + #expect(home.loadpoints.levelSet[car.id] != nil) + + box.setCarPluggedIn(false) + home.loadpoints.reread() + await rig.run(1_000) + let out = try #require(home.loadpoints.points.first) + #expect(!out.pluggedIn) + #expect(home.loadpoints.level(out) == nil) + #expect(home.loadpoints.levelSet[car.id] == nil) + + // The view offers no level now; if one is sent anyway, the box's + // refusal reads as what to do. + let refused = Task { await home.loadpoints.setLevel(out, pct: 60) } + await rig.run(2_000) + await refused.value + #expect(home.loadpoints.outcome(level: out)?.text == "Plug the car in first. FTW takes a level only while the car is plugged in.") + #expect(home.loadpoints.outcome(level: out)?.tone == .failed) + + // Plugging in again starts over: FTW assumes 20 %. + box.setCarPluggedIn(true) + home.loadpoints.reread() + await rig.run(1_000) + let back = try #require(home.loadpoints.points.first) + #expect(home.loadpoints.level(back)?.origin == .assumed) + #expect(home.loadpoints.level(back)?.pct == 20) + } + } + + /// Every demo situation opens, so each screen can be shown without a box. + @Test func everyDemoCarOpens() async throws { + for situation in DemoCar.allCases { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + situation.apply(to: try #require(rig.box).house, nowMs: rig.scheduler.nowMs) + home.loadpoints.activate() + await rig.run(1_500) + let car = try #require(home.loadpoints.points.first) + switch situation { + case .unplugged: #expect(!car.pluggedIn) + case .car: #expect(home.loadpoints.level(car)?.origin == .car) + case .carOld: #expect(home.loadpoints.level(car)?.origin == .carEarlier) + case .assumed: #expect(home.loadpoints.level(car)?.origin == .assumed) + case .estimated, .late: #expect(home.loadpoints.level(car)?.origin == .estimated) + case .noGoal: #expect(car.schedule == nil) + case .charging: #expect(car.manualActive) + } + } + } +} + +extension LoadpointsModel { + @MainActor + func saveGoalAndWait(_ lp: Loadpoint, _ rig: CarFlowTests.Rig, socPct: Double, clock: Int, recurring: Bool, days: Int) async { + let task = Task { await saveGoal(lp, socPct: socPct, clock: clock, recurring: recurring, days: days, zone: zone) } + await rig.run(1_500) + await task.value + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift index e4fff59..be1185b 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/FormatTests.swift @@ -39,26 +39,28 @@ import Testing } @Test func evSentencesSayOnlyWhatTheBoxSaid() throws { - let unplugged = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":false,"current_soc":0.5}"#.utf8))) - #expect(EVText.status(unplugged) == "Not plugged in") - #expect(unplugged.socPct == nil) + try AppWords.$language.withValue(.en) { + let unplugged = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":false,"current_soc":0.5}"#.utf8))) + #expect(EVText.status(unplugged) == "Not plugged in") + #expect(unplugged.socPct == nil) - let charging = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"current_power_w":7200,"current_soc":0.41,"delivered_wh_session":3040}"#.utf8))) - #expect(EVText.status(charging) == "Charging at 7.2 kW") - #expect(charging.socPct == 41) - #expect(EVText.session(charging) == "3.0 kWh this session") + let charging = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"current_power_w":7200,"current_soc":0.41,"delivered_wh_session":3040}"#.utf8))) + #expect(EVText.status(charging) == "Charging at 7.2\u{00A0}kW") + #expect(charging.socPct == 41) + #expect(EVText.session(charging) == "3.0\u{00A0}kWh this session") - let paused = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"manual_active":true,"manual_charge_w":0,"manual":{"state":"paused"}}"#.utf8))) - #expect(EVText.isPaused(paused)) - #expect(EVText.status(paused).hasPrefix("Paused by you.")) + let paused = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"manual_active":true,"manual_charge_w":0,"manual":{"state":"paused"}}"#.utf8))) + #expect(EVText.isPaused(paused)) + #expect(EVText.status(paused) == "Paused by you") - let stale = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"charger":{"known":true,"available":false}}"#.utf8))) - #expect(EVText.status(stale) == "Charger status is out of date. FTW cannot confirm whether the car is charging.") + let stale = Loadpoint(try JSON(parsing: Array(#"{"id":"a","plugged_in":true,"charger":{"known":true,"available":false}}"#.utf8))) + #expect(EVText.status(stale) == "Charger status is out of date.") - #expect(EVText.days(0) == "every day") - #expect(EVText.days(0b0011111) == "weekdays") - #expect(EVText.days(0b1100000) == "weekends") - #expect(EVText.days(0b0000101) == "Mon, Wed") + #expect(EVText.days(0) == "every day") + #expect(EVText.days(0b0011111) == "weekdays") + #expect(EVText.days(0b1100000) == "weekends") + #expect(EVText.days(0b0000101) == "Mon, Wed") + } } /// A level from the car is as old as the car's report. Any other level @@ -81,17 +83,6 @@ import Testing #expect(EVText.readout(capped, amps: 16) == "16 A · 11.0 kW") } - @Test func scheduleClockConvertsThroughUTC() { - var cal = Calendar(identifier: .gregorian) - cal.timeZone = TimeZone(identifier: "Europe/Stockholm")! - let summer = Date(timeIntervalSince1970: 1_750_000_000) - let utc = EVText.minuteUTC(hour: 7, minute: 0, at: summer, calendar: cal) - #expect(utc == 5 * 60) - let back = EVText.localTime(minuteUTC: 300, at: summer, calendar: cal) - #expect(back.hour == 7 && back.minute == 0) - #expect(EVText.minuteUTC(hour: 25, minute: 99, calendar: cal) == nil) - } - @Test func flowUsesMagnitudesAndDirections() { let r = Flow.readings(fields: [F.gridW: -1500, F.pvW: -4000, F.batteryW: 1200, F.batterySoc: 612, F.loadW: 1300]) let grid = r.nodes.first { $0.role == .grid }! From 97b5048c7a491fda58ded041973e8721dd6b0d15 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:21:31 +0200 Subject: [PATCH 2/3] Add the car view and cut Now to what is looked at daily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now keeps four things: "Is FTW in control?" in one calm line, the power flow, the car as one row that opens the car view, and the price card. The headline sentence, today's totals, savings and the fuse go. The line is green only when Core's control feedback says so over a live stream; a cache is grey and dated. The car view, in FTW/Car: - How full the car is, and who says so. Plugged in and unread, FTW assumes 20 % and says so with a badge. 20/40/60/80 and ±5 send loadpoint.soc.set at once; quick taps fold into one send. - The goal, on the household clock, with the next deadline, whether the plan makes it, and when it charges. "Use 80 % by 07:00 on weekdays" when there is none; an editor for the rest. - Charge now, Pause and Back to the plan, each with a receipt: sent, received, done, or why not. - In the demo, a card to plug the car in and out. Every word is in English and Swedish. The tab bar with one tab gives way to a navigation stack. The bands above every screen stop growing past xxxLarge text, and the flow diagram past xLarge. The demo's loopback could deliver a frame before the one sent ahead of it: each frame had its own timer, and live timers with the same delay need not fire in order. An api.chunk before its api.head read as "your box didn't answer". Each timer now delivers the oldest frame. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Car/CarView.swift | 336 ++++++++++++++++++ appleApp/FTW/Car/GoalEditor.swift | 164 +++++++++ appleApp/FTW/UI/FlowDiagram.swift | 20 +- appleApp/FTW/UI/NowView.swift | 265 +++++++------- appleApp/FTW/UI/PriceChart.swift | 20 +- appleApp/FTW/UI/RootView.swift | 82 ++--- appleApp/FTW/UI/Theme.swift | 15 +- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 23 +- .../FTWKit/Sources/FTWKit/Format/EV.swift | 17 + .../FTWKit/Tests/FTWKitTests/CarTests.swift | 53 +++ 10 files changed, 795 insertions(+), 200 deletions(-) create mode 100644 appleApp/FTW/Car/CarView.swift create mode 100644 appleApp/FTW/Car/GoalEditor.swift diff --git a/appleApp/FTW/Car/CarView.swift b/appleApp/FTW/Car/CarView.swift new file mode 100644 index 0000000..9c0c501 --- /dev/null +++ b/appleApp/FTW/Car/CarView.swift @@ -0,0 +1,336 @@ +import FTWDemo +import FTWKit +import SwiftUI + +/// The car: how full it is and who says so, the goal and whether it makes +/// it, and Charge now, Pause and Back to the plan. Every tap is followed to +/// the box's answer, on screen. +struct CarView: View { + let app: AppModel + let home: HomeModels + let id: String? + @State private var correcting = false + @State private var editingGoal = false + + private var model: LoadpointsModel { home.loadpoints } + private var site: SiteModel { home.site } + private var canControl: Bool { site.canConfigure } + + private var lp: Loadpoint? { + id.flatMap { want in model.points.first { $0.id == want } } ?? model.points.first + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let lp { + status(lp) + level(lp) + goal(lp) + if lp.pluggedIn, canControl { controls(lp) } + if app.isDemo { demo(lp) } + } else if model.loaded { + Hint(L("This box has no charger.")) + } else { + ProgressView().frame(maxWidth: .infinity) + } + } + .padding(16) + .frame(maxWidth: 640, alignment: .leading) + .frame(maxWidth: .infinity) + } + .background(Theme.surface) + .navigationTitle(L("Car")) + .navigationBarTitleDisplayMode(.inline) + .refreshable { try? await model.loadChargers() } + .onAppear { + model.activate() + model.watchPlan() + } + .onDisappear { + model.unwatchPlan() + model.deactivate() + } + .sheet(isPresented: $editingGoal) { + if let lp { GoalEditor(model: model, lp: lp) } + } + } + + // MARK: What it does now + + @ViewBuilder private func status(_ lp: Loadpoint) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(EVText.status(lp, zone: model.zone)) + .font(.title3.weight(.semibold)) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + if let session = EVText.session(lp) { Hint(session) } + if model.stale { + Hint(model.error ?? model.readAtMs.map { AppWords.format("Last known %@.", AppWords.clock($0, zone: model.zone)) } ?? L("Waiting for your box."), tone: Theme.generation) + } + } + .accessibilityElement(children: .combine) + } + + // MARK: How full is it? + + @ViewBuilder private func level(_ lp: Loadpoint) -> some View { + let level = model.level(lp) + let assumed = level?.origin == .assumed || level?.origin == .unconfirmed + Card { + Kicker(L("How full is the car?")) + if let level { + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 10) { levelFigure(level, assumed: assumed) } + VStack(alignment: .leading, spacing: 6) { levelFigure(level, assumed: assumed) } + } + // The badge says FTW assumes; the line says what to do. + Text(level.origin == .assumed && canControl ? L("Nobody has told FTW the car's level yet. Choose it below.") : EVText.origin(level, zone: model.zone)) + .font(.subheadline) + .foregroundStyle(assumed ? Theme.fg : Theme.fgDim) + .fixedSize(horizontal: false, vertical: true) + if canControl { + if level.origin == .car, !correcting { + Button(L("Correct the level")) { correcting = true }.buttonStyle(.quiet) + Hint(L("Only correct it if the car shows something else.")) + } else { + LevelChips(current: level.pct) { pct in + Task { await model.setLevel(lp, pct: pct) } + } + } + } + } else if !lp.pluggedIn { + Text(L("Plug in the car to tell FTW its level.")) + .font(.subheadline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(L("Level unknown")).font(.headline) + } + if let outcome = model.outcome(level: lp) { + ReceiptView(outcome: outcome) + } + } + .overlay(alignment: .leading) { + if assumed { + UnevenRoundedRectangle(topLeadingRadius: Theme.radius, bottomLeadingRadius: Theme.radius) + .fill(Theme.generation) + .frame(width: 4) + } + } + } + + @ViewBuilder private func levelFigure(_ level: EVText.Level, assumed: Bool) -> some View { + Text(AppWords.percent(level.pct)) + .font(Theme.percent(.largeTitle, weight: .bold)) + .foregroundStyle(assumed ? Theme.fgMuted : Theme.fg) + .contentTransition(.numericText()) + if assumed { + Text(L("FTW assumes")) + .font(.caption.weight(.semibold)) + .foregroundStyle(Theme.onAccent) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Theme.generation, in: Capsule()) + .fixedSize() + } + } + + // MARK: The goal + + @ViewBuilder private func goal(_ lp: Loadpoint) -> some View { + Card { + Kicker(L("Goal")) + if let s = lp.schedule { + Text(EVText.goal(s, at: Date(timeIntervalSince1970: site.nowMs / 1000), zone: model.zone)) + .font(.headline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + if let next = EVText.nextGoal(lp, nowMs: site.nowMs, zone: model.zone) { + Hint(next) + } + if lp.pluggedIn { + if let r = model.readiness(lp) { + Readiness(readiness: r, text: EVText.readiness(r, level: model.level(lp), nowMs: site.nowMs, zone: model.zone)) + } + if let plan = EVText.plan(lp, windows: model.windows[lp.id] ?? lp.planWindows, nowMs: site.nowMs, zone: model.zone) { + Hint(plan) + } + } + if canControl { + Button(L("Change")) { editingGoal = true }.buttonStyle(.outline) + } + } else { + Text(L("No goal yet. Without one, FTW only charges from spare solar.")) + .font(.subheadline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + if canControl { + Button(AppWords.format("Use %@", EVText.suggestedGoalText())) { + let g = EVText.suggestedGoal + Task { await model.saveGoal(lp, socPct: g.socPct, clock: g.clock, recurring: true, days: g.days, zone: model.zone) } + } + .buttonStyle(.primary) + Button(L("Change")) { editingGoal = true }.buttonStyle(.quiet) + } + } + if let save = model.goalSave[lp.id], !editingGoal { + Hint(LoadpointsModel.goalText(save), tone: { if case .failed = save { return Theme.importing }; return Theme.fgDim }()) + } + } + } + + // MARK: Charge now, Pause, Back to the plan + + @ViewBuilder private func controls(_ lp: Loadpoint) -> some View { + let paused = EVText.isPaused(lp) + let holding = lp.manualActive && !paused + let busy = model.holdSending(lp) + Card { + if holding || paused { + Button(L("Back to the plan")) { Task { await model.backToPlan(lp) } } + .buttonStyle(.primary) + .disabled(busy) + } + if !holding { + Button(L("Charge now")) { Task { await model.chargeNow(lp) } } + .buttonStyle(paused ? .secondary : .primary) + .disabled(busy) + } + if !paused { + Button(L("Pause")) { Task { await model.pause(lp) } } + .buttonStyle(.secondary) + .disabled(busy) + } + Hint(L("Charge now runs until you go back to the plan or unplug.")) + if let outcome = model.outcome(hold: lp) { + ReceiptView(outcome: outcome) + } + } + } + + // MARK: The demo's own cable + + @ViewBuilder private func demo(_ lp: Loadpoint) -> some View { + Card { + Kicker(L("Demo")) + Button(L(lp.pluggedIn ? "Unplug the car" : "Plug in the car")) { + SimulatedBox.current?.setCarPluggedIn(!lp.pluggedIn) + model.reread() + } + .buttonStyle(.outline) + Hint(L("The demo plugs in a car FTW cannot read, so FTW assumes 20 %.")) + } + } +} + +/// 20, 40, 60, 80 and a step of five either way. Each tap goes to the box +/// at once; there is no Save. +struct LevelChips: View { + let current: Double + let choose: (Double) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 68), spacing: 8)], spacing: 8) { + ForEach(EVText.levelPresets, id: \.self) { pct in + chip(AppWords.percent(pct), selected: current.rounded() == pct, label: AppWords.percent(pct)) { choose(pct) } + } + } + HStack(spacing: 8) { + chip("−5", selected: false, label: AppWords.format("Set %@", AppWords.percent(EVText.stepped(current, up: false)))) { choose(EVText.stepped(current, up: false)) } + chip("+5", selected: false, label: AppWords.format("Set %@", AppWords.percent(EVText.stepped(current, up: true)))) { choose(EVText.stepped(current, up: true)) } + } + } + } + + private func chip(_ text: String, selected: Bool, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(text) + .font(Theme.percent(.body)) + .frame(maxWidth: .infinity, minHeight: 44) + .foregroundStyle(selected ? Theme.onAccent : Theme.fg) + .background(selected ? Theme.accent : Theme.surfaceSunken, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) + .overlay(RoundedRectangle(cornerRadius: Theme.radiusSmall).strokeBorder(Theme.line, lineWidth: selected ? 0 : 1)) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + .accessibilityAddTraits(selected ? .isSelected : []) + } +} + +/// Whether the car makes its goal, in the plan's own numbers. +struct Readiness: View { + let readiness: EVText.Readiness + let text: String + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: short ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") + .foregroundStyle(short ? Theme.generation : Theme.exporting) + .accessibilityHidden(true) + Text(text) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + } + + private var short: Bool { + if case .short = readiness { return true } + return false + } +} + +/// The receipt under a control: sent, received, done, and one sentence. +struct ReceiptView: View { + let outcome: EVText.Outcome + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 10) { marks } + VStack(alignment: .leading, spacing: 4) { marks } + } + Text(outcome.text) + .font(.footnote.weight(.medium)) + .foregroundStyle(color) + .fixedSize(horizontal: false, vertical: true) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceSunken, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) + .accessibilityElement(children: .combine) + .animation(.default, value: outcome) + } + + @ViewBuilder private var marks: some View { + mark(L("Sent"), outcome.sent) + mark(L("Received"), outcome.received) + mark(L("Done"), outcome.done) + } + + private func mark(_ label: String, _ state: EVText.Outcome.Mark) -> some View { + HStack(spacing: 4) { + switch state { + case .done: Image(systemName: "checkmark.circle.fill").foregroundStyle(Theme.exporting) + case .active: ProgressView().controlSize(.mini) + case .waiting: Image(systemName: "circle").foregroundStyle(Theme.fgMuted) + case .failed: Image(systemName: "xmark.circle.fill").foregroundStyle(Theme.importing) + } + Text(label) + .font(.caption.weight(.semibold)) + .foregroundStyle(state == .waiting ? Theme.fgMuted : Theme.fg) + } + } + + private var color: Color { + switch outcome.tone { + case .working: return Theme.fgDim + case .done: return Theme.exporting + case .warning: return Theme.generation + case .failed: return Theme.importing + } + } +} diff --git a/appleApp/FTW/Car/GoalEditor.swift b/appleApp/FTW/Car/GoalEditor.swift new file mode 100644 index 0000000..dc63242 --- /dev/null +++ b/appleApp/FTW/Car/GoalEditor.swift @@ -0,0 +1,164 @@ +import FTWKit +import SwiftUI + +/// The goal: how full, by when, on which days. One Save, one PUT. The time +/// is set and shown on the household clock, the one the box keeps. +struct GoalEditor: View { + let model: LoadpointsModel + let lp: Loadpoint + @Environment(\.dismiss) private var dismiss + @State private var socPct: Double + @State private var time: Date + @State private var days: Days + @State private var saving = false + + enum Days: Hashable { + case weekdays, everyDay, weekends, once + case custom(Int) + + init(recurring: Bool, mask: Int) { + guard recurring else { self = .once; return } + switch mask & 0x7f { + case 0, 0x7f: self = .everyDay + case 0b0011111: self = .weekdays + case 0b1100000: self = .weekends + default: self = .custom(mask & 0x7f) + } + } + + var recurring: Bool { self != .once } + + var mask: Int { + switch self { + case .weekdays: return 0b0011111 + case .everyDay, .once: return 0x7f + case .weekends: return 0b1100000 + case .custom(let m): return m + } + } + + var label: String { + switch self { + case .weekdays: return L("Weekdays") + case .everyDay: return L("Every day") + case .weekends: return L("Weekends") + case .once: return L("Once") + case .custom(let m): + let days = EVText.days(m) + return days.prefix(1).uppercased() + days.dropFirst() + } + } + } + + init(model: LoadpointsModel, lp: Loadpoint) { + self.model = model + self.lp = lp + let zone = model.zone + let now = Date() + let s = lp.schedule + _socPct = State(initialValue: s.map { $0.finishAtVehicleLimit ? 100 : $0.socPct } ?? EVText.suggestedGoal.socPct) + let clock = s.map { EVText.goalClock($0, at: now, zone: zone) } ?? EVText.suggestedGoal.clock + _time = State(initialValue: Self.date(clock: clock, zone: zone)) + _days = State(initialValue: s.map { Days(recurring: $0.recurring, mask: $0.days) } ?? .weekdays) + } + + static func calendar(_ zone: TimeZone) -> Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + return cal + } + + /// Today at that clock in the household zone, for the time wheel. + static func date(clock: Int, zone: TimeZone) -> Date { + let cal = calendar(zone) + return cal.date(bySettingHour: clock / 60, minute: clock % 60, second: 0, of: Date()) ?? Date() + } + + private var clock: Int { + let c = Self.calendar(model.zone).dateComponents([.hour, .minute], from: time) + return (c.hour ?? 0) * 60 + (c.minute ?? 0) + } + + private var choices: [Days] { + var out: [Days] = [.weekdays, .everyDay, .weekends, .once] + if case .custom = days { out.insert(days, at: 0) } + return out + } + + private var failure: String? { + if case .failed(let help)? = model.goalSave[lp.id], !saving { return help } + return nil + } + + var body: some View { + NavigationStack { + Form { + Section(L("Charge to")) { + Stepper(value: $socPct, in: 10...100, step: 5) { + Text(AppWords.percent(socPct)).font(Theme.percent(.title2)) + } + } + Section(L("Ready by")) { + DatePicker(L("Ready by"), selection: $time, displayedComponents: .hourAndMinute) + .datePickerStyle(.wheel) + .labelsHidden() + .environment(\.timeZone, model.zone) + .frame(maxWidth: .infinity) + } + Section(L("Repeat")) { + Picker(L("Repeat"), selection: $days) { + ForEach(choices, id: \.self) { Text($0.label).tag($0) } + } + .pickerStyle(.inline) + .labelsHidden() + } + Section { + Text(preview).font(.subheadline).foregroundStyle(Theme.fgDim) + if let failure { + Text(failure).font(.subheadline).foregroundStyle(Theme.importing) + } + } + if lp.schedule != nil { + Section { + Button(L("Remove goal"), role: .destructive) { + Task { + saving = true + await model.removeGoal(lp) + saving = false + if case .removed? = model.goalSave[lp.id] { dismiss() } + } + } + .disabled(saving) + } + } + } + .navigationTitle(L("Goal")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L("Cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if saving { + ProgressView() + } else { + Button(L("Save")) { + Task { + saving = true + await model.saveGoal(lp, socPct: socPct, clock: clock, recurring: days.recurring, days: days.mask, zone: model.zone) + saving = false + if case .saved? = model.goalSave[lp.id] { dismiss() } + } + } + } + } + } + } + } + + /// The goal as it will read on the car view. + private var preview: String { + let s = Loadpoint.Schedule(socPct: socPct, timeOfDayMinUTC: 0, clockMin: clock, surplusUnlockPct: 0, recurring: days.recurring, days: days == .everyDay ? 0 : days.mask, finishAtVehicleLimit: false) + return EVText.goal(s, at: Date(), zone: model.zone) + } +} diff --git a/appleApp/FTW/UI/FlowDiagram.swift b/appleApp/FTW/UI/FlowDiagram.swift index 0c24500..36c6882 100644 --- a/appleApp/FTW/UI/FlowDiagram.swift +++ b/appleApp/FTW/UI/FlowDiagram.swift @@ -39,6 +39,9 @@ struct FlowDiagram: View { .frame(height: Self.height) .frame(maxWidth: 520) .frame(maxWidth: .infinity) + // A drawing with fixed places: past this size its labels would + // collide, and every figure in it is also said in words elsewhere. + .dynamicTypeSize(...DynamicTypeSize.xLarge) } /// Where each node sits. Several nodes in one corner stack toward the @@ -100,21 +103,24 @@ private struct Hub: View { Image(systemName: "house.fill") .font(.title2) .foregroundStyle(Theme.accent) - Text("\(Text(parts.text).font(Theme.number(22, weight: .bold)))\(Text(" \(parts.unit)").font(.caption))") + Text("\(Text(AppWords.decimal(parts.text)).font(Theme.number(22, weight: .bold)))\(Text(" \(parts.unit)").font(.caption))") .foregroundStyle(Theme.fg) - Text("HOME").font(.caption2.weight(.semibold)).tracking(0.8).foregroundStyle(Theme.fgDim) + Text(L("HOME")).font(.caption2.weight(.semibold)).tracking(0.8).foregroundStyle(Theme.fgDim) if let pct = selfPoweredPct { - Text("\(Int(pct.rounded()))% self-powered today") + Text(AppWords.format("%@ self-powered today", AppWords.percent(pct))) .font(.caption2) .foregroundStyle(Theme.fgMuted) .multilineTextAlignment(.center) + .lineLimit(2) + .minimumScaleFactor(0.8) + .frame(maxWidth: 92) } } .frame(width: 118, height: 118) .background(Circle().fill(Theme.surfaceElevated)) .overlay(Circle().strokeBorder(Theme.accent.opacity(0.6), lineWidth: 2)) .accessibilityElement(children: .combine) - .accessibilityLabel("Home using \(parts.joined)") + .accessibilityLabel(AppWords.format("Home using %@", AppWords.power(loadKw * 1000))) } } @@ -127,17 +133,17 @@ private struct Bubble: View { let parts = PowerFormat.parts(node.kw * 1000) let color = Theme.color(node.tone) VStack(spacing: 1) { - Text(node.title).font(.caption2.weight(.semibold)).tracking(0.6).foregroundStyle(Theme.fgDim) + Text(L(node.title)).font(.caption2.weight(.semibold)).tracking(0.6).foregroundStyle(Theme.fgDim) if let name = node.name { Text(name).font(.caption2).foregroundStyle(Theme.fgMuted).lineLimit(1) } - Text("\(Text(parts.text).font(Theme.number(17, weight: .bold)))\(Text(" \(parts.unit)").font(.caption2))") + Text("\(Text(AppWords.decimal(parts.text)).font(Theme.number(17, weight: .bold)))\(Text(" \(parts.unit)").font(.caption2))") .foregroundStyle(color) if let soc = node.socPct { SocBar(pct: soc) } if !node.sub.isEmpty { - Text(node.sub).font(.caption2).foregroundStyle(Theme.fgDim) + Text(L(node.sub)).font(.caption2).foregroundStyle(Theme.fgDim) } if !node.dailyParts.isEmpty { HStack(spacing: 4) { diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift index 78c2797..c80442a 100644 --- a/appleApp/FTW/UI/NowView.swift +++ b/appleApp/FTW/UI/NowView.swift @@ -1,11 +1,12 @@ import FTWKit import SwiftUI -/// Now, the first screen. A glance: one sentence at the top, the house -/// under it, then price, today and the fuse. +/// Now, the first screen, kept to what is looked at every day: whether FTW +/// is in control, the house, the car and the price. struct NowView: View { let app: AppModel let home: HomeModels + let openCar: (String) -> Void private var site: SiteModel { home.site } @@ -13,16 +14,16 @@ struct NowView: View { Group { switch site.session.phase { case .booting: - Title("Your box is starting") - Text("This can take a few minutes after an update while it tidies its database. Nothing is wrong. It will appear here as soon as it is ready.") + Title(L("Your box is starting")) + Text(L("This can take a few minutes after an update. Nothing is wrong. Your home appears here when it is ready.")) if let boot = site.session.boot { Hint("\(Self.bootWords(boot.phase)) · \(boot.pct)%") } case .terminated: - Title("Access ended") - Text(site.session.terminated == .revoked ? "Your access to this home was withdrawn by its owner." : "This session ended.") + Title(L("Access ended")) + Text(site.session.terminated == .revoked ? L("Your access to this home was withdrawn by its owner.") : L("This session ended.")) if site.session.terminated == .revoked { - Button("Your box won't let this phone in?") { app.recovering = true }.buttonStyle(.quiet) + Button(L("Your box won't let this phone in?")) { app.recovering = true }.buttonStyle(.quiet) } default: if site.session.fields.isEmpty { @@ -35,24 +36,22 @@ struct NowView: View { .onAppear { home.status.activate() home.prices.activate() - home.savings.activate() } .onDisappear { home.status.deactivate() home.prices.deactivate() - home.savings.deactivate() } } /// Paired, and not one reading has ever arrived. A hollow house would /// read as a home at zero, so this says what is true instead. @ViewBuilder private var nothingYet: some View { - Title("Nothing from your box yet") - Text(app.connectHelp ?? "This phone is paired to it and keeps trying on its own. Your house appears here as soon as the box answers.") + Title(L("Nothing from your box yet")) + Text(app.connectHelp ?? L("This phone is paired to it and keeps trying on its own. Your home appears here as soon as the box answers.")) if app.connectHelp != nil { - Button("Get this phone back in") { app.recovering = true }.buttonStyle(.primary) + Button(L("Get this phone back in")) { app.recovering = true }.buttonStyle(.primary) } else if !app.isDemo { - Button("Your box won't let this phone in?") { app.recovering = true }.buttonStyle(.quiet) + Button(L("Your box won't let this phone in?")) { app.recovering = true }.buttonStyle(.quiet) } } @@ -60,8 +59,8 @@ struct NowView: View { private var live: Bool { site.isLive } - private var flowFields: [Int: Double] { - Flow.withLoadpointEV(site.session.fields, evW: home.charging.chargeW) + private var chargeW: Double { + home.loadpoints.stale ? 0 : Flow.loadpointChargeW(home.loadpoints.points) } private var watchingStatus: Bool { @@ -74,169 +73,177 @@ struct NowView: View { if let status = home.status.status, statusLive || !live { return Flow.readings(status: status) } - return Flow.readings(fields: flowFields) + return Flow.readings(fields: Flow.withLoadpointEV(site.session.fields, evW: chargeW)) } @ViewBuilder private var house: some View { if let help = app.connectHelp { Card { Text(help) - Button("Get this phone back in") { app.recovering = true }.buttonStyle(.quiet) + Button(L("Get this phone back in")) { app.recovering = true }.buttonStyle(.quiet) } } if site.session.needsUpdate { - Card { Text("This app is older than your box. Some things are hidden until it updates.") } + Card { Text(L("This app is older than your box. Some things are hidden until it updates.")) } } - Text(Explanation.explain(fields: flowFields, dispatchBlockedBy: site.session.dispatchBlockedBy, ceilingW: site.ceilingW).headline) - .font(.title3.weight(.semibold)) - .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) - .accessibilityAddTraits(.isHeader) + InControlRow(answer: InControl.answer( + live: live, + lastKnownMs: site.cachedAtMs ?? site.lastFrameAtMs, + dispatchBlockedBy: site.session.dispatchBlockedBy, + status: home.status.status, + statusFresh: statusLive + )) // Motion claims power is flowing right now, so a cached view holds still. FlowDiagram(readings: readings, moving: live) { _ in } if home.status.status != nil, !statusLive { - Hint("Device details are out of date.\(live ? " Showing live totals." : "")") + Hint(L(live ? "Device details are out of date. Showing live totals." : "Device details are out of date.")) } - NowOutlook(site: site, home: home, statusLive: statusLive) - } - static func bootWords(_ phase: String) -> String { - switch phase { - case "vacuum": return "tidying its records" - case "migrate": return "bringing its records up to date" - case "drivers": return "waking the equipment" - default: return "getting ready" + if site.heardFromBox { + ForEach(home.loadpoints.points) { lp in + CarRow(lp: lp, loadpoints: home.loadpoints) { openCar(lp.id) } + } } - } -} - -/// Price, today and the fuse: the rest of a glance. -private struct NowOutlook: View { - let site: SiteModel - let home: HomeModels - let statusLive: Bool - var body: some View { if let prices = home.prices.prices { Card { PriceChart(prices: prices, nowMs: site.nowMs, compact: true) if home.prices.hasHole { - Hint("Some hours are missing their price.") + Hint(L("Some hours are missing their price.")) } else if prices.stale { - Hint("Tomorrow's rates aren't published yet.") + Hint(L("Tomorrow's rates aren't published yet.")) } } } - if let today = today { - todayCard(today) - } - if let status = home.status.status, let fuse = Flow.fuse(status: status) { - fuseCard(fuse) - } } - private struct Today { - let importWh: Double - let exportWh: Double - let pvWh: Double + static func bootWords(_ phase: String) -> String { + switch phase { + case "vacuum": return "tidying its records" + case "migrate": return "bringing its records up to date" + case "drivers": return "waking the equipment" + default: return "getting ready" + } } +} - private var today: Today? { - guard let t = home.status.status?["energy"]?["today"], t.object != nil else { return nil } - return Today(importWh: EnergyFormat.wholeWh(t["import_wh"]?.number), exportWh: EnergyFormat.wholeWh(t["export_wh"]?.number), pvWh: EnergyFormat.wholeWh(t["pv_wh"]?.number)) - } +/// "Is FTW in control?" in one calm line. Green only when Core says yes +/// over a live stream; a cache is grey and dated. +struct InControlRow: View { + let answer: InControl.Answer - private func todayCard(_ today: Today) -> some View { - Card { - Kicker(statusLive ? "Since midnight" : "Last known") - Text(statusLive ? "Today" : "Last totals").font(.headline) - if !statusLive, let at = home.status.receivedAtMs { - Hint("Energy totals last updated \(Clock.dayAndTime(at)).") - } - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 12) { - EnergyTile(label: "Imported", wh: today.importWh, color: Theme.importing) - EnergyTile(label: "Exported", wh: today.exportWh, color: Theme.exporting) - EnergyTile(label: "Solar", wh: today.pvWh, color: Theme.generation) - if let savings = home.savings.periods { - VStack(alignment: .leading, spacing: 2) { - Text("Saved \(home.prices.currency)").font(.caption).foregroundStyle(Theme.fgDim) - if savings.today.available { - Text(Savings.compact(savings.today.savedMinor)) - .font(Theme.number(20)) - .foregroundStyle(savings.today.savedMinor >= 0 ? Theme.exporting : Theme.importing) - } else { - Text("—").font(Theme.number(20)) - } - if savings.week.available { - Text("\(Savings.compact(savings.week.savedMinor)) this week").font(.caption).italic().foregroundStyle(Theme.fgDim) - } - } + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Image(systemName: symbol) + .font(.body.weight(.semibold)) + .foregroundStyle(color) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Kicker(L("Is FTW in control?")) + Text(answer.title) + .font(.headline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + if let detail = answer.detail { + Hint(detail) } } + Spacer(minLength: 0) + } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + .overlay(alignment: .leading) { + UnevenRoundedRectangle(topLeadingRadius: Theme.radius, bottomLeadingRadius: Theme.radius) + .fill(color) + .frame(width: 4) } + .accessibilityElement(children: .combine) } - private func fuseCard(_ fuse: Flow.Fuse) -> some View { - Card { - Kicker(statusLive ? "Live safety" : "Last known") - Text("Fuse").font(.headline) - if !statusLive, let at = home.status.receivedAtMs { - Hint("Fuse readings last updated \(Clock.dayAndTime(at)).") - } - if !fuse.phases.isEmpty { - ForEach(fuse.phases) { phase in - FuseBar(label: phase.label, amps: phase.amps, pct: phase.pct, exporting: phase.exporting) - } - } else if let fallback = fuse.fallback { - FuseBar(label: "\(Int(fuse.maxAmps)) A", amps: fallback.amps, pct: fallback.pct, exporting: false) - } + private var symbol: String { + switch answer.tone { + case .ok: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .alarm: return "exclamationmark.octagon.fill" + case .neutral: return "circle.dotted" + case .stale: return "clock" } } -} - -struct EnergyTile: View { - let label: String - let wh: Double - let color: Color - var body: some View { - let parts = EnergyFormat.parts(wh) - VStack(alignment: .leading, spacing: 2) { - Text(label).font(.caption).foregroundStyle(Theme.fgDim) - Text("\(Text(parts.text).font(Theme.number(20)))\(Text(" \(parts.unit)").font(.caption))") - .foregroundStyle(color) + private var color: Color { + switch answer.tone { + case .ok: return Theme.exporting + case .warning: return Theme.generation + case .alarm: return Theme.importing + case .neutral, .stale: return Theme.fgMuted } } } -private struct FuseBar: View { - let label: String - let amps: Double - let pct: Double - let exporting: Bool +/// The car on Now: one row that opens the car view. +struct CarRow: View { + let lp: Loadpoint + let loadpoints: LoadpointsModel + let open: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(label).font(.caption.weight(.semibold)).foregroundStyle(Theme.fgDim) - Spacer() - Text("\(PowerFormat.fixed(abs(amps), 1)) A").font(Theme.number(13)) - } - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(Theme.surfaceSunken) - Capsule().fill(color).frame(width: geo.size.width * min(1, max(0, pct / 100))) + Button(action: open) { + HStack(spacing: 12) { + Image(systemName: lp.pluggedIn ? "bolt.car.fill" : "car.side") + .font(.title3) + .foregroundStyle(Theme.mobility) + .frame(width: 32) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + // One text, so it wraps as one line at large sizes. + heading + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + Text(line) + .font(.subheadline) + .foregroundStyle(Theme.fgDim) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) } + Spacer(minLength: 4) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.fgMuted) + .accessibilityHidden(true) } - .frame(height: 6) - } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) } - private var color: Color { - if pct >= 90 { return Theme.importing } - if pct >= 70 { return Theme.generation } - return exporting ? Theme.exporting : Theme.storage + /// "Car 20 % FTW assumes". + private var heading: Text { + let car = Text(L("Car")).font(.headline) + guard let level = loadpoints.level(lp) else { return car } + let pct = Text(AppWords.percent(level.pct)).font(Theme.percent(.headline)).foregroundColor(level.origin == .assumed ? Theme.fgMuted : Theme.fg) + let origin = Text(EVText.originShort(level, zone: loadpoints.zone)).font(.caption).foregroundColor(Theme.fgDim) + return Text("\(car) \(pct) \(origin)") + } + + /// What it does now; unplugged, the goal it will meet next time. + private var line: String { + if loadpoints.stale, let at = loadpoints.readAtMs { + return AppWords.format("Last known %@.", AppWords.clock(at, zone: loadpoints.zone)) + } + if !lp.pluggedIn, let s = lp.schedule { + return "\(EVText.status(lp)) · \(EVText.goal(s, at: Date(), zone: loadpoints.zone))" + } + return EVText.status(lp, zone: loadpoints.zone) } } diff --git a/appleApp/FTW/UI/PriceChart.swift b/appleApp/FTW/UI/PriceChart.swift index f7139f7..34fa97d 100644 --- a/appleApp/FTW/UI/PriceChart.swift +++ b/appleApp/FTW/UI/PriceChart.swift @@ -16,38 +16,38 @@ struct PriceChart: View { VStack(alignment: .leading, spacing: 10) { if compact { VStack(alignment: .leading, spacing: 2) { - Kicker("Market now") - Text("Electricity price").font(.headline) + Kicker(L("Market now")) + Text(L("Electricity price")).font(.headline) } HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { Text("\(Text(PriceStrip.text(summary.current?.totalMinor, prices.currency)).font(Theme.number(28, weight: .bold)))\(Text(" \(unit.perKwh)").font(.caption))") - Text("\(prices.zone.isEmpty ? "—" : prices.zone) · incl. fees and VAT").font(.caption).foregroundStyle(Theme.fgMuted) + Text("\(prices.zone.isEmpty ? "—" : prices.zone) · \(L("incl. fees and VAT"))").font(.caption).foregroundStyle(Theme.fgMuted) } Spacer() VStack(alignment: .trailing, spacing: 2) { - Text("Cheapest 2 h").font(.caption).foregroundStyle(Theme.fgDim) + Text(L("Cheapest 2 h")).font(.caption).foregroundStyle(Theme.fgDim) if let block = summary.cheapest { Text("\(PriceStrip.text(block.meanMinor, prices.currency)) \(unit.label)").font(Theme.number(15)).foregroundStyle(Theme.exporting) - Text("\(Clock.time(block.startMs))–\(Clock.time(block.endMs))").font(.caption).foregroundStyle(Theme.fgDim) + Text("\(AppWords.clock(block.startMs, zone: .current))–\(AppWords.clock(block.endMs, zone: .current))").font(.caption).foregroundStyle(Theme.fgDim) } else { - Text("No 2 h window published").font(.caption).foregroundStyle(Theme.fgDim) + Text(L("No 2 h window published")).font(.caption).foregroundStyle(Theme.fgDim) } } } .accessibilityElement(children: .combine) if summary.bars.isEmpty { - Hint("No prices published ahead yet.") + Hint(L("No prices published ahead yet.")) } else { strip(summary, unit: unit) .frame(height: 64) if let mean = summary.meanMinor { - Hint("Dotted line: average ahead, \(PriceStrip.text(mean, prices.currency)) \(unit.label)", tone: Theme.fgMuted) + Hint(AppWords.format("Dotted line: average ahead, %@", "\(PriceStrip.text(mean, prices.currency)) \(unit.label)"), tone: Theme.fgMuted) } } } else { HStack { - Text("Price to import").font(.headline) + Text(L("Price to import")).font(.headline) Spacer() Text(unit.perKwh).font(.caption).foregroundStyle(Theme.fgMuted) } @@ -95,7 +95,7 @@ struct PriceChart: View { .foregroundStyle(Theme.accent) .lineStyle(StrokeStyle(lineWidth: 1)) .annotation(position: .top, alignment: .leading) { - Text("now").font(Theme.number(10)).foregroundStyle(Theme.accent) + Text(L("now")).font(Theme.number(10)).foregroundStyle(Theme.accent) } } .chartXAxis { diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index a9e017a..7739268 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -21,16 +21,18 @@ struct RootView: View { } } -enum HomeTab: String, Hashable { - case now +/// Where a home can go from Now. M1 has one place: the car. +enum HomeRoute: Hashable { + /// One charger's car; nil is the first. + case car(String?) - /// Where a home opens. Debug builds take `-FTWTab now` and the like, so - /// CI can photograph every screen of the demo. - static var initial: HomeTab { + /// Where a home opens. Debug builds take `-FTWTab car` and the like, so + /// every screen of the demo can be photographed. + static var initial: [HomeRoute] { #if DEBUG - return UserDefaults.standard.string(forKey: "FTWTab").flatMap(HomeTab.init(rawValue:)) ?? .now + return UserDefaults.standard.string(forKey: "FTWTab") == "car" ? [.car(nil)] : [] #else - return .now + return [] #endif } } @@ -38,7 +40,7 @@ enum HomeTab: String, Hashable { struct HomeView: View { let app: AppModel let home: HomeModels - @State private var tab: HomeTab = .initial + @State private var path: [HomeRoute] = HomeRoute.initial var body: some View { VStack(spacing: 0) { @@ -47,23 +49,27 @@ struct HomeView: View { } else { FreshnessBandView(band: home.site.freshness(noCarrier: app.connectHelp != nil), frameAtMs: home.site.lastFrameAtMs) } - ChargingNotice(site: home.site, charging: home.charging) - // The car view is the next tab here. - TabView(selection: $tab) { - Tab("Now", systemImage: "house", value: HomeTab.now) { - Screen(app: app) { - NowView(app: app, home: home) + NavigationStack(path: $path) { + Screen(app: app) { + NowView(app: app, home: home) { path.append(.car($0)) } + } + .toolbar(.hidden, for: .navigationBar) + .navigationDestination(for: HomeRoute.self) { route in + switch route { + case .car(let id): CarView(app: app, home: home, id: id) } } } } - .onAppear { home.charging.activate() } - .onDisappear { home.charging.deactivate() } + // Now and the car read the chargers from one model, so the box is + // asked once every five seconds, not twice. + .onAppear { home.loadpoints.activate() } + .onDisappear { home.loadpoints.deactivate() } } } -/// One tab's scrolling page, with pull to refresh. -private struct Screen: View { +/// One scrolling page, with pull to refresh. +struct Screen: View { let app: AppModel @ViewBuilder var content: Content @@ -116,6 +122,9 @@ struct FreshnessBandView: View { .background(Theme.surfaceSunken.ignoresSafeArea(edges: .top)) .overlay(alignment: .bottom) { Theme.line.frame(height: 1) } .accessibilityElement(children: .combine) + // One line of chrome above every screen: it must not crowd out the + // screen at the largest text sizes. + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) } private var color: Color { @@ -138,45 +147,20 @@ struct DemoBand: View { Circle() .fill(site.session.phase == .streaming ? Theme.accent : Theme.fgMuted) .frame(width: 8, height: 8) - Text(site.session.phase == .streaming ? "Live demo · simulated home" : "Starting the demo") + Text(L(site.session.phase == .streaming ? "Live demo · simulated home" : "Starting the demo")) .font(.footnote.weight(.medium)) + .lineLimit(1) + .minimumScaleFactor(0.7) Spacer() - Button("Exit demo", action: exit) + Button(L("Exit demo"), action: exit) .font(.footnote.weight(.semibold)) + .lineLimit(1) .buttonStyle(.quiet) } .padding(.horizontal, 16) .padding(.vertical, 6) .background(Theme.surfaceSunken.ignoresSafeArea(edges: .top)) .overlay(alignment: .bottom) { Theme.line.frame(height: 1) } - } -} - -/// A car on the cable, said once above every screen. Only what the box -/// said, and dated when it is out of date. -struct ChargingNotice: View { - let site: SiteModel - let charging: ChargingWatch - - var body: some View { - let fresh = charging.fresh && site.session.phase == .streaming - ForEach(site.heardFromBox ? charging.points.filter(\.pluggedIn) : []) { lp in - let current = fresh && lp.charger?.available != false - VStack(alignment: .leading, spacing: 4) { - Text(current ? "Car connected" : "Car status is out of date") - .font(.subheadline.weight(.semibold)) - Hint(current ? EVText.status(lp, canControl: site.canConfigure) : "Waiting for current charger status. The last reading cannot confirm charging.", tone: Theme.fg) - if current, let plan = EVText.plan(lp, nowMs: site.nowMs, canControl: site.canConfigure) { - Hint(plan) - } - if lp.manualSaveError { Hint(EVText.manualSaveErrorText) } - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.surfaceRaised) - .overlay(alignment: .leading) { Theme.mobility.frame(width: 3) } - .overlay(alignment: .bottom) { Theme.line.frame(height: 1) } - } + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) } } diff --git a/appleApp/FTW/UI/Theme.swift b/appleApp/FTW/UI/Theme.swift index 258a9b0..83c0d91 100644 --- a/appleApp/FTW/UI/Theme.swift +++ b/appleApp/FTW/UI/Theme.swift @@ -47,6 +47,12 @@ enum Theme { .system(size: size, weight: weight, design: .monospaced) } + /// A percentage: even digits, and a % that reads as a sign, not a + /// fixed-width glyph. + static func percent(_ style: Font.TextStyle, weight: Font.Weight = .semibold) -> Font { + .system(style, weight: weight).monospacedDigit() + } + private static func pair(_ light: UInt32, _ dark: UInt32) -> Color { return Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(rgb: dark) : UIColor(rgb: light) }) } @@ -106,6 +112,7 @@ struct Hint: View { struct FTWButtonStyle: ButtonStyle { enum Kind { case primary, quiet, outline, danger } var kind: Kind + var wide = false @Environment(\.isEnabled) private var enabled func makeBody(configuration: Configuration) -> some View { @@ -113,7 +120,7 @@ struct FTWButtonStyle: ButtonStyle { .font(.body.weight(kind == .quiet ? .regular : .semibold)) .padding(.vertical, kind == .quiet ? 6 : 11) .padding(.horizontal, kind == .quiet ? 4 : 16) - .frame(maxWidth: kind == .primary || kind == .danger ? .infinity : nil) + .frame(maxWidth: wide || kind == .primary || kind == .danger ? .infinity : nil) .foregroundStyle(foreground) .background(background, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) .overlay { @@ -148,6 +155,8 @@ extension ButtonStyle where Self == FTWButtonStyle { static var quiet: FTWButtonStyle { FTWButtonStyle(kind: .quiet) } static var outline: FTWButtonStyle { FTWButtonStyle(kind: .outline) } static var danger: FTWButtonStyle { FTWButtonStyle(kind: .danger) } + /// The second choice beside a primary one, as wide as it. + static var secondary: FTWButtonStyle { FTWButtonStyle(kind: .outline, wide: true) } } /// A pressed-button choice between a few options, the web app's range picker. @@ -191,3 +200,7 @@ enum Clock { Date(timeIntervalSince1970: ms / 1000).formatted(date: .long, time: .omitted) } } + +/// The app's words in the phone's language, English or Swedish +/// (FTWKit's `AppWords`). Text built from a String is shown as given. +func L(_ en: String) -> String { AppWords.text(en) } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 2aa7a1f..ba0c9e4 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -393,6 +393,14 @@ public final class SimulatedBox { "easee": ["status": "ok", "ev_w": .number(house.evWatts)], ], "fuse": ["max_amps": .number(house.fuseAmps), "phases": 3, "voltage": 230], + // Core's answer to "Are we in control?", one row per device. + "control_feedback": [ + ["driver": "sungrow", "kind": "battery", "mode": "plan", "status": "following", "reason": "plan", "severity": "info", "evidence": "measured", "readings_fresh": true, "actual_w": .number(house.batteryW)], + ["driver": "easee", "kind": "ev", "mode": house.manualHoldW == nil ? "plan" : "manual", + "status": house.evWatts > 100 || !house.carPluggedIn ? "following" : "waiting", + "reason": .string(!house.carPluggedIn ? "not_connected" : house.manualHoldW != nil ? "manual_hold" : house.evWatts > 100 ? "plan" : "no_plan_budget"), + "severity": "info", "evidence": "measured", "readings_fresh": true, "actual_w": .number(house.evWatts)], + ], "phase_amps": .array((0..<3).map { .number(((perPhase + Double($0) * 90) / 230 * 10).rounded() / 10) }), "phase_powers": .array((0..<3).map { .number((perPhase + Double($0) * 90).rounded()) }), ]) @@ -576,6 +584,11 @@ public final class LoopbackCarrier: Carrier { private var closed = false private var onFrame: @MainActor (Bytes) -> Void = { _ in } private var onStatus: @MainActor (CarrierStatus) -> Void = { _ in } + // Frames in flight, oldest first. A real socket keeps order; timers + // with the same delay need not fire in order, so each timer delivers + // the oldest frame rather than its own. + private var toApp: [Bytes] = [] + private var toBox: [Bytes] = [] public init(box: SimulatedBox, scheduler: Scheduler, latencyMs: Double = 120) { self.box = box @@ -583,9 +596,10 @@ public final class LoopbackCarrier: Carrier { self.latencyMs = latencyMs box.send = { [weak self] frame in guard let self else { return } + self.toApp.append(frame) self.scheduler.after(self.latencyMs) { [weak self] in - guard let self, !self.closed else { return } - self.onFrame(frame) + guard let self, !self.closed, !self.toApp.isEmpty else { return } + self.onFrame(self.toApp.removeFirst()) } } scheduler.after(latencyMs) { [weak self] in @@ -602,9 +616,10 @@ public final class LoopbackCarrier: Carrier { public func send(_ frame: Bytes) { guard status.isOpen, !closed else { return } + toBox.append(frame) scheduler.after(latencyMs) { [weak self] in - guard let self, !self.closed else { return } - self.box.receive(frame) + guard let self, !self.closed, !self.toBox.isEmpty else { return } + self.box.receive(self.toBox.removeFirst()) } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift index 24df9ed..36dbf23 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -19,6 +19,16 @@ public struct Loadpoint: Equatable, Sendable, Identifiable { public var days: Int /// The goal is the car's own limit rather than a percentage. public var finishAtVehicleLimit: Bool + + public init(socPct: Double, timeOfDayMinUTC: Int, clockMin: Int?, surplusUnlockPct: Double, recurring: Bool, days: Int, finishAtVehicleLimit: Bool) { + self.socPct = socPct + self.timeOfDayMinUTC = timeOfDayMinUTC + self.clockMin = clockMin + self.surplusUnlockPct = surplusUnlockPct + self.recurring = recurring + self.days = days + self.finishAtVehicleLimit = finishAtVehicleLimit + } } public struct Manual: Equatable, Sendable { @@ -347,6 +357,7 @@ public enum AppWords { "FTW's estimate from energy delivered": "FTW:s uppskattning från laddad energi", "FTW assumes %@. Nobody has told it the real level.": "FTW antar %@. Ingen har angett den riktiga nivån.", "FTW assumes": "FTW antar", + "Nobody has told FTW the car's level yet. Choose it below.": "Ingen har angett bilens nivå än. Välj den nedan.", "from the car": "från bilen", "reported %@": "rapporterad %@", "you set": "du angav", @@ -369,6 +380,12 @@ public enum AppWords { "Remove goal": "Ta bort målet", "Charge to": "Ladda till", "Repeat": "Upprepa", + "Weekdays": "Vardagar", + "Every day": "Varje dag", + "Weekends": "Helger", + "Once": "En gång", + "This box has no charger.": "Boxen har ingen laddare.", + "Set %@": "Ange %@", "Saving the goal…": "Sparar målet…", "Goal saved. FTW is updating the plan.": "Målet är sparat. FTW räknar om planen.", "Goal removed.": "Målet är borttaget.", diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift index 41ba87a..b352ea9 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift @@ -461,6 +461,19 @@ import FTWDemo } } + /// The demo answers "Is FTW in control?" as Core does: from live + /// control feedback, and only while the stream is live. + @Test func theDemoSaysFTWIsInControl() async throws { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + home.status.activate() + await rig.run(3_000) + let site = home.site + let answer = InControl.answer(live: site.isLive, lastKnownMs: site.lastFrameAtMs, dispatchBlockedBy: site.session.dispatchBlockedBy, status: home.status.status, statusFresh: home.status.fresh) + #expect(answer.tone == .ok) + } + /// Every demo situation opens, so each screen can be shown without a box. @Test func everyDemoCarOpens() async throws { for situation in DemoCar.allCases { @@ -492,3 +505,43 @@ extension LoadpointsModel { await task.value } } + +/// Timers that fire in the reverse of the order they were set, as the live +/// scheduler's sleeping tasks may. +@MainActor +final class ReversingScheduler: Scheduler { + var nowMs: Double = 0 + private var due: [@MainActor () -> Void] = [] + + func after(_ ms: Double, _ action: @escaping @MainActor () -> Void) -> Cancellable { + due.append(action) + return Cancellable() + } + + func random() -> Double { 0.5 } + + func fireAll() { + let now = due + due = [] + for action in now.reversed() { action() } + } +} + +@MainActor +@Suite struct LoopbackTests { + /// The demo's answers arrive in the order the box sent them, whatever + /// order the timers fire in. Out of order, a chunk before its head read + /// as "your box didn't answer". + @Test func framesArriveInTheOrderTheyWereSent() { + let scheduler = ReversingScheduler() + let box = SimulatedBox(scheduler: ManualScheduler()) + let carrier = LoopbackCarrier(box: box, scheduler: scheduler) + var got: [Bytes] = [] + carrier.setHandlers(onFrame: { got.append($0) }, onStatus: { _ in }) + scheduler.fireAll() + let sent: [Bytes] = [[1], [2], [3]] + for f in sent { box.send?(f) } + scheduler.fireAll() + #expect(got == sent) + } +} From f8b75d3a58c4bdcc5585190c910eda0a94fa9ca9 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:46:22 +0200 Subject: [PATCH 3/3] Make the car view and Now fast, native and timer-free Nothing asks the box on a timer any more. The chargers are read when a screen that shows them appears, after a command, when the stream says the car started or stopped drawing, and when the stream comes back. The plan's shortfall is read when the car view opens and when the car's plan changes. "Is FTW in control?" now comes from lane 0 alone (the dispatch block and each source's state), so Now no longer polls /api/status. A cold start paints the car from the App Group cache the app writes for its widgets, dated and marked as not live, before the box answers. While nothing at all is known the screens show their shape, not a spinner. LaunchClock logs process start to first frame and first content. A tapped level shows as the chosen chip and the receipt; the number only moves once the box confirms it. Goals send time_of_day_min and today's time_of_day_min_utc, built from nothing read; tests cover a Core with srcfl/ftw#1586 and one without. The car view is a system List with system buttons, haptics on commands and outcomes, symbol effects and numeric transitions. The Now car row zooms into it. Now is split into small views that redraw only what a frame changed. Theme colours move to an asset catalog, so no UIKit is left outside the camera. CLAUDE.md gains "Feel and speed". Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 37 ++ appleApp/FTW/Car/CarView.swift | 482 ++++++++++++------ appleApp/FTW/UI/FlowDiagram.swift | 10 +- appleApp/FTW/UI/LaunchClock.swift | 36 ++ appleApp/FTW/UI/NowView.swift | 285 ++++++----- appleApp/FTW/UI/PriceChart.swift | 2 +- appleApp/FTW/UI/RootView.swift | 26 +- appleApp/FTW/UI/Theme.swift | 46 +- appleApp/FTW/UI/Theme.xcassets/Contents.json | 6 + .../ftw.accent.colorset/Contents.json | 38 ++ .../ftw.exporting.colorset/Contents.json | 38 ++ .../ftw.fg.colorset/Contents.json | 38 ++ .../ftw.fgDim.colorset/Contents.json | 38 ++ .../ftw.fgMuted.colorset/Contents.json | 38 ++ .../ftw.generation.colorset/Contents.json | 38 ++ .../ftw.importing.colorset/Contents.json | 38 ++ .../ftw.line.colorset/Contents.json | 38 ++ .../ftw.mobility.colorset/Contents.json | 38 ++ .../ftw.onAccent.colorset/Contents.json | 38 ++ .../ftw.storage.colorset/Contents.json | 38 ++ .../ftw.surface.colorset/Contents.json | 38 ++ .../Contents.json | 38 ++ .../ftw.surfaceRaised.colorset/Contents.json | 38 ++ .../ftw.surfaceSunken.colorset/Contents.json | 38 ++ .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 28 +- .../FTWKit/Sources/FTWKit/Format/EV.swift | 32 +- .../FTWKit/State/LoadpointsModel.swift | 78 ++- .../Sources/FTWKit/State/NowModels.swift | 86 +--- .../FTWKit/Tests/FTWKitTests/CarTests.swift | 165 ++++-- 29 files changed, 1424 insertions(+), 465 deletions(-) create mode 100644 appleApp/FTW/UI/LaunchClock.swift create mode 100644 appleApp/FTW/UI/Theme.xcassets/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.accent.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.exporting.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.fg.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.fgDim.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.fgMuted.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.generation.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.importing.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.line.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.mobility.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.onAccent.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.storage.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.surface.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.surfaceElevated.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.surfaceRaised.colorset/Contents.json create mode 100644 appleApp/FTW/UI/Theme.xcassets/ftw.surfaceSunken.colorset/Contents.json diff --git a/CLAUDE.md b/CLAUDE.md index 15f07f7..5942a40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,43 @@ The protocol, the QR, the relay and the identity model are specified in - Errors say what happens now, not what broke inside. - Least code that does the whole job. +## Feel and speed + +Fredrik's bar for the iPhone app (2026-10-12): modern, very fast, truly +native, and smooth enough to impress. + +- **First frame from the cache.** No network wait and no spinner before + it. While nothing is known, show the shape of the screen + (`.redacted(reason: .placeholder)`), not a spinner. `LaunchClock` logs + 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 a screen that + shows them appears, after a command, and when the stream says the car + started or stopped drawing. The plan's shortfall is read when the car + view opens and when the car's plan changes. +- **A frame redraws what moved.** Small views that read only what they + show, value inputs, and `Equatable` with `.equatable()` where a frame + would otherwise redraw a whole section. A clock on screen ticks by the + minute unless the second matters. +- **Modern Swift.** Swift 6 strict concurrency, `@Observable`, + async/await and structured concurrency. No Combine, no GCD, no + `ObservableObject`, and no UIKit except the camera. Colours live in + asset catalogs, not `UIColor`. +- **iOS 26's design.** System components (`List`, `Form`, + `NavigationStack`, system button styles), so Liquid Glass appears where + the system puts it. SF Symbols with symbol effects. +- **Feel.** Numbers change with `.contentTransition(.numericText())`. + Motion uses one spring, `Theme.spring`. The car row on Now zooms into + the car view (`navigationTransition(.zoom)`). `.sensoryFeedback` marks + commands and their outcomes. +- **A command answers at once.** Tap, haptic and "Sent" in under 100 ms, + then the box's receipt (`cmd.ack`), then the outcome (`cmd.result`). + Show the intent at once; never show a result the box has not confirmed. +- **The largest text size and dark mode** look as good as the default. + Review all three in the simulator. + ## Non-negotiable invariants - **Never fake live.** Every reading carries its age. diff --git a/appleApp/FTW/Car/CarView.swift b/appleApp/FTW/Car/CarView.swift index 9c0c501..720f85e 100644 --- a/appleApp/FTW/Car/CarView.swift +++ b/appleApp/FTW/Car/CarView.swift @@ -3,49 +3,50 @@ import FTWKit import SwiftUI /// The car: how full it is and who says so, the goal and whether it makes -/// it, and Charge now, Pause and Back to the plan. Every tap is followed to -/// the box's answer, on screen. +/// it, and Charge now, Pause and Back to the plan. Every tap answers at +/// once (a haptic and "Sent"), then the box's receipt, then the outcome; +/// the screen shows intent at once and a result only once the box has +/// confirmed it. struct CarView: View { let app: AppModel let home: HomeModels let id: String? - @State private var correcting = false @State private var editingGoal = false private var model: LoadpointsModel { home.loadpoints } - private var site: SiteModel { home.site } - private var canControl: Bool { site.canConfigure } private var lp: Loadpoint? { id.flatMap { want in model.points.first { $0.id == want } } ?? model.points.first } var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - if let lp { - status(lp) - level(lp) - goal(lp) - if lp.pluggedIn, canControl { controls(lp) } - if app.isDemo { demo(lp) } - } else if model.loaded { - Hint(L("This box has no charger.")) - } else { - ProgressView().frame(maxWidth: .infinity) + List { + if let lp { + StatusSection(lp: lp, model: model, site: home.site) + LevelSection(lp: lp, model: model, canControl: home.site.canConfigure) + GoalSection(lp: lp, model: model, site: home.site, canControl: home.site.canConfigure, editing: $editingGoal) + if lp.pluggedIn, home.site.canConfigure { + ControlSection(lp: lp, model: model) } + if app.isDemo { DemoSection(lp: lp, model: model) } + } else if model.loaded { + Section { Text(L("This box has no charger.")) } + .listRowBackground(Theme.surfaceRaised) + } else { + PlaceholderSections() } - .padding(16) - .frame(maxWidth: 640, alignment: .leading) - .frame(maxWidth: .infinity) } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) .background(Theme.surface) + .animation(Theme.spring, value: model.points) .navigationTitle(L("Car")) .navigationBarTitleDisplayMode(.inline) .refreshable { try? await model.loadChargers() } .onAppear { model.activate() model.watchPlan() + LaunchClock.mark("first content") } .onDisappear { model.unwatchPlan() @@ -55,46 +56,79 @@ struct CarView: View { if let lp { GoalEditor(model: model, lp: lp) } } } +} - // MARK: What it does now +// MARK: What it does now - @ViewBuilder private func status(_ lp: Loadpoint) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(EVText.status(lp, zone: model.zone)) - .font(.title3.weight(.semibold)) - .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) - if let session = EVText.session(lp) { Hint(session) } - if model.stale { - Hint(model.error ?? model.readAtMs.map { AppWords.format("Last known %@.", AppWords.clock($0, zone: model.zone)) } ?? L("Waiting for your box."), tone: Theme.generation) +private struct StatusSection: View { + let lp: Loadpoint + let model: LoadpointsModel + let site: SiteModel + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 4) { + Text(EVText.status(lp, zone: model.zone)) + .font(.title3.weight(.semibold)) + .foregroundStyle(Theme.fg) + .contentTransition(.numericText()) + .fixedSize(horizontal: false, vertical: true) + if let session = EVText.session(lp) { + Hint(session).contentTransition(.numericText()) + } + if let note { + Hint(note.text, tone: note.warn ? Theme.generation : Theme.fgMuted) + } } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) } - .accessibilityElement(children: .combine) + .listRowBackground(Theme.surfaceRaised) } - // MARK: How full is it? + /// When the box said it: plainly when out of date, quietly when it is + /// only a while ago. The chargers are not read on a timer. + private var note: (text: String, warn: Bool)? { + guard let at = model.readAtMs else { return model.stale ? (L("Waiting for your box."), true) : nil } + if model.stale { return (model.error ?? AppWords.format("Last known %@.", AppWords.clock(at, zone: model.zone)), true) } + if site.nowMs - at > 60_000 { return (AppWords.format("Read at %@", AppWords.clock(at, zone: model.zone)), false) } + return nil + } +} - @ViewBuilder private func level(_ lp: Loadpoint) -> some View { +// MARK: How full is it? + +private struct LevelSection: View { + let lp: Loadpoint + let model: LoadpointsModel + let canControl: Bool + @State private var correcting = false + + var body: some View { let level = model.level(lp) let assumed = level?.origin == .assumed || level?.origin == .unconfirmed - Card { - Kicker(L("How full is the car?")) + Section { if let level { - ViewThatFits(in: .horizontal) { - HStack(alignment: .firstTextBaseline, spacing: 10) { levelFigure(level, assumed: assumed) } - VStack(alignment: .leading, spacing: 6) { levelFigure(level, assumed: assumed) } + VStack(alignment: .leading, spacing: 6) { + ViewThatFits(in: .horizontal) { + HStack(alignment: .firstTextBaseline, spacing: 10) { figure(level, assumed: assumed) } + VStack(alignment: .leading, spacing: 6) { figure(level, assumed: assumed) } + } + // The badge says FTW assumes; the line says what to do. + Text(level.origin == .assumed && canControl ? L("Nobody has told FTW the car's level yet. Choose it below.") : EVText.origin(level, zone: model.zone)) + .font(.subheadline) + .foregroundStyle(assumed ? Theme.fg : Theme.fgDim) + .fixedSize(horizontal: false, vertical: true) } - // The badge says FTW assumes; the line says what to do. - Text(level.origin == .assumed && canControl ? L("Nobody has told FTW the car's level yet. Choose it below.") : EVText.origin(level, zone: model.zone)) - .font(.subheadline) - .foregroundStyle(assumed ? Theme.fg : Theme.fgDim) - .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 4) + .accessibilityElement(children: .combine) if canControl { if level.origin == .car, !correcting { - Button(L("Correct the level")) { correcting = true }.buttonStyle(.quiet) - Hint(L("Only correct it if the car shows something else.")) + Button(L("Correct the level")) { + withAnimation(Theme.spring) { correcting = true } + } } else { - LevelChips(current: level.pct) { pct in + LevelChips(current: model.chosenLevel(lp) ?? level.pct) { pct in Task { await model.setLevel(lp, pct: pct) } } } @@ -103,28 +137,30 @@ struct CarView: View { Text(L("Plug in the car to tell FTW its level.")) .font(.subheadline) .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) } else { Text(L("Level unknown")).font(.headline) } if let outcome = model.outcome(level: lp) { ReceiptView(outcome: outcome) } - } - .overlay(alignment: .leading) { - if assumed { - UnevenRoundedRectangle(topLeadingRadius: Theme.radius, bottomLeadingRadius: Theme.radius) - .fill(Theme.generation) - .frame(width: 4) + } header: { + Text(L("How full is the car?")) + } footer: { + if level?.origin == .car, !correcting, canControl { + Text(L("Only correct it if the car shows something else.")) } } + .listRowBackground(Theme.surfaceRaised) + .sensoryFeedback(.selection, trigger: model.chosenLevel(lp)) + .sensoryFeedback(trigger: model.outcome(level: lp)?.tone) { _, tone in Self.feedback(tone) } } - @ViewBuilder private func levelFigure(_ level: EVText.Level, assumed: Bool) -> some View { + @ViewBuilder private func figure(_ level: EVText.Level, assumed: Bool) -> some View { Text(AppWords.percent(level.pct)) .font(Theme.percent(.largeTitle, weight: .bold)) .foregroundStyle(assumed ? Theme.fgMuted : Theme.fg) - .contentTransition(.numericText()) + .contentTransition(.numericText(value: level.pct)) + .animation(Theme.spring, value: level.pct) if assumed { Text(L("FTW assumes")) .font(.caption.weight(.semibold)) @@ -136,29 +172,101 @@ struct CarView: View { } } - // MARK: The goal + static func feedback(_ tone: EVText.Outcome.Tone?) -> SensoryFeedback? { + switch tone { + case .done?: return .success + case .warning?: return .warning + case .failed?: return .error + default: return nil + } + } +} + +/// 20, 40, 60, 80 and a step of five either way. Each tap goes to the box +/// at once; there is no Save. +struct LevelChips: View { + let current: Double + let choose: (Double) -> Void + @Environment(\.dynamicTypeSize) private var textSize - @ViewBuilder private func goal(_ lp: Loadpoint) -> some View { - Card { - Kicker(L("Goal")) - if let s = lp.schedule { - Text(EVText.goal(s, at: Date(timeIntervalSince1970: site.nowMs / 1000), zone: model.zone)) - .font(.headline) - .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) - if let next = EVText.nextGoal(lp, nowMs: site.nowMs, zone: model.zone) { - Hint(next) + var body: some View { + VStack(spacing: 8) { + // Four across, two at the accessibility sizes: a chip never wraps. + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: textSize.isAccessibilitySize ? 2 : 4), spacing: 8) { + ForEach(EVText.levelPresets, id: \.self) { pct in + chip(AppWords.percent(pct), selected: current.rounded() == pct, label: AppWords.percent(pct)) { choose(pct) } } - if lp.pluggedIn { - if let r = model.readiness(lp) { - Readiness(readiness: r, text: EVText.readiness(r, level: model.level(lp), nowMs: site.nowMs, zone: model.zone)) - } - if let plan = EVText.plan(lp, windows: model.windows[lp.id] ?? lp.planWindows, nowMs: site.nowMs, zone: model.zone) { - Hint(plan) + } + HStack(spacing: 8) { + let down = EVText.stepped(current, up: false) + let up = EVText.stepped(current, up: true) + chip("−5", selected: false, label: AppWords.format("Set %@", AppWords.percent(down))) { choose(down) } + chip("+5", selected: false, label: AppWords.format("Set %@", AppWords.percent(up))) { choose(up) } + } + } + .padding(.vertical, 4) + .animation(Theme.spring, value: current) + } + + @ViewBuilder private func chip(_ text: String, selected: Bool, label: String, action: @escaping () -> Void) -> some View { + let title = Text(text) + .font(Theme.percent(.body)) + .lineLimit(1) + .minimumScaleFactor(0.6) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + Group { + if selected { + Button(action: action) { title.foregroundStyle(Theme.onAccent) } + .buttonStyle(.borderedProminent) + .tint(Theme.accent) + } else { + Button(action: action) { title.foregroundStyle(Theme.fg) } + .buttonStyle(.bordered) + .tint(Theme.fgMuted) + } + } + .buttonBorderShape(.roundedRectangle(radius: Theme.radiusSmall)) + .accessibilityLabel(label) + .accessibilityAddTraits(selected ? .isSelected : []) + } +} + +// MARK: The goal + +private struct GoalSection: View { + let lp: Loadpoint + let model: LoadpointsModel + let site: SiteModel + let canControl: Bool + @Binding var editing: Bool + + var body: some View { + Section { + if model.fromCache { + // The cache keeps no goal; say nothing rather than "none". + Text(EVText.suggestedGoalText()) + .font(.headline) + .redacted(reason: .placeholder) + } else if let s = lp.schedule { + VStack(alignment: .leading, spacing: 4) { + Text(EVText.goal(s, at: Date(timeIntervalSince1970: site.nowMs / 1000), zone: model.zone)) + .font(.headline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + if let next = EVText.nextGoal(lp, nowMs: site.nowMs, zone: model.zone) { + Hint(next) } } + .padding(.vertical, 2) + if lp.pluggedIn, let r = model.readiness(lp) { + Readiness(readiness: r, text: EVText.readiness(r, level: model.level(lp), nowMs: site.nowMs, zone: model.zone)) + } + if lp.pluggedIn, let plan = EVText.plan(lp, windows: model.windows[lp.id] ?? lp.planWindows, nowMs: site.nowMs, zone: model.zone) { + Hint(plan) + } if canControl { - Button(L("Change")) { editingGoal = true }.buttonStyle(.outline) + Button(L("Change")) { editing = true } } } else { Text(L("No goal yet. Without one, FTW only charges from spare solar.")) @@ -166,120 +274,153 @@ struct CarView: View { .foregroundStyle(Theme.fg) .fixedSize(horizontal: false, vertical: true) if canControl { - Button(AppWords.format("Use %@", EVText.suggestedGoalText())) { + Button { let g = EVText.suggestedGoal Task { await model.saveGoal(lp, socPct: g.socPct, clock: g.clock, recurring: true, days: g.days, zone: model.zone) } + } label: { + Text(AppWords.format("Use %@", EVText.suggestedGoalText())) + .frame(maxWidth: .infinity) + .foregroundStyle(Theme.onAccent) } - .buttonStyle(.primary) - Button(L("Change")) { editingGoal = true }.buttonStyle(.quiet) + .buttonStyle(.borderedProminent) + .controlSize(.large) + Button(L("Change")) { editing = true } } } - if let save = model.goalSave[lp.id], !editingGoal { + if let save = model.goalSave[lp.id], !editing { Hint(LoadpointsModel.goalText(save), tone: { if case .failed = save { return Theme.importing }; return Theme.fgDim }()) } + } header: { + Text(L("Goal")) + } + .listRowBackground(Theme.surfaceRaised) + .sensoryFeedback(trigger: model.goalSave[lp.id]) { _, save in + switch save { + case .saved?, .removed?: return .success + case .failed?: return .error + default: return nil + } + } + } +} + +/// Whether the car makes its goal, in the plan's own numbers. +struct Readiness: View { + let readiness: EVText.Readiness + let text: String + + var body: some View { + Label { + Text(text) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: short ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") + .foregroundStyle(short ? Theme.generation : Theme.exporting) + .contentTransition(.symbolEffect(.replace)) } + .accessibilityElement(children: .combine) } - // MARK: Charge now, Pause, Back to the plan + private var short: Bool { + if case .short = readiness { return true } + return false + } +} + +// MARK: Charge now, Pause, Back to the plan + +private struct ControlSection: View { + let lp: Loadpoint + let model: LoadpointsModel + @State private var taps = 0 - @ViewBuilder private func controls(_ lp: Loadpoint) -> some View { + var body: some View { let paused = EVText.isPaused(lp) let holding = lp.manualActive && !paused let busy = model.holdSending(lp) - Card { - if holding || paused { - Button(L("Back to the plan")) { Task { await model.backToPlan(lp) } } - .buttonStyle(.primary) - .disabled(busy) - } - if !holding { - Button(L("Charge now")) { Task { await model.chargeNow(lp) } } - .buttonStyle(paused ? .secondary : .primary) - .disabled(busy) - } - if !paused { - Button(L("Pause")) { Task { await model.pause(lp) } } - .buttonStyle(.secondary) - .disabled(busy) + Section { + VStack(spacing: 10) { + if holding || paused { + action(L("Back to the plan"), symbol: "calendar.badge.clock", prominent: true, busy: busy) { await model.backToPlan(lp) } + } + if !holding { + action(L("Charge now"), symbol: "bolt.fill", prominent: !paused, busy: busy) { await model.chargeNow(lp) } + } + if !paused { + action(L("Pause"), symbol: "pause.fill", prominent: false, busy: busy) { await model.pause(lp) } + } } - Hint(L("Charge now runs until you go back to the plan or unplug.")) + .padding(.vertical, 6) if let outcome = model.outcome(hold: lp) { ReceiptView(outcome: outcome) } + } footer: { + Text(L("Charge now runs until you go back to the plan or unplug.")) } + .listRowBackground(Theme.surfaceRaised) + .sensoryFeedback(.impact(weight: .medium), trigger: taps) + .sensoryFeedback(trigger: model.outcome(hold: lp)?.tone) { _, tone in LevelSection.feedback(tone) } } - // MARK: The demo's own cable - - @ViewBuilder private func demo(_ lp: Loadpoint) -> some View { - Card { - Kicker(L("Demo")) - Button(L(lp.pluggedIn ? "Unplug the car" : "Plug in the car")) { - SimulatedBox.current?.setCarPluggedIn(!lp.pluggedIn) - model.reread() + @ViewBuilder private func action(_ title: String, symbol: String, prominent: Bool, busy: Bool, _ run: @escaping () async -> Void) -> some View { + let label = Label(title, systemImage: symbol) + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + Group { + if prominent { + Button { + taps += 1 + Task { await run() } + } label: { label.foregroundStyle(Theme.onAccent) } + .buttonStyle(.borderedProminent) + } else { + Button { + taps += 1 + Task { await run() } + } label: { label.foregroundStyle(Theme.fg) } + .buttonStyle(.bordered) + .tint(Theme.fgMuted) } - .buttonStyle(.outline) - Hint(L("The demo plugs in a car FTW cannot read, so FTW assumes 20 %.")) } + .controlSize(.large) + .disabled(busy) } } -/// 20, 40, 60, 80 and a step of five either way. Each tap goes to the box -/// at once; there is no Save. -struct LevelChips: View { - let current: Double - let choose: (Double) -> Void +// MARK: The demo's own cable + +private struct DemoSection: View { + let lp: Loadpoint + let model: LoadpointsModel var body: some View { - VStack(alignment: .leading, spacing: 8) { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 68), spacing: 8)], spacing: 8) { - ForEach(EVText.levelPresets, id: \.self) { pct in - chip(AppWords.percent(pct), selected: current.rounded() == pct, label: AppWords.percent(pct)) { choose(pct) } - } - } - HStack(spacing: 8) { - chip("−5", selected: false, label: AppWords.format("Set %@", AppWords.percent(EVText.stepped(current, up: false)))) { choose(EVText.stepped(current, up: false)) } - chip("+5", selected: false, label: AppWords.format("Set %@", AppWords.percent(EVText.stepped(current, up: true)))) { choose(EVText.stepped(current, up: true)) } + Section { + Button(L(lp.pluggedIn ? "Unplug the car" : "Plug in the car")) { + SimulatedBox.current?.setCarPluggedIn(!lp.pluggedIn) + model.reread() } + } header: { + Text(L("Demo")) + } footer: { + Text(L("The demo plugs in a car FTW cannot read, so FTW assumes 20 %.")) } - } - - private func chip(_ text: String, selected: Bool, label: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Text(text) - .font(Theme.percent(.body)) - .frame(maxWidth: .infinity, minHeight: 44) - .foregroundStyle(selected ? Theme.onAccent : Theme.fg) - .background(selected ? Theme.accent : Theme.surfaceSunken, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) - .overlay(RoundedRectangle(cornerRadius: Theme.radiusSmall).strokeBorder(Theme.line, lineWidth: selected ? 0 : 1)) - } - .buttonStyle(.plain) - .accessibilityLabel(label) - .accessibilityAddTraits(selected ? .isSelected : []) + .listRowBackground(Theme.surfaceRaised) } } -/// Whether the car makes its goal, in the plan's own numbers. -struct Readiness: View { - let readiness: EVText.Readiness - let text: String - +/// Before the first answer and with nothing cached: the shape of the +/// screen, not a spinner. +private struct PlaceholderSections: View { var body: some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Image(systemName: short ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") - .foregroundStyle(short ? Theme.generation : Theme.exporting) - .accessibilityHidden(true) - Text(text) - .font(.subheadline.weight(.medium)) - .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) + Group { + Section { Text("Plugged in · charges at 01:00").font(.title3.weight(.semibold)) } + Section { Text("20 %").font(Theme.percent(.largeTitle, weight: .bold)) } + Section { Text("80 % by 07:00 on weekdays").font(.headline) } } - .accessibilityElement(children: .combine) - } - - private var short: Bool { - if case .short = readiness { return true } - return false + .listRowBackground(Theme.surfaceRaised) + .redacted(reason: .placeholder) } } @@ -290,19 +431,18 @@ struct ReceiptView: View { var body: some View { VStack(alignment: .leading, spacing: 6) { ViewThatFits(in: .horizontal) { - HStack(spacing: 10) { marks } + HStack(spacing: 12) { marks } VStack(alignment: .leading, spacing: 4) { marks } } Text(outcome.text) .font(.footnote.weight(.medium)) .foregroundStyle(color) + .contentTransition(.opacity) .fixedSize(horizontal: false, vertical: true) } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.surfaceSunken, in: RoundedRectangle(cornerRadius: Theme.radiusSmall)) + .padding(.vertical, 4) + .animation(Theme.spring, value: outcome) .accessibilityElement(children: .combine) - .animation(.default, value: outcome) } @ViewBuilder private var marks: some View { @@ -313,18 +453,34 @@ struct ReceiptView: View { private func mark(_ label: String, _ state: EVText.Outcome.Mark) -> some View { HStack(spacing: 4) { - switch state { - case .done: Image(systemName: "checkmark.circle.fill").foregroundStyle(Theme.exporting) - case .active: ProgressView().controlSize(.mini) - case .waiting: Image(systemName: "circle").foregroundStyle(Theme.fgMuted) - case .failed: Image(systemName: "xmark.circle.fill").foregroundStyle(Theme.importing) - } + Image(systemName: symbol(state)) + .foregroundStyle(tint(state)) + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.pulse, options: .repeating, isActive: state == .active) Text(label) .font(.caption.weight(.semibold)) .foregroundStyle(state == .waiting ? Theme.fgMuted : Theme.fg) } } + private func symbol(_ state: EVText.Outcome.Mark) -> String { + switch state { + case .done: return "checkmark.circle.fill" + case .active: return "circle.dotted.circle" + case .waiting: return "circle" + case .failed: return "xmark.circle.fill" + } + } + + private func tint(_ state: EVText.Outcome.Mark) -> Color { + switch state { + case .done: return Theme.exporting + case .active: return Theme.accent + case .waiting: return Theme.fgMuted + case .failed: return Theme.importing + } + } + private var color: Color { switch outcome.tone { case .working: return Theme.fgDim diff --git a/appleApp/FTW/UI/FlowDiagram.swift b/appleApp/FTW/UI/FlowDiagram.swift index 36c6882..cd9e8a6 100644 --- a/appleApp/FTW/UI/FlowDiagram.swift +++ b/appleApp/FTW/UI/FlowDiagram.swift @@ -7,10 +7,9 @@ import SwiftUI /// /// Dots move along a line only while readings are live. A cached or quiet /// view holds still, because motion says power is flowing right now. -struct FlowDiagram: View { +struct FlowDiagram: View, Equatable { let readings: Flow.Readings let moving: Bool - let tap: (Flow.Role) -> Void private static let height: CGFloat = 340 @@ -27,12 +26,10 @@ struct FlowDiagram: View { Hub(loadKw: readings.loadKw, selfPoweredPct: readings.selfPoweredPctToday) .position(layout.hub) - .onTapGesture { tap(.load) } ForEach(readings.nodes) { node in Bubble(node: node) .position(layout.points[node.id] ?? layout.hub) - .onTapGesture { if node.tappable { tap(node.role) } } } } } @@ -105,6 +102,8 @@ private struct Hub: View { .foregroundStyle(Theme.accent) Text("\(Text(AppWords.decimal(parts.text)).font(Theme.number(22, weight: .bold)))\(Text(" \(parts.unit)").font(.caption))") .foregroundStyle(Theme.fg) + .contentTransition(.numericText(value: loadKw)) + .animation(Theme.spring, value: loadKw) Text(L("HOME")).font(.caption2.weight(.semibold)).tracking(0.8).foregroundStyle(Theme.fgDim) if let pct = selfPoweredPct { Text(AppWords.format("%@ self-powered today", AppWords.percent(pct))) @@ -139,6 +138,8 @@ private struct Bubble: View { } Text("\(Text(AppWords.decimal(parts.text)).font(Theme.number(17, weight: .bold)))\(Text(" \(parts.unit)").font(.caption2))") .foregroundStyle(color) + .contentTransition(.numericText(value: node.kw)) + .animation(Theme.spring, value: node.kw) if let soc = node.socPct { SocBar(pct: soc) } @@ -158,7 +159,6 @@ private struct Bubble: View { .background(RoundedRectangle(cornerRadius: 16).fill(Theme.surfaceRaised)) .overlay(RoundedRectangle(cornerRadius: 16).strokeBorder(color.opacity(0.5), lineWidth: 1.5)) .accessibilityElement(children: .combine) - .accessibilityAddTraits(node.tappable ? .isButton : []) } } diff --git a/appleApp/FTW/UI/LaunchClock.swift b/appleApp/FTW/UI/LaunchClock.swift new file mode 100644 index 0000000..27ef7a0 --- /dev/null +++ b/appleApp/FTW/UI/LaunchClock.swift @@ -0,0 +1,36 @@ +import Darwin +import os + +/// How long from the process starting to each first thing on screen, +/// logged once each and marked for Instruments. Read them with: +/// +/// xcrun simctl spawn log show --last 2m \ +/// --predicate 'subsystem == "energy.ftw.app" AND category == "launch"' +@MainActor +enum LaunchClock { + static let log = Logger(subsystem: "energy.ftw.app", category: "launch") + static let signposter = OSSignposter(logger: log) + private static var seen: Set = [] + + /// Once per event: the first frame, the first content, the stream. + static func mark(_ event: StaticString) { + let name = "\(event)" + guard seen.insert(name).inserted else { return } + signposter.emitEvent(event) + if let ms = sinceProcessStartMs() { + log.notice("\(name, privacy: .public) \(Int(ms.rounded()), privacy: .public) ms after process start") + } + } + + /// The kernel's record of when this process started. + static func sinceProcessStartMs() -> Double? { + var info = kinfo_proc() + var size = MemoryLayout.stride + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()] + guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0 else { return nil } + let start = info.kp_proc.p_starttime + var now = timeval() + gettimeofday(&now, nil) + return Double(now.tv_sec - start.tv_sec) * 1000 + Double(now.tv_usec - start.tv_usec) / 1000 + } +} diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift index c80442a..035e2a1 100644 --- a/appleApp/FTW/UI/NowView.swift +++ b/appleApp/FTW/UI/NowView.swift @@ -3,43 +3,56 @@ import SwiftUI /// Now, the first screen, kept to what is looked at every day: whether FTW /// is in control, the house, the car and the price. +/// +/// Lane 0 drives it. Nothing here asks the box on a timer: the readings +/// arrive on the stream, the price when the day or the published window +/// turns, and the chargers when the screen appears. Each part is its own +/// small view reading only what it shows, so a frame redraws the numbers +/// that moved and nothing else. struct NowView: View { let app: AppModel let home: HomeModels - let openCar: (String) -> Void + let zoom: Namespace.ID + + var body: some View { + NowBody(app: app, home: home, zoom: zoom) + .onAppear { home.prices.activate() } + .onDisappear { home.prices.deactivate() } + } +} + +private struct NowBody: View { + let app: AppModel + let home: HomeModels + let zoom: Namespace.ID private var site: SiteModel { home.site } var body: some View { - Group { - switch site.session.phase { - case .booting: - Title(L("Your box is starting")) - Text(L("This can take a few minutes after an update. Nothing is wrong. Your home appears here when it is ready.")) - if let boot = site.session.boot { - Hint("\(Self.bootWords(boot.phase)) · \(boot.pct)%") - } - case .terminated: - Title(L("Access ended")) - Text(site.session.terminated == .revoked ? L("Your access to this home was withdrawn by its owner.") : L("This session ended.")) - if site.session.terminated == .revoked { - Button(L("Your box won't let this phone in?")) { app.recovering = true }.buttonStyle(.quiet) - } - default: - if site.session.fields.isEmpty { - nothingYet - } else { - house - } + switch site.session.phase { + case .booting: + Title(L("Your box is starting")) + Text(L("This can take a few minutes after an update. Nothing is wrong. Your home appears here when it is ready.")) + if let boot = site.session.boot { + Hint("\(boot.pct)%") + } + case .terminated: + Title(L("Access ended")) + Text(site.session.terminated == .revoked ? L("Your access to this home was withdrawn by its owner.") : L("This session ended.")) + if site.session.terminated == .revoked { + Button(L("Your box won't let this phone in?")) { app.recovering = true }.buttonStyle(.quiet) + } + default: + if !site.session.fields.isEmpty { + house + } else if app.isDemo { + // The demo's first reading is a moment away: the shape of the + // screen, not a sentence about waiting. + InControlRow(answer: InControl.Answer(tone: .neutral, title: L("Starting the demo"), detail: L("Live demo · simulated home"))) + .redacted(reason: .placeholder) + } else { + nothingYet } - } - .onAppear { - home.status.activate() - home.prices.activate() - } - .onDisappear { - home.status.deactivate() - home.prices.deactivate() } } @@ -55,27 +68,6 @@ struct NowView: View { } } - // MARK: The house - - private var live: Bool { site.isLive } - - private var chargeW: Double { - home.loadpoints.stale ? 0 : Flow.loadpointChargeW(home.loadpoints.points) - } - - private var watchingStatus: Bool { - site.documentVisible && site.session.phase == .streaming && site.hasPassthrough - } - - private var statusLive: Bool { home.status.fresh && live && watchingStatus } - - private var readings: Flow.Readings { - if let status = home.status.status, statusLive || !live { - return Flow.readings(status: status) - } - return Flow.readings(fields: Flow.withLoadpointEV(site.session.fields, evW: chargeW)) - } - @ViewBuilder private var house: some View { if let help = app.connectHelp { Card { @@ -86,51 +78,35 @@ struct NowView: View { if site.session.needsUpdate { Card { Text(L("This app is older than your box. Some things are hidden until it updates.")) } } - InControlRow(answer: InControl.answer( - live: live, - lastKnownMs: site.cachedAtMs ?? site.lastFrameAtMs, - dispatchBlockedBy: site.session.dispatchBlockedBy, - status: home.status.status, - statusFresh: statusLive - )) - - // Motion claims power is flowing right now, so a cached view holds still. - FlowDiagram(readings: readings, moving: live) { _ in } - if home.status.status != nil, !statusLive { - Hint(L(live ? "Device details are out of date. Showing live totals." : "Device details are out of date.")) + InControlSection(site: site) + FlowSection(site: site, loadpoints: home.loadpoints) + if site.heardFromBox || home.loadpoints.fromCache { + CarSection(loadpoints: home.loadpoints, zoom: zoom) } + PriceSection(site: site, prices: home.prices) + } +} - if site.heardFromBox { - ForEach(home.loadpoints.points) { lp in - CarRow(lp: lp, loadpoints: home.loadpoints) { openCar(lp.id) } - } - } +// MARK: Is FTW in control? - if let prices = home.prices.prices { - Card { - PriceChart(prices: prices, nowMs: site.nowMs, compact: true) - if home.prices.hasHole { - Hint(L("Some hours are missing their price.")) - } else if prices.stale { - Hint(L("Tomorrow's rates aren't published yet.")) - } - } - } - } +private struct InControlSection: View { + let site: SiteModel - static func bootWords(_ phase: String) -> String { - switch phase { - case "vacuum": return "tidying its records" - case "migrate": return "bringing its records up to date" - case "drivers": return "waking the equipment" - default: return "getting ready" - } + var body: some View { + InControlRow(answer: InControl.answer( + streaming: site.isLive, + lastKnownMs: site.cachedAtMs ?? site.lastFrameAtMs, + dispatchBlockedBy: site.session.dispatchBlockedBy, + sources: site.session.sources + )) + .equatable() + .onAppear { LaunchClock.mark("first content") } } } -/// "Is FTW in control?" in one calm line. Green only when Core says yes -/// over a live stream; a cache is grey and dated. -struct InControlRow: View { +/// "Is FTW in control?" in one calm line. Green only while frames arrive +/// and the box says nothing blocks it; a cache is grey and dated. +struct InControlRow: View, Equatable { let answer: InControl.Answer var body: some View { @@ -138,6 +114,8 @@ struct InControlRow: View { Image(systemName: symbol) .font(.body.weight(.semibold)) .foregroundStyle(color) + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.bounce, value: answer.tone) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Kicker(L("Is FTW in control?")) @@ -161,6 +139,7 @@ struct InControlRow: View { .fill(color) .frame(width: 4) } + .animation(Theme.spring, value: answer) .accessibilityElement(children: .combine) } @@ -168,7 +147,6 @@ struct InControlRow: View { switch answer.tone { case .ok: return "checkmark.circle.fill" case .warning: return "exclamationmark.triangle.fill" - case .alarm: return "exclamationmark.octagon.fill" case .neutral: return "circle.dotted" case .stale: return "clock" } @@ -178,51 +156,85 @@ struct InControlRow: View { switch answer.tone { case .ok: return Theme.exporting case .warning: return Theme.generation - case .alarm: return Theme.importing case .neutral, .stale: return Theme.fgMuted } } } +// MARK: The house + +private struct FlowSection: View { + let site: SiteModel + let loadpoints: LoadpointsModel + + var body: some View { + // The stream carries the car's watts; a box that leaves them out + // gets the charger's last answer. + let evW = loadpoints.stale ? 0 : Flow.loadpointChargeW(loadpoints.points) + // Motion claims power is flowing right now, so a cached view holds still. + FlowDiagram(readings: Flow.readings(fields: Flow.withLoadpointEV(site.session.fields, evW: evW)), moving: site.isLive) + .equatable() + } +} + +// MARK: The car + +private struct CarSection: View { + let loadpoints: LoadpointsModel + let zoom: Namespace.ID + + var body: some View { + ForEach(loadpoints.points) { lp in + NavigationLink(value: HomeRoute.car(lp.id)) { + CarRow(lp: lp, loadpoints: loadpoints) + } + .buttonStyle(.plain) + .matchedTransitionSource(id: lp.id, in: zoom) + } + } +} + /// The car on Now: one row that opens the car view. struct CarRow: View { + static let firstID = "car" + let lp: Loadpoint let loadpoints: LoadpointsModel - let open: () -> Void var body: some View { - Button(action: open) { - HStack(spacing: 12) { - Image(systemName: lp.pluggedIn ? "bolt.car.fill" : "car.side") - .font(.title3) - .foregroundStyle(Theme.mobility) - .frame(width: 32) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { - // One text, so it wraps as one line at large sizes. - heading - .foregroundStyle(Theme.fg) - .fixedSize(horizontal: false, vertical: true) - Text(line) - .font(.subheadline) - .foregroundStyle(Theme.fgDim) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 4) - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(Theme.fgMuted) - .accessibilityHidden(true) + HStack(spacing: 12) { + Image(systemName: lp.pluggedIn ? "bolt.car.fill" : "car.side") + .font(.title3) + .foregroundStyle(Theme.mobility) + .symbolEffect(.pulse, options: .repeating, isActive: lp.powerW >= 100 && !loadpoints.stale) + .contentTransition(.symbolEffect(.replace)) + .frame(width: 32) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + // One text, so it wraps as one line at large sizes. + heading + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + Text(line) + .font(.subheadline) + .foregroundStyle(Theme.fgDim) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .contentTransition(.numericText()) } - .padding(.vertical, 12) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) - .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) - .contentShape(Rectangle()) + Spacer(minLength: 4) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.fgMuted) + .accessibilityHidden(true) } - .buttonStyle(.plain) + .padding(.vertical, 12) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.surfaceRaised, in: RoundedRectangle(cornerRadius: Theme.radius)) + .overlay(RoundedRectangle(cornerRadius: Theme.radius).strokeBorder(Theme.line, lineWidth: 1)) + .contentShape(RoundedRectangle(cornerRadius: Theme.radius)) + .animation(Theme.spring, value: lp) .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) } @@ -231,12 +243,13 @@ struct CarRow: View { private var heading: Text { let car = Text(L("Car")).font(.headline) guard let level = loadpoints.level(lp) else { return car } - let pct = Text(AppWords.percent(level.pct)).font(Theme.percent(.headline)).foregroundColor(level.origin == .assumed ? Theme.fgMuted : Theme.fg) - let origin = Text(EVText.originShort(level, zone: loadpoints.zone)).font(.caption).foregroundColor(Theme.fgDim) + let pct = Text(AppWords.percent(level.pct)).font(Theme.percent(.headline)).foregroundStyle(level.origin == .assumed ? Theme.fgMuted : Theme.fg) + let origin = Text(EVText.originShort(level, zone: loadpoints.zone)).font(.caption).foregroundStyle(Theme.fgDim) return Text("\(car) \(pct) \(origin)") } - /// What it does now; unplugged, the goal it will meet next time. + /// What it does now; unplugged, the goal it will meet next time. From + /// the cache or a lost stream, when the box last said so. private var line: String { if loadpoints.stale, let at = loadpoints.readAtMs { return AppWords.format("Last known %@.", AppWords.clock(at, zone: loadpoints.zone)) @@ -247,3 +260,27 @@ struct CarRow: View { return EVText.status(lp, zone: loadpoints.zone) } } + +// MARK: The price + +private struct PriceSection: View { + let site: SiteModel + let prices: PriceModel + + var body: some View { + if let window = prices.prices { + // The chart marks the minute, not the second: it redraws once a + // minute however often the clock ticks. + let minute = (site.nowMs / 60_000).rounded(.down) * 60_000 + Card { + PriceChart(prices: window, nowMs: minute, compact: true) + .equatable() + if prices.hasHole { + Hint(L("Some hours are missing their price.")) + } else if window.stale { + Hint(L("Tomorrow's rates aren't published yet.")) + } + } + } + } +} diff --git a/appleApp/FTW/UI/PriceChart.swift b/appleApp/FTW/UI/PriceChart.swift index 34fa97d..fc743c8 100644 --- a/appleApp/FTW/UI/PriceChart.swift +++ b/appleApp/FTW/UI/PriceChart.swift @@ -5,7 +5,7 @@ import SwiftUI /// Prices ahead, as bars from zero. Compact on Now: the price this moment /// and the cheapest two hours. Full on Plan: every slot of today and /// tomorrow with the moment marked. -struct PriceChart: View { +struct PriceChart: View, Equatable { let prices: Prices let nowMs: Double var compact = false diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index 7739268..99158c1 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -18,6 +18,7 @@ struct RootView: View { } .background(Theme.surface.ignoresSafeArea()) .tint(Theme.accent) + .onAppear { LaunchClock.mark("first frame") } } } @@ -41,33 +42,48 @@ struct HomeView: View { let app: AppModel let home: HomeModels @State private var path: [HomeRoute] = HomeRoute.initial + /// The car row on Now grows into the car view. + @Namespace private var zoom var body: some View { VStack(spacing: 0) { if app.isDemo { DemoBand(site: home.site) { app.exitDemo() } } else { - FreshnessBandView(band: home.site.freshness(noCarrier: app.connectHelp != nil), frameAtMs: home.site.lastFrameAtMs) + Band(app: app, site: home.site) } NavigationStack(path: $path) { Screen(app: app) { - NowView(app: app, home: home) { path.append(.car($0)) } + NowView(app: app, home: home, zoom: zoom) } .toolbar(.hidden, for: .navigationBar) .navigationDestination(for: HomeRoute.self) { route in switch route { - case .car(let id): CarView(app: app, home: home, id: id) + case .car(let id): + CarView(app: app, home: home, id: id) + .navigationTransition(.zoom(sourceID: id ?? CarRow.firstID, in: zoom)) } } } } - // Now and the car read the chargers from one model, so the box is - // asked once every five seconds, not twice. + // Now and the car read the chargers from one model, so a screen + // appearing asks the box once. .onAppear { home.loadpoints.activate() } .onDisappear { home.loadpoints.deactivate() } } } +/// The freshness band, in its own view so a frame redraws the band and not +/// the screen under it. +private struct Band: View { + let app: AppModel + let site: SiteModel + + var body: some View { + FreshnessBandView(band: site.freshness(noCarrier: app.connectHelp != nil), frameAtMs: site.lastFrameAtMs) + } +} + /// One scrolling page, with pull to refresh. struct Screen: View { let app: AppModel diff --git a/appleApp/FTW/UI/Theme.swift b/appleApp/FTW/UI/Theme.swift index 83c0d91..59d5261 100644 --- a/appleApp/FTW/UI/Theme.swift +++ b/appleApp/FTW/UI/Theme.swift @@ -3,24 +3,25 @@ import SwiftUI /// The web app's design roles (src/styles/tokens.css), light and dark, so the /// native app and the box's own page look like one product. Views read -/// roles, never raw colours. +/// roles, never raw colours. The values live in `Theme.xcassets`, one colour +/// set per role with its dark variant, so the system switches them. enum Theme { - static let surface = pair(0xF4F4F2, 0x0D0D0D) - static let surfaceSunken = pair(0xECECE8, 0x101010) - static let surfaceRaised = pair(0xFAFAF8, 0x161616) - static let surfaceElevated = pair(0xFFFFFF, 0x1E1E1E) - static let line = pair(0xCECEC7, 0x2A2A2A) - static let fg = pair(0x191919, 0xE8E8E8) - static let fgDim = pair(0x4F4F4B, 0xA0A0A0) - static let fgMuted = pair(0x686862, 0x858585) - static let onAccent = pair(0x0A0A0A, 0x0A0A0A) - - static let accent = pair(0xDA7F00, 0xFFAC41) - static let importing = pair(0xCC243D, 0xFF6E74) - static let exporting = pair(0x009639, 0x5FD37F) - static let generation = pair(0xD48500, 0xFFB000) - static let storage = pair(0x008FA8, 0x13DCF6) - static let mobility = pair(0x7F5BB6, 0xCCA8FF) + static let surface = Color("ftw.surface") + static let surfaceSunken = Color("ftw.surfaceSunken") + static let surfaceRaised = Color("ftw.surfaceRaised") + static let surfaceElevated = Color("ftw.surfaceElevated") + static let line = Color("ftw.line") + static let fg = Color("ftw.fg") + static let fgDim = Color("ftw.fgDim") + static let fgMuted = Color("ftw.fgMuted") + static let onAccent = Color("ftw.onAccent") + + static let accent = Color("ftw.accent") + static let importing = Color("ftw.importing") + static let exporting = Color("ftw.exporting") + static let generation = Color("ftw.generation") + static let storage = Color("ftw.storage") + static let mobility = Color("ftw.mobility") static let freshLive = exporting static let freshStale = generation @@ -53,15 +54,8 @@ enum Theme { .system(style, weight: weight).monospacedDigit() } - private static func pair(_ light: UInt32, _ dark: UInt32) -> Color { - return Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(rgb: dark) : UIColor(rgb: light) }) - } -} - -private extension UIColor { - convenience init(rgb: UInt32) { - self.init(red: CGFloat((rgb >> 16) & 0xFF) / 255, green: CGFloat((rgb >> 8) & 0xFF) / 255, blue: CGFloat(rgb & 0xFF) / 255, alpha: 1) - } + /// One spring for everything that moves, so the app moves as one thing. + static let spring = Animation.spring(duration: 0.35, bounce: 0.15) } /// A raised card, the web app's `.card`. diff --git a/appleApp/FTW/UI/Theme.xcassets/Contents.json b/appleApp/FTW/UI/Theme.xcassets/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.accent.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.accent.colorset/Contents.json new file mode 100644 index 0000000..f2cfe90 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.accent.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xDA", + "green": "0x7F", + "blue": "0x00" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0xAC", + "blue": "0x41" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.exporting.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.exporting.colorset/Contents.json new file mode 100644 index 0000000..cfe7820 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.exporting.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x00", + "green": "0x96", + "blue": "0x39" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x5F", + "green": "0xD3", + "blue": "0x7F" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.fg.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.fg.colorset/Contents.json new file mode 100644 index 0000000..990e793 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.fg.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x19", + "green": "0x19", + "blue": "0x19" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xE8", + "green": "0xE8", + "blue": "0xE8" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.fgDim.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.fgDim.colorset/Contents.json new file mode 100644 index 0000000..ef51122 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.fgDim.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x4F", + "green": "0x4F", + "blue": "0x4B" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xA0", + "green": "0xA0", + "blue": "0xA0" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.fgMuted.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.fgMuted.colorset/Contents.json new file mode 100644 index 0000000..12c1c27 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.fgMuted.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x68", + "green": "0x68", + "blue": "0x62" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x85", + "green": "0x85", + "blue": "0x85" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.generation.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.generation.colorset/Contents.json new file mode 100644 index 0000000..b37c49c --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.generation.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xD4", + "green": "0x85", + "blue": "0x00" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0xB0", + "blue": "0x00" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.importing.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.importing.colorset/Contents.json new file mode 100644 index 0000000..866f7d5 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.importing.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xCC", + "green": "0x24", + "blue": "0x3D" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0x6E", + "blue": "0x74" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.line.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.line.colorset/Contents.json new file mode 100644 index 0000000..2776b7d --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.line.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xCE", + "green": "0xCE", + "blue": "0xC7" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x2A", + "green": "0x2A", + "blue": "0x2A" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.mobility.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.mobility.colorset/Contents.json new file mode 100644 index 0000000..771e0e2 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.mobility.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x7F", + "green": "0x5B", + "blue": "0xB6" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xCC", + "green": "0xA8", + "blue": "0xFF" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.onAccent.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.onAccent.colorset/Contents.json new file mode 100644 index 0000000..d67944e --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.onAccent.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x0A", + "green": "0x0A", + "blue": "0x0A" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x0A", + "green": "0x0A", + "blue": "0x0A" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.storage.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.storage.colorset/Contents.json new file mode 100644 index 0000000..25f9aac --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.storage.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x00", + "green": "0x8F", + "blue": "0xA8" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x13", + "green": "0xDC", + "blue": "0xF6" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surface.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.surface.colorset/Contents.json new file mode 100644 index 0000000..b9bc37b --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.surface.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xF4", + "green": "0xF4", + "blue": "0xF2" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x0D", + "green": "0x0D", + "blue": "0x0D" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceElevated.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceElevated.colorset/Contents.json new file mode 100644 index 0000000..8da8c03 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceElevated.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFF", + "green": "0xFF", + "blue": "0xFF" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x1E", + "green": "0x1E", + "blue": "0x1E" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceRaised.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceRaised.colorset/Contents.json new file mode 100644 index 0000000..13843e1 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceRaised.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xFA", + "green": "0xFA", + "blue": "0xF8" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x16", + "green": "0x16", + "blue": "0x16" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceSunken.colorset/Contents.json b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceSunken.colorset/Contents.json new file mode 100644 index 0000000..3829a78 --- /dev/null +++ b/appleApp/FTW/UI/Theme.xcassets/ftw.surfaceSunken.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0xEC", + "green": "0xEC", + "blue": "0xE8" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "red": "0x10", + "green": "0x10", + "blue": "0x10" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index ba0c9e4..a3467df 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -30,10 +30,18 @@ public final class SimulatedBox { /// How long the box takes from ack to result: the demo waits as a /// charger write does, so the receipt has a moment to show. public var resultDelayMs: Double = 0 + /// A Core with srcfl/ftw#1586 reads and sends the ready time as + /// `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 + /// 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 /// car in or out). Nil outside the demo. public private(set) static weak var current: SimulatedBox? public private(set) var receivedCommands: [String] = [] + /// Every API request, as "METHOD /path", so a test can count reads. + public private(set) var receivedRequests: [String] = [] public private(set) var stepUpsSeen = 0 public nonisolated static let modes: [ModeInfo] = [ @@ -350,6 +358,7 @@ public final class SimulatedBox { var query = [String: String]() for e in body["query"]?.entries ?? [] { if let k = e.key.string, let v = e.value.string { query[k] = v } } let requestBody = body["body"]?.byteString.flatMap { try? JSON(parsing: $0) } + receivedRequests.append("\(method) \(path)") guard path.hasPrefix("/api/"), let route = Self.match(method, path) else { refuse(id, "E_UNKNOWN_OP", []) @@ -393,14 +402,6 @@ public final class SimulatedBox { "easee": ["status": "ok", "ev_w": .number(house.evWatts)], ], "fuse": ["max_amps": .number(house.fuseAmps), "phases": 3, "voltage": 230], - // Core's answer to "Are we in control?", one row per device. - "control_feedback": [ - ["driver": "sungrow", "kind": "battery", "mode": "plan", "status": "following", "reason": "plan", "severity": "info", "evidence": "measured", "readings_fresh": true, "actual_w": .number(house.batteryW)], - ["driver": "easee", "kind": "ev", "mode": house.manualHoldW == nil ? "plan" : "manual", - "status": house.evWatts > 100 || !house.carPluggedIn ? "following" : "waiting", - "reason": .string(!house.carPluggedIn ? "not_connected" : house.manualHoldW != nil ? "manual_hold" : house.evWatts > 100 ? "plan" : "no_plan_budget"), - "severity": "info", "evidence": "measured", "readings_fresh": true, "actual_w": .number(house.evWatts)], - ], "phase_amps": .array((0..<3).map { .number(((perPhase + Double($0) * 90) / 230 * 10).rounded() / 10) }), "phase_powers": .array((0..<3).map { .number((perPhase + Double($0) * 90).rounded()) }), ]) @@ -433,9 +434,10 @@ public final class SimulatedBox { case ("PUT", "/api/loadpoints/{id}/schedule"): // The household clock when sent, else the UTC minute read on // today's date: what Core does. + lastScheduleBody = body let today = Date(timeIntervalSince1970: now / 1000) - let clock = body?["time_of_day_min"]?.number.map { Int($0) } - ?? EVText.clockFromUTC(Int(body?["time_of_day_min_utc"]?.number ?? 300), at: today, zone: house.timeZone) + let fromUTC = EVText.clockFromUTC(Int(body?["time_of_day_min_utc"]?.number ?? 300), at: today, zone: house.timeZone) + let clock = readsHouseholdClock ? body?["time_of_day_min"]?.number.map { Int($0) } ?? fromUTC : fromUTC house.schedule = ( body?["soc"]?.number ?? 0.8, clock, @@ -518,12 +520,14 @@ public final class SimulatedBox { ]) } if let s = h.schedule { - lp = lp.setting("schedule", [ + var schedule: JSON = [ "soc": .number(s.socFraction), // Today's UTC minute of the household clock, as Core answers. "time_of_day_min_utc": .number(Double(EVText.minuteUTC(clock: s.clockMin, at: Date(timeIntervalSince1970: now / 1000), zone: h.timeZone))), "recurring": .bool(s.recurring), "days": .number(Double(s.days)), "surplus_unlock_bat_soc": .number(s.surplusUnlock), - ]) + ] + if readsHouseholdClock { schedule = schedule.setting("time_of_day_min", .number(Double(s.clockMin))) } + lp = lp.setting("schedule", schedule) lp = lp.setting("target_soc", .number(s.socFraction)) if let deadline = h.deadlineMs(now) { lp = lp.setting("target_time", .string(ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: deadline / 1000)))) diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift index 36dbf23..2c91992 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -488,40 +488,20 @@ public enum AppWords { // Is FTW in control? "Is FTW in control?": "Styr FTW?", "Yes, FTW is in control": "Ja, FTW styr", - "Every device follows FTW.": "Alla enheter följer FTW.", + "Nothing blocks it, and every device reports.": "Inget stoppar FTW, och alla enheter rapporterar.", + "%@ is not reporting": "%@ rapporterar inte", + "%@ devices are not reporting": "%@ enheter rapporterar inte", + "The meter": "Elmätaren", + "The inverter": "Växelriktaren", + "Read at %@": "Läst kl. %@", "Not live right now": "Inte live just nu", "Last known %@.": "Senast känt %@.", "Waiting for your box.": "Väntar på din box.", "FTW is not in control right now": "FTW styr inte just nu", "A meter stopped reporting. Your home runs on grid power as usual.": "En mätare har slutat rapportera. Hemmet går på nätet som vanligt.", - "Checking…": "Kontrollerar…", - "Asking your box how its devices respond.": "Frågar boxen hur enheterna svarar.", - "Connected and live": "Ansluten och live", - "This box does not report whether devices follow it.": "Den här boxen rapporterar inte om enheterna följer den.", - "FTW controls no device right now": "FTW styr ingen enhet just nu", - "%@ needs attention now": "%@ behöver åtgärd nu", - "%@ devices need attention now": "%@ enheter behöver åtgärd nu", - "%@ needs a look": "%@ behöver en titt", - "%@ devices need a look": "%@ enheter behöver en titt", "The battery": "Batteriet", "The charger": "Laddaren", - "The solar inverter": "Solväxelriktaren", "A device": "En enhet", - "%1$@: %2$@": "%1$@: %2$@", - - // Core's control reasons and statuses - "Following FTW": "Följer FTW", "Waiting": "Väntar", "Limited": "Begränsad", "Not following": "Följer inte", - "No contact": "Ingen kontakt", "Not controlled by FTW": "Styrs inte av FTW", - "Adjusting": "Justerar", "No command yet": "Inget kommando än", "No car connected": "Ingen bil inkopplad", - "Waiting for the plan": "Väntar på planen", "Waiting for spare solar": "Väntar på solöverskott", "Starting the charger": "Startar laddaren", - "Car is full": "Bilen är full", "Car reached its limit": "Bilen nådde sin gräns", "Car not taking power": "Bilen tar ingen ström", - "Battery full": "Batteriet är fullt", "Battery nearly full": "Batteriet är nästan fullt", "Battery nearly empty": "Batteriet är nästan tomt", - "Limited by FTW": "Begränsad av FTW", "Limited by the main fuse": "Begränsad av huvudsäkringen", "Waiting after a fuse limit": "Väntar efter säkringsgräns", - "Limited by FTW's charger setting": "Begränsad av FTW:s laddarinställning", "Charger limit": "Laddarens gräns", "Charger offers less current": "Laddaren ger mindre ström", - "Device fault": "Fel på enheten", "No fresh readings": "Inga färska mätvärden", "Lost control": "Tappade styrningen", - "Commands failing": "Kommandon misslyckas", "Command not confirmed": "Kommandot är inte bekräftat", "Cannot hand back control": "Kan inte lämna tillbaka styrningen", - "No power reading": "Ingen effektmätning", "Monitoring only": "Bara övervakning", "Disabled": "Avstängd", "Own control": "Egen styrning", - "Paused for safety": "Pausad för säkerhets skull", "Checking": "Kontrollerar", // Now "Now": "Nu", diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index 0ba7887..8633717 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -7,6 +7,11 @@ import Observation /// tap to the box's answer and stays on screen, and after any outcome the /// charger is read again, because the box's account is the truth to /// repaint from. +/// +/// 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. @Observable @MainActor public final class LoadpointsModel: Activatable { @@ -39,6 +44,9 @@ public final class LoadpointsModel: Activatable { /// Levels set from this phone, kept while the car stays plugged in, so /// the view can say "you set it". public private(set) var levelSet: [String: EVText.LevelSet] = [:] + /// What is on screen came from the phone's own cache, not the box. True + /// from a cold start until the first read. + public private(set) var fromCache = false @ObservationIgnored private unowned let site: SiteModel @ObservationIgnored private let charging: ChargingWatch? @@ -51,23 +59,55 @@ public final class LoadpointsModel: Activatable { @ObservationIgnored private var reading: Task? @ObservationIgnored private var levelSending: Set = [] @ObservationIgnored private var clearing: [String: Cancellable] = [:] + /// Bumped by each appearance and by `reread`: a new reason to ask. + @ObservationIgnored private var visits = 0 - public init(site: SiteModel, charging: ChargingWatch?) { + /// `cache` is what the app left for its widgets; a cold start paints the + /// car from it before the box has answered. Nil in the demo and tests. + public init(site: SiteModel, charging: ChargingWatch?, cache: CacheReader? = CacheReader.shared()) { self.site = site self.charging = charging + if let siteId = site.siteId, let read = cache?.read(CarEntry.self, siteId: siteId) { + points = read.value.cars.map { Self.loadpoint($0, readAtMs: read.record.readAtMs) } + readAtMs = read.record.readAtMs + fromCache = true + } + } + + /// 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 { + var w: [(String, JSON)] = [ + ("id", .string(car.loadpointID)), + ("plugged_in", .bool(car.pluggedIn)), + ("soc_source", .string(car.socSource)), + ("current_power_w", .number(car.powerW)), + ] + if let pct = car.socPct { w.append(("current_soc", .number(pct / 100))) } + if let pct = car.targetSocPct { w.append(("target_soc", .number(pct / 100))) } + if car.socSource == "vehicle", let at = car.socReadAtMs { w.append(("vehicle_soc_age_s", .number(max(0, readAtMs - at) / 1000))) } + return Loadpoint(.object(w)) } public func activate() { active += 1 + visits += 1 if ask == nil { ask = LiveAsk(site: site, want: { [weak self] in guard let self, self.active > 0, self.site.documentVisible, self.site.hasPassthrough else { return nil } - return "loadpoints \(Int(self.site.nowMs / 5_000))" + return "loadpoints \(self.visits) \(self.drawing)" }, ask: { [weak self] in try await self?.load() }) } ask?.evaluate() } + /// 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 { + guard let w = site.session.fields[Contract.FID.evW] else { return "-" } + return w >= 100 ? "on" : "off" + } + public func deactivate() { active = max(0, active - 1) ask?.evaluate() @@ -82,7 +122,7 @@ public final class LoadpointsModel: Activatable { guard let self, self.planWatchers > 0, self.site.documentVisible, self.site.hasPassthrough, self.inlinePlan else { return nil } let goals = self.points.filter { $0.pluggedIn && $0.schedule != nil && !$0.planPending && !$0.planOutdated } guard !goals.isEmpty else { return nil } - return "plan \(goals.map(Self.planKey).joined(separator: ",")) \(Int(self.site.nowMs / 60_000))" + return "plan \(goals.map(Self.planKey).joined(separator: ","))" }, ask: { [weak self] in try await self?.readShortfall() }) } planAsk?.evaluate() @@ -99,14 +139,17 @@ public final class LoadpointsModel: Activatable { return "\(lp.id):\(lp.socPct ?? -1):\(s?.socPct ?? -1):\(s?.timeOfDayMinUTC ?? -1):\(lp.planWindows.count):\(Int(lp.planWindows.first?.fromMs ?? 0)):\(Int(lp.planTotalWh ?? 0))" } - /// Out of date: the read failed, the stream stopped, or it is old. + /// Out of date: from the cache, the read failed, or the stream stopped. + /// An answer is not out of date for being minutes old: the stream would + /// have said if the car started or stopped drawing. public var stale: Bool { - error != nil || site.session.phase != .streaming || (readAtMs.map { site.nowMs - $0 >= 15_000 } ?? false) + fromCache || error != nil || site.session.phase != .streaming } - /// Read now rather than at the next tick. + /// Ask again now: pull to refresh, or the demo's cable. public func reread() { - Task { try? await self.loadChargers() } + visits += 1 + ask?.evaluate() } // MARK: Reads @@ -144,6 +187,7 @@ public final class LoadpointsModel: Activatable { planMissing = false } loaded = true + fromCache = false readAtMs = site.scheduler.nowMs error = nil site.shareCars(points, fresh: true) @@ -247,18 +291,24 @@ public final class LoadpointsModel: Activatable { } } - /// The level to show: a tap on its way, else where the car stands. + /// The level to show: the box's account. A tap on its way shows as the + /// chosen chip and the receipt, never as the level: only the box's + /// answer moves the number. Between its answer and the next read, the + /// level it confirmed is the newest fact. public func level(_ lp: Loadpoint) -> EVText.Level? { - guard var level = EVText.level(lp, answeredAtMs: readAtMs ?? site.nowMs, nowMs: site.nowMs, set: levelSet[lp.id]) else { return nil } - if let want = levelWanted[lp.id] { - level.pct = want - level.origin = .you - level.atMs = site.nowMs - level.setPct = nil + let set = levelSet[lp.id] + guard var level = EVText.level(lp, answeredAtMs: readAtMs ?? site.nowMs, nowMs: site.nowMs, set: set) else { return nil } + if let set, (readAtMs ?? 0) < set.atMs { + level = EVText.Level(pct: set.pct, origin: .you, atMs: set.atMs, setPct: nil) } return level } + /// The chip to show as chosen: the tap on its way, else the level. + public func chosenLevel(_ lp: Loadpoint) -> Double? { + levelWanted[lp.id] ?? level(lp)?.pct + } + // MARK: Charge now, Pause, Back to the plan /// Charge now: the charger's full current, held until Back to the plan diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift index 5828d29..ee71249 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift @@ -297,84 +297,50 @@ public final class SavingsModel: Activatable { } } -/// "Is FTW in control?" in one calm line. Core owns every verdict: the -/// stream's dispatch block and each device's control feedback with its -/// severity. This only picks words, and never says yes from readings that -/// are not live: a cache shows when it was last known, in grey. +/// "Is FTW in control?" in one calm line, from the stream alone: lane 0 +/// carries the box's dispatch block and each source's state, so the line +/// needs no request and no timer. It is green only while frames arrive +/// and the box says nothing blocks it; from a cache it is grey and dated. +/// Core's per-device control feedback joins it when lane 0 carries it (M5). public enum InControl { - public enum Tone: Equatable, Sendable { case ok, warning, alarm, neutral, stale } + public enum Tone: Equatable, Sendable { case ok, warning, neutral, stale } public struct Answer: Equatable, Sendable { public let tone: Tone public let title: String public let detail: String? + + public init(tone: Tone, title: String, detail: String?) { + self.tone = tone + self.title = title + self.detail = detail + } } - /// - live: the stream is streaming, not the cache, every source live. - /// - status: GET /api/status, nil until it has answered. - /// - statusFresh: that answer is current. - public static func answer(live: Bool, lastKnownMs: Double?, dispatchBlockedBy: [String], status: JSON?, statusFresh: Bool, zone: TimeZone = .current) -> Answer { - guard live else { + /// - streaming: frames are arriving now over a live carrier. + /// - sources: the box's own account of each device it reads. + public static func answer(streaming: Bool, lastKnownMs: Double?, dispatchBlockedBy: [String], sources: [String: Source], zone: TimeZone = .current) -> Answer { + guard streaming else { return Answer(tone: .stale, title: AppWords.text("Not live right now"), detail: lastKnownMs.map { AppWords.format("Last known %@.", AppWords.clock($0, zone: zone)) } ?? AppWords.text("Waiting for your box.")) } if !dispatchBlockedBy.isEmpty { return Answer(tone: .warning, title: AppWords.text("FTW is not in control right now"), detail: AppWords.text("A meter stopped reporting. Your home runs on grid power as usual.")) } - guard let status, statusFresh else { - return Answer(tone: .neutral, title: AppWords.text("Checking…"), detail: AppWords.text("Asking your box how its devices respond.")) - } - guard let rows = status["control_feedback"]?.array else { - // A box from before control feedback: live and not blocked is - // all it says, so that is all this says. - return Answer(tone: .neutral, title: AppWords.text("Connected and live"), detail: AppWords.text("This box does not report whether devices follow it.")) - } - let valid = rows.filter { $0["driver"]?.string != nil && $0["status"]?.string != nil } - let alarms = valid.filter { $0["severity"]?.string == "alarm" } - let warnings = valid.filter { $0["severity"]?.string == "warning" } - if let first = alarms.first { - let title = alarms.count == 1 ? AppWords.format("%@ needs attention now", noun(first)) : AppWords.format("%@ devices need attention now", String(alarms.count)) - return Answer(tone: .alarm, title: title, detail: reasonTitle(first)) + let quiet = sources.values.filter { $0.state == .stale || $0.state == .down }.sorted { $0.name < $1.name } + if let first = quiet.first { + let title = quiet.count == 1 ? AppWords.format("%@ is not reporting", noun(first.kind)) : AppWords.format("%@ devices are not reporting", String(quiet.count)) + return Answer(tone: .warning, title: title, detail: first.name.isEmpty ? nil : first.name) } - if let first = warnings.first { - let title = warnings.count == 1 ? AppWords.format("%@ needs a look", noun(first)) : AppWords.format("%@ devices need a look", String(warnings.count)) - return Answer(tone: .warning, title: title, detail: reasonTitle(first)) - } - if !valid.contains(where: { $0["status"]?.string != "not_controlled" }) { - return Answer(tone: .neutral, title: AppWords.text("FTW controls no device right now"), detail: nil) - } - return Answer(tone: .ok, title: AppWords.text("Yes, FTW is in control"), detail: AppWords.text("Every device follows FTW.")) + return Answer(tone: .ok, title: AppWords.text("Yes, FTW is in control"), detail: AppWords.text("Nothing blocks it, and every device reports.")) } - static func noun(_ row: JSON) -> String { - switch row["kind"]?.string { + static func noun(_ kind: String) -> String { + switch kind { + case "meter": return AppWords.text("The meter") + case "inverter": return AppWords.text("The inverter") case "battery": return AppWords.text("The battery") - case "ev", "v2x_charger": return AppWords.text("The charger") - case "pv": return AppWords.text("The solar inverter") + case "charger", "ev": return AppWords.text("The charger") default: return AppWords.text("A device") } } - - /// Core's reason titles, the box page's words (web/control-feedback.js). - static let reasonTitles: [String: String] = [ - "waiting_response": "Adjusting", "no_command": "No command yet", "not_connected": "No car connected", - "no_plan_budget": "Waiting for the plan", "pv_surplus_pause": "Waiting for spare solar", "wake_kick": "Starting the charger", - "vehicle_complete": "Car is full", "vehicle_limit_completion": "Car reached its limit", "vehicle_not_requesting": "Car not taking power", - "battery_full": "Battery full", "battery_nearly_full": "Battery nearly full", "battery_nearly_empty": "Battery nearly empty", - "core_limit": "Limited by FTW", "fuse_limit": "Limited by the main fuse", "fuse_cooldown": "Waiting after a fuse limit", - "charger_limit": "Limited by FTW's charger setting", "device_limit": "Charger limit", "offered_current_lower": "Charger offers less current", - "device_fault": "Device fault", "telemetry_stale": "No fresh readings", "readings_lost": "Lost control", - "command_failed": "Commands failing", "command_unconfirmed": "Command not confirmed", "default_failed": "Cannot hand back control", - "response_unknown": "No power reading", "observe_only": "Monitoring only", "disabled": "Disabled", "device_control": "Own control", - "site_meter_stale": "Paused for safety", "site_phase_currents_stale": "Paused for safety", - ] - - static let statusTitles: [String: String] = [ - "following": "Following FTW", "waiting": "Waiting", "limited": "Limited", "not_following": "Not following", - "no_contact": "No contact", "not_controlled": "Not controlled by FTW", - ] - - static func reasonTitle(_ row: JSON) -> String { - let title = reasonTitles[row["reason"]?.string ?? ""] ?? statusTitles[row["status"]?.string ?? ""] ?? "Checking" - return AppWords.text(title) - } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift index b352ea9..5c106a8 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift @@ -270,36 +270,36 @@ import FTWDemo // MARK: Is FTW in control? - @Test func inControlIsNeverGreenFromACache() throws { - try AppWords.$language.withValue(.en) { - let following = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"following","reason":"plan","severity":"info"}]}"#.utf8)) - let ok = InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: following, statusFresh: true) + static func source(_ kind: String, _ name: String, _ state: String) -> Source { + Source(.map([("kind", .text(kind)), ("name", .text(name)), ("lastOkMs", .int(0)), ("staleAfterMs", .int(10_000)), ("state", .text(state))]))! + } + + @Test func inControlComesFromTheStreamAndIsNeverGreenFromACache() { + AppWords.$language.withValue(.en) { + let live = ["meter": Self.source("meter", "sdm630", "live"), "inverter": Self.source("inverter", "sungrow", "live")] + let ok = InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: [], sources: live) #expect(ok.tone == .ok) #expect(ok.title == "Yes, FTW is in control") - // The same verdict from a cache, or from an old status read, is - // not a yes. - let cached = InControl.answer(live: false, lastKnownMs: Self.t, dispatchBlockedBy: [], status: following, statusFresh: true, zone: Self.utc) + // The same facts from a cache, or from a stream gone quiet, are not a yes. + let cached = InControl.answer(streaming: false, lastKnownMs: Self.t, dispatchBlockedBy: [], sources: live, zone: Self.utc) #expect(cached.tone == .stale) + #expect(cached.title == "Not live right now") #expect(cached.detail == "Last known 10:00.") - #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: following, statusFresh: false).tone == .neutral) - #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: ["meter"], status: following, statusFresh: true).tone == .warning) - let old = try JSON(parsing: Array(#"{"grid_w":100}"#.utf8)) - #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: old, statusFresh: true).title == "Connected and live") + #expect(InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: ["meter"], sources: live).tone == .warning) - let alarm = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"no_contact","reason":"telemetry_stale","severity":"alarm"},{"driver":"sungrow","kind":"battery","status":"following","reason":"plan","severity":"info"}]}"#.utf8)) - let a = InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: alarm, statusFresh: true) - #expect(a.tone == .alarm) - #expect(a.title == "The charger needs attention now") - #expect(a.detail == "No fresh readings") - - let warnings = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"limited","reason":"fuse_limit","severity":"warning"},{"driver":"sungrow","kind":"battery","status":"limited","reason":"battery_full","severity":"warning"}]}"#.utf8)) - #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: warnings, statusFresh: true).title == "2 devices need a look") + var quiet = live + quiet["inverter"] = Self.source("inverter", "sungrow", "down") + let one = InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: [], sources: quiet) + #expect(one.tone == .warning) + #expect(one.title == "The inverter is not reporting") + #expect(one.detail == "sungrow") + quiet["meter"] = Self.source("meter", "sdm630", "stale") + #expect(InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: [], sources: quiet).title == "2 devices are not reporting") } - try AppWords.$language.withValue(.sv) { - let following = try JSON(parsing: Array(#"{"control_feedback":[{"driver":"easee","kind":"ev","status":"following","reason":"plan","severity":"info"}]}"#.utf8)) - #expect(InControl.answer(live: true, lastKnownMs: nil, dispatchBlockedBy: [], status: following, statusFresh: true).title == "Ja, FTW styr") + AppWords.$language.withValue(.sv) { + #expect(InControl.answer(streaming: true, lastKnownMs: nil, dispatchBlockedBy: [], sources: [:]).title == "Ja, FTW styr") #expect(AppWords.text("Is FTW in control?") == "Styr FTW?") } } @@ -311,11 +311,12 @@ import FTWDemo @Suite struct CarFlowTests { @MainActor final class Rig { - let scheduler = ManualScheduler(nowMs: CarTextTests.t) + let scheduler: ManualScheduler var box: SimulatedBox? var app: AppModel! - init() { + init(nowMs: Double = CarTextTests.t) { + scheduler = ManualScheduler(nowMs: nowMs) app = AppModel(store: MemoryStore(), files: nil, passkeys: nil, scheduler: scheduler, build: "test", ua: "test", demo: { [unowned self] s in let box = SimulatedBox(scheduler: s, house: SimulatedHouse(timeZone: CarTextTests.stockholm)) box.resultDelayMs = 900 @@ -354,7 +355,11 @@ import FTWDemo let level = Task { await home.loadpoints.setLevel(car, pct: 40) } await rig.run(100) #expect(home.loadpoints.levelStep[car.id] == EVText.Step(ask: .level(40))) - #expect(home.loadpoints.level(car)?.pct == 40) + // The tap shows as the chosen chip, never as the level: only the + // box's answer moves the number. + #expect(home.loadpoints.chosenLevel(car) == 40) + #expect(home.loadpoints.level(car)?.pct == 20) + #expect(home.loadpoints.level(car)?.origin == .assumed) await rig.run(300) #expect(home.loadpoints.levelStep[car.id]?.acked == true) #expect(home.loadpoints.levelStep[car.id]?.result == nil) @@ -461,17 +466,119 @@ import FTWDemo } } - /// The demo answers "Is FTW in control?" as Core does: from live - /// control feedback, and only while the stream is live. + /// The demo answers "Is FTW in control?" from the stream alone. @Test func theDemoSaysFTWIsInControl() async throws { let rig = Rig() rig.app.startDemo() let home = try #require(rig.app.home) - home.status.activate() await rig.run(3_000) let site = home.site - let answer = InControl.answer(live: site.isLive, lastKnownMs: site.lastFrameAtMs, dispatchBlockedBy: site.session.dispatchBlockedBy, status: home.status.status, statusFresh: home.status.fresh) + let answer = InControl.answer(streaming: site.isLive, lastKnownMs: site.lastFrameAtMs, dispatchBlockedBy: site.session.dispatchBlockedBy, sources: site.session.sources) #expect(answer.tone == .ok) + // No request was needed for it. + #expect(try #require(rig.box).receivedRequests.isEmpty) + } + + /// No timer reads the chargers. A screen appearing does, a command + /// does, and the stream saying the car started drawing does. + @Test func theChargersAreReadOnEventsNotOnATimer() async throws { + let rig = Rig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + func reads() -> Int { box.receivedRequests.filter { $0 == "GET /api/loadpoints" }.count } + home.loadpoints.activate() + await rig.run(1_000) + #expect(reads() == 1) + await rig.run(60_000, step: 500) + #expect(reads() == 1) + + // A second screen appearing is a reason to look again. + home.loadpoints.activate() + await rig.run(1_000) + #expect(reads() == 2) + home.loadpoints.deactivate() + + // Charge now: one read after the box's answer, one when the stream + // shows the car drawing power. + let car = try #require(home.loadpoints.points.first) + let charge = Task { await home.loadpoints.chargeNow(car) } + await rig.run(2_000) + await charge.value + #expect(reads() == 3) + #expect(home.loadpoints.points.first?.powerW == 0) + await rig.run(5_000, step: 250) + #expect(reads() == 4) + #expect(home.loadpoints.points.first?.powerW == 11_000) + await rig.run(60_000, step: 500) + #expect(reads() == 4) + + // The plan's shortfall: read when the car view opens and when the + // car's plan changes, not once a minute. + let back = Task { await home.loadpoints.backToPlan(car) } + await rig.run(3_000) + await back.value + func plans() -> Int { box.receivedRequests.filter { $0 == "GET /api/mpc/plan" }.count } + home.loadpoints.watchPlan() + await rig.run(1_000) + #expect(plans() == 1) + await rig.run(180_000, step: 1_000) + #expect(plans() == 1) + } + + /// A cold start paints the car the app last saw, from the cache it + /// writes for its widgets, dated and marked as not live. + @Test func aColdStartPaintsTheCarFromTheCache() throws { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-car-\(UUID().uuidString)") + let shared = AppGroupFiles(directory: dir) + let readAt = CarTextTests.t - 600_000 + let car = CarEntry.Car(loadpointID: "garage", pluggedIn: true, socPct: 64, socSource: "vehicle", socReadAtMs: readAt - 120_000, powerW: 0, targetSocPct: 80) + try shared.write(CacheRecord(siteId: "site-1", carrier: .relay, srcState: .live, readAtMs: readAt, value: CarEntry(cars: [car]))) + let scheduler = ManualScheduler(nowMs: CarTextTests.t) + let site = SiteModel(siteId: "site-1", build: "test", ua: "test", scheduler: scheduler, files: nil) + let model = LoadpointsModel(site: site, charging: nil, cache: CacheReader(sources: [shared])) + let lp = try #require(model.points.first) + #expect(model.fromCache) + #expect(model.stale) + #expect(model.readAtMs == readAt) + #expect(lp.pluggedIn) + #expect(lp.targetSocPct == 80) + let level = try #require(model.level(lp)) + #expect(level.pct == 64) + // The car's report was two minutes old when cached, ten minutes ago. + #expect(level.origin == .carEarlier) + #expect(level.atMs == readAt - 120_000) + // Another home's cache paints nothing. + let other = SiteModel(siteId: "site-2", build: "test", ua: "test", scheduler: scheduler, files: nil) + #expect(LoadpointsModel(site: other, charging: nil, cache: CacheReader(sources: [shared])).points.isEmpty) + } + + /// A save sends the household clock and today's UTC minute, built from + /// nothing read. A Core with srcfl/ftw#1586 takes the clock; an older + /// one reads the minute on today's date. Either way 07:00 is kept. + @Test func aGoalSavesBothClocksAndEitherCoreKeepsTheTime() async throws { + for (day, utc) in [(24, 300), (26, 360)] { + for household in [true, false] { + let rig = Rig(nowMs: CarTextTests.noon(day).timeIntervalSince1970 * 1000) + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + box.readsHouseholdClock = household + home.loadpoints.activate() + await rig.run(1_500) + let car = try #require(home.loadpoints.points.first) + await home.loadpoints.saveGoalAndWait(car, rig, socPct: 80, clock: 7 * 60, recurring: true, days: 0b0011111) + let body = try #require(box.lastScheduleBody) + #expect(body["time_of_day_min"]?.number == 420) + #expect(body["time_of_day_min_utc"]?.number == Double(utc)) + #expect(box.house.schedule?.clockMin == 420) + // Read back: the clock when the box sends it, the minute otherwise. + let back = try #require(home.loadpoints.points.first?.schedule) + #expect(back.clockMin == (household ? 420 : nil)) + #expect(back.timeOfDayMinUTC == utc) + #expect(EVText.goalClock(back, at: CarTextTests.noon(day), zone: home.loadpoints.zone) == 420) + } + } } /// Every demo situation opens, so each screen can be shown without a box.