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 new file mode 100644 index 00000000..6b13c191 --- /dev/null +++ b/appleApp/FTW/Car/CarFirst.swift @@ -0,0 +1,359 @@ +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) + } + // 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) + } +} + +/// 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) + } + // 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)) + .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 + } + } + .accessibilityElement(children: .contain) + .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 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 { + 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..fe9d9105 --- /dev/null +++ b/appleApp/FTW/Car/CarSettings.swift @@ -0,0 +1,279 @@ +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: cars.minuteMs, zone: cars.zone)) + } 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 / 1000 + } + } + + private func form(_ car: Car) -> some View { + let canEdit = cars.canEdit + let choices = cars.sourceChoices(for: car) + 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) + .textInputAutocapitalization(.words) + .submitLabel(.done) + .disabled(!canEdit) + .accessibilityIdentifier("car.settings.name") + } + .listRowBackground(Theme.raised) + Section(C("Battery size")) { + CarSize(kWh: $kWh) + .disabled(!canEdit) + } + .listRowBackground(Theme.raised) + if canEdit { + Section { + Button(L("Save")) { + Task { await cars.update(car, name: newName, capacityWh: newWh) } + } + .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)) + .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")) { + CarSize(kWh: $kWh) + } + .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).rounded()) + saving = false + if cars.edit[CarsModel.newCarKey] == .saved { dismiss() } + } + } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || !CarSize.range.contains(kWh)) + .accessibilityIdentifier("car.add.save") + } + } + } + } + } +} + +/// 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/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..ca6443a8 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, finishAtLimit: car.goal?.finishAtLimit == true && $0 >= 100) }, + 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..2c6678b0 100644 --- a/appleApp/FTW/Push/Notifications.swift +++ b/appleApp/FTW/Push/Notifications.swift @@ -41,7 +41,17 @@ 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. + 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 +86,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 +138,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 +165,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 +175,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..4852377a 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,19 @@ 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; + // 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) + .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 level = home.loadpoints.carLevel { let guessed = level.origin == .assumed || level.origin == .unconfirmed Text(AppWords.percent(level.pct)) .font(Theme.figure(levelSize)) @@ -915,31 +928,71 @@ 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 || !cars.visitors.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) + } + } + // 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) { + 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) + } } /// The home's one extra row: what it saved today, into the savings view. 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..e625cfef 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -96,6 +96,26 @@ 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? + /// 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 + /// 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 @@ -126,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() } @@ -189,12 +212,13 @@ 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)), ("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 { @@ -246,7 +270,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 +447,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 +471,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 +614,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 +761,7 @@ public final class SimulatedBox { } else { lp = lp.setting("plan_windows", []) } - return lp + return chargerCarJSON(lp) } // MARK: Frames out @@ -845,6 +879,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 +919,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 +927,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..469cc64c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedCars.swift @@ -0,0 +1,345 @@ +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 } + } + + /// 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)" + } + + /// 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: c.goal?.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..8241f34f --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Format/Cars.swift @@ -0,0 +1,514 @@ +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 = 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 = 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 + } + 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 } + } +} + +/// 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? + 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/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/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..bf72fb09 --- /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, 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) + } + 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..24152341 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/CarsModel.swift @@ -0,0 +1,681 @@ +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]>? + /// 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 + /// 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(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 + + /// "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 } + // 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() + } + + /// 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 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 }) + 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 } + /// 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? { + 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) } + } + + /// 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 { + 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. 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))), + ("finish_at_limit", .bool(finishAtLimit))], 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. 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 = 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) + } + + /// 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 key == Self.newCarKey { addingID = nil } + 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 and + /// that is not a charger. + public func sourceChoices(for car: Car) -> [String] { + 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 + } + + // 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..ee024fd3 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,33 @@ 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() { + 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 { @@ -947,6 +983,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/Sources/FTWKit/State/NowFlow.swift b/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift index 183350f2..b54570a8 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift @@ -123,7 +123,7 @@ extension NowFlow { } if fields[Contract.FID.evW] == nil, !points.isEmpty { fields[Contract.FID.evW] = chargeW } let fromCharger = fields[Contract.FID.evW] != wire - let car = points.first.flatMap { loadpoints.level($0) } + 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 e8b7b7ff..9e79eae6 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 }) } @@ -1188,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) @@ -1216,16 +1215,47 @@ 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)? + + /// 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 } + carNames = names + 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. 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 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) + 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 +1433,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 +1452,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..1d816012 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,87 @@ 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? + /// 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: 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?, source: String, socReadAtMs: Double?, socEstimate: Bool, targetSocPct: Double?, loadpointID: String?) { + self.id = id + self.name = name + self.socPct = socPct + self.source = source + 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..63584b60 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 @@ -96,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/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..cc5f67be 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,35 +286,106 @@ 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))") } - return Car( + } + 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)) + func charger(_ c: CarEntry.Car) -> Car { + let assumed = c.socSource == "assumed" + let level = c.socPct.map { PlanGlance.percent($0) } + var car = 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 + ) + 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 + /// pick. + public struct Asking: Equatable, Sendable, Identifiable { + 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?, 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/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..e7aa7634 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CarsTests.swift @@ -0,0 +1,518 @@ +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(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") + #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, 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) + }) + } + + 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, 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) + #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) + #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") + // 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. + @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]) + } + + /// 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() { + 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..dbe0cfdc --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/CarShareTests.swift @@ -0,0 +1,157 @@ +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, 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) + 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"]) + } + + /// 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 { + 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/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") + } + } +} diff --git a/appleApp/FTWWatch/CarPage.swift b/appleApp/FTWWatch/CarPage.swift index 5c8912dd..bb43425d 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, zone: model.zone).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,60 @@ 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) { + 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) + } + 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 +116,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 +148,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 +178,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 } }