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 new file mode 100644 index 0000000..720f85e --- /dev/null +++ b/appleApp/FTW/Car/CarView.swift @@ -0,0 +1,492 @@ +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 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 editingGoal = false + + private var model: LoadpointsModel { home.loadpoints } + + private var lp: Loadpoint? { + id.flatMap { want in model.points.first { $0.id == want } } ?? model.points.first + } + + var body: some View { + 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() + } + } + .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() + model.deactivate() + } + .sheet(isPresented: $editingGoal) { + if let lp { GoalEditor(model: model, lp: lp) } + } + } +} + +// MARK: What it does now + +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) + } + .listRowBackground(Theme.surfaceRaised) + } + + /// 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 + } +} + +// 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 + Section { + if let level { + 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) + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + if canControl { + if level.origin == .car, !correcting { + Button(L("Correct the level")) { + withAnimation(Theme.spring) { correcting = true } + } + } else { + LevelChips(current: model.chosenLevel(lp) ?? 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) + } else { + Text(L("Level unknown")).font(.headline) + } + if let outcome = model.outcome(level: lp) { + ReceiptView(outcome: outcome) + } + } 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 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(value: level.pct)) + .animation(Theme.spring, value: level.pct) + 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() + } + } + + 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 + + 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) } + } + } + 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")) { editing = true } + } + } 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 { + 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(.borderedProminent) + .controlSize(.large) + Button(L("Change")) { editing = true } + } + } + 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) + } + + 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 + + var body: some View { + let paused = EVText.isPaused(lp) + let holding = lp.manualActive && !paused + let busy = model.holdSending(lp) + 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) } + } + } + .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) } + } + + @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) + } + } + .controlSize(.large) + .disabled(busy) + } +} + +// MARK: The demo's own cable + +private struct DemoSection: View { + let lp: Loadpoint + let model: LoadpointsModel + + var body: some View { + 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 %.")) + } + .listRowBackground(Theme.surfaceRaised) + } +} + +/// Before the first answer and with nothing cached: the shape of the +/// screen, not a spinner. +private struct PlaceholderSections: View { + var body: some View { + 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) } + } + .listRowBackground(Theme.surfaceRaised) + .redacted(reason: .placeholder) + } +} + +/// 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: 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(.vertical, 4) + .animation(Theme.spring, value: outcome) + .accessibilityElement(children: .combine) + } + + @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) { + 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 + 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..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,18 +26,19 @@ 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) } } } } } .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 +100,26 @@ 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) + .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("\(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 +132,19 @@ 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) + .contentTransition(.numericText(value: node.kw)) + .animation(Theme.spring, value: node.kw) 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) { @@ -152,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 78c2797..035e2a1 100644 --- a/appleApp/FTW/UI/NowView.swift +++ b/appleApp/FTW/UI/NowView.swift @@ -1,242 +1,286 @@ 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. +/// +/// 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 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("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.") - 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.") - if site.session.terminated == .revoked { - Button("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() - 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) } } - // MARK: The house - - private var live: Bool { site.isLive } - - private var flowFields: [Int: Double] { - Flow.withLoadpointEV(site.session.fields, evW: home.charging.chargeW) - } - - 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: flowFields) - } - @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) - - // 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." : "")") + InControlSection(site: site) + FlowSection(site: site, loadpoints: home.loadpoints) + if site.heardFromBox || home.loadpoints.fromCache { + CarSection(loadpoints: home.loadpoints, zoom: zoom) } - NowOutlook(site: site, home: home, statusLive: statusLive) + PriceSection(site: site, prices: home.prices) } +} - 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" - } +// MARK: Is FTW in control? + +private struct InControlSection: View { + let site: SiteModel + + 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") } } } -/// Price, today and the fuse: the rest of a glance. -private struct NowOutlook: View { - let site: SiteModel - let home: HomeModels - let statusLive: Bool +/// "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 { - 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.") - } else if prices.stale { - Hint("Tomorrow's rates aren't published yet.") + HStack(alignment: .firstTextBaseline, spacing: 10) { + 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?")) + Text(answer.title) + .font(.headline) + .foregroundStyle(Theme.fg) + .fixedSize(horizontal: false, vertical: true) + if let detail = answer.detail { + Hint(detail) } } + Spacer(minLength: 0) } - if let today = today { - todayCard(today) + .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) } - if let status = home.status.status, let fuse = Flow.fuse(status: status) { - fuseCard(fuse) + .animation(Theme.spring, value: answer) + .accessibilityElement(children: .combine) + } + + private var symbol: String { + switch answer.tone { + case .ok: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .neutral: return "circle.dotted" + case .stale: return "clock" } } - private struct Today { - let importWh: Double - let exportWh: Double - let pvWh: Double + private var color: Color { + switch answer.tone { + case .ok: return Theme.exporting + case .warning: return Theme.generation + case .neutral, .stale: return Theme.fgMuted + } } +} - 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)) +// 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() } +} - 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) - } - } - } +// 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) } } +} - 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) +/// The car on Now: one row that opens the car view. +struct CarRow: View { + static let firstID = "car" + + let lp: Loadpoint + let loadpoints: LoadpointsModel + + var body: some View { + 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()) } + Spacer(minLength: 4) + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.fgMuted) + .accessibilityHidden(true) } + .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) } -} -struct EnergyTile: View { - let label: String - let wh: Double - let color: Color + /// "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)).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)") + } - 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) + /// 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)) } + 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) } } -private struct FuseBar: View { - let label: String - let amps: Double - let pct: Double - let exporting: Bool +// MARK: The price + +private struct PriceSection: View { + let site: SiteModel + let prices: PriceModel 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))) + 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.")) } } - .frame(height: 6) } - .accessibilityElement(children: .combine) - } - - private var color: Color { - if pct >= 90 { return Theme.importing } - if pct >= 70 { return Theme.generation } - return exporting ? Theme.exporting : Theme.storage } } diff --git a/appleApp/FTW/UI/PriceChart.swift b/appleApp/FTW/UI/PriceChart.swift index f7139f7..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 @@ -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..99158c1 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -18,19 +18,22 @@ struct RootView: View { } .background(Theme.surface.ignoresSafeArea()) .tint(Theme.accent) + .onAppear { LaunchClock.mark("first frame") } } } -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,32 +41,51 @@ 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 + /// 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) } - 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, 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) + .navigationTransition(.zoom(sourceID: id ?? CarRow.firstID, in: zoom)) } } } } - .onAppear { home.charging.activate() } - .onDisappear { home.charging.deactivate() } + // 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 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 +138,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 +163,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..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 @@ -47,15 +48,14 @@ enum Theme { .system(size: size, weight: weight, design: .monospaced) } - private static func pair(_ light: UInt32, _ dark: UInt32) -> Color { - return Color(UIColor { $0.userInterfaceStyle == .dark ? UIColor(rgb: dark) : UIColor(rgb: light) }) + /// 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 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`. @@ -106,6 +106,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 +114,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 +149,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 +194,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/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 1b3ecb5..a3467df 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -27,7 +27,21 @@ 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 + /// 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] = [ @@ -242,7 +256,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 +299,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 @@ -336,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", []) @@ -402,14 +425,22 @@ 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. + lastScheduleBody = body + let today = Date(timeIntervalSince1970: now / 1000) + 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, - 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 +456,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 +469,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 +492,52 @@ 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)), + 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), - ]) - // 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())]] : []) + ] + 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)))) + } + } + 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", []) } @@ -525,6 +588,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 @@ -532,9 +600,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 @@ -551,9 +620,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()) } } @@ -572,8 +642,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..2c91992 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -8,11 +8,27 @@ 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 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 { @@ -46,13 +62,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 +92,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 +125,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 +153,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 +196,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 +219,334 @@ 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", + "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", + "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", + "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.", + "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", + "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.", + "The battery": "Batteriet", + "The charger": "Laddaren", + "A device": "En enhet", + + // 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 +554,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) } - // MARK: Sentences + /// 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 + } - /// 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." + /// "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) + } + } + + /// 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.") + } + } + + /// 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 +805,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 +1054,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 +1082,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..8633717 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -2,81 +2,154 @@ 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. +/// +/// 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 { - 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] = [:] + /// 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? @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? - - public init(site: SiteModel, charging: ChargingWatch?) { + @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 + + /// `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() } - /// Out of date: the read failed, the stream stopped, or it is old. + /// 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: ","))" + }, 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: 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 + } + + /// Ask again now: pull to refresh, or the demo's cable. + public func reread() { + visits += 1 + ask?.evaluate() } // MARK: Reads @@ -95,35 +168,36 @@ 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 + fromCache = false 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 +212,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 +227,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 +252,207 @@ 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: 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? { + 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 } - /// 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) + /// 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 } - /// 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) + // MARK: Charge now, Pause, Back to the plan + + /// 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 readiness(_ lp: Loadpoint) -> EVText.Readiness? { + EVText.readiness(lp, level: level(lp), shortfallWh: shortfallWh[lp.id], nowMs: site.nowMs, zone: zone) } - public func surplusShown(_ lp: Loadpoint) -> Bool { - surplusDraft[lp.id] ?? acceptedSurplus[lp.id] ?? lp.surplusOnly + public func outcome(level lp: Loadpoint) -> EVText.Outcome? { + levelStep[lp.id].map { EVText.outcome($0, lp) } } - /// 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)) - } - - /// 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..ee71249 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowModels.swift @@ -296,3 +296,51 @@ public final class SavingsModel: Activatable { periods = next.today.available || next.week.available ? next : nil } } + +/// "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, 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 + } + } + + /// - 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.")) + } + 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) + } + 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(_ 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 "charger", "ev": return AppWords.text("The charger") + default: return AppWords.text("A device") + } + } +} 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..5c106a8 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarTests.swift @@ -0,0 +1,654 @@ +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? + + 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 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(streaming: true, lastKnownMs: nil, dispatchBlockedBy: ["meter"], sources: live).tone == .warning) + + 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") + } + 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?") + } + } +} + +/// 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 + var box: SimulatedBox? + var app: AppModel! + + 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 + 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))) + // 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) + 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) + } + } + + /// 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) + await rig.run(3_000) + let site = home.site + 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. + @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 + } +} + +/// 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) + } +} 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 }!