From 9ef1aa2620fea3bb0f12b98a60690906a0789083 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 11:39:27 +0200 Subject: [PATCH 1/3] iPhone and watch: the car first, from the box's car list (car step 4) On a box that lists der.car the app reads GET /api/cars after each hello and draws the car before the charger: its name and level with source and age, its limit, goal and plan, and the charger as one line while the car is on it. A car with the cable out still shows its last level. Two cars and no answer ask "Which car?" in the car view, on the watch and on the notification, one tap through session.car.set. Settings list the cars: rename, battery size, level sources, add and remove. A box without der.car keeps today's charger view. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Car/CarFirst.swift | 340 ++++++++++ appleApp/FTW/Car/CarSettings.swift | 252 +++++++ appleApp/FTW/Car/CarView.swift | 90 ++- appleApp/FTW/Car/GoalEditor.swift | 57 +- appleApp/FTW/Push/Notifications.swift | 53 +- appleApp/FTW/UI/ModeSheet.swift | 18 +- appleApp/FTW/UI/NodeView.swift | 81 ++- appleApp/FTW/UI/RootView.swift | 14 +- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 43 +- .../Sources/FTWDemo/SimulatedCars.swift | 331 ++++++++++ .../FTWKit/Sources/FTWKit/Format/Cars.swift | 507 ++++++++++++++ .../FTWKit/Sources/FTWKit/Format/EV.swift | 8 + .../Sources/FTWKit/Identity/CloudKeys.swift | 8 +- .../Sources/FTWKit/Protocol/Contract.swift | 2 + .../Sources/FTWKit/Protocol/Messages.swift | 11 +- .../Sources/FTWKit/State/AppModel.swift | 3 + .../Sources/FTWKit/State/CarShare.swift | 46 ++ .../Sources/FTWKit/State/CarsModel.swift | 621 ++++++++++++++++++ .../Sources/FTWKit/State/HomeCache.swift | 3 + .../FTWKit/State/LoadpointsModel.swift | 28 +- .../FTWKit/Sources/FTWKit/State/NowFlow.swift | 3 + .../Sources/FTWKit/State/SiteModel.swift | 68 +- .../Sources/FTWKit/State/WatchShare.swift | 39 +- appleApp/FTWKit/Sources/FTWShared/Cache.swift | 76 ++- .../FTWKit/Sources/FTWShared/CloudKeys.swift | 19 +- .../FTWKit/Sources/FTWShared/PushAlert.swift | 20 +- .../FTWKit/Sources/FTWShared/PushNotice.swift | 67 +- .../Resources/sv.lproj/Watch.strings | 7 + .../Sources/FTWShared/Watch/WatchGlance.swift | 91 ++- .../Sources/FTWShared/Watch/WatchWire.swift | 3 + .../Sources/FTWShared/Watch/WatchWords.swift | 7 + .../FTWKit/Tests/FTWKitTests/CarsTests.swift | 389 +++++++++++ .../Tests/FTWSharedTests/CarShareTests.swift | 97 +++ appleApp/FTWWatch/CarPage.swift | 62 +- appleApp/FTWWatch/WatchModel.swift | 2 +- 35 files changed, 3336 insertions(+), 130 deletions(-) create mode 100644 appleApp/FTW/Car/CarFirst.swift create mode 100644 appleApp/FTW/Car/CarSettings.swift create mode 100644 appleApp/FTWKit/Sources/FTWDemo/SimulatedCars.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Format/Cars.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/CarShare.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/State/CarsModel.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/CarsTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/CarShareTests.swift diff --git a/appleApp/FTW/Car/CarFirst.swift b/appleApp/FTW/Car/CarFirst.swift new file mode 100644 index 00000000..30b9ec46 --- /dev/null +++ b/appleApp/FTW/Car/CarFirst.swift @@ -0,0 +1,340 @@ +import FTWKit +import SwiftUI + +// The car first, on a box that keeps cars (docs/cars.md in srcfl/ftw): its +// name and level in large type with where the level comes from and how old +// it is, its charge limit, its goal and plan. The charger is one line under +// it, only while the car is on it. A car that is not plugged in still shows, +// with its last level and its age. + +/// The car's own words; Swedish beside them in `CarWords`. +func C(_ en: String) -> String { CarWords.text(en) } + +/// The car's name over its level, the 10-cell meter, where the level comes +/// from and how old it is, and the limit the car reported. The level is a +/// reading, never live: ink, held still, faint when FTW assumes it. +struct CarHero: View { + let car: Car + let cars: CarsModel + let site: SiteModel + let zone: TimeZone + @ScaledMetric(relativeTo: .largeTitle) private var figureSize: CGFloat = 60 + + var body: some View { + let level = cars.level(car) + let faint = level?.guessed ?? true + VStack(alignment: .leading, spacing: 4) { + Text(car.shownName) + .font(.title3.weight(.semibold)) + .foregroundStyle(Theme.ink) + .lineLimit(2) + .accessibilityIdentifier("car.name") + Text(level.map(CarText.percent) ?? "—") + .font(Theme.figure(figureSize)) + .tracking(-1) + .foregroundStyle(faint ? Theme.ink3 : Theme.ink) + .contentTransition(.numericText(value: level?.pct ?? 0)) + .lineLimit(1) + .minimumScaleFactor(0.5) + .accessibilityIdentifier("car.level") + if let level { + CellMeter(pct: level.pct, tint: Theme.car, live: !faint, glows: false) + .frame(width: 120, height: 5) + .padding(.bottom, 4) + } + Text(level.map { CarText.origin($0, nowMs: site.minuteMs, zone: zone) } ?? C("Level unknown")) + .font(.subheadline) + .foregroundStyle(Theme.ink2) + .contentTransition(.opacity) + .accessibilityIdentifier("car.origin") + if let limit = CarText.limit(car) { + Text(limit) + .font(.subheadline) + .foregroundStyle(Theme.ink2) + } + } + .dynamicTypeSize(...DynamicTypeSize.accessibility2) + .accessibilityElement(children: .combine) + } +} + +/// The charger the car is on, in one line, and what it does; or that the +/// car is not plugged in. +struct ChargerLine: View { + let car: Car + let lp: Loadpoint? + let model: LoadpointsModel + let cars: CarsModel + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if let lp { + Text(CarText.charger(lp)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.ink) + .accessibilityIdentifier("car.charger") + if let c = lp.chargerCar, let note = CarText.identified(c, name: { cars.car($0)?.shownName }) { + Text(note) + .font(.footnote) + .foregroundStyle(Theme.ink2) + } + } else { + Text(C("Not plugged in")) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.ink) + .accessibilityIdentifier("car.charger") + } + } + .accessibilityElement(children: .combine) + } +} + +/// "Which car?" with one button per car the box offers, and "None of +/// these". One tap names the plug-in's car; the receipt under it says how +/// far the box has confirmed it. +struct WhichCar: View { + let lp: Loadpoint + let cars: CarsModel + let canChoose: Bool + + var body: some View { + let choices = cars.choices(lp) + let step = cars.choice[lp.id] + VStack(alignment: .leading, spacing: 10) { + Kicker(C("Which car?")) + Text(CarWords.format("Which car is on %@?", CarText.chargerName(lp))) + .font(.headline) + .foregroundStyle(Theme.ink) + .fixedSize(horizontal: false, vertical: true) + if canChoose { + FlowRow(spacing: 8) { + ForEach(choices) { car in + chip(car.shownName, chosen: step?.carID == car.id, id: car.id) { await cars.choose(lp, carID: car.id) } + } + chip(C("None of these"), chosen: step?.carID == "", id: "none") { await cars.choose(lp, carID: nil) } + } + .disabled(step.map { !$0.settled } ?? false) + Text(C("Until you choose, FTW charges it as a visitor's car, from spare solar.")) + .font(.footnote) + .foregroundStyle(Theme.ink2) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(C("Tap the car so FTW plans for its level and goal.")) + .font(.footnote) + .foregroundStyle(Theme.ink2) + } + if let step { + let said = cars.choiceText(step) + CommandReceipt(receipt: cars.choiceReceipt(step), text: said.text, tone: said.tone) + .transition(.opacity) + } + } + .animation(Theme.spring, value: step) + .sensoryFeedback(trigger: step?.result) { _, result in + switch result { + case .applied?: return .success + case .unconfirmed?: return .warning + case .refused?: return .error + case nil: return nil + } + } + .accessibilityIdentifier("car.which") + } + + private func chip(_ title: String, chosen: Bool, id: String, action: @escaping @MainActor () async -> Void) -> some View { + Group { + if chosen { + Button { Task { await action() } } label: { label(title).foregroundStyle(Theme.ground) } + .buttonStyle(.glassProminent) + .tint(Theme.ink) + } else { + Button { Task { await action() } } label: { label(title).foregroundStyle(Theme.ink) } + .buttonStyle(.glass) + } + } + .accessibilityIdentifier("car.which.\(id)") + } + + private func label(_ title: String) -> some View { + Text(title) + .font(.body.weight(.semibold)) + .lineLimit(1) + .padding(.vertical, 4) + .padding(.horizontal, 4) + } +} + +/// Buttons in a row that wraps when they run out of width. +struct FlowRow: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let rows = arrange(width: proposal.width ?? .infinity, subviews) + let height = rows.map(\.height).reduce(0, +) + spacing * CGFloat(max(0, rows.count - 1)) + let width = rows.map(\.width).max() ?? 0 + return CGSize(width: proposal.width ?? width, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var y = bounds.minY + for row in arrange(width: bounds.width, subviews) { + var x = bounds.minX + for i in row.items { + let size = subviews[i].sizeThatFits(.unspecified) + subviews[i].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + } + y += row.height + spacing + } + } + + private func arrange(width: CGFloat, _ subviews: Subviews) -> [(items: [Int], width: CGFloat, height: CGFloat)] { + var rows: [(items: [Int], width: CGFloat, height: CGFloat)] = [] + var current: (items: [Int], width: CGFloat, height: CGFloat) = ([], 0, 0) + for i in subviews.indices { + let size = subviews[i].sizeThatFits(.unspecified) + let next = current.items.isEmpty ? size.width : current.width + spacing + size.width + if next > width, !current.items.isEmpty { + rows.append(current) + current = ([i], size.width, size.height) + } else { + current = (current.items + [i], next, max(current.height, size.height)) + } + } + if !current.items.isEmpty { rows.append(current) } + return rows + } +} + +/// How full the car is, told by tap, as the car's own level +/// (`car.soc.set`). A level the car reported is corrected only on purpose. +struct CarLevelBlock: View { + let car: Car + let cars: CarsModel + let canControl: Bool + @State private var correcting = false + + var body: some View { + let level = cars.level(car) + let fromCar = level?.source == .vehicle && level?.estimate == false + VStack(alignment: .leading, spacing: 10) { + Kicker(L("How full is the car?")) + if level == nil || level?.source == .assumed { + Text(canControl ? L("Nobody has told FTW the car's level yet. Choose it below.") : C("Level unknown")) + .font(.subheadline) + .foregroundStyle(Theme.ink) + .fixedSize(horizontal: false, vertical: true) + } + if canControl { + if fromCar, !correcting { + Button(L("Correct the level")) { + withAnimation(Theme.spring) { correcting = true } + } + .buttonStyle(.glass) + } else { + LevelChips(current: cars.chosenLevel(car) ?? level?.pct ?? EVText.assumedPct) { pct in + Task { await cars.setLevel(car, pct: pct) } + } + } + } + if let receipt = cars.receipt(level: car), let outcome = cars.outcome(level: car) { + CommandReceipt(receipt: receipt, text: outcome.text, tone: outcome.tone) + .transition(.opacity) + } + if fromCar, !correcting, canControl { + Text(L("Only correct it if the car shows something else.")) + .font(.footnote) + .foregroundStyle(Theme.ink3) + } + } + .animation(Theme.spring, value: cars.receipt(level: car)) + .sensoryFeedback(.selection, trigger: cars.chosenLevel(car)) + .sensoryFeedback(trigger: cars.outcome(level: car)?.tone) { _, tone in LevelBlock.feedback(tone) } + } +} + +/// The car's goal, one per car, used wherever it is plugged in; on a +/// charger, whether the plan makes it. +struct CarGoalBlock: View { + let car: Car + let lp: Loadpoint? + let cars: CarsModel + let model: LoadpointsModel + let site: SiteModel + @Binding var editing: Bool + + var body: some View { + let zone = model.zone + VStack(alignment: .leading, spacing: 8) { + Kicker(L("Goal")) + if let goal = car.goal { + let s = CarsModel.schedule(goal, nowMs: site.minuteMs, zone: zone) + Text(EVText.goal(s, at: Date(timeIntervalSince1970: site.minuteMs / 1000), zone: zone)) + .font(.headline) + .foregroundStyle(Theme.ink) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("car.goal") + if let lp, let next = EVText.nextGoal(lp, nowMs: site.minuteMs, zone: zone), lp.schedule != nil { + Hint(next, tone: Theme.ink2) + } else { + Hint(AppWords.format("Next: %@", AppWords.day(EVText.nextDeadlineMs(s, nowMs: site.minuteMs, zone: zone), nowMs: site.minuteMs, zone: zone)), tone: Theme.ink2) + } + if let lp, let r = model.readiness(lp) { + Readiness(readiness: r, text: EVText.readiness(r, level: model.level(lp), nowMs: site.minuteMs, zone: zone)) + } + if cars.canControl { + Button(L("Change")) { editing = true } + .buttonStyle(.glass) + } + } else { + Text(L("No goal yet. Without one, FTW only charges from spare solar.")) + .font(.subheadline) + .foregroundStyle(Theme.ink) + .fixedSize(horizontal: false, vertical: true) + if cars.canControl { + HStack(spacing: 8) { + Button { + let g = EVText.suggestedGoal + Task { await cars.saveGoal(car, socPct: g.socPct, clock: g.clock, recurring: true, days: g.days) } + } label: { + Text(AppWords.format("Use %@", EVText.suggestedGoalText())) + .lineLimit(2) + .frame(maxWidth: .infinity) + } + .buttonStyle(.glassProminent) + .tint(Theme.ink) + Button(L("Change")) { editing = true } + .buttonStyle(.glass) + } + .controlSize(.large) + } + } + if let save = cars.goalSave[car.id], !editing { + Hint(LoadpointsModel.goalText(save), tone: { if case .failed = save { return Theme.alarm }; return Theme.ink2 }()) + } + } + .sensoryFeedback(trigger: cars.goalSave[car.id]) { _, save in + switch save { + case .saved?, .removed?: return .success + case .failed?: return .error + default: return nil + } + } + } +} + +/// A plug-in the box cannot name and no longer asks about: a visitor's. +struct VisitorLine: View { + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(C("A visitor's car")) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.ink) + Text(C("FTW does not know this car, so it shows no level.")) + .font(.footnote) + .foregroundStyle(Theme.ink2) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + } +} diff --git a/appleApp/FTW/Car/CarSettings.swift b/appleApp/FTW/Car/CarSettings.swift new file mode 100644 index 00000000..ac20a6bc --- /dev/null +++ b/appleApp/FTW/Car/CarSettings.swift @@ -0,0 +1,252 @@ +import FTWKit +import SwiftUI + +/// The owner's cars, in Settings: each with its level, into its own page; +/// and a way to add one. The box keeps the list; each change is one +/// command, and the list is read again after it. +struct CarsSettingsView: View { + let cars: CarsModel + @State private var adding = false + + var body: some View { + List { + Section { + ForEach(cars.ordered) { car in + NavigationLink { CarSettingsView(cars: cars, id: car.id) } label: { + VStack(alignment: .leading, spacing: 2) { + Text(car.shownName).foregroundStyle(Theme.ink) + Text(line(car)) + .font(.footnote) + .foregroundStyle(Theme.ink2) + .lineLimit(2) + } + } + .accessibilityIdentifier("settings.car.\(car.id)") + } + if cars.cars.isEmpty { + Text(C("No car yet")) + .foregroundStyle(Theme.ink2) + } + } footer: { + Text(cars.canEdit ? C("The cars FTW plans for. Each keeps its own level, goal and sources.") : C("Only the home's owner can change the cars.")) + } + if cars.canEdit { + Section { + Button { adding = true } label: { + Label(C("Add a car"), systemImage: "plus") + } + .accessibilityIdentifier("settings.cars.add") + if let edit = cars.edit[CarsModel.newCarKey] { + Text(CarsModel.editText(edit)) + .font(.footnote) + .foregroundStyle(edit.isFailure ? Theme.alarm : Theme.ink2) + } + } + } + } + .scrollContentBackground(.hidden) + .background(Theme.ground) + .navigationTitle(C("Cars")) + .navigationBarTitleDisplayMode(.inline) + .animation(Theme.spring, value: cars.cars) + .refreshable { try? await cars.load() } + .onAppear { cars.activate() } + .onDisappear { cars.deactivate() } + .sheet(isPresented: $adding) { AddCarView(cars: cars) } + } + + /// "72 % · from the car · 1 h ago · On Easee" + private func line(_ car: Car) -> String { + var parts: [String] = [] + if let level = cars.level(car) { + parts.append(CarText.percent(level) + " · " + CarText.origin(level, nowMs: Date().timeIntervalSince1970 * 1000, zone: .current)) + } else { + parts.append(C("Level unknown")) + } + parts.append(cars.charger(of: car).map { CarWords.format("On %@", CarText.chargerName($0)) } ?? C("Not plugged in")) + return parts.joined(separator: " · ") + } +} + +extension CarsModel.Edit { + var isFailure: Bool { + if case .failed = self { return true } + return false + } +} + +/// One car: its name, its battery, the sources that report its level, and +/// removing it. Name and battery go together with Save; a source goes at +/// once. +struct CarSettingsView: View { + let cars: CarsModel + let id: String + @State private var name = "" + @State private var kWh: Double = 60 + @State private var started = false + @State private var confirmingRemove = false + @Environment(\.dismiss) private var dismiss + + private var car: Car? { cars.car(id) } + + var body: some View { + Group { + if let car { + form(car) + } else { + Text(C("FTW no longer has this car.")) + .foregroundStyle(Theme.ink2) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .background(Theme.ground) + .navigationTitle(car?.shownName ?? C("Car")) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + guard !started, let car else { return } + started = true + name = car.name + kWh = car.capacityWh > 0 ? (car.capacityWh / 1000).rounded() : 60 + } + } + + private func form(_ car: Car) -> some View { + let canEdit = cars.canEdit + let choices = cars.sourceChoices(for: car) + let changed = name.trimmingCharacters(in: .whitespacesAndNewlines) != car.name || (kWh * 1000).rounded() != car.capacityWh.rounded() + return Form { + Section(C("Name")) { + TextField(C("Name"), text: $name) + .textInputAutocapitalization(.words) + .submitLabel(.done) + .disabled(!canEdit) + .accessibilityIdentifier("car.settings.name") + } + .listRowBackground(Theme.raised) + Section(C("Battery size")) { + Stepper(value: $kWh, in: 10...200, step: 1) { + Text(CarWords.format("%@ kWh", String(Int(kWh)))) + .font(Theme.figure(22)) + } + .disabled(!canEdit) + } + .listRowBackground(Theme.raised) + if canEdit { + Section { + Button(L("Save")) { + Task { await cars.update(car, name: name, capacityWh: kWh * 1000) } + } + .disabled(!changed || name.trimmingCharacters(in: .whitespaces).isEmpty || cars.edit[car.id] == .saving) + .accessibilityIdentifier("car.settings.save") + if let edit = cars.edit[car.id] { + Text(CarsModel.editText(edit)) + .font(.footnote) + .foregroundStyle(edit.isFailure ? Theme.alarm : Theme.ink2) + } + } + .listRowBackground(Theme.raised) + } + Section { + ForEach(choices, id: \.self) { source in + Toggle(source, isOn: Binding( + get: { car.sources.contains(source) }, + set: { on in + let next = on ? car.sources + [source] : car.sources.filter { $0 != source } + Task { await cars.update(car, sources: next) } + } + )) + .font(.body.monospaced()) + .disabled(!canEdit || cars.edit[car.id] == .saving) + } + if choices.isEmpty { + Text(C("No source reports this car's level yet.")) + .foregroundStyle(Theme.ink2) + } + } header: { + Text(C("Level sources")) + } footer: { + if car.sources.isEmpty, !choices.isEmpty { Text(C("No source reports this car's level yet.")) } + } + .listRowBackground(Theme.raised) + if canEdit { + Section { + Button(C("Remove this car"), role: .destructive) { confirmingRemove = true } + .accessibilityIdentifier("car.settings.remove") + } + .listRowBackground(Theme.raised) + } + } + .scrollContentBackground(.hidden) + .confirmationDialog(CarWords.format("Remove %@?", car.shownName), isPresented: $confirmingRemove, titleVisibility: .visible) { + Button(C("Remove"), role: .destructive) { + Task { + await cars.remove(car) + if cars.edit[car.id] == .removed { dismiss() } + } + } + } message: { + Text(C("FTW forgets the car and its goal. A session on it becomes a visitor's.")) + } + } +} + +/// A new car: a name and its battery. The phone mints its id, the same on +/// every box its owner has. +struct AddCarView: View { + let cars: CarsModel + @State private var name = "" + @State private var kWh: Double = 60 + @State private var saving = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section(C("Name")) { + TextField(C("New car"), text: $name) + .textInputAutocapitalization(.words) + .accessibilityIdentifier("car.add.name") + } + .listRowBackground(Theme.raised) + Section(C("Battery size")) { + Stepper(value: $kWh, in: 10...200, step: 1) { + Text(CarWords.format("%@ kWh", String(Int(kWh)))) + .font(Theme.figure(22)) + } + } + .listRowBackground(Theme.raised) + if case .failed(let why)? = cars.edit[CarsModel.newCarKey], !saving { + Section { + Text(why).foregroundStyle(Theme.alarm) + } + .listRowBackground(Theme.raised) + } + } + .scrollContentBackground(.hidden) + .background(Theme.ground) + .navigationTitle(C("Add a car")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(C("Cancel")) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if saving { + ProgressView() + } else { + Button(C("Add")) { + Task { + saving = true + await cars.add(name: name, capacityWh: kWh * 1000) + saving = false + if cars.edit[CarsModel.newCarKey] == .saved { dismiss() } + } + } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + .accessibilityIdentifier("car.add.save") + } + } + } + } + } +} diff --git a/appleApp/FTW/Car/CarView.swift b/appleApp/FTW/Car/CarView.swift index 7c1df225..a9fe5d45 100644 --- a/appleApp/FTW/Car/CarView.swift +++ b/appleApp/FTW/Car/CarView.swift @@ -2,10 +2,12 @@ import FTWDemo import FTWKit import SwiftUI -/// The car, in the node view's material: the power now as the hero, with -/// the car's level, its meter and where the level comes from beside it; -/// what the charger does in words; the car's plan for the hours ahead with -/// the price under it; then the level and the goal. Plan, Charge now and +/// The car, in the node view's material. On a box that keeps cars, the car +/// comes first: its name and level in large type with where the level +/// comes from and how old it is, its limit, then the charger in one line +/// while the car is on it, the plan, the level and the goal. On a box +/// without cars, or for a plug-in the box cannot name, the charger's power +/// is the hero, with the level beside it, as before. Plan, Charge now and /// Pause sit in a glass bar at the foot, where the thumb is /// and in the same places whatever the car does, with the receipt log /// under them: SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED, each slot lit on @@ -21,15 +23,67 @@ struct CarView: View { @State private var text = PeriodText() private var model: LoadpointsModel { home.loadpoints } + private var cars: CarsModel { home.cars } + + /// On a box that keeps cars, the car this view is about: the one named, + /// the one on the charger named, or the first, unless a charger asks + /// which car is on it. Nil for a plug-in the box cannot name. + private var car: Car? { + guard cars.keepsCars else { return nil } + if let c = cars.car(id) { return c } + if let id, let lp = model.points.first(where: { $0.id == id }) { return cars.car(on: lp) } + guard id == nil, cars.asking.isEmpty else { return nil } + return cars.ordered.first + } + /// The charger: the car's while it is on one; else the one named or the + /// first, as before cars. private var lp: Loadpoint? { - id.flatMap { want in model.points.first { $0.id == want } } ?? model.points.first + if let car { return cars.charger(of: car) } + if cars.keepsCars, id == nil, let asking = cars.asking.first { return asking } + return id.flatMap { want in model.points.first { $0.id == want } } ?? model.points.first } var body: some View { + let car = self.car + let lp = self.lp ScrollView { VStack(alignment: .leading, spacing: 0) { - if let lp { + if let lp, cars.keepsCars, lp.chargerCar?.asking == true { + WhichCar(lp: lp, cars: cars, canChoose: cars.canControl) + .padding(.horizontal, 20) + .padding(.bottom, 28) + } + if let car { + CarHero(car: car, cars: cars, site: home.site, zone: model.zone) + .padding(.horizontal, 20) + ChargerLine(car: car, lp: lp, model: model, cars: cars) + .padding(.horizontal, 20) + .padding(.top, 16) + if let lp { + StatusLines(lp: lp, model: model, site: home.site) + .padding(.horizontal, 20) + .padding(.top, 4) + CarPlan(lp: lp, model: model, home: home, scrub: scrub, text: text) + .padding(.top, 24) + } + CarLevelBlock(car: car, cars: cars, canControl: cars.canControl) + .padding(.horizontal, 20) + .padding(.top, 28) + CarGoalBlock(car: car, lp: lp, cars: cars, model: model, site: home.site, editing: $editingGoal) + .padding(.horizontal, 20) + .padding(.top, 28) + if app.isDemo, let demo = lp ?? model.points.first { + DemoBlock(lp: demo, model: model) + .padding(.horizontal, 20) + .padding(.top, 28) + } + } else if let lp { + if cars.keepsCars, cars.unnamed(lp), lp.chargerCar?.asking != true { + VisitorLine() + .padding(.horizontal, 20) + .padding(.bottom, 20) + } // The hours ahead are a plan, not a reading: a finger on // the plan leaves the hero on now. NodeHero(site: home.site, node: .car, home: home, trace: trace, scrub: scrub, timebase: .ahead, text: text) @@ -79,11 +133,16 @@ struct CarView: View { } .scrollEdgeEffectStyle(.hard, for: .bottom) .animation(Theme.spring, value: model.points) + .animation(Theme.spring, value: cars.cars) .navigationTitle(L("Car")) .navigationBarTitleDisplayMode(.inline) - .refreshable { try? await model.loadChargers() } + .refreshable { + try? await model.loadChargers() + if cars.keepsCars { try? await cars.load() } + } .onAppear { model.activate() + cars.activate() model.watchPlan() home.prices.activate() LaunchClock.mark("first content") @@ -91,10 +150,15 @@ struct CarView: View { .onDisappear { model.unwatchPlan() model.deactivate() + cars.deactivate() home.prices.deactivate() } .sheet(isPresented: $editingGoal) { - if let lp { GoalEditor(model: model, lp: lp) } + if let car { + GoalEditor(cars: cars, car: car, zone: model.zone) + } else if let lp { + GoalEditor(model: model, lp: lp) + } } } } @@ -103,7 +167,7 @@ struct CarView: View { /// Under the hero: what the charger does in words, this plug-in's energy, /// and when the box last said so once that is old. -private struct StatusLines: View { +struct StatusLines: View { let lp: Loadpoint let model: LoadpointsModel let site: SiteModel @@ -147,7 +211,7 @@ private struct StatusLines: View { /// its charging as ghost bars, the price under the same slots in eight /// levels, and a finger that reads each slot. Under it, the plan in one /// sentence, or the slot under the finger. -private struct CarPlan: View { +struct CarPlan: View { let lp: Loadpoint let model: LoadpointsModel let home: HomeModels @@ -187,7 +251,7 @@ private struct CarPlan: View { /// How full the car is, told by tap. The level itself is the hero's; here /// is where it comes from, the chips that tell FTW, and the level's /// receipt. -private struct LevelBlock: View { +struct LevelBlock: View { let lp: Loadpoint let model: LoadpointsModel let canControl: Bool @@ -399,7 +463,7 @@ struct Readiness: View { /// The one the box reports is chosen; the one asked for turns while it /// travels; a tap on the chosen one does nothing. Under them the receipt /// log has room of its own, reserved, so nothing moves when it arrives. -private struct ControlBar: View { +struct ControlBar: View { let lp: Loadpoint let model: LoadpointsModel @Environment(\.dynamicTypeSize) private var typeSize @@ -579,7 +643,7 @@ private struct ReceiptSpace: View { // MARK: The demo's own cable -private struct DemoBlock: View { +struct DemoBlock: View { let lp: Loadpoint let model: LoadpointsModel diff --git a/appleApp/FTW/Car/GoalEditor.swift b/appleApp/FTW/Car/GoalEditor.swift index c6ed00a6..738dafa8 100644 --- a/appleApp/FTW/Car/GoalEditor.swift +++ b/appleApp/FTW/Car/GoalEditor.swift @@ -1,11 +1,17 @@ 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. +/// The goal: how full, by when, on which days. One Save, one command: the +/// charger's schedule on a box without cars, the car's own goal on one with +/// them. The time is set and shown on the household clock, the one the box +/// keeps. struct GoalEditor: View { - let model: LoadpointsModel - let lp: Loadpoint + let zone: TimeZone + let hasGoal: Bool + /// How the last save went, as the model holds it. + let state: @MainActor () -> LoadpointsModel.GoalSave? + let save: @MainActor (_ socPct: Double, _ clock: Int, _ recurring: Bool, _ days: Int) async -> Void + let remove: @MainActor () async -> Void @Environment(\.dismiss) private var dismiss @State private var socPct: Double @State private var time: Date @@ -50,12 +56,29 @@ struct GoalEditor: View { } } + /// A charger's goal, on a box without cars or for a car it cannot name. init(model: LoadpointsModel, lp: Loadpoint) { - self.model = model - self.lp = lp - let zone = model.zone + self.init(schedule: lp.schedule, zone: model.zone, state: { model.goalSave[lp.id] }, + save: { await model.saveGoal(lp, socPct: $0, clock: $1, recurring: $2, days: $3, zone: model.zone) }, + remove: { await model.removeGoal(lp) }) + } + + /// A car's own goal, used wherever it is plugged in. + init(cars: CarsModel, car: Car, zone: TimeZone) { + self.init(schedule: car.goal.map { CarsModel.schedule($0, nowMs: Date().timeIntervalSince1970 * 1000, zone: zone) }, zone: zone, + state: { cars.goalSave[car.id] }, + save: { await cars.saveGoal(car, socPct: $0, clock: $1, recurring: $2, days: $3) }, + remove: { await cars.removeGoal(car) }) + } + + private init(schedule s: Loadpoint.Schedule?, zone: TimeZone, state: @escaping @MainActor () -> LoadpointsModel.GoalSave?, + save: @escaping @MainActor (Double, Int, Bool, Int) async -> Void, remove: @escaping @MainActor () async -> Void) { + self.zone = zone + hasGoal = s != nil + self.state = state + self.save = save + self.remove = remove 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)) @@ -75,7 +98,7 @@ struct GoalEditor: View { } private var clock: Int { - let c = Self.calendar(model.zone).dateComponents([.hour, .minute], from: time) + let c = Self.calendar(zone).dateComponents([.hour, .minute], from: time) return (c.hour ?? 0) * 60 + (c.minute ?? 0) } @@ -86,7 +109,7 @@ struct GoalEditor: View { } private var failure: String? { - if case .failed(let help)? = model.goalSave[lp.id], !saving { return help } + if case .failed(let help)? = state(), !saving { return help } return nil } @@ -103,7 +126,7 @@ struct GoalEditor: View { DatePicker(L("Ready by"), selection: $time, displayedComponents: .hourAndMinute) .datePickerStyle(.wheel) .labelsHidden() - .environment(\.timeZone, model.zone) + .environment(\.timeZone, zone) .frame(maxWidth: .infinity) } .listRowBackground(Theme.raised) @@ -122,14 +145,14 @@ struct GoalEditor: View { } } .listRowBackground(Theme.raised) - if lp.schedule != nil { + if hasGoal { Section { Button(L("Remove goal"), role: .destructive) { Task { saving = true - await model.removeGoal(lp) + await remove() saving = false - if case .removed? = model.goalSave[lp.id] { dismiss() } + if case .removed? = state() { dismiss() } } } .disabled(saving) @@ -153,9 +176,9 @@ struct GoalEditor: View { Button(L("Save")) { Task { saving = true - await model.saveGoal(lp, socPct: socPct, clock: clock, recurring: days.recurring, days: days.mask, zone: model.zone) + await save(socPct, clock, days.recurring, days.mask) saving = false - if case .saved? = model.goalSave[lp.id] { dismiss() } + if case .saved? = state() { dismiss() } } } } @@ -167,6 +190,6 @@ struct GoalEditor: View { /// 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) + return EVText.goal(s, at: Date(), zone: zone) } } diff --git a/appleApp/FTW/Push/Notifications.swift b/appleApp/FTW/Push/Notifications.swift index 88adf160..77f3ee41 100644 --- a/appleApp/FTW/Push/Notifications.swift +++ b/appleApp/FTW/Push/Notifications.swift @@ -42,6 +42,12 @@ enum Notifications { let center = UNUserNotificationCenter.current() center.delegate = delegate center.setNotificationCategories(categories(.preferred())) + // "Which car?" names the owner's cars on its buttons: registered + // again whenever the cars or their names change. + SiteModel.carButtons = { cars in + Self.cars = cars + UNUserNotificationCenter.current().setNotificationCategories(categories(.preferred())) + } PushTokens.setAsk { await askAndRegister() } #if DEBUG if UserDefaults.standard.bool(forKey: "FTWPushRegister") { @@ -76,10 +82,23 @@ enum Notifications { Task { await PushTokens.received(token, environment: environment) } } + /// The owner's cars, for the buttons of "Which car?". + private static var cars: [CloudHome.Car] = [] + /// The level and Charge now run behind the lock screen once the phone is - /// unlocked; Open brings the app up. + /// unlocked; Open brings the app up. "Which car?" gets one category for + /// each set of the owner's cars, a button per car and None of these. static func categories(_ language: PushText.Language) -> Set { - Set(PushActions.Category.allCases.map { category in + let which = PushActions.carCategories(cars).map { set in + UNNotificationCategory( + identifier: set.id, + actions: set.cars.map { car in + UNNotificationAction(identifier: PushActions.carAction(car.id), title: car.name, options: [.authenticationRequired], icon: UNNotificationActionIcon(systemImageName: "car.side")) + } + [UNNotificationAction(identifier: PushActions.carAction(nil), title: PushActions.noneTitle(language), options: [.authenticationRequired])], + intentIdentifiers: [] + ) + } + return Set(which).union(PushActions.Category.allCases.map { category in UNNotificationCategory( identifier: category.rawValue, actions: category.actions.map { action in @@ -115,6 +134,25 @@ enum Notifications { let out = await runner.run(command, loadpointID: loadpointID) log.info("push: \(action.rawValue, privacy: .public): \(String(describing: out.kind), privacy: .public): \(out.text, privacy: .public)") } + + /// A car's button on "Which car?": only for a verified event that + /// offered that car, on the plug-in it named, in the home this app opens. + static func choose(_ carID: String, alert: PushAlert, home: CloudHome?) async { + guard PushActions.allowsCar(carID, for: alert, home: home) else { + log.notice("push: car not sent: \(alert.verdict.rawValue, privacy: .public) \(alert.kind, privacy: .public)") + return + } + guard let home, let loadpointID = home.loadpointID(opaque: alert.lp), let session = alert.args["session"], !session.isEmpty else { + log.notice("push: car not sent: no charger or plug-in for this event on this phone") + return + } + guard let runner = IntentRunner.shared, runner.opens(siteId: home.siteId) else { + log.notice("push: car not sent: not the home this app opens") + return + } + let out = await runner.hold { await $0.cars.perform(choice: carID.isEmpty ? nil : carID, loadpointID: loadpointID, session: session) } + log.info("push: car: \(String(describing: out?.kind), privacy: .public): \(out?.text ?? "", privacy: .public)") + } } /// Where the system hands a pressed button and a notification that arrives @@ -123,7 +161,9 @@ final class NotificationButtons: NSObject, UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { // A tap on the notification, Open, or a dismissal asks the box // nothing. - guard let action = PushActions.Action(rawValue: response.actionIdentifier), let command = PushActions.command(action) else { return } + let car = PushActions.carChoice(response.actionIdentifier) + let action = PushActions.Action(rawValue: response.actionIdentifier) + guard car != nil || action.flatMap(PushActions.command) != nil else { return } let keys = CloudKeys.shared() let alert = PushAlert.read( userInfo: response.notification.request.content.userInfo, @@ -131,7 +171,12 @@ final class NotificationButtons: NSObject, UNUserNotificationCenterDelegate { offset: { keys?.offset(home: $0) }, nowMs: Int64(response.notification.date.timeIntervalSince1970 * 1000) ) - await Notifications.act(action, command, alert: alert, home: keys?.read(home: alert.home)) + let home = keys?.read(home: alert.home) + if let car { + await Notifications.choose(car, alert: alert, home: home) + } else if let action, let command = PushActions.command(action) { + await Notifications.act(action, command, alert: alert, home: home) + } } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { diff --git a/appleApp/FTW/UI/ModeSheet.swift b/appleApp/FTW/UI/ModeSheet.swift index 269e5549..9f5c387d 100644 --- a/appleApp/FTW/UI/ModeSheet.swift +++ b/appleApp/FTW/UI/ModeSheet.swift @@ -16,15 +16,18 @@ struct ModeSheet: View { var app: AppModel? /// The plan, to say what a change of style moved. var plan: PlanShare? + /// The owner's cars, on a box that keeps them. + var cars: CarsModel? /// The change the sheet opened on, which is not its own to close for: /// a sheet opened again just after a mode was applied stays open. @State private var openedOn: ModeModel.Change? @Environment(\.dismiss) private var dismiss - init(mode: ModeModel, app: AppModel? = nil, plan: PlanShare? = nil) { + init(mode: ModeModel, app: AppModel? = nil, plan: PlanShare? = nil, cars: CarsModel? = nil) { self.mode = mode self.app = app self.plan = plan + self.cars = cars _openedOn = State(initialValue: mode.change) } /// The sheet's height for its content, measured once laid out. @@ -36,6 +39,19 @@ struct ModeSheet: View { List { planSection manualSection + if app != nil, let cars, cars.keepsCars { + Section { + NavigationLink { CarsSettingsView(cars: cars) } label: { + LabeledContent { + Text(cars.cars.map(\.shownName).joined(separator: ", ")) + .lineLimit(1) + } label: { + Label(C("Cars"), systemImage: "car.side") + } + } + .accessibilityIdentifier("settings.cars") + } + } if let app, let access = app.home?.access { AccessSettingsSection(app: app, access: access) } diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index f6d4d6d5..c546a6b8 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -188,7 +188,7 @@ struct NodeView: View { .padding(.horizontal, 20) .padding(.top, 4) } else if node == .car { - CarRow(loadpoints: home.loadpoints, zoom: zoom) + CarRow(home: home, zoom: zoom) .padding(.horizontal, 20) .padding(.top, 4) } else if node == .home { @@ -228,6 +228,7 @@ struct NodeView: View { } } + // MARK: - The trace /// The live trace's state for one node's view: the geometry for the newest @@ -430,7 +431,18 @@ struct SecondFact: View { case .car: // Never worked out from power: the level as the car, this // phone or the box last gave it, with where from and how old. - if let lp = home.loadpoints.points.first, let level = home.loadpoints.level(lp) { + // On a box that keeps cars, the car's own, plugged in or not. + if home.cars.keepsCars, let car = home.cars.ordered.first, let level = home.cars.level(car) { + Text(CarText.percent(level)) + .font(Theme.figure(levelSize)) + .foregroundStyle(level.guessed ? Theme.ink3 : Theme.ink) + .contentTransition(.numericText(value: level.pct)) + CellMeter(pct: level.pct, tint: Theme.car, live: !level.guessed, glows: false) + .frame(width: 74, height: 4) + Text(CarText.origin(level, nowMs: site.minuteMs, zone: home.loadpoints.zone)) + .font(.footnote) + .foregroundStyle(Theme.ink2) + } else if let lp = home.loadpoints.points.first, let level = home.loadpoints.level(lp) { let guessed = level.origin == .assumed || level.origin == .unconfirmed Text(AppWords.percent(level.pct)) .font(Theme.figure(levelSize)) @@ -915,30 +927,61 @@ private struct ModeRow: View { } } -/// The car's one extra row: what it does now, into the car view. +/// The car's one extra row: what it does now, into the car view. On a box +/// that keeps cars, a row per car, the one on a charger first, each with +/// its level, and "Which car?" while a charger asks. private struct CarRow: View { - let loadpoints: LoadpointsModel + let home: HomeModels let zoom: Namespace.ID var body: some View { - if let lp = loadpoints.points.first { - NavigationLink(value: HomeRoute.car(lp.id)) { - HStack(spacing: 6) { - Text(EVText.status(lp, zone: loadpoints.zone)).foregroundStyle(Theme.ink) - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(Theme.ink3) - Spacer(minLength: 0) + let cars = home.cars + let loadpoints = home.loadpoints + if cars.keepsCars, !cars.cars.isEmpty || !cars.asking.isEmpty { + VStack(alignment: .leading, spacing: 0) { + if let asking = cars.asking.first { + row(.car(asking.id), id: "node.car.which") { + Text(C("Which car?")).foregroundStyle(Theme.ink) + Text(verbatim: "·").foregroundStyle(Theme.ink3) + Text(CarText.chargerName(asking)).foregroundStyle(Theme.ink2) + } + } + ForEach(cars.ordered.prefix(cars.asking.isEmpty ? 2 : 1)) { car in + row(.car(car.id), id: "node.car.open") { + Text(car.shownName).foregroundStyle(Theme.ink) + if let level = cars.level(car) { + Text(verbatim: "·").foregroundStyle(Theme.ink3) + Text(CarText.percent(level)).foregroundStyle(level.guessed ? Theme.ink3 : Theme.ink).monospacedDigit() + } + Text(verbatim: "·").foregroundStyle(Theme.ink3) + Text(cars.charger(of: car).map { EVText.status($0, zone: loadpoints.zone) } ?? C("Not plugged in")).foregroundStyle(Theme.ink2) + } } - .font(.subheadline) - .lineLimit(1) - .frame(minHeight: 44) - .contentShape(Rectangle()) } - .buttonStyle(.plain) - .matchedTransitionSource(id: HomeRoute.car(lp.id), in: zoom) - .accessibilityIdentifier("node.car.open") + } else if let lp = loadpoints.points.first { + row(.car(lp.id), id: "node.car.open") { + Text(EVText.status(lp, zone: loadpoints.zone)).foregroundStyle(Theme.ink) + } + } + } + + private func row(_ route: HomeRoute, id: String, @ViewBuilder label: () -> Label) -> some View { + NavigationLink(value: route) { + HStack(spacing: 6) { + label() + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.ink3) + Spacer(minLength: 0) + } + .font(.subheadline) + .lineLimit(1) + .frame(minHeight: 44) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .matchedTransitionSource(id: route, in: zoom) + .accessibilityIdentifier(id) } } diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index 31fc316c..bdd2d496 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -129,7 +129,7 @@ struct HomeView: View { } .sheet(item: $sheet) { which in switch which { - case .settings: ModeSheet(mode: home.nodes.mode, app: app, plan: home.nodes.planShare) + case .settings: ModeSheet(mode: home.nodes.mode, app: app, plan: home.nodes.planShare, cars: home.cars) case .mode: ModeSheet(mode: home.nodes.mode, plan: home.nodes.planShare) case .about(let start): AboutView(app: app, start: start) } @@ -143,9 +143,15 @@ struct HomeView: View { path.append(.offer(link)) } // 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() } + // appearing asks the box once; the cars after each hello. + .onAppear { + home.loadpoints.activate() + home.cars.activate() + } + .onDisappear { + home.loadpoints.deactivate() + home.cars.deactivate() + } #if DEBUG // For screenshots: `-FTWSheet settings` opens the settings, and // `-FTWAbout about`, `remove` or a file name such as `LICENSE` opens diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 1c749749..cb4023ac 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -96,6 +96,20 @@ public final class SimulatedBox { /// box of another build. public var moreCaps: [String] = [] + /// A Core that keeps cars apart from chargers (`der.car`, + /// srcfl/ftw#1650): the cars it keeps. Nil is a box from before cars, + /// whose charger is the car. + public var cars: [SimulatedCar]? + /// The garage charger's plug-in on a box that keeps cars. + public var garage: SimulatedSession? + /// The ids an owner removed, as `GET /api/cars` lists them. + public internal(set) var removed: [String] = [] + var sessions = 0 + /// The session's energy when someone last set the plugged-in car's + /// level, and when: the energy since makes it an estimate. + var levelMarkWh: Double = 0 + var levelSetAtMs: Double? + public init(scheduler: Scheduler, house: SimulatedHouse = SimulatedHouse(), requireStepUp: Bool = false, uptimeAtStartMs: Double = 3_600_000) { self.scheduler = scheduler self.house = house @@ -189,7 +203,7 @@ public final class SimulatedBox { ("mode", .text(booting ? "booting" : "full")), ("box", .map([("id", .text("demo-box")), ("build", .text("0.131.0-demo")), ("tz", .text(house.timeZone.identifier))])), ("clock", .map([("source", .text("ntp")), ("syncedAtMs", .number(scheduler.nowMs - 60_000)), ("uptimeMs", .number(uptimeMs))])), - ("caps", .array((Self.caps + moreCaps).map { .text($0) })), + ("caps", .array((Self.caps + moreCaps + (cars == nil ? [] : [Contract.capDerCar])).map { .text($0) })), ("capsHash", .text("demo")), ("modes", .array(Self.modes.map { .map([("key", .text($0.key)), ("label", .text($0.label)), ("tooltip", .text($0.tooltip)), ("tier", .text($0.tier))]) })), ("role", .text(Contract.roleOwner)), @@ -246,7 +260,11 @@ public final class SimulatedBox { } // Keyed by driver, as Core keys them: a meter, a hybrid inverter with // the battery, a second inverter on the garage roof, and the charger. - return .map([("sdm630", src("meter", "sdm630")), ("sungrow", src("inverter", "sungrow")), ("fronius", src("inverter", "fronius")), ("easee", src("charger", "easee"))]) + var all: [(String, CBOR)] = [("sdm630", src("meter", "sdm630")), ("sungrow", src("inverter", "sungrow")), ("fronius", src("inverter", "fronius")), ("easee", src("charger", "easee"))] + // A box that keeps cars reads the Tesla's level through Home + // Assistant, as Fredrik's does. + if cars != nil { all.append(("tesla-ha", src("vehicle", "tesla-ha"))) } + return .map(all) } private func sendSnap() { @@ -419,13 +437,15 @@ public final class SimulatedBox { house.surplusOnly = args["surplus_only"]?.bool ?? false result(cmdId, "applied", observed: house.surplusOnly ? 1 : 0) replan() + case Contract.opSessionCarSet, Contract.opCarSocSet, Contract.opCarGoalSet, Contract.opCarAdd, Contract.opCarUpdate, Contract.opCarRemove: + actCar(cmdId, op, args) default: result(cmdId, "rejected", error: ("E_UNKNOWN_OP", [])) } } - private func result(_ cmdId: String, _ state: String, observed: Double? = nil, error: (String, [(String, CBOR)])? = nil) { - var pairs: [(String, CBOR)] = [("cmdId", .text(cmdId)), ("state", .text(state))] + func result(_ cmdId: String, _ state: String, observed: Double? = nil, error: (String, [(String, CBOR)])? = nil, extra: [(String, CBOR)] = []) { + var pairs: [(String, CBOR)] = [("cmdId", .text(cmdId)), ("state", .text(state))] + extra if let observed { pairs.append(("observed", .map([("value", .number(observed)), ("src", .text("core")), ("uptimeMs", .number(uptimeMs))]))) } if let error { pairs.append(("error", .map([("code", .text(error.0)), ("args", .map(error.1))]))) } sendControl(Envelope(t: "cmd.result", b: .map(pairs))) @@ -441,6 +461,7 @@ public final class SimulatedBox { ("GET", "/api/energy/daily", .read, nil), ("GET", "/api/savings/daily", .read, nil), ("GET", "/api/loadpoints", .read, nil), + ("GET", "/api/cars", .read, nil), ("GET", "/api/mpc/plan", .read, nil), ("GET", "/api/planner/prefs", .read, nil), ("GET", "/api/energy/history", .read, nil), @@ -583,6 +604,9 @@ public final class SimulatedBox { return (200, savingsAnswer(days: days, nowMs: now)) case ("GET", "/api/loadpoints"): return (200, ["enabled": true, "loadpoints": [loadpointJSON()]]) + case ("GET", "/api/cars"): + // A box from before cars has no such route. + return cars == nil ? (404, [:]) : (200, carsJSON()) case ("GET", "/api/planner/prefs"): let mapped = house.batteryExport == "allowed" ? "planner_arbitrage" : "planner_passive_arbitrage" return (200, [ @@ -727,7 +751,7 @@ public final class SimulatedBox { } else { lp = lp.setting("plan_windows", []) } - return lp + return chargerCarJSON(lp) } // MARK: Frames out @@ -845,6 +869,12 @@ extension SimulatedBox { if let name = UserDefaults.standard.string(forKey: "FTWDemoCar"), let car = DemoCar(rawValue: name) { car.apply(to: box.house, nowMs: scheduler.nowMs) } + #endif + // The demo is a Core that keeps cars, with one car on the charger. + // For screenshots: `-FTWDemoCars two`, or `old` for a box from + // before cars. + (UserDefaults.standard.string(forKey: "FTWDemoCars").flatMap(DemoCars.init) ?? .one).apply(to: box, nowMs: scheduler.nowMs) + #if DEBUG // For recordings: `-FTWDemoGap YES` holds a tile back for 2 s. if UserDefaults.standard.bool(forKey: "FTWDemoGap") { box.holdTileMs = 2_000 } // For the grid widget: `-FTWDemoSunny YES` makes today a long @@ -879,6 +909,7 @@ extension SimulatedBox { /// Plug the demo car in or out. public func setCarPluggedIn(_ on: Bool) { if on { house.plugIn() } else { house.unplug() } + carCable(pluggedIn: on) replan() } @@ -886,7 +917,7 @@ extension SimulatedBox { /// sends. A box that keeps no cars names none. func planCharger(nowMs: Double) -> PlanCharger { let pending = replanning != nil - var car = PlanCharger(id: "garage", pluggedIn: house.carPluggedIn, pending: pending, outdated: pending, goalRev: goalRev, windows: [], totalWh: 0) + var car = PlanCharger(id: "garage", carId: pluggedCarID, pluggedIn: house.carPluggedIn, pending: pending, outdated: pending, goalRev: goalRev, windows: [], totalWh: 0) if let p = house.carPlan(nowMs), p.endMs > p.startMs, p.endMs > nowMs { car.windows = [Loadpoint.Window(fromMs: p.startMs.rounded(), toMs: p.endMs.rounded(), peakW: nil, energyWh: p.wh)] car.totalWh = p.wh diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedCars.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedCars.swift new file mode 100644 index 00000000..407f775b --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedCars.swift @@ -0,0 +1,331 @@ +import Foundation +import FTWKit + +/// A car the demo box keeps, as a Core with `der.car` keeps it +/// (docs/cars.md in srcfl/ftw). The car on the garage charger takes its +/// level and goal from the house, which charges it; a car that is away +/// keeps the level it had when it left. +public struct SimulatedCar: Sendable, Equatable { + public var id: String + public var name: String + public var make: String + public var capacityWh: Double + public var socFraction: Double? + /// vehicle, manual or assumed. + public var socSource: String + /// When the car reported the level or someone set it. + public var readAtMs: Double? + public var energySinceWh: Double + public var chargeLimit: Double? + public var goal: Car.Goal? + public var surplusOnly: Bool + public var sources: [String] + + public init(id: String, name: String, make: String = "", capacityWh: Double = 64_000, socFraction: Double? = nil, socSource: String = "", readAtMs: Double? = nil, + energySinceWh: Double = 0, chargeLimit: Double? = nil, goal: Car.Goal? = nil, surplusOnly: Bool = false, sources: [String] = []) { + self.id = id + self.name = name + self.make = make + self.capacityWh = capacityWh + self.socFraction = socFraction + self.socSource = socSource + self.readAtMs = readAtMs + self.energySinceWh = energySinceWh + self.chargeLimit = chargeLimit + self.goal = goal + self.surplusOnly = surplusOnly + self.sources = sources + } +} + +/// The garage charger's plug-in, as a Core with cars keeps it. +public struct SimulatedSession: Sendable, Equatable { + public var id: String + /// Empty while the box does not know which car this is. + public var carID: String + /// One of the registry's `car_identified_by`. + public var by: String + public var choose: [String] +} + +/// The car situations the demo opens in for a box that keeps cars, so each +/// screen of the car view can be shown without a box: `-FTWDemoCars two`. +public enum DemoCars: String, CaseIterable, Sendable { + /// A box from before cars: the charger is the car. + case old + /// One car, on the charger. The demo's default. + case one + /// One car, away: its last level from the car an hour ago, as on + /// Fredrik's box with the cable out. + case away + /// Two cars: one on the charger, which said so itself, one away. + case two + /// Two cars, and the charger asks which one is plugged in. + case asking + + public static let modelY = "6f1d2a3b4c5d6e7f8091a2b3c4d5e6f7" + public static let zoe = "0a1b2c3d4e5f60718293a4b5c6d7e8f9" + + @MainActor + public func apply(to box: SimulatedBox, nowMs: Double) { + let tesla = SimulatedCar(id: Self.modelY, name: "Model Y", make: "Tesla", capacityWh: 76_000, chargeLimit: 0.8, + goal: Car.Goal(targetPermille: 800, readyByMin: 7 * 60, days: Car.Goal.everyDay, finishAtLimit: false), sources: ["tesla-ha"]) + let zoe = SimulatedCar(id: Self.zoe, name: "Zoe", make: "Renault", capacityWh: 52_000, socFraction: 0.45, socSource: "manual", readAtMs: nowMs - 3 * 3_600_000) + switch self { + case .old: + box.cars = nil + case .one: + box.keepCars([tesla], on: Self.modelY, by: "only") + case .away: + var away = tesla + away.socFraction = 0.72 + away.socSource = "vehicle" + away.readAtMs = nowMs - 67 * 60_000 + box.house.unplug() + box.keepCars([away], on: nil, by: "") + case .two: + box.keepCars([tesla, zoe], on: Self.modelY, by: "car") + case .asking: + box.keepCars([tesla, zoe], on: nil, by: "asking") + } + } +} + +extension SimulatedBox { + /// Keep cars as a Core with `der.car` does, with `on` plugged into the + /// garage when the house's car is plugged in. + public func keepCars(_ list: [SimulatedCar], on carID: String?, by: String) { + cars = list + garage = house.carPluggedIn ? SimulatedSession(id: newSessionID(), carID: carID ?? "", by: by, choose: by == "asking" ? Array(list.prefix(4).map(\.id)) : []) : nil + if let car = list.first(where: { $0.id == carID }) { house.carCapacityWh = car.capacityWh } + } + + func newSessionID() -> String { + sessions += 1 + return "s\(sessions)" + } + + /// The car on the garage charger, when the box knows which. + var pluggedCarID: String? { + guard house.carPluggedIn, let id = garage?.carID, !id.isEmpty else { return nil } + return id + } + + /// A car as the box holds it now: the plugged-in car from the house. + func car(_ c: SimulatedCar) -> Car { + var out = Car(id: c.id, name: c.name, make: c.make, capacityWh: c.capacityWh, chargeLimitPermille: c.chargeLimit.map { Int(($0 * 1000).rounded()) }, + goal: c.goal, surplusOnly: c.surplusOnly, sources: c.sources) + let now = scheduler.nowMs + if c.id == pluggedCarID { + let h = house + out.socPermille = Int((h.carSocFraction * 1000).rounded()) + switch h.carSocSource { + case "vehicle": + out.socSource = CarSocSource.vehicle.rawValue + out.socAgeS = h.carReportedAtMs.map { max(0, (now - $0) / 1000).rounded() } + case "assumed": + out.socSource = CarSocSource.assumed.rawValue + out.socEstimate = true + default: + out.socSource = CarSocSource.manual.rawValue + out.socAgeS = levelSetAtMs.map { max(0, (now - $0) / 1000).rounded() } + out.energySinceWh = max(0, h.sessionWh - levelMarkWh).rounded() + out.socEstimate = out.energySinceWh > 1_000 + } + out.goal = h.schedule.map { s in + Car.Goal(targetPermille: Int((s.socFraction * 1000).rounded()), readyByMin: s.clockMin, days: s.recurring ? (s.days & 0x7F == 0 ? Car.Goal.everyDay : s.days) : 0, finishAtLimit: false) + } + out.surplusOnly = h.surplusOnly + out.capacityWh = h.carCapacityWh + } else if let f = c.socFraction { + out.socPermille = Int((f * 1000).rounded()) + out.socSource = c.socSource + out.socAgeS = c.readAtMs.map { max(0, (now - $0) / 1000).rounded() } + out.energySinceWh = c.energySinceWh + out.socEstimate = c.socSource == "assumed" || c.energySinceWh > 1_000 + } + return out + } + + func carsJSON() -> JSON { + ["cars": .array((cars ?? []).map { Self.json(car($0)) }), "removed": .array(removed.map { ["id": .string($0)] })] + } + + /// A car in the box's HTTP spelling. + static func json(_ c: Car) -> JSON { + var w: JSON = [ + CarField.id.http: .string(c.id), CarField.name.http: .string(c.name), CarField.capacityWh.http: .number(c.capacityWh), + CarField.energySinceWh.http: .number(c.energySinceWh), CarField.socEstimate.http: .bool(c.socEstimate), CarField.surplusOnly.http: .bool(c.surplusOnly), + ] + if !c.make.isEmpty { w = w.setting(CarField.make.http, .string(c.make)) } + if let p = c.socPermille { w = w.setting(CarField.socPermille.http, .number(Double(p))) } + if !c.socSource.isEmpty { w = w.setting(CarField.socSource.http, .string(c.socSource)) } + if let a = c.socAgeS { w = w.setting(CarField.socAgeS.http, .number(a)) } + if let l = c.chargeLimitPermille { w = w.setting(CarField.chargeLimitPermille.http, .number(Double(l))) } + if let g = c.goal { + w = w.setting(CarField.goal.http, [ + CarField.goalTargetPermille.http: .number(Double(g.targetPermille)), CarField.goalReadyByMin.http: .number(Double(g.readyByMin)), + CarField.goalDays.http: .number(Double(g.days)), CarField.goalFinishAtLimit.http: .bool(g.finishAtLimit), + ]) + } + if !c.sources.isEmpty { w = w.setting(CarField.sources.http, .array(c.sources.map { .string($0) })) } + return w + } + + /// A car in brief, as a `cmd.result` carries it. + static func cbor(_ c: Car) -> CBOR { + var pairs: [(String, CBOR)] = [(CarField.id.wire, .text(c.id)), (CarField.energySinceWh.wire, .number(c.energySinceWh)), + (CarField.socEstimate.wire, .bool(c.socEstimate)), (CarField.surplusOnly.wire, .bool(c.surplusOnly))] + if let p = c.socPermille { pairs.append((CarField.socPermille.wire, .int(p))) } + if !c.socSource.isEmpty { pairs.append((CarField.socSource.wire, .text(c.socSource))) } + if let a = c.socAgeS { pairs.append((CarField.socAgeS.wire, .number(a))) } + if let l = c.chargeLimitPermille { pairs.append((CarField.chargeLimitPermille.wire, .int(l))) } + if let g = c.goal { + pairs.append((CarField.goal.wire, .map([("targetPermille", .int(g.targetPermille)), ("readyByMin", .int(g.readyByMin)), ("days", .int(g.days)), ("finishAtLimit", .bool(g.finishAtLimit))]))) + } + return .map(pairs) + } + + /// The garage's car fields on `GET /api/loadpoints`, and the level the + /// one-release overlap shows there: a visitor's or an unnamed car's is + /// unknown, with no number. + func chargerCarJSON(_ lp: JSON) -> JSON { + guard cars != nil, house.carPluggedIn, let g = garage else { return lp } + var out = lp.setting(ChargerCarField.session.http, .string(g.id)) + if !g.carID.isEmpty { out = out.setting(ChargerCarField.carId.http, .string(g.carID)) } + if !g.by.isEmpty { out = out.setting(ChargerCarField.carIdentifiedBy.http, .string(g.by)) } + if !g.choose.isEmpty { out = out.setting(ChargerCarField.choose.http, .array(g.choose.map { .string($0) })) } + if g.carID.isEmpty { + out = out.setting("soc_source", "unknown").setting("current_soc", .null) + out = out.setting("vehicle_soc", .null).setting("vehicle_driver", "") + } + return out + } + + /// The charger brief a `session.car.set` result carries. + func chargerCBOR() -> CBOR { + let g = garage + var pairs: [(String, CBOR)] = [(ChargerCarField.session.rawValue, .text(g?.id ?? ""))] + if let id = g?.carID, !id.isEmpty { pairs.append((ChargerCarField.carId.rawValue, .text(id))) } + if let by = g?.by, !by.isEmpty { pairs.append((ChargerCarField.carIdentifiedBy.rawValue, .text(by))) } + return .map(pairs) + } + + /// The cable moved on a box that keeps cars: the car that left keeps + /// its level; a new plug-in is named by the only car, or asked about. + func carCable(pluggedIn: Bool) { + guard var list = cars else { return } + if !pluggedIn { + if let id = garage?.carID, let i = list.firstIndex(where: { $0.id == id }) { + let held = car(list[i]) + list[i].socFraction = held.socPermille.map { Double($0) / 1000 } + list[i].socSource = held.socSource + list[i].readAtMs = held.socAgeS.map { scheduler.nowMs - $0 * 1000 } ?? scheduler.nowMs + list[i].energySinceWh = held.energySinceWh + list[i].goal = held.goal + } + cars = list + garage = nil + return + } + let only = list.count == 1 ? list[0].id : "" + garage = SimulatedSession(id: newSessionID(), carID: only, by: list.count == 1 ? "only" : (list.isEmpty ? "none" : "asking"), + choose: list.count > 1 ? Array(list.prefix(4).map(\.id)) : []) + if let car = list.first(where: { $0.id == only }) { house.carCapacityWh = car.capacityWh } + levelMarkWh = 0 + levelSetAtMs = nil + } + + /// The car ops, as Core answers them on a box that keeps cars. + func actCar(_ cmdId: String, _ op: String, _ args: CBOR) { + guard var list = cars else { + result(cmdId, "rejected", error: ("E_UNKNOWN_OP", [("op", .text(op))])) + return + } + let id = args["id"]?.string ?? "" + func refuse(_ code: String, _ reason: String) { + result(cmdId, "rejected", error: (code, [("op", .text(op)), ("reason", .text(reason))])) + } + switch op { + case Contract.opSessionCarSet: + guard id == "garage" else { return refuse("E_NOT_FOUND", "charger") } + guard house.carPluggedIn, var g = garage, args["session"]?.string == g.id else { return refuse("E_PRECONDITION", "session") } + let want = args["car_id"]?.string ?? "" + guard want.isEmpty || list.contains(where: { $0.id == want }) else { return refuse("E_NOT_FOUND", "car") } + g.carID = want + g.by = want.isEmpty ? "none" : "tap" + g.choose = [] + garage = g + if let car = list.first(where: { $0.id == want }) { + house.carCapacityWh = car.capacityWh + if let goal = car.goal { + house.schedule = (Double(goal.targetPermille) / 1000, goal.readyByMin, goal.days != 0, goal.days == Car.Goal.everyDay ? 0 : goal.days, house.schedule?.surplusUnlock ?? 0) + } + } + result(cmdId, "applied", extra: [("charger", chargerCBOR())]) + replan() + case Contract.opCarSocSet: + guard let i = list.firstIndex(where: { $0.id == id }) else { return refuse("E_NOT_FOUND", "car") } + let soc = max(0, min(1, args["soc"]?.double ?? 0)) + if id == pluggedCarID { + house.carSocFraction = soc + house.carSocSource = "inferred" + house.carReportedAtMs = house.carReportedAtMs.map { min($0, scheduler.nowMs - 1) } + levelMarkWh = house.sessionWh + levelSetAtMs = scheduler.nowMs + replan() + } else { + list[i].socFraction = soc + list[i].socSource = CarSocSource.manual.rawValue + list[i].readAtMs = scheduler.nowMs + list[i].energySinceWh = 0 + cars = list + } + result(cmdId, "applied", observed: soc, extra: [("car", Self.cbor(car(list[i])))]) + case Contract.opCarGoalSet: + guard let i = list.firstIndex(where: { $0.id == id }) else { return refuse("E_NOT_FOUND", "car") } + var goal = list[i].goal + if args["clear"]?.bool == true { goal = nil } + if let target = args["target"]?.double, let ready = args["ready_by_min"]?.int { + goal = Car.Goal(targetPermille: Int((target * 1000).rounded()), readyByMin: ready, days: args["days"]?.int ?? 0, finishAtLimit: args["finish_at_limit"]?.bool ?? false) + } + if let surplus = args["surplus_only"]?.bool { list[i].surplusOnly = surplus } + list[i].goal = goal + cars = list + if id == pluggedCarID { + house.schedule = goal.map { (Double($0.targetPermille) / 1000, $0.readyByMin, $0.days != 0, $0.days == Car.Goal.everyDay ? 0 : $0.days, house.schedule?.surplusUnlock ?? 0) } + if let surplus = args["surplus_only"]?.bool { house.surplusOnly = surplus } + replan() + } + result(cmdId, "applied", extra: [("car", Self.cbor(car(list[i])))]) + case Contract.opCarAdd: + guard Car.isID(id) else { return refuse("E_PRECONDITION", "invalid") } + guard list.count < 32 else { return result(cmdId, "rejected", error: ("E_CAP_EXCEEDED", [("op", .text(op)), ("reason", .text("cars"))])) } + let c = SimulatedCar(id: id, name: args["name"]?.string ?? "", capacityWh: args["capacity_wh"]?.double ?? 60_000) + if let i = list.firstIndex(where: { $0.id == id }) { list[i].name = c.name } else { list.append(c) } + removed.removeAll { $0 == id } + cars = list + result(cmdId, "applied", extra: [("car", Self.cbor(car(c)))]) + case Contract.opCarUpdate: + guard let i = list.firstIndex(where: { $0.id == id }) else { return refuse("E_NOT_FOUND", "car") } + if let name = args["name"]?.string { list[i].name = name } + if let wh = args["capacity_wh"]?.double { + list[i].capacityWh = wh + if id == pluggedCarID { house.carCapacityWh = wh } + } + if let sources = args["sources"]?.array { list[i].sources = sources.compactMap(\.string) } + cars = list + result(cmdId, "applied", extra: [("car", Self.cbor(car(list[i])))]) + case Contract.opCarRemove: + guard let i = list.firstIndex(where: { $0.id == id }) else { return refuse("E_NOT_FOUND", "car") } + list.remove(at: i) + removed.append(id) + cars = list + if garage?.carID == id { garage?.carID = ""; garage?.by = "none" } + result(cmdId, "applied") + replan() + default: + result(cmdId, "rejected", error: ("E_UNKNOWN_OP", [])) + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Cars.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Cars.swift new file mode 100644 index 00000000..88fed0eb --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Cars.swift @@ -0,0 +1,507 @@ +import Foundation +import FTWShared + +// Cars (docs/cars.md in srcfl/ftw). A car belongs to its owner, not to the +// home, and visits chargers. A box that lists `der.car` keeps each car's +// level, goal and sources apart from the charger, and each charger names +// the car on it. A box without it treats each charger as its car, and the +// app draws the charger as before. + +/// A car's names on the wire, from the registry's `car_fields`. A name with +/// a dot is a field of the object before the dot. `ContractTests` holds this +/// list to the registry's. +public enum CarField: String, CaseIterable, Sendable { + case id, boxMinted, ownerId, name, make, vin, capacityWh, maxW, phases + case socPermille, socSource, socAgeS, energySinceWh, socEstimate, chargeLimitPermille + case goal + case goalTargetPermille = "goal.targetPermille" + case goalReadyByMin = "goal.readyByMin" + case goalDays = "goal.days" + case goalFinishAtLimit = "goal.finishAtLimit" + case surplusOnly, identifiers, sources + + /// The name on the session: the part after the dot. + public var wire: String { rawValue.split(separator: ".").last.map(String.init) ?? rawValue } + /// The name on the box's HTTP routes, which spell every registry name + /// in snake_case. + public var http: String { snakeCase(wire) } +} + +/// What a charger carries about the car on it, from the registry's +/// `charger_car_fields`. +public enum ChargerCarField: String, CaseIterable, Sendable { + case session, carId, carIdentifiedBy, carCorrected, carCorrectedFrom, choose + + public var http: String { snakeCase(rawValue) } +} + +/// Where a car's level comes from, from the registry's `car_soc_sources`. +public enum CarSocSource: String, CaseIterable, Sendable, Codable { + /// A reading from the car itself. + case vehicle + /// A level someone set. + case manual + /// A known car's plug-in default. Always an estimate. + case assumed +} + +/// How the box knows which car is on a charger, from the registry's +/// `car_identified_by`. +public enum CarIdentifiedBy: String, CaseIterable, Sendable, Codable { + case charger, car, only, tap + /// The box raised `car.which` and waits for a tap. + case asking + /// A visitor: a car the box cannot name. + case none +} + +/// "socPermille" → "soc_permille", as the box spells a registry name on its +/// HTTP routes. +func snakeCase(_ camel: String) -> String { + var out = "" + for c in camel { + if c.isUppercase { + out += "_" + c.lowercased() + } else { + out.append(c) + } + } + return out +} + +/// One car as the box keeps it, read tolerantly from `GET /api/cars` or +/// from a `cmd.result`. A field the box leaves out (a viewer sees no VIN) +/// reads as empty. +public struct Car: Equatable, Sendable, Identifiable, Codable { + /// What the car should reach and when. One goal per car, used wherever + /// it is plugged in. + public struct Goal: Equatable, Sendable, Codable { + public var targetPermille: Int + /// Minutes after midnight in the box's time zone. + public var readyByMin: Int + /// ISO weekday mask, Monday in bit 0. 0 is once, 127 every day. + public var days: Int + /// Charge on to the car's own limit rather than to the target. + public var finishAtLimit: Bool + + public init(targetPermille: Int, readyByMin: Int, days: Int, finishAtLimit: Bool) { + self.targetPermille = targetPermille + self.readyByMin = readyByMin + self.days = days + self.finishAtLimit = finishAtLimit + } + + /// Every day, as a goal's days say it. + public static let everyDay = 0x7F + } + + /// 128 bits as 32 lowercase hex digits. + public var id: String + /// The box made the car from its old settings. + public var boxMinted: Bool + public var name: String + public var make: String + public var vin: String + public var capacityWh: Double + /// The most the car's own charger takes; 0 when unknown. + public var maxW: Double + public var phases: Int + /// The level. The car's own number, or the person's, while + /// `socEstimate` is false. Nil when the box has none. + public var socPermille: Int? + /// One of `CarSocSource`, as the box said it. + public var socSource: String + /// Seconds since the car reported the level or someone set it, counted + /// when the box answered. + public var socAgeS: Double? + /// Energy delivered to the car since that reading. + public var energySinceWh: Double + public var socEstimate: Bool + /// The charge limit the car reported with its level. + public var chargeLimitPermille: Int? + public var goal: Goal? + public var surplusOnly: Bool + public var identifiers: [String] + /// The vehicle drivers on this box that report the car's level. + public var sources: [String] + + public init(id: String, name: String, boxMinted: Bool = false, make: String = "", vin: String = "", capacityWh: Double = 0, maxW: Double = 0, phases: Int = 0, + socPermille: Int? = nil, socSource: String = "", socAgeS: Double? = nil, energySinceWh: Double = 0, socEstimate: Bool = false, + chargeLimitPermille: Int? = nil, goal: Goal? = nil, surplusOnly: Bool = false, identifiers: [String] = [], sources: [String] = []) { + self.id = id + self.boxMinted = boxMinted + self.name = name + self.make = make + self.vin = vin + self.capacityWh = capacityWh + self.maxW = maxW + self.phases = phases + self.socPermille = socPermille + self.socSource = socSource + self.socAgeS = socAgeS + self.energySinceWh = energySinceWh + self.socEstimate = socEstimate + self.chargeLimitPermille = chargeLimitPermille + self.goal = goal + self.surplusOnly = surplusOnly + self.identifiers = identifiers + self.sources = sources + } + + /// From the box's HTTP route, in snake_case. + public init?(_ w: JSON) { + self.init(read: CarRead( + num: { f in w[f.http]?.number }, + text: { f in w[f.http]?.string }, + flag: { f in w[f.http]?.bool }, + list: { f in w[f.http]?.array?.compactMap(\.string) }, + goal: { w[CarField.goal.http].flatMap { g in g.object == nil ? nil : CarRead( + num: { f in g[f.http]?.number }, text: { _ in nil }, flag: { f in g[f.http]?.bool }, list: { _ in nil }, goal: { nil }) } } + )) + } + + /// From the session, in the registry's own spelling: a `cmd.result`. + public init?(cbor c: CBOR) { + guard c.entries != nil else { return nil } + self.init(read: CarRead( + num: { f in c[f.wire]?.double }, + text: { f in c[f.wire]?.string }, + flag: { f in c[f.wire]?.bool }, + list: { f in c[f.wire]?.array?.compactMap(\.string) }, + goal: { c[CarField.goal.wire].flatMap { g in g.entries == nil ? nil : CarRead( + num: { f in g[f.wire]?.double }, text: { _ in nil }, flag: { f in g[f.wire]?.bool }, list: { _ in nil }, goal: { nil }) } } + )) + } + + private init?(read r: CarRead) { + guard let id = r.text(.id), !id.isEmpty else { return nil } + self.id = id + boxMinted = r.flag(.boxMinted) ?? false + name = r.text(.name) ?? "" + make = r.text(.make) ?? "" + vin = r.text(.vin) ?? "" + capacityWh = max(0, r.num(.capacityWh) ?? 0) + maxW = max(0, r.num(.maxW) ?? 0) + phases = Int(r.num(.phases) ?? 0) + socPermille = r.num(.socPermille).flatMap { (0...1000).contains($0) ? Int($0.rounded()) : nil } + socSource = r.text(.socSource) ?? "" + socAgeS = r.num(.socAgeS).flatMap { $0 >= 0 ? $0 : nil } + energySinceWh = r.num(.energySinceWh) ?? 0 + socEstimate = r.flag(.socEstimate) ?? false + chargeLimitPermille = r.num(.chargeLimitPermille).flatMap { (1...1000).contains($0) ? Int($0.rounded()) : nil } + if let g = r.goal(), let target = g.num(.goalTargetPermille), let ready = g.num(.goalReadyByMin) { + goal = Goal(targetPermille: Int(target.rounded()), readyByMin: Int(ready), days: Int(g.num(.goalDays) ?? 0) & 0x7F, + finishAtLimit: g.flag(.goalFinishAtLimit) ?? false) + } else { + goal = nil + } + surplusOnly = r.flag(.surplusOnly) ?? false + identifiers = r.list(.identifiers) ?? [] + sources = r.list(.sources) ?? [] + } + + public var source: CarSocSource? { CarSocSource(rawValue: socSource) } + + /// The level and where it comes from, dated on the phone's clock from + /// when the box answered. Nil when the box has none. + public func level(answeredAtMs: Double) -> CarLevel? { + guard let permille = socPermille else { return nil } + let source = source ?? .assumed + let at = source == .assumed ? nil : socAgeS.map { answeredAtMs - $0 * 1000 } + return CarLevel(pct: (Double(permille) / 10).rounded(), source: source, estimate: socEstimate || source == .assumed, atMs: at, energySinceWh: energySinceWh) + } + + /// The name to show: the owner's, else the maker's, else "Car". + public var shownName: String { + if !name.trimmingCharacters(in: .whitespaces).isEmpty { return name } + if !make.isEmpty { return make } + return CarWords.text("Car") + } + + /// A new car's id, minted on the phone: 128 random bits as 32 lowercase + /// hex digits, the same on every box its owner has. + public static func mintID() -> String { + var g = SystemRandomNumberGenerator() + return (0..<16).map { _ in String(format: "%02x", UInt8.random(in: 0...255, using: &g)) }.joined() + } + + /// Whether an id is one a car can have. + public static func isID(_ id: String) -> Bool { + id.count == 32 && id.allSatisfy { $0.isHexDigit && !$0.isUppercase } + } +} + +/// Reads a car's fields by their registry names, from JSON or CBOR. +private struct CarRead { + let num: (CarField) -> Double? + let text: (CarField) -> String? + let flag: (CarField) -> Bool? + let list: (CarField) -> [String]? + let goal: () -> CarRead? +} + +/// A car's level as the screen says it. +public struct CarLevel: Equatable, Sendable { + public var pct: Double + public var source: CarSocSource + /// The box counts it as an estimate: energy has flowed since the + /// reading, it was read before the car last left a charger, or it is + /// assumed. + public var estimate: Bool + /// When the car reported it or someone set it. Nil when assumed. + public var atMs: Double? + public var energySinceWh: Double + + /// Whether it is a guess the screen draws faint: assumed, or an + /// estimate with no reading under it. + public var guessed: Bool { source == .assumed } +} + +/// What a charger says about the car on it. Empty while nothing is plugged +/// in, and from a box that keeps no cars. +public struct ChargerCar: Equatable, Sendable, Codable { + /// This plug-in's id. A tap names it, and the box refuses a tap for a + /// plug-in that has ended. + public var session: String + /// The session's car; empty while the box does not know which. + public var carID: String + /// One of `CarIdentifiedBy`, as the box said it. + public var identifiedBy: String + /// A better rule replaced the car the box named first, which was + /// `correctedFrom`. + public var corrected: Bool + public var correctedFrom: String + /// While the box asks: the cars a tap may pick, at most four. + public var choose: [String] + + public init(session: String = "", carID: String = "", identifiedBy: String = "", corrected: Bool = false, correctedFrom: String = "", choose: [String] = []) { + self.session = session + self.carID = carID + self.identifiedBy = identifiedBy + self.corrected = corrected + self.correctedFrom = correctedFrom + self.choose = choose + } + + /// From `GET /api/loadpoints`; nil when the charger carries none. + init?(json w: JSON) { + let f = { (k: ChargerCarField) in w[k.http] } + let session = f(.session)?.string ?? "" + let car = f(.carId)?.string ?? "" + let by = f(.carIdentifiedBy)?.string ?? "" + guard !session.isEmpty || !car.isEmpty || !by.isEmpty else { return nil } + self.init(session: session, carID: car, identifiedBy: by, corrected: f(.carCorrected)?.bool ?? false, + correctedFrom: f(.carCorrectedFrom)?.string ?? "", choose: f(.choose)?.array?.compactMap(\.string) ?? []) + } + + /// From a `cmd.result` after `session.car.set`. + init?(cbor c: CBOR) { + guard c.entries != nil else { return nil } + let f = { (k: ChargerCarField) in c[k.rawValue] } + self.init(session: f(.session)?.string ?? "", carID: f(.carId)?.string ?? "", identifiedBy: f(.carIdentifiedBy)?.string ?? "", + corrected: f(.carCorrected)?.bool ?? false, correctedFrom: f(.carCorrectedFrom)?.string ?? "", + choose: f(.choose)?.array?.compactMap(\.string) ?? []) + } + + public var by: CarIdentifiedBy? { CarIdentifiedBy(rawValue: identifiedBy) } + /// The box waits for someone to say which car this is. + public var asking: Bool { by == .asking } +} + +// MARK: - Words + +/// The car's sentences. Swedish lives in the table below, as AppWords does +/// it; a sentence without a translation falls back to the app's own. +public enum CarWords { + public static func text(_ en: String) -> String { + AppWords.language == .sv ? (sv[en] ?? NodeWords.text(en)) : en + } + + /// `%@` in order, or `%1$@` by position. + 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 + } + + static let sv: [String: String] = [ + "Car": "Bil", + "Cars": "Bilar", + "Which car?": "Vilken bil?", + "Which car is on %@?": "Vilken bil står på %@?", + "None of these": "Ingen av dessa", + "Tap the car so FTW plans for its level and goal.": "Tryck på bilen så planerar FTW efter dess nivå och mål.", + "Until you choose, FTW charges it as a visitor's car, from spare solar.": "Tills du väljer laddar FTW den som en besökares bil, med överbliven sol.", + "A visitor's car": "En besökares bil", + "FTW does not know this car, so it shows no level.": "FTW känner inte bilen, så den visar ingen nivå.", + "Sending your choice…": "Skickar ditt val…", + "FTW received your choice.": "FTW har fått ditt val.", + "FTW now plans for %@.": "FTW planerar nu för %@.", + "FTW now charges it as a visitor's car.": "FTW laddar den nu som en besökares bil.", + "Level unknown": "Nivån okänd", + "from the car": "från bilen", + "from the car %@ + %@": "från bilen %@ + %@", + "set by hand": "satt för hand", + "set by hand %@ + %@": "satt för hand %@ + %@", + "assumed at plug-in": "antagen vid inkoppling", + "yesterday %@": "i går %@", + "Limit %@": "Laddgräns %@", + "On %@ · %@ max": "På %@ · max %@", + "On %@": "På %@", + "Not plugged in": "Inte inkopplad", + "Plugged in": "Inkopplad", + "Charging": "Laddar", + "FTW first took it for %@.": "FTW trodde först att det var %@.", + "The car said it was plugged in here.": "Bilen sa själv att den kopplades in här.", + "Only one car could be here.": "Bara en bil kunde stå här.", + "The charger named the car.": "Laddaren angav bilen.", + "Set the level": "Ange nivån", + "Plug in the car, or let the car report, to update its level.": "Koppla in bilen, eller låt bilen rapportera, så uppdateras nivån.", + "FTW no longer has this car.": "FTW har inte längre den här bilen.", + "FTW no longer has that charger.": "FTW har inte längre den laddaren.", + "That plug-in has ended.": "Den inkopplingen är slut.", + "That car is on another charger.": "Den bilen står på en annan laddare.", + "FTW could not take that.": "FTW kunde inte ta emot det.", + "Your box does not keep cars yet. It may need an update.": "Din box håller inte bilar än. Den kan behöva uppdateras.", + "Another car already has that VIN or tag.": "En annan bil har redan det VIN-numret eller den taggen.", + "FTW keeps at most 32 cars.": "FTW håller högst 32 bilar.", + "Name": "Namn", + "Battery size": "Batteristorlek", + "Level sources": "Nivåkällor", + "No source reports this car's level yet.": "Ingen källa rapporterar bilens nivå än.", + "Add a source": "Lägg till en källa", + "Add a car": "Lägg till en bil", + "Remove this car": "Ta bort bilen", + "Remove %@?": "Ta bort %@?", + "FTW forgets the car and its goal. A session on it becomes a visitor's.": "FTW glömmer bilen och dess mål. En pågående laddning blir en besökares.", + "Saving…": "Sparar…", + "Saved.": "Sparat.", + "Removed.": "Borttagen.", + "The cars FTW plans for. Each keeps its own level, goal and sources.": "Bilarna FTW planerar för. Var och en har egen nivå, eget mål och egna källor.", + "Only the home's owner can change the cars.": "Bara hemmets ägare kan ändra bilarna.", + "%@ kWh": "%@ kWh", + "New car": "Ny bil", + "Charges at most %@": "Laddar högst %@", + "No car yet": "Ingen bil än", + "Add your car in Settings so FTW can plan for it.": "Lägg till bilen under Inställningar så kan FTW planera för den.", + "Cancel": "Avbryt", + "Remove": "Ta bort", + "Add": "Lägg till", + "Done": "Klar", + ] +} + +/// The car's sentences, from what the box said and nothing more. +public enum CarText { + /// "72 %", or "≈ 22 %" for an estimate. + public static func percent(_ l: CarLevel) -> String { + (l.estimate ? "≈\u{00A0}" : "") + AppWords.percent(l.pct) + } + + /// Where the level comes from and how old it is: "from the car · 1 h + /// ago", "from the car 16:31 + 6 kWh", "set by hand · 20 min ago", + /// "assumed at plug-in". A reading is dated in minutes, never seconds; + /// once energy has flowed it says how much since when. + public static func origin(_ l: CarLevel, nowMs: Double, zone: TimeZone) -> String { + switch l.source { + case .assumed: + return CarWords.text("assumed at plug-in") + case .vehicle, .manual: + let from = CarWords.text(l.source == .vehicle ? "from the car" : "set by hand") + guard let at = l.atMs else { return from } + if l.estimate, l.energySinceWh >= 50 { + return CarWords.format(l.source == .vehicle ? "from the car %@ + %@" : "set by hand %@ + %@", when(at, nowMs: nowMs, zone: zone), AppWords.energy(l.energySinceWh)) + } + let age = max(0, nowMs - at) + return from + " · " + PowerFormat.age(age < 60_000 ? 0 : (age / 60_000).rounded(.down) * 60_000) + } + } + + /// "16:31" today, "yesterday 16:31", else "12 Oct 16:31". + static func when(_ ms: Double, nowMs: Double, zone: TimeZone) -> String { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + let days = cal.dateComponents([.day], from: cal.startOfDay(for: Date(timeIntervalSince1970: ms / 1000)), to: cal.startOfDay(for: Date(timeIntervalSince1970: nowMs / 1000))).day ?? 0 + let time = AppWords.clock(ms, zone: zone) + switch days { + case ...0: return time + case 1: return CarWords.format("yesterday %@", time) + default: return AppWords.day(ms, nowMs: nowMs, zone: zone) + } + } + + /// "Limit 80 %": the charge limit the car reported. + public static func limit(_ car: Car) -> String? { + car.chargeLimitPermille.map { CarWords.format("Limit %@", AppWords.percent((Double($0) / 10).rounded())) } + } + + /// A charger's name as the box gives it: its driver, capitalised. + public static func chargerName(_ lp: Loadpoint) -> String { + let raw = lp.driverName.flatMap { $0.isEmpty ? nil : $0 } ?? lp.id + return raw.prefix(1).uppercased() + raw.dropFirst() + } + + /// "On Easee · 11 kW max": the charger the car is on, one line. + public static func charger(_ lp: Loadpoint) -> String { + guard let max = lp.maxChargeW, max > 0 else { return CarWords.format("On %@", chargerName(lp)) } + return CarWords.format("On %@ · %@ max", chargerName(lp), AppWords.power(max)) + } + + /// How the box knew which car this is, when that is worth a line: a + /// correction is shown, never silent. + public static func identified(_ c: ChargerCar, name: (String) -> String?) -> String? { + if c.corrected, !c.correctedFrom.isEmpty, let was = name(c.correctedFrom) { + return CarWords.format("FTW first took it for %@.", was) + } + return nil + } + + /// What a refused car op means for the person. + public static func refusal(_ r: CmdResult) -> String { + let reason = r.errorArgs["reason"]?.string + switch r.errorCode { + case "E_NOT_FOUND": return CarWords.text(reason == "charger" ? "FTW no longer has that charger." : "FTW no longer has this car.") + case "E_PRECONDITION": + switch reason { + case "session": return CarWords.text("That plug-in has ended.") + case "elsewhere": return CarWords.text("That car is on another charger.") + default: return CarWords.text("FTW could not take that.") + } + case "E_UNAVAILABLE" where r.errorArgs["subsystem"]?.string == "cars": + return CarWords.text("Your box does not keep cars yet. It may need an update.") + case "E_DUPLICATE": return CarWords.text("Another car already has that VIN or tag.") + case "E_CAP_EXCEEDED": return CarWords.text("FTW keeps at most 32 cars.") + default: return EVText.refusal(r) + } + } +} + +extension CarLevel { + /// The level in the words a charger's level is said in, for the + /// sentences and records written before cars: the car's own number is + /// "from the car", a number someone set is theirs, anything after energy + /// has flowed is FTW's estimate, and an assumption stays one. + public func asLevel(nowMs: Double) -> EVText.Level { + let origin: EVText.Level.Origin + switch (source, estimate) { + case (.assumed, _): origin = .assumed + case (.vehicle, false): origin = atMs.map { nowMs - $0 > EVText.carLevelMaxAgeMs } == true ? .carEarlier : .car + case (.manual, false): origin = .you + case (_, true): origin = .estimated + } + return EVText.Level(pct: pct, origin: origin, atMs: origin == .estimated ? nil : atMs, setPct: nil) + } + + /// The charger's word for the level's source, as `CarEntry` keeps it. + public var legacySource: String { + switch (source, estimate) { + case (.assumed, _): return "assumed" + case (.vehicle, false): return "vehicle" + default: return "inferred" + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift index 5c2f768a..40a7b3ba 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/EV.swift @@ -112,6 +112,12 @@ public struct Loadpoint: Equatable, Sendable, Identifiable, Codable { public var boostReservePct: Double? public var boostStopReason: String? public var schedule: Schedule? + /// The charger's driver, as the box names it: what the car view calls + /// the charger. Nil in a record from before the app kept it. + public var driverName: String? + /// The car on this charger, from a box that keeps cars. Nil while + /// nothing is plugged in and from a box without cars. + public var chargerCar: ChargerCar? /// When the level was read, on the phone's clock. A level from the car /// is as old as the car's last report; any other level is the box's @@ -148,7 +154,9 @@ public struct Loadpoint: Equatable, Sendable, Identifiable, Codable { public init(_ w: JSON) { let num: (String) -> Double? = { w[$0]?.number } id = w["id"]?.string ?? "" + driverName = w["driver_name"]?.string pluggedIn = w["plugged_in"]?.bool == true + chargerCar = pluggedIn ? ChargerCar(json: w) : nil powerW = num("current_power_w") ?? 0 let fraction = num("current_soc") if w["plugged_in"]?.bool == false { diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift index 20ef0f38..b61c98d7 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift @@ -71,13 +71,17 @@ public struct CloudIdentity: Codable, Equatable, Sendable { public var sources: [String: String] /// Anonymous loadpoint id to the loadpoint id the box session uses. public var loadpoints: [String: String] + /// Anonymous car id to the car's id, from a box that keeps cars. Nil in + /// what an older build kept. + public var cars: [String: String]? - public init(pk: Bytes, boot: Bytes, sources: [String: String] = [:], loadpoints: [String: String] = [:]) { + public init(pk: Bytes, boot: Bytes, sources: [String: String] = [:], loadpoints: [String: String] = [:], cars: [String: String]? = nil) { self.pk = pk home = CloudWire.homeID(pk: pk) self.boot = boot self.sources = sources self.loadpoints = loadpoints + self.cars = cars } /// hello_ok's `cloud`, or nil when it is absent or malformed. A home id @@ -85,7 +89,7 @@ public struct CloudIdentity: Codable, Equatable, Sendable { init?(_ c: CBOR?) { guard let c, let pk = c["pk"]?.byteString, pk.count == CloudWire.publicKeyBytes, let boot = c["boot"]?.byteString, boot.count == CloudWire.bootBytes else { return nil } - self.init(pk: pk, boot: boot, sources: Self.names(c["sources"]), loadpoints: Self.names(c["loadpoints"])) + self.init(pk: pk, boot: boot, sources: Self.names(c["sources"]), loadpoints: Self.names(c["loadpoints"]), cars: c["cars"] == nil ? nil : Self.names(c["cars"])) if let said = c["home"]?.string, said != home { return nil } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift index de1ea291..5b82788f 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -92,6 +92,8 @@ public enum Contract { ] public static let scopeModeWrite = "ftw.mode.write" + /// The owner's list of cars: `car.add`, `car.update`, `car.remove`. + public static let scopeCarsWrite = "ftw.cars.write" public static let capabilities = [ "status.core", "status.phases", "status.drivers", diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift index b5ed46f6..9c206b6b 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Messages.swift @@ -294,13 +294,20 @@ public struct CmdResult: Equatable, Sendable { public var observedValue: Double? public var errorCode: String? public var errorArgs: [String: CBOR] + /// After a car op: the car as the box holds it now, in brief (docs/cars.md, + /// Ops). Absent from a session whose frames are too small for it. + public var car: Car? + /// After `session.car.set`: the charger's car as the box holds it now. + public var charger: ChargerCar? - public init(cmdId: String, state: State, observedValue: Double? = nil, errorCode: String? = nil, errorArgs: [String: CBOR] = [:]) { + public init(cmdId: String, state: State, observedValue: Double? = nil, errorCode: String? = nil, errorArgs: [String: CBOR] = [:], car: Car? = nil, charger: ChargerCar? = nil) { self.cmdId = cmdId self.state = state self.observedValue = observedValue self.errorCode = errorCode self.errorArgs = errorArgs + self.car = car + self.charger = charger } init(_ c: CBOR) { @@ -309,6 +316,8 @@ public struct CmdResult: Equatable, Sendable { observedValue = c["observed"]?["value"]?.double errorCode = c["error"]?["code"]?.string errorArgs = c["error"]?["args"]?.textMap ?? [:] + car = c["car"].flatMap { Car(cbor: $0) } + charger = c["charger"].flatMap { ChargerCar(cbor: $0) } } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index dedd4421..55330240 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -15,6 +15,8 @@ public final class HomeModels { public let charging: ChargingWatch public let savings: SavingsModel public let loadpoints: LoadpointsModel + /// The owner's cars, from a box that keeps them apart from chargers. + public let cars: CarsModel /// Live, history, plan and mode for Now's nodes. public let nodes: NodeModels /// Today over the grid, for the grid widget. @@ -29,6 +31,7 @@ public final class HomeModels { charging = ChargingWatch(site: site) savings = SavingsModel(site: site) loadpoints = LoadpointsModel(site: site, charging: charging, cache: cache) + cars = CarsModel(site: site, loadpoints: loadpoints) nodes = NodeModels(site: site, loadpoints: loadpoints, prices: prices, cache: cache) gridToday = GridTodayShare(site: site, history: nodes.history, prices: prices) // The nodes Now drew, so the next launch draws them in their places diff --git a/appleApp/FTWKit/Sources/FTWKit/State/CarShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/CarShare.swift new file mode 100644 index 00000000..266a78e6 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/CarShare.swift @@ -0,0 +1,46 @@ +import Foundation +import FTWShared + +/// The car record the widgets and the watch read, built once from the +/// chargers and, on a box that keeps cars, the cars: each charger with the +/// level of the car on it, the owner's cars with their last levels whether +/// or not they are plugged in, and the chargers that ask which car this is. +@MainActor +enum CarShare { + static func entry(_ points: [Loadpoint], answeredAtMs: Double, level: (Loadpoint) -> Double?, cars: CarsModel?) -> CarEntry { + let keeps = cars?.keepsCars == true + let chargers = points.map { lp -> CarEntry.Car in + var c = CarEntry.Car( + loadpointID: lp.id, + pluggedIn: lp.pluggedIn, + socPct: level(lp) ?? lp.socPct, + socSource: lp.socSource, + socReadAtMs: lp.socReadAtMs(answeredAtMs: answeredAtMs), + powerW: lp.powerW, + targetSocPct: lp.targetSocPct, + holding: LoadpointsModel.holding(lp) + ) + if keeps, let cars, let car = cars.car(on: lp) { + c.carID = car.id + c.carName = car.shownName + let l = cars.level(car) + c.socPct = l?.pct + c.socSource = l?.legacySource ?? "" + c.socReadAtMs = l?.atMs + c.targetSocPct = car.goal.map { (Double($0.targetPermille) / 10).rounded() } ?? c.targetSocPct + } + return c + } + guard keeps, let cars else { return CarEntry(cars: chargers) } + let owned = cars.ordered.map { car -> CarEntry.Owned in + let l = cars.level(car) + return CarEntry.Owned(id: car.id, name: car.shownName, socPct: l?.pct, socSource: l?.legacySource ?? "", socReadAtMs: l?.atMs, + socEstimate: l?.estimate ?? false, targetSocPct: car.goal.map { (Double($0.targetPermille) / 10).rounded() }, + loadpointID: cars.charger(of: car)?.id) + } + let asking = cars.asking.map { lp in + CarEntry.Asking(loadpointID: lp.id, session: lp.chargerCar?.session ?? "", choices: cars.choices(lp).map { .init(id: $0.id, name: $0.shownName) }) + } + return CarEntry(cars: chargers, owned: owned, asking: asking) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/CarsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/CarsModel.swift new file mode 100644 index 00000000..8669e44d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/CarsModel.swift @@ -0,0 +1,621 @@ +import Foundation +import FTWShared +import Observation + +/// The owner's cars, as the box keeps them (docs/cars.md in srcfl/ftw). +/// +/// A box that lists `der.car` keeps each car apart from the charger: its +/// level, its goal and its sources. The charger names the car on it, and +/// this model puts the two together: which car is on which charger, which +/// charger asks "Which car?", and each car's level when nothing is plugged +/// in. A box without `der.car` has no list; the car view draws the charger +/// as before, and this model reads nothing. +/// +/// No timer asks the box. The list is read after each hello, when the home +/// comes back on screen and the last read is over a minute old, after a +/// car command, when a read of the chargers or a pushed plan shows a car +/// that moved (plugged, named, or its level), and FTW Cloud's car class +/// moves each car's level as the box sends it. +@Observable +@MainActor +public final class CarsModel: Activatable { + /// The cars as the box last listed them, and when and how that reached + /// this phone. + public private(set) var stamp: Stamped<[Car]>? + public var cars: [Car] { stamp?.value ?? [] } + /// Whether the box has ever answered in this session. + public private(set) var loaded = false + public private(set) var error: String? + /// The last level per car, from the tap to the box's answer. + public private(set) var levelStep: [String: EVText.Step] = [:] + /// A level tapped and not yet answered. + public private(set) var levelWanted: [String: Double] = [:] + public private(set) var goalSave: [String: LoadpointsModel.GoalSave] = [:] + /// The last "Which car?" answer per charger. + public private(set) var choice: [String: Choice] = [:] + /// The last change to the list per car; `newCarKey` while a car is + /// being added. + public private(set) var edit: [String: Edit] = [:] + + public static let newCarKey = "new" + + /// One answer to "Which car?", from the tap to the box's answer. + public struct Choice: Equatable, Sendable { + public enum Result: Equatable, Sendable { + case applied + case unconfirmed + case refused(String) + } + + /// The car tapped; empty for "None of these". + public var carID: String + public var acked = false + public var result: Result? + + public var settled: Bool { result != nil } + } + + /// One change to the list: add, rename, capacity, sources or remove. + public enum Edit: Equatable, Sendable { + case saving + case saved + case removed + case failed(String) + } + + @ObservationIgnored private unowned let site: SiteModel + @ObservationIgnored private unowned let loadpoints: LoadpointsModel + @ObservationIgnored private var ask: LiveAsk? + @ObservationIgnored private var active = 0 + @ObservationIgnored private var reading: Task? + @ObservationIgnored private var token = 0 + @ObservationIgnored private var levelSending: Set = [] + @ObservationIgnored private var clearing: [String: Cancellable] = [:] + /// Bumped each time the session starts streaming: the list is read + /// after each hello. + @ObservationIgnored private var hellos = 0 + @ObservationIgnored private var wasStreaming = false + /// Bumped when a read of the chargers or a pushed plan shows a car that + /// moved: a reason to read the list once. + @ObservationIgnored private var news = 0 + /// What the chargers last said about their cars, so the same again is + /// no news. + @ObservationIgnored private var chargerSeen: [String] = [] + @ObservationIgnored private var planSeen: [String] = [] + /// FTW Cloud's cars as last taken, so they are taken once. + @ObservationIgnored private var cloudTaken: Stamped<[Car]>? + + /// How long a read of the list stands when the home comes back on + /// screen. A car that is not plugged in changes only when its own + /// source reads it. + nonisolated static let readStandsMs: Double = 60_000 + + public init(site: SiteModel, loadpoints: LoadpointsModel) { + self.site = site + self.loadpoints = loadpoints + // A cold start paints the cars as last read, from the sealed home + // file, before the box has answered. + if let kept = site.kept?.cars { stamp = kept.cached } + site.keep { [weak self] home in + guard let self, let stamp = self.stamp else { return } + home.cars = stamp + } + site.observe { [weak self] in + self?.noticeHello() + self?.takeCloud() + } + loadpoints.cars = self + } + + // MARK: What the box offers + + /// The box keeps cars apart from chargers. Before hello this is what + /// the box said last time, from the home file. + public var keepsCars: Bool { site.session.caps.contains(Contract.capDerCar) } + + /// This phone may keep the owner's list: add, rename, remove. + public var canEdit: Bool { + site.canConfigure && site.session.scopes.contains(Contract.scopeCarsWrite) && site.heardFromBox + } + + /// This phone may set a car's goal and level and say which car is on a + /// charger. + public var canControl: Bool { site.canConfigure && site.heardFromBox } + + // MARK: Reading + + public func activate() { + active += 1 + if ask == nil { + ask = LiveAsk(site: site, order: .loadpoints, ttlMs: Self.readStandsMs, want: { [weak self] in + guard let self, self.active > 0, self.keepsCars, self.site.documentVisible, self.site.hasPassthrough else { return nil } + return "cars \(self.hellos) \(self.news)" + }, ask: { [weak self] in try await self?.load() }) + } + ask?.evaluate() + } + + public func deactivate() { + active = max(0, active - 1) + ask?.evaluate() + } + + private func noticeHello() { + let streaming = site.session.phase == .streaming + defer { wasStreaming = streaming } + guard streaming, !wasStreaming else { return } + hellos += 1 + ask?.evaluate() + } + + /// Read the list now. Two reads at once are one. + public func load() async throws { + if let reading { + try await reading.value + return + } + let task = Task { @MainActor in try await self.read() } + reading = task + defer { reading = nil } + try await task.value + } + + private func read() async throws { + token += 1 + let mine = token + do { + let wire = try await site.callBox(.get, "/api/cars") + guard mine == token else { return } + let list = (wire?["cars"]?.array ?? []).compactMap(Car.init) + take(Stamped(list, asOfMs: site.scheduler.nowMs, origin: .relay)) + if !loaded { loaded = true } + if error != nil { error = nil } + } catch { + guard mine == token else { return } + if let e = error as? BoxAPIError, cars.isEmpty { + self.error = e.help + } else { + self.error = AppWords.text("Not up to date — your box is out of reach") + } + throw error + } + } + + private func take(_ next: Stamped<[Car]>) { + guard let taken = Stamped.taking(next, over: stamp) else { return } + stamp = taken + loadpoints.shareOutside() + site.shareCarNames(ordered) + } + + // MARK: From outside the app + + /// "Which car?" from a notification's button: the session is opened by + /// the caller; this waits for the stream, reads the chargers, and taps + /// as the car view would, for the plug-in the notification named. + public func perform(choice carID: String?, loadpointID: String, session: String, waitMs: Double = LoadpointsModel.remoteStreamWaitMs) async -> RemoteOutcome { + guard await WatchShare.streaming(site, within: waitMs) else { + return RemoteOutcome(.failed, AppWords.text("Not connected to FTW right now. Try again when the connection is back.")) + } + guard keepsCars else { return RemoteOutcome(.failed, AppWords.text("Your box cannot do this yet. It may need an update.")) } + try? await loadpoints.loadChargers() + guard let lp = loadpoints.points.first(where: { $0.id == loadpointID }), lp.chargerCar?.session == session else { + return RemoteOutcome(.failed, CarWords.text("That plug-in has ended.")) + } + await choose(lp, carID: carID) + guard let c = choice[loadpointID] else { return RemoteOutcome(.failed, AppWords.text("That did not go through. Try again.")) } + let said = choiceText(c) + switch c.result { + case .applied?: return RemoteOutcome(.done, said.text) + case .unconfirmed?, nil: return RemoteOutcome(.pending, said.text) + case .refused?: return RemoteOutcome(.failed, said.text) + } + } + + /// A read of the chargers landed. A car that moved (plugged in, named, + /// asked about, or its level) is a reason to read the list once, so the + /// car view and the widgets show the car's own level. + func chargersRead(_ points: [Loadpoint]) { + guard keepsCars else { return } + let seen = points.map { lp in + "\(lp.id) \(lp.pluggedIn) \(lp.chargerCar?.carID ?? "-") \(lp.chargerCar?.identifiedBy ?? "-") \(lp.socPct ?? -1) \(lp.socSource)" + } + defer { chargerSeen = seen } + guard seen != chargerSeen, !chargerSeen.isEmpty || points.contains(where: { $0.chargerCar != nil }) else { return } + news += 1 + ask?.evaluate() + } + + /// A pushed plan names each charger's car. A car this phone has not + /// read, or a charger whose car changed, is read once. + func planTaken(_ chargers: [PlanCharger]) { + guard keepsCars else { return } + let seen = chargers.map { "\($0.id) \($0.carId ?? "-")" } + defer { planSeen = seen } + let unknown = chargers.contains { c in c.carId.map { id in !cars.contains { $0.id == id } } ?? false } + guard unknown || (seen != planSeen && !planSeen.isEmpty) else { return } + news += 1 + ask?.evaluate() + } + + /// FTW Cloud's car class: each car's level and goal as the box sent + /// them, under ids the phone maps from the table the box gave over + /// Noise. Taken when newer than the box's last answer here. + private func takeCloud() { + guard keepsCars, let cloud = site.cloudCarList, cloud != cloudTaken else { return } + cloudTaken = cloud + guard let stamp, stamp.fromCache || stamp.asOfMs < cloud.asOfMs else { return } + let byID = Dictionary(cloud.value.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) + let shift = (cloud.asOfMs - stamp.asOfMs) / 1000 + let next = stamp.value.map { car -> Car in + guard let c = byID[car.id] else { + var aged = car + aged.socAgeS = car.socAgeS.map { $0 + shift } + return aged + } + var out = car + out.socPermille = c.socPermille + out.socSource = c.socSource + out.socAgeS = c.socAgeS + out.energySinceWh = c.energySinceWh + out.socEstimate = c.socEstimate + out.chargeLimitPermille = c.chargeLimitPermille + out.goal = c.goal + out.surplusOnly = c.surplusOnly + return out + } + take(Stamped(next, asOfMs: cloud.asOfMs, origin: .cloud)) + } + + // MARK: What the screens read + + public var readAtMs: Double? { stamp?.asOfMs } + public var fromCache: Bool { stamp?.fromCache ?? false } + + public func car(_ id: String?) -> Car? { + guard let id, !id.isEmpty else { return nil } + return cars.first { $0.id == id } + } + + /// The charger a car is plugged into, as the chargers last said. + public func charger(of car: Car) -> Loadpoint? { + loadpoints.points.first { $0.pluggedIn && $0.chargerCar?.carID == car.id } + } + + /// The car on a charger, when the box knows which. + public func car(on lp: Loadpoint) -> Car? { + guard lp.pluggedIn else { return nil } + return car(lp.chargerCar?.carID) + } + + /// The cars in the order the screens show them: each on a charger + /// first, then the rest as the box lists them. + public var ordered: [Car] { + let on = cars.filter { charger(of: $0) != nil } + return on + cars.filter { charger(of: $0) == nil } + } + + /// The chargers that ask "Which car?": a car is plugged in, the box + /// waits for a tap, and it named the cars a tap may pick. + public var asking: [Loadpoint] { + loadpoints.points.filter { $0.pluggedIn && $0.chargerCar?.asking == true && !($0.chargerCar?.session.isEmpty ?? true) } + } + + /// The cars a tap may pick on a charger that asks, by name, in the + /// box's order. A car this phone has not read yet is left out until + /// the list is read. + public func choices(_ lp: Loadpoint) -> [Car] { + let ids = lp.chargerCar?.choose ?? [] + return ids.compactMap { car($0) } + } + + /// A plugged-in charger whose car the box cannot name: a visitor's, or + /// one it still asks about. + public func unnamed(_ lp: Loadpoint) -> Bool { + lp.pluggedIn && (lp.chargerCar?.carID ?? "").isEmpty + } + + /// The car's level, dated from when the box said it. A level tapped and + /// confirmed shows at once from the box's answer. + public func level(_ car: Car) -> CarLevel? { + car.level(answeredAtMs: readAtMs ?? site.nowMs) + } + + /// The chip to show as chosen: the tap on its way, else the level. + public func chosenLevel(_ car: Car) -> Double? { + levelWanted[car.id] ?? level(car)?.pct + } + + /// Out of date: from the cache, the read failed, or the stream stopped. + public var stale: Bool { + fromCache || error != nil || site.session.phase != .streaming + } + + // MARK: Which car? + + /// One tap names the car on a charger, or none of them for a visitor. + /// The tap names the plug-in, so the box refuses it once the car has + /// left. + public func choose(_ lp: Loadpoint, carID: String?, cmdId: String? = nil) async { + guard let session = lp.chargerCar?.session, !session.isEmpty else { return } + if let step = choice[lp.id], !step.settled { return } + let id = lp.id + let want = carID ?? "" + clearing["choice \(id)"]?.cancel() + choice[id] = Choice(carID: want) + let result: Choice.Result + do { + let r = try await site.core.command(Contract.opSessionCarSet, args: [("id", .text(id)), ("session", .text(session)), ("car_id", .text(want))], onAck: { [weak self] in + guard let self, self.choice[id]?.carID == want else { return } + self.choice[id]?.acked = true + }, cmdId: cmdId) + switch r.state { + case .applied: result = .applied + case .unconfirmed: result = .unconfirmed + case .superseded, .rejected, .expired: result = .refused(CarText.refusal(r)) + } + if r.state == .applied, let held = r.charger { loadpoints.takeChargerCar(id, held) } + } catch { + result = .refused(EVText.failure(error)) + } + guard choice[id]?.carID == want else { return } + choice[id]?.acked = true + choice[id]?.result = result + if result == .applied { fadeLater("choice \(id)") { $0.choice[id] = nil } } + // The box planned again for the car it now has; read what it says. + try? await loadpoints.loadChargers() + try? await load() + } + + /// What the receipt under "Which car?" says. + public func choiceText(_ c: Choice) -> (text: String, tone: EVText.Outcome.Tone) { + switch c.result { + case nil: return (CarWords.text(c.acked ? "FTW received your choice." : "Sending your choice…"), .working) + case .applied?: + if c.carID.isEmpty { return (CarWords.text("FTW now charges it as a visitor's car."), .done) } + return (CarWords.format("FTW now plans for %@.", car(c.carID)?.shownName ?? CarWords.text("Car")), .done) + case .unconfirmed?: return (AppWords.text("FTW received it but has not confirmed the result yet."), .warning) + case .refused(let why)?: return (why, .failed) + } + } + + /// The same answer in the three marks the watch shows. + public func choiceOutcome(_ c: Choice) -> EVText.Outcome { + let said = choiceText(c) + switch c.result { + case nil: return EVText.Outcome(sent: .done, received: c.acked ? .done : .active, done: c.acked ? .active : .waiting, text: said.text, tone: said.tone) + case .applied?: return EVText.Outcome(sent: .done, received: .done, done: .done, text: said.text, tone: said.tone) + case .unconfirmed?: return EVText.Outcome(sent: .done, received: .done, done: .waiting, text: said.text, tone: said.tone) + case .refused?: return EVText.Outcome(sent: .done, received: c.acked ? .done : .failed, done: .failed, text: said.text, tone: said.tone) + } + } + + public func choiceReceipt(_ c: Choice) -> ReceiptLog { + var r = ReceiptLog(accepted: c.acked, slots: [.sent, .accepted, .confirmed]) + switch c.result { + case .applied?: r.confirmed = true + case .refused(let why)?: r.refused = why + case .unconfirmed?, nil: break + } + return r + } + + // MARK: The level + + /// The level a person tapped, as the car's own (`car.soc.set`). Taps made + /// while one is on its way fold into the next send. + public func setLevel(_ car: Car, pct: Double) async { + let id = car.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, ask: .level(want), op: Contract.opCarSocSet, args: [("id", .text(id)), ("soc", .number(want / 100))]) + if levelWanted[id] == want { + try? await load() + if levelWanted[id] == want { levelWanted[id] = nil } + } + } + } + + public func receipt(level car: Car) -> ReceiptLog? { + levelStep[car.id].map { EVText.receipt($0, nil) } + } + + public func outcome(level car: Car) -> EVText.Outcome? { + levelStep[car.id].map { EVText.outcome($0, nil) } + } + + private func follow(_ id: String, ask: EVText.Step.Ask, op: String, args: [(String, CBOR)]) async { + let key = "level \(id)" + clearing[key]?.cancel() + levelStep[id] = EVText.Step(ask: ask) + let result: EVText.Step.Result + do { + let r = try await site.core.command(op, args: args, onAck: { [weak self] in + guard let self, self.levelStep[id]?.ask == ask else { return } + self.levelStep[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 : CarText.refusal(r)) + case .rejected, .expired: result = .refused(CarText.refusal(r)) + } + if let car = r.car { patch(car) } + } catch { + result = .refused(EVText.failure(error)) + } + guard levelStep[id]?.ask == ask else { return } + levelStep[id]?.acked = true + levelStep[id]?.result = result + if case .applied = result { fadeLater(key) { $0.levelStep[id] = nil } } + } + + /// A car as a `cmd.result` gave it, in brief: its level, goal and + /// surplus choice now. The rest stays as last read. + private func patch(_ brief: Car) { + guard var s = stamp, let i = s.value.firstIndex(where: { $0.id == brief.id }) else { return } + let now = site.scheduler.nowMs + let shift = (now - s.asOfMs) / 1000 + s.value = s.value.enumerated().map { j, car in + guard j == i else { + var aged = car + aged.socAgeS = car.socAgeS.map { $0 + shift } + return aged + } + var out = car + out.socPermille = brief.socPermille + out.socSource = brief.socSource + out.socAgeS = brief.socAgeS + out.energySinceWh = brief.energySinceWh + out.socEstimate = brief.socEstimate + out.chargeLimitPermille = brief.chargeLimitPermille ?? car.chargeLimitPermille + out.goal = brief.goal + out.surplusOnly = brief.surplusOnly + return out + } + s.asOfMs = now + s.origin = .relay + stamp = s + loadpoints.shareOutside() + } + + // MARK: The goal + + /// One goal per car (`car.goal.set`), used wherever it is plugged in. + /// `recurring` false is once; every day is all seven days. + public func saveGoal(_ car: Car, socPct: Double, clock: Int, recurring: Bool, days: Int) async { + guard (0..<1440).contains(clock) else { goalSave[car.id] = .failed(AppWords.text("Choose a valid ready time.")); return } + guard (10...100).contains(socPct) else { goalSave[car.id] = .failed(AppWords.text("Choose a charge target from 10 to 100 %.")); return } + guard !recurring || days & 0x7F != 0 else { goalSave[car.id] = .failed(AppWords.text("Choose at least one day.")); return } + await goalOp(car, [("target", .number(socPct / 100)), ("ready_by_min", .int(clock)), ("days", .int(Self.goalDays(recurring: recurring, days: days)))], removing: false) + } + + public func removeGoal(_ car: Car) async { + await goalOp(car, [("clear", .bool(true))], removing: true) + } + + /// A goal's days on the wire: 0 once, 127 every day, else the mask. + nonisolated static func goalDays(recurring: Bool, days: Int) -> Int { + guard recurring else { return 0 } + let m = days & 0x7F + return m == 0 ? Car.Goal.everyDay : m + } + + private func goalOp(_ car: Car, _ args: [(String, CBOR)], removing: Bool) async { + goalSave[car.id] = .saving + do { + let r = try await site.core.command(Contract.opCarGoalSet, args: [("id", .text(car.id))] + args) + switch r.state { + case .applied: + if let brief = r.car { patch(brief) } + goalSave[car.id] = removing ? .removed : .saved + case .unconfirmed: + goalSave[car.id] = .failed(AppWords.text("FTW received it but has not confirmed the result yet.")) + case .superseded, .rejected, .expired: + goalSave[car.id] = .failed(CarText.refusal(r)) + } + } catch { + goalSave[car.id] = .failed(EVText.failure(error)) + } + try? await load() + } + + /// The goal as the car view's sentences read it: the car's goal in a + /// charger's schedule, on the household clock. + public static func schedule(_ goal: Car.Goal, nowMs: Double, zone: TimeZone) -> Loadpoint.Schedule { + let at = Date(timeIntervalSince1970: nowMs / 1000) + let everyDay = goal.days & 0x7F == 0x7F + return Loadpoint.Schedule( + socPct: (Double(goal.targetPermille) / 10).rounded(), + timeOfDayMinUTC: EVText.minuteUTC(clock: goal.readyByMin, at: at, zone: zone), + clockMin: goal.readyByMin, + surplusUnlockPct: 0, + recurring: goal.days != 0, + days: everyDay ? 0 : goal.days & 0x7F, + finishAtVehicleLimit: goal.finishAtLimit + ) + } + + // MARK: The list + + /// Add a car with the id this phone mints. + public func add(name: String, capacityWh: Double) async { + let id = Car.mintID() + var args: [(String, CBOR)] = [("id", .text(id)), ("name", .text(name.trimmingCharacters(in: .whitespacesAndNewlines)))] + if capacityWh > 0 { args.append(("capacity_wh", .int(Int(capacityWh.rounded())))) } + await listOp(Self.newCarKey, Contract.opCarAdd, args, removing: false) + } + + /// Change what the box knows about a car: what is nil stays. + public func update(_ car: Car, name: String? = nil, capacityWh: Double? = nil, sources: [String]? = nil) async { + var args: [(String, CBOR)] = [("id", .text(car.id))] + if let name { args.append(("name", .text(name.trimmingCharacters(in: .whitespacesAndNewlines)))) } + if let capacityWh { args.append(("capacity_wh", .int(Int(max(0, capacityWh).rounded())))) } + if let sources { args.append(("sources", .array(sources.map { .text($0) }))) } + guard args.count > 1 else { return } + await listOp(car.id, Contract.opCarUpdate, args, removing: false) + } + + /// Remove a car. The box keeps its id as removed, and a session on it + /// becomes a visitor's. + public func remove(_ car: Car) async { + await listOp(car.id, Contract.opCarRemove, [("id", .text(car.id))], removing: true) + } + + private func listOp(_ key: String, _ op: String, _ args: [(String, CBOR)], removing: Bool) async { + clearing["edit \(key)"]?.cancel() + edit[key] = .saving + do { + let r = try await site.core.command(op, args: args) + switch r.state { + case .applied: + edit[key] = removing ? .removed : .saved + if removing, var s = stamp { + s.value.removeAll { $0.id == key } + stamp = s + } + fadeLater("edit \(key)") { $0.edit[key] = nil } + case .unconfirmed: + edit[key] = .failed(AppWords.text("FTW received it but has not confirmed the result yet.")) + case .superseded, .rejected, .expired: + edit[key] = .failed(CarText.refusal(r)) + } + } catch { + edit[key] = .failed(EVText.failure(error)) + } + try? await load() + if removing { try? await loadpoints.loadChargers() } + } + + public static func editText(_ e: Edit) -> String { + switch e { + case .saving: return CarWords.text("Saving…") + case .saved: return CarWords.text("Saved.") + case .removed: return CarWords.text("Removed.") + case .failed(let why): return why + } + } + + /// The level sources this box could bind to a car: each source the + /// car has, then each driver the box reports that no other car has. + public func sourceChoices(for car: Car) -> [String] { + let taken = Set(cars.filter { $0.id != car.id }.flatMap(\.sources)) + let others = site.session.sources.keys.filter { !taken.contains($0) && !car.sources.contains($0) }.sorted() + return car.sources + others + } + + // MARK: Shared + + private func fadeLater(_ key: String, _ clear: @escaping @MainActor (CarsModel) -> Void) { + clearing[key]?.cancel() + clearing[key] = site.scheduler.after(LoadpointsModel.doneShownMs) { [weak self] in + guard let self else { return } + clear(self) + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/HomeCache.swift b/appleApp/FTWKit/Sources/FTWKit/State/HomeCache.swift index d5bb1e73..5a44f7a0 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/HomeCache.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/HomeCache.swift @@ -47,6 +47,8 @@ public struct HomeCache: Codable, Equatable, Sendable { public var forecast: Stamped<[PlanModel.Forecast]>? public var prices: Stamped? public var chargers: Stamped? + /// The owner's cars, from a box that keeps them. + public var cars: Stamped<[Car]>? /// The nodes Now drew, in its order. public var nodes: [EnergyNode]? @@ -63,6 +65,7 @@ public struct HomeCache: Codable, Equatable, Sendable { c.forecast = forecast?.cached c.prices = prices?.cached c.chargers = chargers?.cached + c.cars = cars?.cached return c } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index baa33267..926994de 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -119,6 +119,9 @@ public final class LoadpointsModel: Activatable { /// covers a cable plugged in while nobody looked. nonisolated static let readStandsMs: Double = 60_000 + /// The owner's cars, from a box that keeps them. Set by `CarsModel`. + @ObservationIgnored public internal(set) weak var cars: CarsModel? + /// The Live Activity for Charge now. A test sets its own; otherwise the /// app's, which it installs at launch. @ObservationIgnored public var chargeNowHost: (any ChargeNowHost)? @@ -222,9 +225,19 @@ public final class LoadpointsModel: Activatable { if nextShortfall != shortfallWh { shortfallWh = nextShortfall } shortfallReadAtMs = site.scheduler.nowMs if let stamp, lps != stamp.value { self.stamp = Stamped(lps, asOfMs: stamp.asOfMs, origin: stamp.origin) } + cars?.planTaken(chargers) ask?.evaluate() } + /// The charger's car as the box's answer to a tap gave it, before the + /// next read: the car view names the car at once. + func takeChargerCar(_ id: String, _ held: ChargerCar) { + guard var s = stamp, let i = s.value.firstIndex(where: { $0.id == id }), s.value[i].pluggedIn else { return } + s.value[i].chargerCar = held + stamp = s + shareOutside() + } + /// A charger's plan as the box pushed it. static func plan(_ lp: inout Loadpoint, from car: PlanCharger) { let gone = car.outdated && !car.pending @@ -397,7 +410,8 @@ public final class LoadpointsModel: Activatable { if !loaded { loaded = true } readAtMs = now if error != nil { error = nil } - site.shareCars(points, fresh: true) + shareOutside() + cars?.chargersRead(points) readOutside() planAsk?.evaluate() } catch { @@ -499,11 +513,21 @@ public final class LoadpointsModel: Activatable { } } + /// Leave the chargers, and the cars on them, for the widgets and the + /// watch, as the box last described them. + func shareOutside() { + site.shareCars(points, fresh: !fromCache && error == nil, cars: cars) + } + /// 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. + /// level it confirmed is the newest fact. On a box that keeps cars, a + /// charger whose car it knows shows that car's own level. public func level(_ lp: Loadpoint) -> EVText.Level? { + if let cars, cars.keepsCars, let car = cars.car(on: lp) { + return cars.level(car).map { $0.asLevel(nowMs: site.nowMs) } + } 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 { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift index 183350f2..6390e234 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift @@ -123,7 +123,10 @@ extension NowFlow { } if fields[Contract.FID.evW] == nil, !points.isEmpty { fields[Contract.FID.evW] = chargeW } let fromCharger = fields[Contract.FID.evW] != wire + // On a box that keeps cars, a car with the cable out still has its + // last level. let car = points.first.flatMap { loadpoints.level($0) } + ?? loadpoints.cars.flatMap { cars in cars.keepsCars ? cars.ordered.first.flatMap { cars.level($0)?.asLevel(nowMs: site.nowMs) } : nil } return readings( fields: fields, freshness: { fid in fid == Contract.FID.evW && fromCharger ? loadpoints.freshness : site.freshness(fid: fid) }, diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index e8b7b7ff..4ba85209 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -121,6 +121,13 @@ public final class SiteModel { /// models that show them. Each change comes with a `notify`. @ObservationIgnored public private(set) var cloudPrices: Stamped? @ObservationIgnored public internal(set) var cloudCars: Stamped<[CarEntry.Car]>? + /// FTW Cloud's car class under the cars' own ids, from a box that keeps + /// cars: each car's level, goal and surplus choice, and nothing that + /// names it. + @ObservationIgnored public internal(set) var cloudCarList: Stamped<[Car]>? + /// The owner's cars by name, in the car model's order, for the buttons + /// of a `car.which`. + @ObservationIgnored var carNames: [CloudHome.Car] = [] @ObservationIgnored private var destroyed = false /// The box session's own state, as the last frame left it. @ObservationIgnored private var relay = SessionState() @@ -865,22 +872,12 @@ public final class SiteModel { return write(record, to: shared) } - /// Leave the chargers for the widgets, as the box just described them. - /// `fresh` says whether the box's own reading of the charger is current. - func shareCars(_ points: [Loadpoint], fresh: Bool) { + /// Leave the chargers for the widgets, as the box just described them, + /// with the cars on them from a box that keeps cars. `fresh` says + /// whether the box's own reading of the charger is current. + func shareCars(_ points: [Loadpoint], fresh: Bool, cars: CarsModel? = nil) { let now = scheduler.nowMs - let entry = CarEntry(cars: points.map { lp in - CarEntry.Car( - loadpointID: lp.id, - pluggedIn: lp.pluggedIn, - socPct: lp.socPct, - socSource: lp.socSource, - socReadAtMs: lp.socReadAtMs(answeredAtMs: now), - powerW: lp.powerW, - targetSocPct: lp.targetSocPct, - holding: LoadpointsModel.holding(lp) - ) - }) + let entry = CarShare.entry(points, answeredAtMs: now, level: { _ in nil }, cars: cars) shareCarEntry(entry, answering: fresh && points.allSatisfy { $0.charger?.available != false }) } @@ -1216,16 +1213,31 @@ extension SiteModel { shared.map { CloudKeys(directory: $0.directory) } } + /// The app registers a notification category for each set of the + /// owner's cars; this tells it when the cars or their names change. + public static var carButtons: (@MainActor ([CloudHome.Car]) -> Void)? + + func shareCarNames(_ cars: [Car]) { + let names = cars.map { CloudHome.Car(opaque: "", id: $0.id, name: $0.shownName) } + guard names != carNames else { return } + carNames = names + shareCloudHome() + } + /// Give the notification extension what the box said over Noise: its - /// key, its names, and rule U's anchor. Written only when it changed. + /// key, its names, and rule U's anchor; and the owner's cars under the + /// ids FTW Cloud names them by. Written only when it changed. private func shareCloudHome() { guard let siteId, let keys = cloudKeys, let identity = cloudIdentity else { return } + let opaque = Dictionary((identity.cars ?? [:]).map { ($0.value, $0.key) }, uniquingKeysWith: { a, _ in a }) + let cars = identity.cars == nil ? nil : carNames.compactMap { c in opaque[c.id].map { CloudHome.Car(opaque: $0, id: c.id, name: c.name) } } let row = CloudHome(home: identity.home, siteId: siteId, boxKey: Data(identity.pk), - loadpoints: identity.loadpoints, sources: identity.sources, offset: ruleU) + loadpoints: identity.loadpoints, sources: identity.sources, offset: ruleU, cars: cars) guard row != sharedCloudHome else { return } if let old = sharedCloudHome, old.home != row.home { keys.remove(home: old.home) } do { try keys.write(row) + if row.cars != sharedCloudHome?.cars { Self.carButtons?(row.cars ?? []) } sharedCloudHome = row } catch { log.error("cloud keys not written: \(String(describing: error), privacy: .public)") @@ -1403,6 +1415,7 @@ extension SiteModel { /// The cars as the cloud's lp has them, for the car's models and the /// widgets, dated when the box sent them. private func takeCloudCars(_ at: Double) { + takeCloudCarList(at) guard let view = cloudView, let lp = view.lp, let identity = cloudIdentity else { return } let now = scheduler.nowMs let g = view.grade(nowMs: Int64(now)) @@ -1421,7 +1434,26 @@ extension SiteModel { ) } cloudCars = Stamped(cars, asOfMs: view.sentMs(lp.header, arrivalMs: Int64(at)).map(Double.init) ?? at, origin: .cloud) - shareCarEntry(CarEntry(cars: cars), answering: lp.loadpoints.allSatisfy { g.loadpoints[$0.id] == .live }, via: .cloud) + // The owner's cars and the chargers asking stay as the box session + // last gave them; the car model moves their levels from the cloud. + let last = lastCars?.entry + shareCarEntry(CarEntry(cars: cars, owned: last?.owned, asking: last?.asking), answering: lp.loadpoints.allSatisfy { g.loadpoints[$0.id] == .live }, via: .cloud) + } + + /// The car class under the ids the box gave over Noise, dated when the + /// box sent it. A car the table does not name is left out. + private func takeCloudCarList(_ at: Double) { + guard let view = cloudView, let wire = view.cars, let names = cloudIdentity?.cars else { return } + let sent = view.sentMs(wire.header, arrivalMs: Int64(at)).map(Double.init) ?? at + let list = wire.cars.compactMap { c -> Car? in + guard let id = names[c.id] else { return nil } + return Car(id: id, name: "", socPermille: c.socPermille.map(Int.init), socSource: c.socSource, socAgeS: c.socAgeS.map(Double.init), + energySinceWh: Double(c.energySinceWh), socEstimate: c.socEstimate, chargeLimitPermille: c.chargeLimitPermille.map(Int.init), + goal: c.goal.map { Car.Goal(targetPermille: Int($0.targetPermille), readyByMin: Int($0.readyByMin), days: Int($0.days), finishAtLimit: $0.finishAtLimit) }, + surplusOnly: c.surplusOnly) + } + let next = Stamped(list, asOfMs: sent, origin: .cloud) + if next != cloudCarList { cloudCarList = next } } /// The cloud's price window in the box session's shape, for the price diff --git a/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift index 4343d967..2327ac99 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift @@ -40,15 +40,17 @@ public enum WatchShare { /// the wrist sees what the app would. Call the returned closure when the /// watch looks away. public static func looking(_ home: HomeModels) -> @MainActor () -> Void { - let (site, prices, chargers, plan, mode) = (home.site, home.prices, home.loadpoints, home.nodes.plan, home.nodes.mode) + let (site, prices, chargers, plan, mode, cars) = (home.site, home.prices, home.loadpoints, home.nodes.plan, home.nodes.mode, home.cars) site.beginWatching() prices.activate() chargers.activate() + cars.activate() plan.activate(forecast: true) mode.activate() return { prices.deactivate() chargers.deactivate() + cars.deactivate() plan.deactivate(forecast: true) mode.deactivate() site.endWatching() @@ -96,18 +98,7 @@ public enum WatchShare { static func cars(_ home: HomeModels, key: String, nowMs: Double) -> CacheRecord? { let lps = home.loadpoints guard let stamp = lps.stamp else { return nil } - let entry = CarEntry(cars: stamp.value.map { lp in - CarEntry.Car( - loadpointID: lp.id, - pluggedIn: lp.pluggedIn, - socPct: lps.level(lp)?.pct ?? lp.socPct, - socSource: lp.socSource, - socReadAtMs: lp.socReadAtMs(answeredAtMs: stamp.asOfMs), - powerW: lp.powerW, - targetSocPct: lp.targetSocPct, - holding: LoadpointsModel.holding(lp) - ) - }) + let entry = CarShare.entry(stamp.value, answeredAtMs: stamp.asOfMs, level: { lps.level($0)?.pct }, cars: home.cars) let answering = !lps.stale && stamp.value.allSatisfy { $0.charger?.available != false } return CacheRecord(siteId: key, carrier: carrier(stamp.origin, site: home.site), srcState: answering ? .live : .stale, readAtMs: stamp.asOfMs, value: entry) } @@ -216,6 +207,26 @@ public enum WatchShare { } return out.kind == .failed ? refused(out.text) : WatchReceipt(commandID: c.id, op: c.op, sent: .done, accepted: .done, confirmed: out.kind == .done ? .done : .waiting, text: out.text, tone: out.kind == .done ? .done : .warning) + case .chooseCar(let id, let session, let carID): + let cars = home.cars + guard await streaming(home.site, within: streamWaitMs) else { + return refused(AppWords.text("Not connected to FTW right now. Try again when the connection is back.")) + } + guard cars.keepsCars, home.site.canConfigure else { return refused(AppWords.text("Your box cannot do this yet. It may need an update.")) } + try? await lps.loadChargers() + guard let lp = lps.points.first(where: { $0.id == id }), lp.chargerCar?.session == session else { + return refused(CarWords.text("That plug-in has ended.")) + } + let since = Since(cars.choice[id]) + let follower = Follower(read: { + guard let chosen = since.fresh(cars.choice[id]) else { return nil } + return receipt(cars.choiceOutcome(chosen), c) + }, step: step) + defer { follower.stop() } + await cars.choose(lp, carID: carID.isEmpty ? nil : carID, cmdId: c.id) + guard let done = cars.choice[id] else { return refused(AppWords.text("That did not go through. Try again.")) } + return receipt(cars.choiceOutcome(done), c) + case .mode(let key): let mode = home.nodes.mode guard mode.mayControl else { return refused(WatchWord.cannotChangeMode.text) } @@ -261,7 +272,7 @@ public enum WatchShare { case .chargeNow: return .chargeNow case .pause: return .pause case .backToPlan: return .resume - case .mode: return nil + case .mode, .chooseCar: return nil } } diff --git a/appleApp/FTWKit/Sources/FTWShared/Cache.swift b/appleApp/FTWKit/Sources/FTWShared/Cache.swift index b6e37e9a..5b72f321 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Cache.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Cache.swift @@ -250,8 +250,13 @@ public struct CarEntry: CacheValue { /// Whether Charge now is on, in the box's account. Nil in a record /// written before the app kept it. public var holding: Bool? + /// The car on this charger, from a box that keeps cars: its id and + /// the owner's name for it. Nil while the box does not know which, + /// and from a box without cars. + public var carID: String? + public var carName: String? - public init(loadpointID: String, pluggedIn: Bool, socPct: Double?, socSource: String, socReadAtMs: Double?, powerW: Double, targetSocPct: Double?, holding: Bool? = nil) { + public init(loadpointID: String, pluggedIn: Bool, socPct: Double?, socSource: String, socReadAtMs: Double?, powerW: Double, targetSocPct: Double?, holding: Bool? = nil, carID: String? = nil, carName: String? = nil) { self.loadpointID = loadpointID self.pluggedIn = pluggedIn self.socPct = socPct @@ -260,19 +265,84 @@ public struct CarEntry: CacheValue { self.powerW = powerW self.targetSocPct = targetSocPct self.holding = holding + self.carID = carID + self.carName = carName + } + } + + /// One of the owner's cars, from a box that keeps cars: its level + /// whether or not it is plugged in. + public struct Owned: Codable, Sendable, Equatable { + public var id: String + public var name: String + /// Whole percent. Nil when the box has none. + public var socPct: Double? + /// In the same words as a charger's: vehicle, inferred or assumed. + public var socSource: String + /// When the car reported the level or someone set it. + public var socReadAtMs: Double? + /// The box counts the level as an estimate. + public var socEstimate: Bool + /// The goal, whole percent. + public var targetSocPct: Double? + /// The charger the car is plugged into; nil when it is not. + public var loadpointID: String? + + public init(id: String, name: String, socPct: Double?, socSource: String, socReadAtMs: Double?, socEstimate: Bool, targetSocPct: Double?, loadpointID: String?) { + self.id = id + self.name = name + self.socPct = socPct + self.socSource = socSource + self.socReadAtMs = socReadAtMs + self.socEstimate = socEstimate + self.targetSocPct = targetSocPct + self.loadpointID = loadpointID + } + } + + /// A charger whose car the box asks about: one tap names it. + public struct Asking: Codable, Sendable, Equatable { + public struct Choice: Codable, Sendable, Equatable { + public var id: String + public var name: String + + public init(id: String, name: String) { + self.id = id + self.name = name + } + } + + public var loadpointID: String + /// The plug-in the tap names. + public var session: String + /// At most four, in the box's order. + public var choices: [Choice] + + public init(loadpointID: String, session: String, choices: [Choice]) { + self.loadpointID = loadpointID + self.session = session + self.choices = choices } } public var cars: [Car] + /// The owner's cars, from a box that keeps them. Nil from a box without + /// cars, whose chargers are the cars. + public var owned: [Owned]? + /// The chargers that ask which car is plugged in. + public var asking: [Asking]? - public init(cars: [Car]) { + public init(cars: [Car], owned: [Owned]? = nil, asking: [Asking]? = nil) { self.cars = cars + self.owned = owned + self.asking = asking } /// The same cars at the same levels, whenever each level was read. A /// rewrite of that tells a widget nothing new. public func sameReading(_ other: CarEntry) -> Bool { func levels(_ e: CarEntry) -> [Car] { e.cars.map { var c = $0; c.socReadAtMs = nil; return c } } - return levels(self) == levels(other) + func owned(_ e: CarEntry) -> [Owned]? { e.owned?.map { var c = $0; c.socReadAtMs = nil; return c } } + return levels(self) == levels(other) && owned(self) == owned(other) && asking == other.asking } } diff --git a/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift b/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift index fdc4e621..8c5e46e8 100644 --- a/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift +++ b/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift @@ -21,6 +21,22 @@ public struct CloudHome: Codable, Sendable, Equatable { /// Rule U's anchor, so an event from a box without a synced clock can /// still be dated. Nil until the phone has learned one over Noise. public var offset: Offset? + /// The owner's cars, in the order the app lists them, with their + /// opaque ids: the buttons of a `car.which`. Nil from a box without + /// cars, and in a row an older build wrote. + public var cars: [Car]? + + public struct Car: Codable, Sendable, Equatable { + public var opaque: String + public var id: String + public var name: String + + public init(opaque: String, id: String, name: String) { + self.opaque = opaque + self.id = id + self.name = name + } + } /// Rule U's anchor: phone time minus box uptime, the smallest the phone /// learned over Noise for `boot`, and when it last learned it. @@ -36,13 +52,14 @@ public struct CloudHome: Codable, Sendable, Equatable { } } - public init(home: String, siteId: String, boxKey: Data, loadpoints: [String: String] = [:], sources: [String: String] = [:], offset: Offset? = nil) { + public init(home: String, siteId: String, boxKey: Data, loadpoints: [String: String] = [:], sources: [String: String] = [:], offset: Offset? = nil, cars: [Car]? = nil) { self.home = home self.siteId = siteId self.boxKey = boxKey self.loadpoints = loadpoints self.sources = sources self.offset = offset + self.cars = cars } /// The charger an event means. An event that names none means the only diff --git a/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift b/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift index b793d5cc..22236f09 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift @@ -73,19 +73,22 @@ public struct PushAlert: Sendable, Equatable { public let src: String? /// The opaque id of the loadpoint it concerns. public let lp: String? + /// The opaque ids of the cars a `car.which` offers, in the box's order. + public let cars: [String] public let boot: Data? public let seq: UInt64 /// When it happened by the phone's reading of the box clock, less rule /// U's drift margin when rule U dates it: never later than it arrived. public let atMs: Int64 - init(_ verdict: Verdict, home: String, kind: String = "", args: [String: String] = [:], src: String? = nil, lp: String? = nil, boot: Data? = nil, seq: UInt64 = 0, atMs: Int64 = 0) { + init(_ verdict: Verdict, home: String, kind: String = "", args: [String: String] = [:], src: String? = nil, lp: String? = nil, cars: [String] = [], boot: Data? = nil, seq: UInt64 = 0, atMs: Int64 = 0) { self.verdict = verdict self.home = home self.kind = kind self.args = args self.src = src self.lp = lp + self.cars = cars self.boot = boot self.seq = seq self.atMs = atMs @@ -159,7 +162,7 @@ public struct PushAlert: Sendable, Equatable { !age.overflow, age.partialValue <= (notice ? noticeMaxAgeMs : maxAgeMs) else { return generic } - return PushAlert(.event, home: home, kind: ev.kind, args: ev.args, src: ev.src, lp: ev.lp, boot: ev.boot.map { Data($0) }, seq: ev.seq, atMs: at) + return PushAlert(.event, home: home, kind: ev.kind, args: ev.args, src: ev.src, lp: ev.lp, cars: ev.cars, boot: ev.boot.map { Data($0) }, seq: ev.seq, atMs: at) } /// When the event was sent, as the Go reference dates it: the earlier @@ -234,6 +237,7 @@ public struct PushAlert: Sendable, Equatable { var args: [String: String] = [:] var src: String? var lp: String? + var cars: [String] = [] init?(_ b: [UInt8]) { guard let m = (try? PushCBOR.decode(b))?.textMap else { return nil } @@ -254,6 +258,7 @@ public struct PushAlert: Sendable, Equatable { args = try field("args", \.textOfText) ?? [:] src = try field("src", \.text) lp = try field("lp", \.text) + cars = try field("cars", \.textList) ?? [] } catch { return nil } @@ -311,6 +316,17 @@ extension PushCBOR { return nil } + /// A list of text, as Go's []string. + var textList: [String]? { + guard case .array(let items) = self else { return nil } + var out = [String]() + for item in items { + guard case .text(let s) = item else { return nil } + out.append(s) + } + return out + } + /// A text-to-text map. A null value reads as empty text, as in Go. var textOfText: [String: String]? { guard let m = textMap else { return nil } diff --git a/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift b/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift index 8f7a0c09..2708b85b 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift @@ -85,6 +85,70 @@ public enum PushActions { } } +// MARK: Which car? + +extension PushActions { + /// "Which car?" gets one button per car the box offers and "None of + /// these", with the names from the phone's own table. A category's + /// buttons are fixed when the app registers it, so the app registers + /// one for each set of the owner's cars (`carCategories`) and the + /// extension picks the set an event offers. + public static let carPrefix = "ftw.car." + public static let carNone = "ftw.car.none" + static let carCategoryPrefix = "ftw.car.which." + /// The most cars whose every set gets a category: 63 of them. + public static let carSetMax = 6 + /// docs/cars.md: at most four buttons, else none and the tap opens FTW. + public static let carButtonsMax = 4 + + /// A category for each set of up to four of `cars`, each in their order. + public static func carCategories(_ cars: [CloudHome.Car]) -> [(id: String, cars: [CloudHome.Car])] { + guard !cars.isEmpty, cars.count <= carSetMax else { return [] } + return (1..<(1 << cars.count)).compactMap { mask in + let set = cars.indices.filter { mask & (1 << $0) != 0 }.map { cars[$0] } + return set.count <= carButtonsMax ? (carCategoryPrefix + String(mask), set) : nil + } + } + + /// The category a verified `car.which` gets: the set of cars it offers, + /// when the phone knows each of them. Nil gives no buttons. + public static func carCategory(for alert: PushAlert, home: CloudHome?) -> String? { + guard alert.verdict == .event, alert.kind == PushKind.carWhich, let cars = home?.cars, cars.count <= carSetMax, + !alert.cars.isEmpty, alert.cars.count <= carButtonsMax else { return nil } + var mask = 0 + for opaque in alert.cars { + guard let i = cars.firstIndex(where: { $0.opaque == opaque }) else { return nil } + mask |= 1 << i + } + return carCategoryPrefix + String(mask) + } + + /// A car's button; nil is "None of these". + public static func carAction(_ carID: String?) -> String { carID.map { carPrefix + $0 } ?? carNone } + + /// The car a button names: its id, "" for None of these, nil for any + /// other button. + public static func carChoice(_ actionID: String) -> String? { + if actionID == carNone { return "" } + guard actionID.hasPrefix(carPrefix) else { return nil } + let id = String(actionID.dropFirst(carPrefix.count)) + return id.count == 32 && id.allSatisfy({ $0.isHexDigit && !$0.isUppercase }) ? id : nil + } + + /// Whether a car's button may run: a verified `car.which` that offered + /// that car. None of these is always one of its buttons. + public static func allowsCar(_ carID: String, for alert: PushAlert, home: CloudHome?) -> Bool { + guard alert.verdict == .event, alert.kind == PushKind.carWhich, carCategory(for: alert, home: home) != nil else { return false } + if carID.isEmpty { return true } + guard let car = home?.cars?.first(where: { $0.id == carID }) else { return false } + return alert.cars.contains(car.opaque) + } + + public static func noneTitle(_ language: PushText.Language) -> String { + language == .sv ? "Ingen av dessa" : "None of these" + } +} + /// Everything the extension sets on a notification, from one checked alert. public struct PushNotice: Sendable, Equatable { public var title: String @@ -105,7 +169,7 @@ public struct PushNotice: Sendable, Equatable { let words = PushText.content(alert, sourceName: home?.sourceName(opaque: alert.src), language: language, decimalSeparator: decimalSeparator) title = words.title body = words.body - category = PushActions.category(for: alert)?.rawValue ?? "" + category = PushActions.category(for: alert)?.rawValue ?? PushActions.carCategory(for: alert, home: home) ?? "" level = Self.level(alert) thread = alert.home verified = alert.verdict == .generic ? [:] : ["verdict": alert.verdict.rawValue, "home": alert.home] @@ -114,6 +178,7 @@ public struct PushNotice: Sendable, Equatable { verified["seq"] = String(alert.seq) verified["atMs"] = String(alert.atMs) if let lp = home?.loadpointID(opaque: alert.lp) { verified["loadpointID"] = lp } + if let session = alert.args["session"], alert.kind == PushKind.carWhich { verified["session"] = session } } } diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Watch.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Watch.strings index 9d175fa2..b2b28c15 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Watch.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Watch.strings @@ -80,3 +80,10 @@ "Shows up in the Smart Stack while power is at its cheapest." = "Syns i Smart Stack när elen är som billigast."; "FTW plan" = "FTW-plan"; "The next 24 hours on a dial, and what FTW plans now." = "De närmaste 24 timmarna på en tavla, och vad FTW planerar nu."; +"Which car?" = "Vilken bil?"; +"None of these" = "Ingen av dessa"; +"Is %@ the car on the charger?" = "Är det %@ som står på laddaren?"; +"from the car · %@" = "från bilen · %@"; +"set by hand · %@" = "satt för hand · %@"; +"≈ %@" = "≈ %@"; +"assumed at plug-in" = "antagen vid inkoppling"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift index b65a3c81..b56b735e 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift @@ -271,7 +271,12 @@ public enum WatchGlance { public let next: String? /// "AS OF 14:05" public let asOf: String - public var id: String { loadpointID } + /// On a box that keeps cars: the car, its name, and where its level + /// comes from and how old it is ("from the car · 1 h ago"). + public var carID: String? = nil + public var name: String? = nil + public var origin: String? = nil + public var id: String { carID ?? loadpointID } /// The page's shape, drawn redacted while nothing is known. Never /// shown as a car. @@ -281,38 +286,88 @@ public enum WatchGlance { public static func cars(_ record: CacheRecord?, plan: PlanEntry?, nowMs: Double, zone: TimeZone) -> [Car] { guard let record else { return [] } let clock = ZoneClock.of(zone) - return record.value.cars.map { c in - let assumed = c.socSource == "assumed" - let level = c.socPct.map { PlanGlance.percent($0) } - let state: String - if c.holding == true { - state = WatchWord.chargeNowOn.text - } else if c.pluggedIn, c.powerW > PlanGlance.idleW { - state = WatchWord.chargingAt.format(Words.decimal(PowerFormat.text(c.powerW))) - } else { - state = c.pluggedIn ? WatchWord.pluggedIn.text : WatchWord.notPluggedIn.text - } + func state(_ c: CarEntry.Car?) -> String { + guard let c else { return WatchWord.notPluggedIn.text } + if c.holding == true { return WatchWord.chargeNowOn.text } + if c.pluggedIn, c.powerW > PlanGlance.idleW { return WatchWord.chargingAt.format(Words.decimal(PowerFormat.text(c.powerW))) } + return c.pluggedIn ? WatchWord.pluggedIn.text : WatchWord.notPluggedIn.text + } + func next(_ loadpointID: String) -> String? { let window = plan?.charges - .filter { $0.loadpointID == c.loadpointID && $0.toMs > nowMs } + .filter { $0.loadpointID == loadpointID && $0.toMs > nowMs } .min { $0.fromMs < $1.fromMs } - let next = window.map { w in + return window.map { w in WatchWord.chargesAt.format("\(w.fromMs <= nowMs ? PlanWord.nowLower.text : clock.label(w.fromMs))–\(clock.label(w.toMs))") } + } + func cells(_ pct: Double?) -> Int? { pct.map { min(10, max(0, Int(($0 / 10).rounded(.down)))) } } + let asOf = WatchWord.asOf.format(clock.label(record.readAtMs)) + // A box that keeps cars: each of the owner's cars, plugged in or not, + // with its own level and how old it is. + if let owned = record.value.owned { + return owned.map { o in + let charger = o.loadpointID.flatMap { id in record.value.cars.first { $0.loadpointID == id } } + let level = o.socPct.map { PlanGlance.percent($0) } + let shown = level.map { o.socEstimate || o.socSource == "assumed" ? WatchWord.estimated.format($0) : $0 } + let origin: String? + switch o.socSource { + case _ where o.socPct == nil: origin = nil + case "assumed": origin = WatchWord.assumedAtPlugIn.text + default: + let age = o.socReadAtMs.map { PowerFormat.age(max(0, nowMs - $0) < 60_000 ? 0 : (max(0, nowMs - $0) / 60_000).rounded(.down) * 60_000) } + origin = age.map { (o.socSource == "vehicle" ? WatchWord.fromCar : WatchWord.setByHand).format($0) } + } + var car = Car( + loadpointID: o.loadpointID ?? "", + levelPct: o.socPct, + cells: cells(o.socPct), + levelText: shown, + state: state(charger), + holding: charger?.holding == true, + pluggedIn: charger?.pluggedIn == true, + goal: o.targetSocPct.map { WatchWord.goal.format(PlanGlance.percent($0)) }, + next: o.loadpointID.flatMap(next), + asOf: asOf + ) + car.carID = o.id + car.name = o.name + car.origin = origin + return car + } + } + return record.value.cars.map { c in + let assumed = c.socSource == "assumed" + let level = c.socPct.map { PlanGlance.percent($0) } return Car( loadpointID: c.loadpointID, levelPct: c.socPct, - cells: c.socPct.map { min(10, max(0, Int(($0 / 10).rounded(.down)))) }, + cells: cells(c.socPct), levelText: level.map { assumed ? WatchWord.levelAssumed.format($0) : $0 }, - state: state, + state: state(c), holding: c.holding == true, pluggedIn: c.pluggedIn, goal: c.targetSocPct.map { WatchWord.goal.format(PlanGlance.percent($0)) }, - next: next, - asOf: WatchWord.asOf.format(clock.label(record.readAtMs)) + next: next(c.loadpointID), + asOf: asOf ) } } + /// A charger that asks which car is on it, with the cars one tap may + /// pick. + public struct Asking: Equatable, Sendable, Identifiable { + public let loadpointID: String + public let session: String + public let choices: [CarEntry.Asking.Choice] + public var id: String { loadpointID } + } + + public static func asking(_ record: CacheRecord?) -> [Asking] { + (record?.value.asking ?? []).filter { !$0.session.isEmpty && !$0.choices.isEmpty }.map { + Asking(loadpointID: $0.loadpointID, session: $0.session, choices: $0.choices) + } + } + // MARK: Cheap power /// A stretch of cheap power, for the Smart Stack. diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift index f4a63758..de3602b8 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWire.swift @@ -370,6 +370,9 @@ public struct WatchCommand: Codable, Sendable, Equatable, Identifiable { case pause(loadpointID: String) case backToPlan(loadpointID: String) case mode(String) + /// Which car is on a charger: one tap names the plug-in's car, or + /// none of them when `carID` is empty. + case chooseCar(loadpointID: String, session: String, carID: String) } /// How long a command stays worth running. A tap that reaches the diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWords.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWords.swift index caf3f1b7..92a36afc 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWords.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchWords.swift @@ -78,6 +78,13 @@ public enum WatchWord: String, CaseIterable, Sendable { case chargesAt = "Charges %@" case levelAssumed = "%@ assumed" case noCharger = "FTW has no charger" + case whichCar = "Which car?" + case noneOfThese = "None of these" + case chooseCarAsk = "Is %@ the car on the charger?" + case fromCar = "from the car · %@" + case setByHand = "set by hand · %@" + case estimated = "≈ %@" + case assumedAtPlugIn = "assumed at plug-in" case chargeNow = "Charge now" case pause = "Pause" case backToPlan = "Back to the plan" diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CarsTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CarsTests.swift new file mode 100644 index 00000000..6a2dc25d --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarsTests.swift @@ -0,0 +1,389 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWDemo +import FTWShared + +/// The car as its own entity (docs/cars.md in srcfl/ftw): the names come +/// from the registry, the level is the car's own and says how old it is, +/// and a box without `der.car` keeps the charger as the car. +@Suite struct CarWireTests { + @Test func theCarNamesAreTheRegistrys() { + #expect(CarField.allCases.map(\.rawValue) == Contract.carFields) + #expect(ChargerCarField.allCases.map(\.rawValue) == Contract.chargerCarFields) + #expect(CarSocSource.allCases.map(\.rawValue) == Contract.carSocSources) + #expect(CarIdentifiedBy.allCases.map(\.rawValue) == Contract.carIdentifiedBy) + #expect(Contract.scopes.contains(Contract.scopeCarsWrite)) + // The box spells each one in snake_case on its HTTP routes. + #expect(CarField.socPermille.http == "soc_permille") + #expect(CarField.goalReadyByMin.http == "ready_by_min") + #expect(CarField.goalReadyByMin.wire == "readyByMin") + #expect(ChargerCarField.carIdentifiedBy.http == "car_identified_by") + } + + /// Fredrik's box on 17 October 2026, its VIN left out: the Tesla, an + /// hour old from Home Assistant, with nothing delivered since. + static let homeBox = """ + {"cars":[{"id":"264348898b35373ce3d9c9473fae9704","box_minted":true,"name":"Car 1","make":"Tesla","capacity_wh":76000, + "soc_permille":720,"soc_source":"vehicle","soc_age_s":4067,"energy_since_wh":0,"soc_estimate":false,"charge_limit_permille":800, + "goal":{"target_permille":800,"ready_by_min":420,"days":127,"finish_at_limit":false},"surplus_only":false,"sources":["tesla-ha"]}],"removed":null} + """ + + @Test func theHomeBoxsCarReadsAsTheCarsOwnLevelAnHourOld() throws { + try AppWords.$language.withValue(.en) { + let wire = try JSON(parsing: Array(Self.homeBox.utf8)) + let car = try #require(wire["cars"]?.array?.first.flatMap(Car.init)) + #expect(car.id == "264348898b35373ce3d9c9473fae9704") + #expect(car.boxMinted) + #expect(car.shownName == "Car 1") + #expect(car.capacityWh == 76_000) + #expect(car.sources == ["tesla-ha"]) + #expect(car.goal == Car.Goal(targetPermille: 800, readyByMin: 420, days: 127, finishAtLimit: false)) + let read = CarTextTests.t + let level = try #require(car.level(answeredAtMs: read)) + #expect(level.pct == 72) + #expect(level.source == .vehicle) + #expect(!level.estimate) + #expect(level.atMs == read - 4_067_000) + // Never fake live: an old reading says how old it is, and stays + // the car's own while nothing has flowed. + #expect(CarText.percent(level) == "72\u{00A0}%") + #expect(CarText.origin(level, nowMs: read, zone: CarTextTests.stockholm) == "from the car · 1 h ago") + #expect(CarText.origin(level, nowMs: read + 3 * 3_600_000, zone: CarTextTests.stockholm) == "from the car · 4 h ago") + #expect(CarText.limit(car) == "Limit 80\u{00A0}%") + #expect(level.asLevel(nowMs: read).origin == .carEarlier) + #expect(level.legacySource == "vehicle") + } + } + + @Test func anEstimateSaysWhatFlowedSinceTheReading() throws { + try AppWords.$language.withValue(.en) { + let zone = CarTextTests.stockholm + let read = CarTextTests.t + let at = read - 2 * 3_600_000 + var car = Car(id: Car.mintID(), name: "", make: "Tesla", socPermille: 220, socSource: "vehicle", socAgeS: (read - at) / 1000, energySinceWh: 6_000, socEstimate: true) + let level = try #require(car.level(answeredAtMs: read)) + #expect(CarText.percent(level) == "≈\u{00A0}22\u{00A0}%") + #expect(CarText.origin(level, nowMs: read, zone: zone) == "from the car \(AppWords.clock(at, zone: zone)) + 6.0\u{00A0}kWh") + #expect(level.asLevel(nowMs: read).origin == .estimated) + #expect(level.legacySource == "inferred") + #expect(car.shownName == "Tesla") + + car.socSource = "assumed" + car.socAgeS = nil + let assumed = try #require(car.level(answeredAtMs: read)) + #expect(assumed.estimate && assumed.atMs == nil) + #expect(CarText.origin(assumed, nowMs: read, zone: zone) == "assumed at plug-in") + #expect(assumed.legacySource == "assumed") + + car.socSource = "manual" + car.socEstimate = false + car.socAgeS = 20 * 60 + car.energySinceWh = 0 + let set = try #require(car.level(answeredAtMs: read)) + #expect(CarText.origin(set, nowMs: read, zone: zone) == "set by hand · 20 min ago") + #expect(set.asLevel(nowMs: read).origin == .you) + + car.socPermille = nil + #expect(car.level(answeredAtMs: read) == nil) + } + } + + @Test func aChargerNamesItsCarAndTheResultCarriesIt() throws { + let json = """ + {"id":"easee","driver_name":"easee","plugged_in":true,"max_charge_w":11000,"session":"s9","car_identified_by":"asking", + "choose":["6f1d2a3b4c5d6e7f8091a2b3c4d5e6f7","0a1b2c3d4e5f60718293a4b5c6d7e8f9"],"soc_source":"unknown"} + """ + let lp = Loadpoint(try JSON(parsing: Array(json.utf8))) + let c = try #require(lp.chargerCar) + #expect(c.asking && c.carID.isEmpty && c.session == "s9") + #expect(c.choose.count == 2) + #expect(lp.socPct == nil) + AppWords.$language.withValue(.en) { + #expect(CarText.charger(lp) == "On Easee · 11\u{00A0}kW max") + } + // Nothing plugged in names no car, whatever the fields say. + let out = Loadpoint(try JSON(parsing: Array(#"{"id":"easee","plugged_in":false,"car_id":"x"}"#.utf8))) + #expect(out.chargerCar == nil) + + let result = CmdResult(.map([ + ("cmdId", .text("c")), ("state", .text("applied")), + ("car", .map([("id", .text(DemoCars.zoe)), ("socPermille", .int(450)), ("socSource", .text("manual")), ("socAgeS", .int(5)), ("energySinceWh", .int(0)), ("socEstimate", .bool(false)), ("surplusOnly", .bool(false))])), + ("charger", .map([("session", .text("s9")), ("carId", .text(DemoCars.zoe)), ("carIdentifiedBy", .text("tap"))])), + ])) + #expect(result.car?.socPermille == 450) + #expect(result.charger == ChargerCar(session: "s9", carID: DemoCars.zoe, identifiedBy: "tap")) + } + + @Test func aMintedIDIsOneTheBoxTakes() { + let id = Car.mintID() + #expect(Car.isID(id)) + #expect(id != Car.mintID()) + #expect(!Car.isID("ABCDEF0123456789abcdef0123456789")) + // A goal's days: once is 0, every day all seven. + #expect(CarsModel.goalDays(recurring: false, days: 0b0011111) == 0) + #expect(CarsModel.goalDays(recurring: true, days: 0) == 127) + #expect(CarsModel.goalDays(recurring: true, days: 0b0011111) == 0b0011111) + } +} + +/// The car list against the demo box: a Core with cars, and one from before. +@MainActor +@Suite struct CarsFlowTests { + @MainActor + final class Rig { + let scheduler: ManualScheduler + var box: SimulatedBox? + var app: AppModel! + + init(_ cars: DemoCars, car: DemoCar? = nil, 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 = 300 + car?.apply(to: box.house, nowMs: s.nowMs) + cars.apply(to: box, nowMs: s.nowMs) + self.box = box + return LoopbackCarrier(box: box, scheduler: s) + }) + } + + func start() throws -> (HomeModels, SimulatedBox) { + app.startDemo() + let home = try #require(app.home) + home.loadpoints.activate() + home.cars.activate() + return (home, try #require(box)) + } + + 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() } + } + + /// Run one command to its end on the manual clock. + func settle(_ ms: Double = 3_000, _ work: @escaping @MainActor () async -> Void) async { + let task = Task { @MainActor in await work() } + await run(ms) + await task.value + } + } + + func carReads(_ box: SimulatedBox) -> Int { box.receivedRequests.filter { $0 == "GET /api/cars" }.count } + + @Test func anOldBoxKeepsTheChargerAsTheCarAndIsNeverAskedForCars() async throws { + let rig = Rig(.old) + let (home, box) = try rig.start() + await rig.run(2_000) + #expect(!home.cars.keepsCars) + #expect(carReads(box) == 0) + #expect(home.cars.cars.isEmpty) + let lp = try #require(home.loadpoints.points.first) + #expect(lp.chargerCar == nil) + // Today's view: the charger's level, as before. + #expect(home.loadpoints.level(lp)?.origin == .assumed) + #expect(home.loadpoints.level(lp)?.pct == 20) + let entry = CarShare.entry(home.loadpoints.points, answeredAtMs: rig.scheduler.nowMs, level: { _ in nil }, cars: home.cars) + #expect(entry.owned == nil && entry.asking == nil) + #expect(entry.cars.first?.carID == nil) + } + + /// The cable is out and the charger knows no car, yet the car shows + /// with its last level and how old it is. Read once after hello, and + /// not again while nothing moves. + @Test func aCarWithTheCableOutShowsItsLastLevelAndAge() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig(.away) + let (home, box) = try rig.start() + await rig.run(2_000) + #expect(home.cars.keepsCars) + #expect(carReads(box) == 1) + let car = try #require(home.cars.cars.first) + #expect(car.shownName == "Model Y") + #expect(home.cars.charger(of: car) == nil) + #expect(home.loadpoints.points.first?.pluggedIn == false) + let level = try #require(home.cars.level(car)) + #expect(level.pct == 72) + #expect(CarText.origin(level, nowMs: rig.scheduler.nowMs, zone: CarTextTests.stockholm) == "from the car · 1 h ago") + // The widgets and the watch get the car, plugged in or not. + let entry = CarShare.entry(home.loadpoints.points, answeredAtMs: rig.scheduler.nowMs, level: { _ in nil }, cars: home.cars) + #expect(entry.owned == [CarEntry.Owned(id: DemoCars.modelY, name: "Model Y", socPct: 72, socSource: "vehicle", socReadAtMs: level.atMs, socEstimate: false, targetSocPct: 80, loadpointID: nil)]) + #expect(entry.asking == []) + + // No timer asks again. + await rig.run(120_000, step: 1_000) + #expect(carReads(box) == 1) + } + } + + @Test func theCarOnTheChargerShowsItsOwnLevelThere() async throws { + let rig = Rig(.one, car: .car) + let (home, box) = try rig.start() + await rig.run(2_000) + let lp = try #require(home.loadpoints.points.first) + #expect(lp.chargerCar?.carID == DemoCars.modelY) + #expect(lp.chargerCar?.by == .only) + let car = try #require(home.cars.car(on: lp)) + #expect(home.cars.charger(of: car)?.id == "garage") + #expect(home.cars.ordered.first?.id == car.id) + #expect(home.cars.level(car)?.source == .vehicle) + #expect(home.loadpoints.level(lp)?.pct == 64) + #expect(home.loadpoints.level(lp)?.origin == .car) + // The plan the box pushes when it replans names the car. + box.replan() + await rig.run(1_000) + #expect(home.site.session.plan?.chargers?.first?.carId == DemoCars.modelY) + let entry = CarShare.entry(home.loadpoints.points, answeredAtMs: rig.scheduler.nowMs, level: { _ in nil }, cars: home.cars) + #expect(entry.cars.first?.carName == "Model Y") + #expect(entry.cars.first?.socPct == 64) + #expect(entry.owned?.first?.loadpointID == "garage") + #expect(carReads(box) >= 1) + } + + /// Two cars and no answer: the charger asks, and one tap names the car. + @Test func twoCarsWithNoAnswerAskAndOneTapNamesTheCar() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig(.asking) + let (home, box) = try rig.start() + await rig.run(2_000) + let lp = try #require(home.cars.asking.first) + #expect(home.cars.choices(lp).map(\.shownName) == ["Model Y", "Zoe"]) + #expect(home.cars.unnamed(lp)) + // An unnamed car's level is never a number. + #expect(home.loadpoints.level(lp) == nil) + let entry = CarShare.entry(home.loadpoints.points, answeredAtMs: rig.scheduler.nowMs, level: { _ in nil }, cars: home.cars) + #expect(entry.asking == [CarEntry.Asking(loadpointID: "garage", session: lp.chargerCar?.session ?? "", choices: [.init(id: DemoCars.modelY, name: "Model Y"), .init(id: DemoCars.zoe, name: "Zoe")])]) + + let tap = Task { await home.cars.choose(lp, carID: DemoCars.zoe) } + await rig.run(100) + #expect(home.cars.choice["garage"] == CarsModel.Choice(carID: DemoCars.zoe)) + #expect(home.cars.choiceText(try #require(home.cars.choice["garage"])).text == "Sending your choice…") + await rig.run(200) + #expect(home.cars.choice["garage"]?.acked == true) + await rig.run(2_000) + await tap.value + #expect(box.receivedCommands.last == Contract.opSessionCarSet) + let c = try #require(home.cars.choice["garage"]) + #expect(c.result == .applied) + #expect(home.cars.choiceText(c).text == "FTW now plans for Zoe.") + #expect(home.cars.choiceReceipt(c).confirmed) + let named = try #require(home.loadpoints.points.first) + #expect(named.chargerCar?.carID == DemoCars.zoe) + #expect(named.chargerCar?.by == .tap) + #expect(home.cars.asking.isEmpty) + #expect(home.cars.car(on: named)?.shownName == "Zoe") + #expect(home.loadpoints.level(named)?.pct == 20) + } + } + + @Test func noneOfTheseMakesAVisitorAndAnEndedPlugInIsRefused() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig(.asking) + let (home, box) = try rig.start() + await rig.run(2_000) + let lp = try #require(home.cars.asking.first) + // The car leaves before the tap lands: the box refuses it. + box.setCarPluggedIn(false) + box.setCarPluggedIn(true) + await rig.settle { await home.cars.choose(lp, carID: DemoCars.modelY) } + #expect(home.cars.choice["garage"]?.result == .refused("That plug-in has ended.")) + + await rig.run(2_000) + let again = try #require(home.cars.asking.first) + let tap = Task { await home.cars.choose(again, carID: nil) } + await rig.run(2_000) + await tap.value + let c = try #require(home.cars.choice["garage"]) + #expect(c.result == .applied) + #expect(home.cars.choiceText(c).text == "FTW now charges it as a visitor's car.") + #expect(home.loadpoints.points.first?.chargerCar?.by == CarIdentifiedBy.none) + } + } + + @Test func theGoalAndTheLevelAreTheCarsOwn() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig(.two) + let (home, box) = try rig.start() + await rig.run(2_000) + let tesla = try #require(home.cars.car(DemoCars.modelY)) + let zoe = try #require(home.cars.car(DemoCars.zoe)) + + // A goal for the car that is away: kept on the car. + await rig.settle { await home.cars.saveGoal(zoe, socPct: 60, clock: 6 * 60 + 30, recurring: true, days: 0b0011111) } + #expect(box.receivedCommands.last == Contract.opCarGoalSet) + #expect(home.cars.goalSave[zoe.id] == .saved) + #expect(home.cars.car(zoe.id)?.goal == Car.Goal(targetPermille: 600, readyByMin: 390, days: 0b0011111, finishAtLimit: false)) + #expect(box.cars?.first(where: { $0.id == zoe.id })?.goal?.readyByMin == 390) + + // A goal for the car on the charger plans that charger. + await rig.settle { await home.cars.saveGoal(tesla, socPct: 90, clock: 7 * 60, recurring: false, days: 0) } + #expect(home.cars.car(tesla.id)?.goal == Car.Goal(targetPermille: 900, readyByMin: 420, days: 0, finishAtLimit: false)) + #expect(box.house.schedule?.socFraction == 0.9) + #expect(box.house.schedule?.recurring == false) + + await rig.settle { await home.cars.removeGoal(zoe) } + #expect(home.cars.goalSave[zoe.id] == .removed) + #expect(home.cars.car(zoe.id)?.goal == nil) + + // The level, as the car's own: set by hand, now. + let level = Task { await home.cars.setLevel(zoe, pct: 55) } + await rig.run(100) + #expect(home.cars.levelStep[zoe.id] == EVText.Step(ask: .level(55))) + #expect(home.cars.chosenLevel(zoe) == 55) + await rig.run(2_000) + await level.value + #expect(box.receivedCommands.last == Contract.opCarSocSet) + #expect(home.cars.levelStep[zoe.id]?.result == .applied(observed: 0.55)) + let after = try #require(home.cars.car(zoe.id).flatMap { home.cars.level($0) }) + #expect(after.pct == 55) + #expect(after.source == .manual) + #expect(CarText.origin(after, nowMs: rig.scheduler.nowMs, zone: CarTextTests.stockholm) == "set by hand · just now") + } + } + + @Test func theOwnerKeepsTheListAddRenameCapacitySourcesRemove() async throws { + let rig = Rig(.one) + let (home, box) = try rig.start() + await rig.run(2_000) + #expect(home.cars.canEdit) + let tesla = try #require(home.cars.car(DemoCars.modelY)) + + await rig.settle { await home.cars.update(tesla, name: " Teslan ", capacityWh: 75_000) } + #expect(home.cars.edit[tesla.id] == .saved) + #expect(home.cars.car(tesla.id)?.name == "Teslan") + #expect(home.cars.car(tesla.id)?.capacityWh == 75_000) + + // The sources this box could bind: the car's, then the box's others. + #expect(home.cars.sourceChoices(for: tesla).first == "tesla-ha") + await rig.settle { await home.cars.update(tesla, sources: []) } + #expect(home.cars.car(tesla.id)?.sources == []) + + await rig.settle { await home.cars.add(name: "Zoe", capacityWh: 52_000) } + #expect(home.cars.edit[CarsModel.newCarKey] == .saved) + let zoe = try #require(home.cars.cars.first { $0.name == "Zoe" }) + #expect(Car.isID(zoe.id)) + #expect(zoe.capacityWh == 52_000) + + await rig.settle { await home.cars.remove(zoe) } + #expect(home.cars.edit[zoe.id] == .removed) + #expect(home.cars.car(zoe.id) == nil) + #expect(box.removed == [zoe.id]) + #expect(box.receivedCommands.suffix(1) == [Contract.opCarRemove]) + } + + /// An old box refuses the car ops it does not know, and the words say + /// what happens now. + @Test func aRefusedCarOpSaysWhatHappensNow() { + AppWords.$language.withValue(.en) { + let r = CmdResult(cmdId: "c", state: .rejected, errorCode: "E_PRECONDITION", errorArgs: ["reason": .text("elsewhere")]) + #expect(CarText.refusal(r) == "That car is on another charger.") + #expect(CarText.refusal(CmdResult(cmdId: "c", state: .rejected, errorCode: "E_DUPLICATE")) == "Another car already has that VIN or tag.") + #expect(CarText.refusal(CmdResult(cmdId: "c", state: .rejected, errorCode: "E_NOT_FOUND", errorArgs: ["reason": .text("car")])) == "FTW no longer has this car.") + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/CarShareTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/CarShareTests.swift new file mode 100644 index 00000000..c7cdad38 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/CarShareTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +@testable import FTWShared + +/// The car outside the app, on a box that keeps cars: the notification's +/// "Which car?" buttons, the watch's car page and the record both read. +@Suite struct CarShareTests { + static let tesla = "6f1d2a3b4c5d6e7f8091a2b3c4d5e6f7" + static let zoe = "0a1b2c3d4e5f60718293a4b5c6d7e8f9" + static let cars = [CloudHome.Car(opaque: "oT", id: tesla, name: "Model Y"), CloudHome.Car(opaque: "oZ", id: zoe, name: "Zoe")] + static let home = CloudHome(home: "h", siteId: "s1", boxKey: Data(), loadpoints: ["oL": "easee"], cars: cars) + + static func which(_ cars: [String], session: String = "s9") -> PushAlert { + PushAlert(.event, home: "h", kind: PushKind.carWhich, args: ["session": session], lp: "oL", cars: cars, seq: 1, atMs: 1) + } + + @Test func eachSetOfTheOwnersCarsHasItsButtons() { + let sets = PushActions.carCategories(Self.cars) + #expect(sets.map(\.id) == ["ftw.car.which.1", "ftw.car.which.2", "ftw.car.which.3"]) + #expect(sets[2].cars.map(\.name) == ["Model Y", "Zoe"]) + // More cars than the sets can cover: no buttons, the tap opens FTW. + let many = (0..<7).map { CloudHome.Car(opaque: "o\($0)", id: String(repeating: "\($0)", count: 32), name: "Car \($0)") } + #expect(PushActions.carCategories(many).isEmpty) + // At most four buttons a set. + let six = Array(many.prefix(6)) + #expect(PushActions.carCategories(six).allSatisfy { $0.cars.count <= 4 }) + #expect(PushActions.carCategories(six).count == 56) + } + + @Test func aVerifiedCarWhichGetsTheSetItOffers() { + let alert = Self.which(["oZ", "oT"]) + #expect(PushActions.carCategory(for: alert, home: Self.home) == "ftw.car.which.3") + #expect(PushActions.carCategory(for: Self.which(["oZ"]), home: Self.home) == "ftw.car.which.2") + // A car this phone does not know, or no table: no buttons. + #expect(PushActions.carCategory(for: Self.which(["oX"]), home: Self.home) == nil) + #expect(PushActions.carCategory(for: alert, home: CloudHome(home: "h", siteId: "s1", boxKey: Data())) == nil) + // Only a verified event gets buttons. + #expect(PushActions.carCategory(for: PushAlert(.generic, home: "h", kind: PushKind.carWhich, cars: ["oT"]), home: Self.home) == nil) + + let notice = PushNotice(alert, home: Self.home, language: .en) + #expect(notice.category == "ftw.car.which.3") + #expect(notice.verified["session"] == "s9") + #expect(notice.verified["loadpointID"] == "easee") + #expect(notice.title == "Which car is plugged in?") + } + + @Test func aButtonNamesOneOfTheCarsTheEventOffered() { + #expect(PushActions.carChoice(PushActions.carAction(Self.tesla)) == Self.tesla) + #expect(PushActions.carChoice(PushActions.carAction(nil)) == "") + #expect(PushActions.carChoice(PushActions.Action.level20.rawValue) == nil) + #expect(PushActions.carChoice("ftw.car.NOTANID") == nil) + let alert = Self.which(["oT"]) + #expect(PushActions.allowsCar(Self.tesla, for: alert, home: Self.home)) + #expect(PushActions.allowsCar("", for: alert, home: Self.home)) + // Zoe was not offered. + #expect(!PushActions.allowsCar(Self.zoe, for: alert, home: Self.home)) + // A car's button never runs for another kind. + let other = PushAlert(.event, home: "h", kind: PushKind.chargingConnected, lp: "oL", cars: ["oT"]) + #expect(!PushActions.allowsCar(Self.tesla, for: other, home: Self.home)) + } + + @Test func theWatchShowsTheCarWithTheCableOutAndHowOldItsLevelIs() { + let t: Double = 1_791_799_200_000 + let entry = CarEntry( + cars: [CarEntry.Car(loadpointID: "easee", pluggedIn: false, socPct: nil, socSource: "", socReadAtMs: nil, powerW: 0, targetSocPct: nil, holding: false)], + owned: [CarEntry.Owned(id: Self.tesla, name: "Model Y", socPct: 72, socSource: "vehicle", socReadAtMs: t - 67 * 60_000, socEstimate: false, targetSocPct: 80, loadpointID: nil)], + asking: [] + ) + let record = CacheRecord(siteId: "s1", carrier: .relay, srcState: .live, readAtMs: t, value: entry) + let car = WatchGlance.cars(record, plan: nil, nowMs: t, zone: .current)[0] + #expect(car.name == "Model Y") + #expect(car.levelText == "72\u{00A0}%") + #expect(car.origin == "from the car · 1 h ago") + #expect(car.state == "Not plugged in") + #expect(!car.pluggedIn) + #expect(car.loadpointID.isEmpty) + #expect(car.goal == "Goal 80\u{00A0}%") + #expect(WatchGlance.asking(record).isEmpty) + + var asked = entry + asked.asking = [CarEntry.Asking(loadpointID: "easee", session: "s9", choices: [.init(id: Self.tesla, name: "Model Y"), .init(id: Self.zoe, name: "Zoe")])] + let ask = WatchGlance.asking(CacheRecord(siteId: "s1", carrier: .relay, srcState: .live, readAtMs: t, value: asked)) + #expect(ask.map(\.loadpointID) == ["easee"]) + #expect(ask.first?.choices.map(\.name) == ["Model Y", "Zoe"]) + } + + /// A record an older build left, before cars, still reads, as the + /// charger's own. + @Test func aRecordFromBeforeCarsStillReads() throws { + let json = #"{"cars":[{"loadpointID":"garage","pluggedIn":true,"socPct":42,"socSource":"assumed","powerW":0}]}"# + let old = try JSONDecoder().decode(CarEntry.self, from: Data(json.utf8)) + #expect(old.owned == nil && old.asking == nil) + #expect(old.cars.first?.carID == nil) + let home = try JSONDecoder().decode(CloudHome.self, from: JSONEncoder().encode(CloudHome(home: "h", siteId: "s1", boxKey: Data()))) + #expect(home.cars == nil) + } +} diff --git a/appleApp/FTWWatch/CarPage.swift b/appleApp/FTWWatch/CarPage.swift index 5c8912dd..6096f287 100644 --- a/appleApp/FTWWatch/CarPage.swift +++ b/appleApp/FTWWatch/CarPage.swift @@ -7,7 +7,10 @@ import SwiftUI /// The box's answers come back as the receipt, which takes the place of /// what the car does until the page is left. /// -/// With more than one charger, the one with a car plugged in shows. +/// With more than one charger, the one with a car plugged in shows. On a +/// box that keeps cars the car shows plugged in or not, with its name and +/// how old its level is, and a charger that asks "Which car?" takes the +/// page until one tap answers it. struct CarPage: View { let model: WatchModel @State private var asking: Ask? @@ -24,8 +27,11 @@ struct CarPage: View { let nowMs = timeline.date.timeIntervalSince1970 * 1000 let cars = WatchGlance.cars(model.context?.cars, plan: model.context?.plan?.value, nowMs: nowMs, zone: model.zone) let car = cars.first(where: \.pluggedIn) ?? cars.first + let ask = WatchGlance.asking(model.context?.cars).first Group { - if let car { + if let ask { + OnePage { WhichCarBlock(ask: ask, model: model) } + } else if let car { OnePage { CarBlock(car: car, model: model, ask: { asking = $0 }) } } else if model.context?.cars == nil { // Nothing read yet is not "no charger": the shape of the @@ -49,6 +55,49 @@ struct CarPage: View { } } +/// "Which car?": one button per car the box offers, and "None of these". +/// One tap names the car; the box's answer comes back as the receipt. +private struct WhichCarBlock: View { + let ask: WatchGlance.Asking + let model: WatchModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(WatchWord.whichCar.text) + .font(.headline) + .foregroundStyle(Theme.ink) + if let r = model.receipt(for: ask.loadpointID) { + ReceiptLine(receipt: r) + } + let off = !model.canSend || model.sending(ask.loadpointID) + ForEach(ask.choices, id: \.id) { c in + choice(c.name, carID: c.id, off: off) + } + choice(WatchWord.noneOfThese.text, carID: "", off: off) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func choice(_ title: String, carID: String, off: Bool) -> some View { + Button { + model.send(.chooseCar(loadpointID: ask.loadpointID, session: ask.session, carID: carID)) + } label: { + Text(title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.ink) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(maxWidth: .infinity, minHeight: 30) + .background(Theme.raised, in: RoundedRectangle(cornerRadius: 9)) + .contentShape(RoundedRectangle(cornerRadius: 9)) + } + .buttonStyle(.plain) + .disabled(off) + .opacity(off ? 0.4 : 1) + .accessibilityIdentifier("watch.car.which.\(carID.isEmpty ? "none" : carID)") + } +} + private struct CarBlock: View { let car: WatchGlance.Car let model: WatchModel @@ -56,6 +105,12 @@ private struct CarBlock: View { var body: some View { VStack(alignment: .leading, spacing: 2) { + if let name = car.name { + Text(name) + .font(.footnote.weight(.semibold)) + .foregroundStyle(Theme.ink2) + .lineLimit(1) + } // The level beside its meter, or the meter under it where the // width runs out, as with "20 % antaget". ViewThatFits(in: .horizontal) { @@ -82,7 +137,7 @@ private struct CarBlock: View { } } Spacer(minLength: 0) - let off = !model.canSend || model.sending(car.loadpointID) + let off = !model.canSend || model.sending(car.loadpointID) || car.loadpointID.isEmpty HStack(spacing: 6) { command("bolt.fill", WatchWord.chargeNow.text, .chargeNow(loadpointID: car.loadpointID), WatchWord.chargeNowAsk.text, off: off, on: car.holding) .accessibilityIdentifier("watch.car.chargeNow") @@ -112,6 +167,7 @@ private struct CarBlock: View { /// window, or why the buttons are off. private var state: some View { VStack(alignment: .leading, spacing: 2) { + if let origin = car.origin { line(origin) } HStack(alignment: .firstTextBaseline, spacing: 4) { Text(car.state) .font(.footnote.weight(.semibold)) diff --git a/appleApp/FTWWatch/WatchModel.swift b/appleApp/FTWWatch/WatchModel.swift index 70b5f428..caedf85f 100644 --- a/appleApp/FTWWatch/WatchModel.swift +++ b/appleApp/FTWWatch/WatchModel.swift @@ -238,7 +238,7 @@ final class WatchModel { static func target(_ op: WatchCommand.Op) -> String { switch op { - case .chargeNow(let id), .pause(let id), .backToPlan(let id): return id + case .chargeNow(let id), .pause(let id), .backToPlan(let id), .chooseCar(let id, _, _): return id case .mode: return modeTarget } } From 6c98b2aa08fed6890903aff4d25f98b6266e2dc8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 11:48:47 +0200 Subject: [PATCH 2/3] Cars: one tap tested in the UI, receipt under the charger, Siri's unplugged car The choice's receipt stays under the charger line once the view turns into the named car. The list is read once after hello, not again on the first read of the chargers. "Is the car ready?" gives an unplugged car's last level. Level sources leave the chargers out. CarsTour taps Zoe on the demo and photographs each case. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 ++ appleApp/FTW/Car/CarFirst.swift | 8 ++ .../Sources/FTWKit/State/CarsModel.swift | 10 ++- .../FTWKit/State/LoadpointsModel.swift | 5 ++ .../FTWKit/Tests/FTWKitTests/CarsTests.swift | 8 +- appleApp/FTWUITests/CarsTour.swift | 84 +++++++++++++++++++ 6 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 appleApp/FTWUITests/CarsTour.swift diff --git a/CLAUDE.md b/CLAUDE.md index 60cf710a..e5dacbaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,11 @@ iPhone-only app on iOS 26: pairing, a lean Now, a car view and price widgets. The car view sets the level with 20/40/60/80 and ±5 through `loadpoint.soc.set`, the goal through the schedule PUT, and has Charge now, Pause and Back to the plan. M1 needs no server and no change in Core. +On a box that lists `der.car` (docs/cars.md in srcfl/ftw) the car is its +own entity: the app reads `GET /api/cars`, draws the car before the +charger, sets its level and goal with `car.soc.set` and `car.goal.set`, and +answers "Which car?" with `session.car.set`. A box without `der.car` keeps +the charger view. His decisions: - iOS 26 is the minimum. iPhone only, no macOS. diff --git a/appleApp/FTW/Car/CarFirst.swift b/appleApp/FTW/Car/CarFirst.swift index 30b9ec46..04c678ad 100644 --- a/appleApp/FTW/Car/CarFirst.swift +++ b/appleApp/FTW/Car/CarFirst.swift @@ -78,6 +78,13 @@ struct ChargerLine: View { .font(.footnote) .foregroundStyle(Theme.ink2) } + // The tap that named this car, until its receipt fades. + if let step = cars.choice[lp.id], step.carID == car.id { + let said = cars.choiceText(step) + CommandReceipt(receipt: cars.choiceReceipt(step), text: said.text, tone: said.tone) + .padding(.top, 6) + .transition(.opacity) + } } else { Text(C("Not plugged in")) .font(.subheadline.weight(.medium)) @@ -138,6 +145,7 @@ struct WhichCar: View { case nil: return nil } } + .accessibilityElement(children: .contain) .accessibilityIdentifier("car.which") } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/CarsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/CarsModel.swift index 8669e44d..e6cfcf7a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/CarsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/CarsModel.swift @@ -221,7 +221,10 @@ public final class CarsModel: Activatable { "\(lp.id) \(lp.pluggedIn) \(lp.chargerCar?.carID ?? "-") \(lp.chargerCar?.identifiedBy ?? "-") \(lp.socPct ?? -1) \(lp.socSource)" } defer { chargerSeen = seen } - guard seen != chargerSeen, !chargerSeen.isEmpty || points.contains(where: { $0.chargerCar != nil }) else { return } + // The first read after a hello is news only for a car the list does + // not have; the hello reads the list anyway. + let unknown = loaded && points.contains { lp in car(on: lp) == nil && !(lp.chargerCar?.carID ?? "").isEmpty } + guard unknown || (!chargerSeen.isEmpty && seen != chargerSeen) else { return } news += 1 ask?.evaluate() } @@ -602,9 +605,10 @@ public final class CarsModel: Activatable { } /// The level sources this box could bind to a car: each source the - /// car has, then each driver the box reports that no other car has. + /// car has, then each driver the box reports that no other car has and + /// that is not a charger. public func sourceChoices(for car: Car) -> [String] { - let taken = Set(cars.filter { $0.id != car.id }.flatMap(\.sources)) + let taken = Set(cars.filter { $0.id != car.id }.flatMap(\.sources)).union(loadpoints.points.compactMap(\.driverName)) let others = site.session.sources.keys.filter { !taken.contains($0) && !car.sources.contains($0) }.sorted() return car.sources + others } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index 926994de..e6064996 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -971,6 +971,11 @@ public final class LoadpointsModel: Activatable { } answer.receipt = outcome(hold: lp)?.text if let level { answer.details.append("\(AppWords.percent(level.pct)) · \(EVText.originShort(level, zone: zone))") } + // A car with the cable out still has its last level, on a box that + // keeps cars. + if level == nil, let cars, cars.keepsCars, let car = cars.ordered.first, let l = cars.level(car) { + answer.details.append("\(car.shownName): \(CarText.percent(l)) · \(CarText.origin(l, nowMs: site.nowMs, zone: zone))") + } if !holds, let plan = EVText.plan(lp, windows: windows[lp.id] ?? lp.planWindows, nowMs: site.nowMs, zone: zone) { answer.details.append(plan) } else if let goal = EVText.nextGoal(lp, nowMs: site.nowMs, zone: zone), gist == .pluggedIn { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CarsTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CarsTests.swift index 6a2dc25d..255ff831 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CarsTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarsTests.swift @@ -214,6 +214,10 @@ import FTWShared let entry = CarShare.entry(home.loadpoints.points, answeredAtMs: rig.scheduler.nowMs, level: { _ in nil }, cars: home.cars) #expect(entry.owned == [CarEntry.Owned(id: DemoCars.modelY, name: "Model Y", socPct: 72, socSource: "vehicle", socReadAtMs: level.atMs, socEstimate: false, targetSocPct: 80, loadpointID: nil)]) #expect(entry.asking == []) + // "Is the car ready?" says where the car stands, with its level. + let answer = home.loadpoints.answer(locked: false) + #expect(answer.gist == .unplugged) + #expect(answer.details.contains("Model Y: 72\u{00A0}% · from the car · 1 h ago")) // No timer asks again. await rig.run(120_000, step: 1_000) @@ -242,7 +246,9 @@ import FTWShared #expect(entry.cars.first?.carName == "Model Y") #expect(entry.cars.first?.socPct == 64) #expect(entry.owned?.first?.loadpointID == "garage") - #expect(carReads(box) >= 1) + // Read once after hello; the first read of the chargers asks nothing + // more, and nor does the replan's push of the same car. + #expect(carReads(box) == 1) } /// Two cars and no answer: the charger asks, and one tap names the car. diff --git a/appleApp/FTWUITests/CarsTour.swift b/appleApp/FTWUITests/CarsTour.swift new file mode 100644 index 00000000..92bd9cfa --- /dev/null +++ b/appleApp/FTWUITests/CarsTour.swift @@ -0,0 +1,84 @@ +import XCTest + +/// The car as its own entity against the demo box (docs/cars.md in +/// srcfl/ftw): one tap names the car on a charger that asks. The tour that +/// photographs each case is skipped unless asked for: +/// +/// TEST_RUNNER_FTW_SHOTS=1 xcodebuild test … \ +/// -only-testing:FTWUITests/CarsTour -resultBundlePath cars.xcresult +/// xcrun xcresulttool export attachments --path cars.xcresult --output-path cars +final class CarsTour: XCTestCase { + static let zoe = "0a1b2c3d4e5f60718293a4b5c6d7e8f9" + + private func launch(_ more: [String], lang: String = "en") -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-FTWDemo", "YES", "-AppleLanguages", "(\(lang))", "-AppleLocale", lang == "sv" ? "sv_SE" : "en_US"] + more + app.launch() + return app + } + + private func shoot(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } + + private func text(_ app: XCUIApplication, _ part: String) -> XCUIElement { + app.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS %@", part)).firstMatch + } + + /// Two cars and no answer: the car view asks, one tap names Zoe, and + /// the view becomes Zoe's with the box's receipt under the charger. + @MainActor + func testOneTapNamesTheCar() throws { + let app = launch(["-FTWTab", "car", "-FTWDemoCars", "asking"]) + let zoe = app.buttons["car.which.\(Self.zoe)"] + XCTAssertTrue(zoe.waitForExistence(timeout: 15), "the car view never asked which car") + shoot(app, "asking") + zoe.tap() + XCTAssertTrue(text(app, "FTW now plans for Zoe.").waitForExistence(timeout: 10), "the box's answer never showed") + XCTAssertFalse(app.buttons["car.which.\(Self.zoe)"].exists) + shoot(app, "asking-answered") + } + + /// Each case for review: one car with the cable out, one plugged in, + /// two with no answer, two in the car's node view, the cars in + /// Settings, and a box from before cars. + @MainActor + func testShots() throws { + let env = ProcessInfo.processInfo.environment + try XCTSkipUnless(env["FTW_SHOTS"] == "1", "Set TEST_RUNNER_FTW_SHOTS=1 to photograph the cars.") + let lang = env["FTW_LANG"] ?? "en" + let cases: [(String, [String])] = [ + ("away-car", ["-FTWTab", "car", "-FTWDemoCars", "away"]), + ("away-node", ["-FTWNode", "car", "-FTWDemoCars", "away"]), + ("plugged-car", ["-FTWTab", "car", "-FTWDemoCars", "one", "-FTWDemoCar", "car"]), + ("two-node", ["-FTWNode", "car", "-FTWDemoCars", "two", "-FTWDemoCar", "car"]), + ("asking-node", ["-FTWNode", "car", "-FTWDemoCars", "asking"]), + ("old-box-car", ["-FTWTab", "car", "-FTWDemoCars", "old"]), + ] + for (name, args) in cases { + let app = launch(args, lang: lang) + Thread.sleep(forTimeInterval: 6) + shoot(app, name) + app.terminate() + } + let app = launch(["-FTWSheet", "settings", "-FTWDemoCars", "two", "-FTWDemoCar", "car"], lang: lang) + let cars = app.buttons["settings.cars"] + // The sheet fits its height to the plan's rows; the cars sit under + // them once the box has said it keeps cars. + Thread.sleep(forTimeInterval: 4) + for _ in 0..<4 where !cars.isHittable { app.swipeUp() } + XCTAssertTrue(cars.waitForExistence(timeout: 5)) + cars.tap() + Thread.sleep(forTimeInterval: 2) + shoot(app, "settings-cars") + let first = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'settings.car.'")).firstMatch + if first.waitForExistence(timeout: 5) { + first.tap() + Thread.sleep(forTimeInterval: 2) + shoot(app, "settings-car") + } + } +} From 3a3051967ed3ede330196bba8d0493e7a99c3653 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 12:26:33 +0200 Subject: [PATCH 3/3] Cars: the review's fixes (srcfl/ftw-app#85) - The watch keeps the car's own source (vehicle, manual, assumed) in CarEntry.Owned, so an estimate from the car never reads "set by hand". - The watch keeps the chargers until the list is read, and a plugged-in charger whose car the box cannot name, so a visitor still gets Charge now. - A visitor never shows another car's level on Now or in the car's node, and the node lists its charger. - "Which car?" buttons survive a launch: the categories start from the App Group row. - A car FTW Cloud's table lacks asks for hello again, without a sub, so the stream runs on; the table keeps the box's order. - Settings send only what changed, with a battery from 1 to 300 kWh in 0.1 kWh steps. - A stale or cached list says how old it is; a goal to the car's limit stays one; a retried add keeps its id; no Int() on a NaN from the wire. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Car/CarFirst.swift | 11 ++ appleApp/FTW/Car/CarSettings.swift | 59 +++++--- appleApp/FTW/Car/GoalEditor.swift | 2 +- appleApp/FTW/Push/Notifications.swift | 4 + appleApp/FTW/UI/NodeView.swift | 20 ++- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 12 +- .../Sources/FTWDemo/SimulatedCars.swift | 16 ++- .../FTWKit/Sources/FTWKit/Format/Cars.swift | 13 +- .../Sources/FTWKit/Protocol/Session.swift | 32 +++++ .../Sources/FTWKit/State/CarShare.swift | 2 +- .../Sources/FTWKit/State/CarsModel.swift | 70 +++++++++- .../FTWKit/State/LoadpointsModel.swift | 12 ++ .../FTWKit/Sources/FTWKit/State/NowFlow.swift | 5 +- .../Sources/FTWKit/State/SiteModel.swift | 22 ++- appleApp/FTWKit/Sources/FTWShared/Cache.swift | 13 +- .../FTWKit/Sources/FTWShared/CloudKeys.swift | 14 ++ .../Sources/FTWShared/Watch/WatchGlance.swift | 97 +++++++------ .../FTWKit/Tests/FTWKitTests/CarsTests.swift | 127 +++++++++++++++++- .../Tests/FTWSharedTests/CarShareTests.swift | 62 ++++++++- appleApp/FTWWatch/CarPage.swift | 19 ++- 20 files changed, 521 insertions(+), 91 deletions(-) diff --git a/appleApp/FTW/Car/CarFirst.swift b/appleApp/FTW/Car/CarFirst.swift index 04c678ad..6b13c191 100644 --- a/appleApp/FTW/Car/CarFirst.swift +++ b/appleApp/FTW/Car/CarFirst.swift @@ -52,6 +52,14 @@ struct CarHero: View { .font(.subheadline) .foregroundStyle(Theme.ink2) } + // The list from the cache or a read that failed says so: the + // level is the box's last word, dated. + if let note = cars.staleNote { + Text(note) + .font(.footnote) + .foregroundStyle(Theme.alarm) + .fixedSize(horizontal: false, vertical: true) + } } .dynamicTypeSize(...DynamicTypeSize.accessibility2) .accessibilityElement(children: .combine) @@ -282,6 +290,9 @@ struct CarGoalBlock: View { .foregroundStyle(Theme.ink) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("car.goal") + if cars.fromCache, let at = cars.readAtMs { + Hint(AppWords.format("Last known %@.", AppWords.clock(at, zone: zone)), tone: Theme.ink2) + } if let lp, let next = EVText.nextGoal(lp, nowMs: site.minuteMs, zone: zone), lp.schedule != nil { Hint(next, tone: Theme.ink2) } else { diff --git a/appleApp/FTW/Car/CarSettings.swift b/appleApp/FTW/Car/CarSettings.swift index ac20a6bc..fe9d9105 100644 --- a/appleApp/FTW/Car/CarSettings.swift +++ b/appleApp/FTW/Car/CarSettings.swift @@ -59,7 +59,7 @@ struct CarsSettingsView: View { private func line(_ car: Car) -> String { var parts: [String] = [] if let level = cars.level(car) { - parts.append(CarText.percent(level) + " · " + CarText.origin(level, nowMs: Date().timeIntervalSince1970 * 1000, zone: .current)) + parts.append(CarText.percent(level) + " · " + CarText.origin(level, nowMs: cars.minuteMs, zone: cars.zone)) } else { parts.append(C("Level unknown")) } @@ -106,14 +106,19 @@ struct CarSettingsView: View { guard !started, let car else { return } started = true name = car.name - kWh = car.capacityWh > 0 ? (car.capacityWh / 1000).rounded() : 60 + kWh = car.capacityWh / 1000 } } private func form(_ car: Car) -> some View { let canEdit = cars.canEdit let choices = cars.sourceChoices(for: car) - let changed = name.trimmingCharacters(in: .whitespacesAndNewlines) != car.name || (kWh * 1000).rounded() != car.capacityWh.rounded() + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + // Only what changed goes to the box, so a rename never rewrites the + // battery size. + let newName = trimmed != car.name ? trimmed : nil + let newWh = (kWh * 1000).rounded() != car.capacityWh.rounded() ? (kWh * 1000).rounded() : nil + let valid = !trimmed.isEmpty && (newWh.map { CarSize.range.contains($0 / 1000) } ?? true) return Form { Section(C("Name")) { TextField(C("Name"), text: $name) @@ -124,19 +129,16 @@ struct CarSettingsView: View { } .listRowBackground(Theme.raised) Section(C("Battery size")) { - Stepper(value: $kWh, in: 10...200, step: 1) { - Text(CarWords.format("%@ kWh", String(Int(kWh)))) - .font(Theme.figure(22)) - } - .disabled(!canEdit) + CarSize(kWh: $kWh) + .disabled(!canEdit) } .listRowBackground(Theme.raised) if canEdit { Section { Button(L("Save")) { - Task { await cars.update(car, name: name, capacityWh: kWh * 1000) } + Task { await cars.update(car, name: newName, capacityWh: newWh) } } - .disabled(!changed || name.trimmingCharacters(in: .whitespaces).isEmpty || cars.edit[car.id] == .saving) + .disabled((newName == nil && newWh == nil) || !valid || cars.edit[car.id] == .saving) .accessibilityIdentifier("car.settings.save") if let edit = cars.edit[car.id] { Text(CarsModel.editText(edit)) @@ -209,10 +211,7 @@ struct AddCarView: View { } .listRowBackground(Theme.raised) Section(C("Battery size")) { - Stepper(value: $kWh, in: 10...200, step: 1) { - Text(CarWords.format("%@ kWh", String(Int(kWh)))) - .font(Theme.figure(22)) - } + CarSize(kWh: $kWh) } .listRowBackground(Theme.raised) if case .failed(let why)? = cars.edit[CarsModel.newCarKey], !saving { @@ -237,12 +236,12 @@ struct AddCarView: View { Button(C("Add")) { Task { saving = true - await cars.add(name: name, capacityWh: kWh * 1000) + await cars.add(name: name, capacityWh: (kWh * 1000).rounded()) saving = false if cars.edit[CarsModel.newCarKey] == .saved { dismiss() } } } - .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || !CarSize.range.contains(kWh)) .accessibilityIdentifier("car.add.save") } } @@ -250,3 +249,31 @@ struct AddCarView: View { } } } + +/// A battery's size in kWh with one decimal, as Core takes it: from 1 to +/// 300 kWh, so a small plug-in hybrid fits as well as a large truck. +struct CarSize: View { + @Binding var kWh: Double + static let range: ClosedRange = 1...300 + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + // The field is as wide as its figure, so the unit sits beside it. + TextField(String(60), value: $kWh, format: .number.precision(.fractionLength(0...1))) + .accessibilityLabel(C("Battery size")) + .keyboardType(.decimalPad) + .font(Theme.figure(22)) + .fixedSize() + .accessibilityIdentifier("car.settings.kwh") + Text(verbatim: "kWh") + .font(.headline) + .foregroundStyle(Theme.ink2) + Spacer(minLength: 0) + if !Self.range.contains(kWh) { + Text(verbatim: "1–300") + .font(.footnote) + .foregroundStyle(Theme.alarm) + } + } + } +} diff --git a/appleApp/FTW/Car/GoalEditor.swift b/appleApp/FTW/Car/GoalEditor.swift index 738dafa8..ca6443a8 100644 --- a/appleApp/FTW/Car/GoalEditor.swift +++ b/appleApp/FTW/Car/GoalEditor.swift @@ -67,7 +67,7 @@ struct GoalEditor: View { init(cars: CarsModel, car: Car, zone: TimeZone) { self.init(schedule: car.goal.map { CarsModel.schedule($0, nowMs: Date().timeIntervalSince1970 * 1000, zone: zone) }, zone: zone, state: { cars.goalSave[car.id] }, - save: { await cars.saveGoal(car, socPct: $0, clock: $1, recurring: $2, days: $3) }, + save: { await cars.saveGoal(car, socPct: $0, clock: $1, recurring: $2, days: $3, finishAtLimit: car.goal?.finishAtLimit == true && $0 >= 100) }, remove: { await cars.removeGoal(car) }) } diff --git a/appleApp/FTW/Push/Notifications.swift b/appleApp/FTW/Push/Notifications.swift index 77f3ee41..2c6678b0 100644 --- a/appleApp/FTW/Push/Notifications.swift +++ b/appleApp/FTW/Push/Notifications.swift @@ -41,6 +41,10 @@ enum Notifications { static func start() { let center = UNUserNotificationCenter.current() center.delegate = delegate + // The categories replace the ones registered before, so the cars' + // come from the App Group row: a launch in the background for a + // push keeps its "Which car?" buttons. + cars = CloudKeys.shared()?.lastCars() ?? [] center.setNotificationCategories(categories(.preferred())) // "Which car?" names the owner's cars on its buttons: registered // again whenever the cars or their names change. diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index c546a6b8..4852377a 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -431,8 +431,9 @@ struct SecondFact: View { case .car: // Never worked out from power: the level as the car, this // phone or the box last gave it, with where from and how old. - // On a box that keeps cars, the car's own, plugged in or not. - if home.cars.keepsCars, let car = home.cars.ordered.first, let level = home.cars.level(car) { + // On a box that keeps cars, the car's own, plugged in or not; + // none for a plugged-in car the box cannot name. + if home.cars.keepsCars, let car = home.cars.shownCar, let level = home.cars.level(car) { Text(CarText.percent(level)) .font(Theme.figure(levelSize)) .foregroundStyle(level.guessed ? Theme.ink3 : Theme.ink) @@ -442,7 +443,7 @@ struct SecondFact: View { Text(CarText.origin(level, nowMs: site.minuteMs, zone: home.loadpoints.zone)) .font(.footnote) .foregroundStyle(Theme.ink2) - } else if let lp = home.loadpoints.points.first, let level = home.loadpoints.level(lp) { + } else if let level = home.loadpoints.carLevel { let guessed = level.origin == .assumed || level.origin == .unconfirmed Text(AppWords.percent(level.pct)) .font(Theme.figure(levelSize)) @@ -937,7 +938,7 @@ private struct CarRow: View { var body: some View { let cars = home.cars let loadpoints = home.loadpoints - if cars.keepsCars, !cars.cars.isEmpty || !cars.asking.isEmpty { + if cars.keepsCars, !cars.cars.isEmpty || !cars.asking.isEmpty || !cars.visitors.isEmpty { VStack(alignment: .leading, spacing: 0) { if let asking = cars.asking.first { row(.car(asking.id), id: "node.car.which") { @@ -946,7 +947,16 @@ private struct CarRow: View { Text(CarText.chargerName(asking)).foregroundStyle(Theme.ink2) } } - ForEach(cars.ordered.prefix(cars.asking.isEmpty ? 2 : 1)) { car in + // A visitor's charger, or one whose car the box no longer + // asks about: still a way into its controls. + ForEach(cars.visitors) { lp in + row(.car(lp.id), id: "node.car.visitor") { + Text(C("A visitor's car")).foregroundStyle(Theme.ink) + Text(verbatim: "·").foregroundStyle(Theme.ink3) + Text(EVText.status(lp, zone: loadpoints.zone)).foregroundStyle(Theme.ink2) + } + } + ForEach(cars.ordered.prefix(cars.asking.isEmpty && cars.visitors.isEmpty ? 2 : 1)) { car in row(.car(car.id), id: "node.car.open") { Text(car.shownName).foregroundStyle(Theme.ink) if let level = cars.level(car) { diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index cb4023ac..e625cfef 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -102,6 +102,12 @@ public final class SimulatedBox { public var cars: [SimulatedCar]? /// The garage charger's plug-in on a box that keeps cars. public var garage: SimulatedSession? + /// A box linked to FTW Cloud: hello carries its session information, + /// with the table of opaque car ids for the cars it keeps then. + public var linkedToCloud = false + /// Every hello, and every sub, as they came. + public private(set) var hellos = 0 + public private(set) var subs = 0 /// The ids an owner removed, as `GET /api/cars` lists them. public internal(set) var removed: [String] = [] var sessions = 0 @@ -140,8 +146,11 @@ public final class SimulatedBox { let body = env.b ?? CBOR.emptyMap if ["price.get", "hist.query", "plan.get"].contains(env.t) { asks.append(env.t) } switch env.t { - case "hello": onHello(body) + case "hello": + hellos += 1 + onHello(body) case "sub": + subs += 1 let first = subscription == nil subscription = Subscription(bucket: body["bucket"]?.int ?? 512, hz: body["hz"]?.double ?? 1) if first { sendSnap() } @@ -209,6 +218,7 @@ public final class SimulatedBox { ("role", .text(Contract.roleOwner)), ("scopes", .array(Contract.scopes.map { .text($0) })), ] + if linkedToCloud, cars != nil { pairs.append(("cloud", cloudSession())) } if booting { pairs.append(("boot", .map([("phase", .text("vacuum")), ("pct", .int(40)), ("etaMs", .null)]))) } else if let sub = wantsSub, sub.entries != nil { diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedCars.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedCars.swift index 407f775b..469cc64c 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedCars.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedCars.swift @@ -100,6 +100,19 @@ extension SimulatedBox { if let car = list.first(where: { $0.id == carID }) { house.carCapacityWh = car.capacityWh } } + /// FTW Cloud's session information as hello carries it: the box's key, + /// its boot, and each car under an opaque id. The key is a fixed point, + /// which the demo never signs with. + func cloudSession() -> CBOR { + let pk: Bytes = [0x04] + Bytes(repeating: 0x2a, count: 64) + let table = (cars ?? []).map { (Self.opaque($0.id), CBOR.text($0.id)) } + return .map([("pk", .bytes(pk)), ("boot", .bytes(Bytes(repeating: 0x0b, count: 16))), ("sources", .map([(String, CBOR)]())), + ("loadpoints", .map([("o-garage", .text("garage"))])), ("cars", .map(table))]) + } + + /// The demo's stand-in for HMAC(grantKey, "car\0" ‖ id). + public static func opaque(_ id: String) -> String { "o-" + id.prefix(8) } + func newSessionID() -> String { sessions += 1 return "s\(sessions)" @@ -133,7 +146,8 @@ extension SimulatedBox { out.socEstimate = out.energySinceWh > 1_000 } out.goal = h.schedule.map { s in - Car.Goal(targetPermille: Int((s.socFraction * 1000).rounded()), readyByMin: s.clockMin, days: s.recurring ? (s.days & 0x7F == 0 ? Car.Goal.everyDay : s.days) : 0, finishAtLimit: false) + Car.Goal(targetPermille: Int((s.socFraction * 1000).rounded()), readyByMin: s.clockMin, days: s.recurring ? (s.days & 0x7F == 0 ? Car.Goal.everyDay : s.days) : 0, + finishAtLimit: c.goal?.finishAtLimit ?? false) } out.surplusOnly = h.surplusOnly out.capacityWh = h.carCapacityWh diff --git a/appleApp/FTWKit/Sources/FTWKit/Format/Cars.swift b/appleApp/FTWKit/Sources/FTWKit/Format/Cars.swift index 88fed0eb..8241f34f 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Format/Cars.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Cars.swift @@ -182,15 +182,15 @@ public struct Car: Equatable, Sendable, Identifiable, Codable { vin = r.text(.vin) ?? "" capacityWh = max(0, r.num(.capacityWh) ?? 0) maxW = max(0, r.num(.maxW) ?? 0) - phases = Int(r.num(.phases) ?? 0) + phases = whole(r.num(.phases)) ?? 0 socPermille = r.num(.socPermille).flatMap { (0...1000).contains($0) ? Int($0.rounded()) : nil } socSource = r.text(.socSource) ?? "" socAgeS = r.num(.socAgeS).flatMap { $0 >= 0 ? $0 : nil } energySinceWh = r.num(.energySinceWh) ?? 0 socEstimate = r.flag(.socEstimate) ?? false chargeLimitPermille = r.num(.chargeLimitPermille).flatMap { (1...1000).contains($0) ? Int($0.rounded()) : nil } - if let g = r.goal(), let target = g.num(.goalTargetPermille), let ready = g.num(.goalReadyByMin) { - goal = Goal(targetPermille: Int(target.rounded()), readyByMin: Int(ready), days: Int(g.num(.goalDays) ?? 0) & 0x7F, + if let g = r.goal(), let target = whole(g.num(.goalTargetPermille)), let ready = whole(g.num(.goalReadyByMin)) { + goal = Goal(targetPermille: target, readyByMin: ready, days: (whole(g.num(.goalDays)) ?? 0) & 0x7F, finishAtLimit: g.flag(.goalFinishAtLimit) ?? false) } else { goal = nil @@ -231,6 +231,13 @@ public struct Car: Equatable, Sendable, Identifiable, Codable { } } +/// A whole number from the wire, or nil: a NaN or a huge value never +/// reaches `Int`, which would trap. +private func whole(_ v: Double?) -> Int? { + guard let v, v.isFinite, abs(v) < 1e9 else { return nil } + return Int(v.rounded()) +} + /// Reads a car's fields by their registry names, from JSON or CBOR. private struct CarRead { let num: (CarField) -> Double? diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift index 3bdca5dd..e2d36d8a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift @@ -145,6 +145,9 @@ public final class Session { private var carrier: Carrier? private var subscription: Subscription private var helloSub: Subscription? + /// A hello asked for while streaming, for its session information + /// alone: its `hello_ok` leaves the stream as it is. + private var refreshingHello = false private var nextRequestID: UInt32 = 1 private var bootRetry: Cancellable? @@ -625,6 +628,20 @@ public final class Session { private func onHelloOk(_ b: HelloOk) { var s = state + if refreshingHello, s.phase == .streaming, b.mode != .booting { + // The answer to `refreshHello`: what the box says about this + // session, and nothing about the stream, which runs on. + refreshingHello = false + s.caps = Set(b.caps) + s.role = b.role ?? Contract.roleOwner + s.scopes = Set(b.scopes ?? Contract.roleScopes[b.role ?? Contract.roleOwner] ?? []) + s.modes = b.modes + s.box = b.box + s.cloud = b.cloud + state = s + return + } + refreshingHello = false s.proto = b.proto s.mode = b.mode s.caps = Set(b.caps) @@ -765,9 +782,24 @@ public final class Session { // MARK: Frames out + /// Ask the box again for what hello says about this session, such as + /// FTW Cloud's table of opaque ids, while the stream runs. A hello with + /// no `sub` is answered with `hello_ok` alone, so the subscription and + /// its frames go on as they were. + public func refreshHello() { + guard state.phase == .streaming, !refreshingHello else { return } + refreshingHello = true + send(Envelope(t: "hello", b: .map([ + ("proto", .map([("min", .int(Proto.min)), ("max", .int(Proto.max))])), + ("app", .map([("build", .text(build)), ("ua", .text(ua))])), + ("locales", .array(locales.map { .text($0) })), + ]))) + } + private func sendHello() { // Any hello supersedes a retry waiting to send one. bootRetry?.cancel() + refreshingHello = false helloSub = subscription send(Envelope(t: "hello", b: .map([ ("proto", .map([("min", .int(Proto.min)), ("max", .int(Proto.max))])), diff --git a/appleApp/FTWKit/Sources/FTWKit/State/CarShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/CarShare.swift index 266a78e6..bf72fb09 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/CarShare.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/CarShare.swift @@ -34,7 +34,7 @@ enum CarShare { guard keeps, let cars else { return CarEntry(cars: chargers) } let owned = cars.ordered.map { car -> CarEntry.Owned in let l = cars.level(car) - return CarEntry.Owned(id: car.id, name: car.shownName, socPct: l?.pct, socSource: l?.legacySource ?? "", socReadAtMs: l?.atMs, + return CarEntry.Owned(id: car.id, name: car.shownName, socPct: l?.pct, source: l?.source.rawValue ?? "", socReadAtMs: l?.atMs, socEstimate: l?.estimate ?? false, targetSocPct: car.goal.map { (Double($0.targetPermille) / 10).rounded() }, loadpointID: cars.charger(of: car)?.id) } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/CarsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/CarsModel.swift index e6cfcf7a..24152341 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/CarsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/CarsModel.swift @@ -84,6 +84,10 @@ public final class CarsModel: Activatable { @ObservationIgnored private var planSeen: [String] = [] /// FTW Cloud's cars as last taken, so they are taken once. @ObservationIgnored private var cloudTaken: Stamped<[Car]>? + /// The id of a car being added, kept until the box confirms it. + @ObservationIgnored private var addingID: String? + /// The cars the cloud table lacked when hello was last asked for again. + @ObservationIgnored private var askedTableFor: [String] = [] /// How long a read of the list stands when the home comes back on /// screen. A car that is not plugged in changes only when its own @@ -185,7 +189,21 @@ public final class CarsModel: Activatable { guard let taken = Stamped.taking(next, over: stamp) else { return } stamp = taken loadpoints.shareOutside() - site.shareCarNames(ordered) + site.shareCarNames(taken.value) + refreshCloudTable() + } + + /// FTW Cloud names a car by an opaque id the box gives in hello. A car + /// the table lacks, one added here or on the box page, gets no cloud + /// level and no "Which car?" button until the table is asked for again: + /// once per set of missing cars. + private func refreshCloudTable() { + guard keepsCars, let table = site.session.cloud?.cars else { return } + let known = Set(table.values) + let missing = cars.map(\.id).filter { !known.contains($0) } + guard !missing.isEmpty, missing != askedTableFor else { return } + askedTableFor = missing + site.refreshHello() } // MARK: From outside the app @@ -245,7 +263,12 @@ public final class CarsModel: Activatable { /// them, under ids the phone maps from the table the box gave over /// Noise. Taken when newer than the box's last answer here. private func takeCloud() { - guard keepsCars, let cloud = site.cloudCarList, cloud != cloudTaken else { return } + guard let cloud = site.cloudCarList else { return } + take(cloud: cloud) + } + + func take(cloud: Stamped<[Car]>) { + guard keepsCars, cloud != cloudTaken else { return } cloudTaken = cloud guard let stamp, stamp.fromCache || stamp.asOfMs < cloud.asOfMs else { return } let byID = Dictionary(cloud.value.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) @@ -273,6 +296,19 @@ public final class CarsModel: Activatable { // MARK: What the screens read public var readAtMs: Double? { stamp?.asOfMs } + /// The clock the screens date levels by, ticking by the minute. + public var minuteMs: Double { site.minuteMs } + /// The household zone, from the box's hello. + public var zone: TimeZone { loadpoints.zone } + + /// When the list is out of date, what to say: the box's last word and + /// when, or why there is none now. Nil while it stands. + public var staleNote: String? { + guard stale else { return nil } + if let error, !fromCache { return error } + guard let at = readAtMs else { return AppWords.text("Waiting for your box.") } + return AppWords.format("Last known %@.", AppWords.clock(at, zone: zone)) + } public var fromCache: Bool { stamp?.fromCache ?? false } public func car(_ id: String?) -> Car? { @@ -312,6 +348,20 @@ public final class CarsModel: Activatable { return ids.compactMap { car($0) } } + /// The plugged-in chargers with a car the box cannot name and no longer + /// asks about: a visitor's. + public var visitors: [Loadpoint] { + loadpoints.points.filter { unnamed($0) && $0.chargerCar?.asking != true } + } + + /// The car whose level stands beside the car's power: the one on a + /// charger, else the first. Nil while a plugged-in car is one the box + /// cannot name, so a visitor never shows another car's level. + public var shownCar: Car? { + if let plugged = loadpoints.points.first(where: \.pluggedIn) { return car(on: plugged) } + return ordered.first + } + /// A plugged-in charger whose car the box cannot name: a visitor's, or /// one it still asks about. public func unnamed(_ lp: Loadpoint) -> Bool { @@ -490,12 +540,14 @@ public final class CarsModel: Activatable { // MARK: The goal /// One goal per car (`car.goal.set`), used wherever it is plugged in. - /// `recurring` false is once; every day is all seven days. - public func saveGoal(_ car: Car, socPct: Double, clock: Int, recurring: Bool, days: Int) async { + /// `recurring` false is once; every day is all seven days. The goal is + /// replaced whole, so "to the car's own limit" goes with it each time. + public func saveGoal(_ car: Car, socPct: Double, clock: Int, recurring: Bool, days: Int, finishAtLimit: Bool = false) async { guard (0..<1440).contains(clock) else { goalSave[car.id] = .failed(AppWords.text("Choose a valid ready time.")); return } guard (10...100).contains(socPct) else { goalSave[car.id] = .failed(AppWords.text("Choose a charge target from 10 to 100 %.")); return } guard !recurring || days & 0x7F != 0 else { goalSave[car.id] = .failed(AppWords.text("Choose at least one day.")); return } - await goalOp(car, [("target", .number(socPct / 100)), ("ready_by_min", .int(clock)), ("days", .int(Self.goalDays(recurring: recurring, days: days)))], removing: false) + await goalOp(car, [("target", .number(socPct / 100)), ("ready_by_min", .int(clock)), ("days", .int(Self.goalDays(recurring: recurring, days: days))), + ("finish_at_limit", .bool(finishAtLimit))], removing: false) } public func removeGoal(_ car: Car) async { @@ -546,9 +598,12 @@ public final class CarsModel: Activatable { // MARK: The list - /// Add a car with the id this phone mints. + /// Add a car with the id this phone mints. A try after one the box did + /// not confirm keeps the id, so it updates that car rather than adding + /// a second. public func add(name: String, capacityWh: Double) async { - let id = Car.mintID() + let id = addingID ?? Car.mintID() + addingID = id var args: [(String, CBOR)] = [("id", .text(id)), ("name", .text(name.trimmingCharacters(in: .whitespacesAndNewlines)))] if capacityWh > 0 { args.append(("capacity_wh", .int(Int(capacityWh.rounded())))) } await listOp(Self.newCarKey, Contract.opCarAdd, args, removing: false) @@ -578,6 +633,7 @@ public final class CarsModel: Activatable { switch r.state { case .applied: edit[key] = removing ? .removed : .saved + if key == Self.newCarKey { addingID = nil } if removing, var s = stamp { s.value.removeAll { $0.id == key } stamp = s diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index e6064996..ee024fd3 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -513,6 +513,18 @@ public final class LoadpointsModel: Activatable { } } + /// The level beside the car's power on Now and in the car's node: the + /// car on the charger with a car plugged in, else the first charger's. + /// On a box that keeps cars, a car with every cable out still has its + /// last level; a plugged-in car the box cannot name has none, and never + /// borrows another car's. + public var carLevel: EVText.Level? { + let plugged = points.first(where: \.pluggedIn) + if let lp = plugged ?? points.first, let level = level(lp) { return level } + guard plugged == nil, let cars, cars.keepsCars, let car = cars.ordered.first else { return nil } + return cars.level(car)?.asLevel(nowMs: site.nowMs) + } + /// Leave the chargers, and the cars on them, for the widgets and the /// watch, as the box last described them. func shareOutside() { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift index 6390e234..b54570a8 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift @@ -123,10 +123,7 @@ extension NowFlow { } if fields[Contract.FID.evW] == nil, !points.isEmpty { fields[Contract.FID.evW] = chargeW } let fromCharger = fields[Contract.FID.evW] != wire - // On a box that keeps cars, a car with the cable out still has its - // last level. - let car = points.first.flatMap { loadpoints.level($0) } - ?? loadpoints.cars.flatMap { cars in cars.keepsCars ? cars.ordered.first.flatMap { cars.level($0)?.asLevel(nowMs: site.nowMs) } : nil } + let car = loadpoints.carLevel return readings( fields: fields, freshness: { fid in fid == Contract.FID.evW && fromCharger ? loadpoints.freshness : site.freshness(fid: fid) }, diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 4ba85209..9e79eae6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -1185,6 +1185,8 @@ extension SiteModel { return } adoptCloud(identity, offset: nil) + // A fresh table can name cars the last car class could not. + takeCloudCarList(scheduler.nowMs) if identity.home != storedHome { storedHome = identity.home learnedHome?(identity.home) @@ -1217,6 +1219,8 @@ extension SiteModel { /// owner's cars; this tells it when the cars or their names change. public static var carButtons: (@MainActor ([CloudHome.Car]) -> Void)? + /// The owner's cars in the box's order, so a category's set of buttons + /// means the same cars whichever car is plugged in. func shareCarNames(_ cars: [Car]) { let names = cars.map { CloudHome.Car(opaque: "", id: $0.id, name: $0.shownName) } guard names != carNames else { return } @@ -1224,13 +1228,27 @@ extension SiteModel { shareCloudHome() } + /// The cars under the opaque ids FTW Cloud names them by, in the order + /// given. Nil from a box whose hello carries no car table; a car the + /// table does not have yet is left out. + nonisolated static func cloudCars(_ names: [CloudHome.Car], table: [String: String]?) -> [CloudHome.Car]? { + guard let table else { return nil } + let opaque = Dictionary(table.map { ($0.value, $0.key) }, uniquingKeysWith: { a, _ in a }) + return names.compactMap { c in opaque[c.id].map { CloudHome.Car(opaque: $0, id: c.id, name: c.name) } } + } + + /// Ask the box again for hello's session information: FTW Cloud's table + /// of opaque car ids lacks a car added since. + func refreshHello() { + core.refreshHello() + } + /// Give the notification extension what the box said over Noise: its /// key, its names, and rule U's anchor; and the owner's cars under the /// ids FTW Cloud names them by. Written only when it changed. private func shareCloudHome() { guard let siteId, let keys = cloudKeys, let identity = cloudIdentity else { return } - let opaque = Dictionary((identity.cars ?? [:]).map { ($0.value, $0.key) }, uniquingKeysWith: { a, _ in a }) - let cars = identity.cars == nil ? nil : carNames.compactMap { c in opaque[c.id].map { CloudHome.Car(opaque: $0, id: c.id, name: c.name) } } + let cars = Self.cloudCars(carNames, table: identity.cars) let row = CloudHome(home: identity.home, siteId: siteId, boxKey: Data(identity.pk), loadpoints: identity.loadpoints, sources: identity.sources, offset: ruleU, cars: cars) guard row != sharedCloudHome else { return } diff --git a/appleApp/FTWKit/Sources/FTWShared/Cache.swift b/appleApp/FTWKit/Sources/FTWShared/Cache.swift index 5b72f321..1d816012 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Cache.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Cache.swift @@ -277,22 +277,25 @@ public struct CarEntry: CacheValue { public var name: String /// Whole percent. Nil when the box has none. public var socPct: Double? - /// In the same words as a charger's: vehicle, inferred or assumed. - public var socSource: String + /// Where the level comes from, in the car's own words (the registry's + /// `car_soc_sources`): vehicle, manual or assumed. A charger's word + /// stays on `Car`. + public var source: String /// When the car reported the level or someone set it. public var socReadAtMs: Double? - /// The box counts the level as an estimate. + /// The box counts the level as an estimate: energy has flowed since + /// the reading, or it is assumed. public var socEstimate: Bool /// The goal, whole percent. public var targetSocPct: Double? /// The charger the car is plugged into; nil when it is not. public var loadpointID: String? - public init(id: String, name: String, socPct: Double?, socSource: String, socReadAtMs: Double?, socEstimate: Bool, targetSocPct: Double?, loadpointID: String?) { + public init(id: String, name: String, socPct: Double?, source: String, socReadAtMs: Double?, socEstimate: Bool, targetSocPct: Double?, loadpointID: String?) { self.id = id self.name = name self.socPct = socPct - self.socSource = socSource + self.source = source self.socReadAtMs = socReadAtMs self.socEstimate = socEstimate self.targetSocPct = targetSocPct diff --git a/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift b/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift index 8c5e46e8..63584b60 100644 --- a/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift +++ b/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift @@ -113,6 +113,20 @@ public struct CloudKeys: Sendable { return row } + /// The owner's cars from the row written last: what the app registers + /// "Which car?" buttons for at launch, before any home opens. A launch + /// in the background for a push has no session to ask. + public func lastCars() -> [CloudHome.Car] { + let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey])) ?? [] + let rows = files.compactMap { url -> (row: CloudHome, at: Date)? in + let name = url.lastPathComponent + guard name.hasPrefix("cloud-"), let row = read(home: String(name.dropFirst("cloud-".count))), let cars = row.cars, !cars.isEmpty else { return nil } + let at = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast + return (row, at) + } + return rows.max { $0.at < $1.at }?.row.cars ?? [] + } + /// The box key for a home, for `PushAlert.read`. public func boxKey(home: String) -> Data? { read(home: home)?.boxKey diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift index b56b735e..cc5f67be 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift @@ -302,43 +302,10 @@ public enum WatchGlance { } func cells(_ pct: Double?) -> Int? { pct.map { min(10, max(0, Int(($0 / 10).rounded(.down)))) } } let asOf = WatchWord.asOf.format(clock.label(record.readAtMs)) - // A box that keeps cars: each of the owner's cars, plugged in or not, - // with its own level and how old it is. - if let owned = record.value.owned { - return owned.map { o in - let charger = o.loadpointID.flatMap { id in record.value.cars.first { $0.loadpointID == id } } - let level = o.socPct.map { PlanGlance.percent($0) } - let shown = level.map { o.socEstimate || o.socSource == "assumed" ? WatchWord.estimated.format($0) : $0 } - let origin: String? - switch o.socSource { - case _ where o.socPct == nil: origin = nil - case "assumed": origin = WatchWord.assumedAtPlugIn.text - default: - let age = o.socReadAtMs.map { PowerFormat.age(max(0, nowMs - $0) < 60_000 ? 0 : (max(0, nowMs - $0) / 60_000).rounded(.down) * 60_000) } - origin = age.map { (o.socSource == "vehicle" ? WatchWord.fromCar : WatchWord.setByHand).format($0) } - } - var car = Car( - loadpointID: o.loadpointID ?? "", - levelPct: o.socPct, - cells: cells(o.socPct), - levelText: shown, - state: state(charger), - holding: charger?.holding == true, - pluggedIn: charger?.pluggedIn == true, - goal: o.targetSocPct.map { WatchWord.goal.format(PlanGlance.percent($0)) }, - next: o.loadpointID.flatMap(next), - asOf: asOf - ) - car.carID = o.id - car.name = o.name - car.origin = origin - return car - } - } - return record.value.cars.map { c in + func charger(_ c: CarEntry.Car) -> Car { let assumed = c.socSource == "assumed" let level = c.socPct.map { PlanGlance.percent($0) } - return Car( + var car = Car( loadpointID: c.loadpointID, levelPct: c.socPct, cells: cells(c.socPct), @@ -350,7 +317,55 @@ public enum WatchGlance { next: next(c.loadpointID), asOf: asOf ) + car.name = c.carName + return car + } + // A box that keeps cars: each of the owner's cars, plugged in or not, + // with its own level and how old it is, then each plugged-in charger + // whose car the box cannot name, so Charge now still reaches a + // visitor. Until the list is read, the chargers as before. + guard let owned = record.value.owned, !owned.isEmpty else { return record.value.cars.map(charger) } + let cars = owned.map { o in + let on = o.loadpointID.flatMap { id in record.value.cars.first { $0.loadpointID == id } } + let level = o.socPct.map { PlanGlance.percent($0) } + let shown = level.map { o.socEstimate ? WatchWord.estimated.format($0) : $0 } + let origin: String? + switch o.source { + case _ where o.socPct == nil: origin = nil + case CarSource.assumed: origin = WatchWord.assumedAtPlugIn.text + case CarSource.vehicle, CarSource.manual: + let age = o.socReadAtMs.map { PowerFormat.age(max(0, nowMs - $0) < 60_000 ? 0 : (max(0, nowMs - $0) / 60_000).rounded(.down) * 60_000) } + origin = age.map { (o.source == CarSource.vehicle ? WatchWord.fromCar : WatchWord.setByHand).format($0) } + default: origin = nil + } + var car = Car( + loadpointID: o.loadpointID ?? "", + levelPct: o.socPct, + cells: cells(o.socPct), + levelText: shown, + state: state(on), + holding: on?.holding == true, + pluggedIn: on?.pluggedIn == true, + goal: o.targetSocPct.map { WatchWord.goal.format(PlanGlance.percent($0)) }, + next: o.loadpointID.flatMap(next), + asOf: asOf + ) + car.carID = o.id + car.name = o.name + car.origin = origin + return car } + let named = Set(owned.compactMap(\.loadpointID)) + return cars + record.value.cars.filter { $0.pluggedIn && $0.carID == nil && !named.contains($0.loadpointID) }.map(charger) + } + + /// A car's level sources as `CarEntry.Owned` carries them: the registry's + /// `car_soc_sources`, held to it by a test in FTWKit. + public enum CarSource { + public static let vehicle = "vehicle" + public static let manual = "manual" + public static let assumed = "assumed" + public static let all = [vehicle, manual, assumed] } /// A charger that asks which car is on it, with the cars one tap may @@ -359,12 +374,18 @@ public enum WatchGlance { public let loadpointID: String public let session: String public let choices: [CarEntry.Asking.Choice] + /// The phone read it live, or else when: "AS OF 14:05". + public let live: Bool + public let asOf: String public var id: String { loadpointID } } - public static func asking(_ record: CacheRecord?) -> [Asking] { - (record?.value.asking ?? []).filter { !$0.session.isEmpty && !$0.choices.isEmpty }.map { - Asking(loadpointID: $0.loadpointID, session: $0.session, choices: $0.choices) + public static func asking(_ record: CacheRecord?, zone: TimeZone = .current) -> [Asking] { + guard let record else { return [] } + let live = record.carrier.connected && record.srcState == .live + let asOf = WatchWord.asOf.format(ZoneClock.of(zone).label(record.readAtMs)) + return (record.value.asking ?? []).filter { !$0.session.isEmpty && !$0.choices.isEmpty }.map { + Asking(loadpointID: $0.loadpointID, session: $0.session, choices: $0.choices, live: live, asOf: asOf) } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CarsTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CarsTests.swift index 255ff831..e7aa7634 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CarsTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarsTests.swift @@ -13,6 +13,7 @@ import FTWShared #expect(ChargerCarField.allCases.map(\.rawValue) == Contract.chargerCarFields) #expect(CarSocSource.allCases.map(\.rawValue) == Contract.carSocSources) #expect(CarIdentifiedBy.allCases.map(\.rawValue) == Contract.carIdentifiedBy) + #expect(WatchGlance.CarSource.all == Contract.carSocSources) #expect(Contract.scopes.contains(Contract.scopeCarsWrite)) // The box spells each one in snake_case on its HTTP routes. #expect(CarField.socPermille.http == "soc_permille") @@ -136,13 +137,14 @@ import FTWShared var box: SimulatedBox? var app: AppModel! - init(_ cars: DemoCars, car: DemoCar? = nil, nowMs: Double = CarTextTests.t) { + init(_ cars: DemoCars, car: DemoCar? = nil, cloud: Bool = false, 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 = 300 car?.apply(to: box.house, nowMs: s.nowMs) cars.apply(to: box, nowMs: s.nowMs) + box.linkedToCloud = cloud self.box = box return LoopbackCarrier(box: box, scheduler: s) }) @@ -212,7 +214,7 @@ import FTWShared #expect(CarText.origin(level, nowMs: rig.scheduler.nowMs, zone: CarTextTests.stockholm) == "from the car · 1 h ago") // The widgets and the watch get the car, plugged in or not. let entry = CarShare.entry(home.loadpoints.points, answeredAtMs: rig.scheduler.nowMs, level: { _ in nil }, cars: home.cars) - #expect(entry.owned == [CarEntry.Owned(id: DemoCars.modelY, name: "Model Y", socPct: 72, socSource: "vehicle", socReadAtMs: level.atMs, socEstimate: false, targetSocPct: 80, loadpointID: nil)]) + #expect(entry.owned == [CarEntry.Owned(id: DemoCars.modelY, name: "Model Y", socPct: 72, source: "vehicle", socReadAtMs: level.atMs, socEstimate: false, targetSocPct: 80, loadpointID: nil)]) #expect(entry.asking == []) // "Is the car ready?" says where the car stands, with its level. let answer = home.loadpoints.answer(locked: false) @@ -382,6 +384,127 @@ import FTWShared #expect(box.receivedCommands.suffix(1) == [Contract.opCarRemove]) } + /// FTW Cloud's car class moves a car's level as the box sends it: newer + /// than the box's last answer here, and only for a car the list has. + @Test func theCloudsCarClassMovesTheLevel() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig(.two, car: .car) + let (home, _) = try rig.start() + await rig.run(2_000) + let read = try #require(home.cars.readAtMs) + let before = try #require(home.cars.car(DemoCars.zoe)) + // Ten minutes on, the Tesla reports 66 % through the cloud. + let at = read + 600_000 + var tesla = Car(id: DemoCars.modelY, name: "") + tesla.socPermille = 660 + tesla.socSource = CarSocSource.vehicle.rawValue + tesla.socAgeS = 20 + let stranger = Car(id: Car.mintID(), name: "", socPermille: 300, socSource: "vehicle", socAgeS: 1) + home.cars.take(cloud: Stamped([tesla, stranger], asOfMs: at, origin: .cloud)) + let level = try #require(home.cars.car(DemoCars.modelY).flatMap { home.cars.level($0) }) + #expect(level.pct == 66) + #expect(level.atMs == at - 20_000) + #expect(home.cars.stamp?.origin == .cloud) + // The list keeps its own cars and names; a car it has not read + // is not added from the cloud, which names nothing. + #expect(home.cars.cars.map(\.id) == [DemoCars.modelY, DemoCars.zoe]) + #expect(home.cars.car(DemoCars.modelY)?.name == "Model Y") + // The car the cloud did not mention keeps its reading's time. + let zoe = try #require(home.cars.car(DemoCars.zoe)) + #expect(home.cars.level(zoe)?.atMs == before.level(answeredAtMs: read)?.atMs) + // An older copy changes nothing. + var old = tesla + old.socPermille = 500 + home.cars.take(cloud: Stamped([old], asOfMs: read - 1_000, origin: .cloud)) + #expect(home.cars.car(DemoCars.modelY).flatMap { home.cars.level($0) }?.pct == 66) + } + } + + /// Never fake live: a plugged-in car the box cannot name has no level on + /// Now or in the car's node, and never borrows another car's; its + /// charger still has a way in. + @Test func aVisitorNeverShowsAnotherCarsLevel() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig(.asking) + let (home, _) = try rig.start() + await rig.run(2_000) + #expect(home.loadpoints.carLevel == nil) + #expect(home.cars.shownCar == nil) + #expect(NowFlow.readings(site: home.site, loadpoints: home.loadpoints).first { $0.node == .car }?.levelPct == nil) + let lp = try #require(home.cars.asking.first) + await rig.settle { await home.cars.choose(lp, carID: nil) } + #expect(home.cars.visitors.map(\.id) == ["garage"]) + #expect(home.loadpoints.carLevel == nil) + #expect(home.cars.shownCar == nil) + + // With every cable out, the first car's last level shows. + let away = Rig(.away) + let (other, _) = try away.start() + await away.run(2_000) + #expect(other.loadpoints.carLevel?.pct == 72) + #expect(other.cars.shownCar?.id == DemoCars.modelY) + #expect(NowFlow.readings(site: other.site, loadpoints: other.loadpoints).first { $0.node == .car }?.levelPct == 72) + } + } + + /// The notification's table keeps the box's order, whichever car is + /// plugged in, so a category's buttons always mean the same cars. + @Test func theNotificationTableKeepsTheBoxsOrder() async throws { + let rig = Rig(.asking) + let (home, _) = try rig.start() + await rig.run(2_000) + let lp = try #require(home.cars.asking.first) + await rig.settle { await home.cars.choose(lp, carID: DemoCars.zoe) } + #expect(home.cars.ordered.map(\.id) == [DemoCars.zoe, DemoCars.modelY]) + #expect(home.site.carNames.map(\.id) == [DemoCars.modelY, DemoCars.zoe]) + let table = [SimulatedBox.opaque(DemoCars.zoe): DemoCars.zoe, SimulatedBox.opaque(DemoCars.modelY): DemoCars.modelY] + let cars = SiteModel.cloudCars(home.site.carNames, table: table) + #expect(cars?.map(\.id) == [DemoCars.modelY, DemoCars.zoe]) + #expect(cars?.map(\.opaque) == [SimulatedBox.opaque(DemoCars.modelY), SimulatedBox.opaque(DemoCars.zoe)]) + #expect(SiteModel.cloudCars(home.site.carNames, table: nil) == nil) + // A car the table lacks is left out until it is asked for again. + #expect(SiteModel.cloudCars(home.site.carNames, table: [SimulatedBox.opaque(DemoCars.zoe): DemoCars.zoe])?.map(\.id) == [DemoCars.zoe]) + } + + /// A car added after hello is missing from FTW Cloud's table: the app + /// asks for hello again, without a sub, and the stream runs on. + @Test func aNewCarAsksForTheCloudTableAgainOnce() async throws { + let rig = Rig(.one, cloud: true) + let (home, box) = try rig.start() + await rig.run(2_000) + #expect(home.site.session.cloud?.cars?.values.sorted() == [DemoCars.modelY]) + #expect(box.hellos == 1) + let subs = box.subs + await rig.settle { await home.cars.add(name: "Zoe", capacityWh: 52_000) } + await rig.run(1_000) + let zoe = try #require(home.cars.cars.first { $0.name == "Zoe" }) + #expect(box.hellos == 2) + #expect(box.subs == subs) + #expect(home.site.session.phase == .streaming) + #expect(home.site.session.cloud?.cars?.values.contains(zoe.id) == true) + // The table now has every car: nothing more to ask. + await rig.settle { try? await home.cars.load() } + #expect(box.hellos == 2) + } + + @Test func aGoalToTheCarsLimitStaysOneAndARetriedAddKeepsItsID() async throws { + let rig = Rig(.one) + let (home, box) = try rig.start() + await rig.run(2_000) + let tesla = try #require(home.cars.car(DemoCars.modelY)) + await rig.settle { await home.cars.saveGoal(tesla, socPct: 100, clock: 7 * 60, recurring: true, days: 0x7F, finishAtLimit: true) } + #expect(home.cars.car(tesla.id)?.goal?.finishAtLimit == true) + + // A box that never answers: the add is not confirmed, and the next + // try sends the same id. + box.neverConfirm = true + await rig.settle(40_000) { await home.cars.add(name: "Zoe", capacityWh: 52_000) } + box.neverConfirm = false + await rig.settle { await home.cars.add(name: "Zoe", capacityWh: 52_000) } + #expect(home.cars.cars.filter { $0.name == "Zoe" }.count == 1) + #expect(box.cars?.filter { $0.name == "Zoe" }.count == 1) + } + /// An old box refuses the car ops it does not know, and the words say /// what happens now. @Test func aRefusedCarOpSaysWhatHappensNow() { diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/CarShareTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/CarShareTests.swift index c7cdad38..dbe0cfdc 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/CarShareTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/CarShareTests.swift @@ -63,7 +63,7 @@ import Testing let t: Double = 1_791_799_200_000 let entry = CarEntry( cars: [CarEntry.Car(loadpointID: "easee", pluggedIn: false, socPct: nil, socSource: "", socReadAtMs: nil, powerW: 0, targetSocPct: nil, holding: false)], - owned: [CarEntry.Owned(id: Self.tesla, name: "Model Y", socPct: 72, socSource: "vehicle", socReadAtMs: t - 67 * 60_000, socEstimate: false, targetSocPct: 80, loadpointID: nil)], + owned: [CarEntry.Owned(id: Self.tesla, name: "Model Y", socPct: 72, source: "vehicle", socReadAtMs: t - 67 * 60_000, socEstimate: false, targetSocPct: 80, loadpointID: nil)], asking: [] ) let record = CacheRecord(siteId: "s1", carrier: .relay, srcState: .live, readAtMs: t, value: entry) @@ -84,6 +84,66 @@ import Testing #expect(ask.first?.choices.map(\.name) == ["Model Y", "Zoe"]) } + /// The car's own estimate stays the car's: an estimate from the car is + /// "≈" and "from the car", never "set by hand". + @Test func theWatchKeepsAnEstimateFromTheCarTheCars() { + let t: Double = 1_791_799_200_000 + func glance(_ source: String, estimate: Bool) -> WatchGlance.Car { + let entry = CarEntry(cars: [], owned: [CarEntry.Owned(id: Self.tesla, name: "Model Y", socPct: 75, source: source, socReadAtMs: t - 3_600_000, socEstimate: estimate, targetSocPct: nil, loadpointID: nil)], asking: []) + return WatchGlance.cars(CacheRecord(siteId: "s1", carrier: .relay, srcState: .live, readAtMs: t, value: entry), plan: nil, nowMs: t, zone: .current)[0] + } + let estimate = glance("vehicle", estimate: true) + #expect(estimate.levelText == "≈ 75\u{00A0}%") + #expect(estimate.origin == "from the car · 1 h ago") + #expect(glance("manual", estimate: false).origin == "set by hand · 1 h ago") + #expect(glance("manual", estimate: false).levelText == "75\u{00A0}%") + #expect(glance("assumed", estimate: true).origin == "assumed at plug-in") + } + + /// Until the list is read, and for a car the box cannot name, the + /// watch keeps the chargers: a visitor's car still gets Charge now. + @Test func theWatchKeepsAChargerWithNoNamedCar() { + let t: Double = 1_791_799_200_000 + let visitor = CarEntry.Car(loadpointID: "easee", pluggedIn: true, socPct: nil, socSource: "unknown", socReadAtMs: nil, powerW: 7_000, targetSocPct: nil, holding: false) + func record(_ entry: CarEntry) -> CacheRecord { CacheRecord(siteId: "s1", carrier: .relay, srcState: .live, readAtMs: t, value: entry) } + let unread = WatchGlance.cars(record(CarEntry(cars: [visitor], owned: [], asking: [])), plan: nil, nowMs: t, zone: .current) + #expect(unread.map(\.loadpointID) == ["easee"]) + #expect(unread.first?.state.hasPrefix("Charging") == true) + #expect(unread.first?.levelText == nil) + let away = CarEntry.Owned(id: Self.tesla, name: "Model Y", socPct: 72, source: "vehicle", socReadAtMs: t, socEstimate: false, targetSocPct: nil, loadpointID: nil) + let both = WatchGlance.cars(record(CarEntry(cars: [visitor], owned: [away], asking: [])), plan: nil, nowMs: t, zone: .current) + #expect(both.map(\.id) == [Self.tesla, "easee"]) + #expect(both.first(where: \.pluggedIn)?.loadpointID == "easee") + // A charger whose car is named is the car's row, not a second one. + var named = visitor + named.carID = Self.tesla + let on = CarEntry.Owned(id: Self.tesla, name: "Model Y", socPct: 72, source: "vehicle", socReadAtMs: t, socEstimate: false, targetSocPct: nil, loadpointID: "easee") + #expect(WatchGlance.cars(record(CarEntry(cars: [named], owned: [on], asking: [])), plan: nil, nowMs: t, zone: .current).count == 1) + } + + /// A question the phone did not read live says when it did. + @Test func theWatchDatesAnOldQuestion() { + let t: Double = 1_791_799_200_000 + let entry = CarEntry(cars: [], owned: nil, asking: [CarEntry.Asking(loadpointID: "easee", session: "s9", choices: [.init(id: Self.tesla, name: "Model Y")])]) + let old = WatchGlance.asking(CacheRecord(siteId: "s1", carrier: .cache, srcState: .stale, readAtMs: t, value: entry), zone: TimeZone(identifier: "UTC")!) + #expect(old.first?.live == false) + #expect(old.first?.asOf.hasPrefix("AS OF") == true) + #expect(WatchGlance.asking(CacheRecord(siteId: "s1", carrier: .relay, srcState: .live, readAtMs: t, value: entry)).first?.live == true) + } + + /// At launch the buttons come from the row written last. + @Test func theButtonsAtLaunchComeFromTheLastRow() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("carkeys-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let keys = CloudKeys(directory: dir) + #expect(keys.lastCars().isEmpty) + let key = Data([0x04] + [UInt8](repeating: 1, count: 64)) + try keys.write(CloudHome(home: "aaaaaaaaaaaaaaaaaaaaaaaaaa", siteId: "s1", boxKey: key, cars: Self.cars)) + #expect(keys.lastCars() == Self.cars) + try keys.write(CloudHome(home: "bbbbbbbbbbbbbbbbbbbbbbbbbb", siteId: "s2", boxKey: key)) + #expect(keys.lastCars() == Self.cars) + } + /// A record an older build left, before cars, still reads, as the /// charger's own. @Test func aRecordFromBeforeCarsStillReads() throws { diff --git a/appleApp/FTWWatch/CarPage.swift b/appleApp/FTWWatch/CarPage.swift index 6096f287..bb43425d 100644 --- a/appleApp/FTWWatch/CarPage.swift +++ b/appleApp/FTWWatch/CarPage.swift @@ -27,7 +27,7 @@ struct CarPage: View { let nowMs = timeline.date.timeIntervalSince1970 * 1000 let cars = WatchGlance.cars(model.context?.cars, plan: model.context?.plan?.value, nowMs: nowMs, zone: model.zone) let car = cars.first(where: \.pluggedIn) ?? cars.first - let ask = WatchGlance.asking(model.context?.cars).first + let ask = WatchGlance.asking(model.context?.cars, zone: model.zone).first Group { if let ask { OnePage { WhichCarBlock(ask: ask, model: model) } @@ -63,9 +63,20 @@ private struct WhichCarBlock: View { var body: some View { VStack(alignment: .leading, spacing: 4) { - Text(WatchWord.whichCar.text) - .font(.headline) - .foregroundStyle(Theme.ink) + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(WatchWord.whichCar.text) + .font(.headline) + .foregroundStyle(Theme.ink) + Spacer(minLength: 2) + // A question the phone did not read live says when it did. + if !ask.live { + Text(ask.asOf) + .font(Theme.mono(.caption2)) + .foregroundStyle(Theme.ink3) + .lineLimit(1) + .fixedSize() + } + } if let r = model.receipt(for: ask.loadpointID) { ReceiptLine(receipt: r) }