diff --git a/appleApp/FTW/Push/Notifications.swift b/appleApp/FTW/Push/Notifications.swift index ace1148..af5f419 100644 --- a/appleApp/FTW/Push/Notifications.swift +++ b/appleApp/FTW/Push/Notifications.swift @@ -79,6 +79,14 @@ enum Notifications { } } + /// Whether this iPhone lets FTW notify, for Home's notifications. + static func allowed() async -> UNAuthorizationStatus { + await UNUserNotificationCenter.current().notificationSettings().authorizationStatus + } + + /// FTW's own page in the iPhone's Settings, where a refusal is undone. + static var settingsURL: URL? { URL(string: UIApplication.openNotificationSettingsURLString) } + static func received(token: Data) { #if DEBUG log.notice("push token \(token.byteArray.hex, privacy: .public) (\(environment.rawValue, privacy: .public))") diff --git a/appleApp/FTW/UI/HomeTab.swift b/appleApp/FTW/UI/HomeTab.swift index 1461299..e5b872b 100644 --- a/appleApp/FTW/UI/HomeTab.swift +++ b/appleApp/FTW/UI/HomeTab.swift @@ -2,7 +2,7 @@ import FTWKit import SwiftUI /// Home, the fourth tab: the home itself, in the system's own list. Its -/// cars, its assistants and the watch; then the homes on this +/// cars, its assistants, the watch and its notifications; then the homes on this /// phone, this phone's own settings, and what FTW is. What the title's /// menu and the settings sheet held before the tabs, in one place a /// newcomer finds. @@ -25,6 +25,7 @@ struct HomeTabView: View { if app.watch.canAdd(home) { WatchSettingsSection(app: app, home: home) } + notify homes phone price @@ -56,6 +57,17 @@ struct HomeTabView: View { } } + /// Which notifications the box sends, and whether this iPhone lets FTW + /// notify. + private var notify: some View { + Section { + NavigationLink { NotifySettingsView(home: home) } label: { + Label(L("Notifications"), systemImage: "bell") + } + .accessibilityIdentifier("settings.notify") + } + } + /// The homes this phone holds, the open one ticked, and the way to /// pair another. private var homes: some View { diff --git a/appleApp/FTW/UI/NotifySettingsView.swift b/appleApp/FTW/UI/NotifySettingsView.swift new file mode 100644 index 0000000..578855d --- /dev/null +++ b/appleApp/FTW/UI/NotifySettingsView.swift @@ -0,0 +1,113 @@ +import FTWKit +import SwiftUI +import UserNotifications + +/// Home › Notifications: whether this iPhone lets FTW notify, then the box's +/// own choices, the same ones the web app and the box's settings show. The +/// box decides when to send; these say which kinds it sends at all. +struct NotifySettingsView: View { + let home: HomeModels + @State private var allowed: UNAuthorizationStatus? + @Environment(\.scenePhase) private var scenePhase + + private var rules: NotifyRules { home.notify } + + var body: some View { + List { + phone + box + if rules.phase == .ready, rules.canChange { + ForEach(NotifyRules.Group.allCases, id: \.self) { group in + let kinds = rules.kinds.filter { NotifyRules.group($0) == group } + if !kinds.isEmpty { + Section(Self.title(group)) { + ForEach(kinds, id: \.self) { kind in + Toggle(NotifyRules.label(kind), isOn: Binding( + get: { rules.on[kind] == true }, + set: { value in Task { await rules.set(kind, value) } } + )) + .accessibilityIdentifier("notify.\(kind)") + } + } + .disabled(!rules.boxOn) + } + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Theme.ground) + .navigationTitle(L("Notifications")) + .navigationBarTitleDisplayMode(.inline) + .animation(Theme.spring, value: rules.kinds) + // Read when the view opens and when the session comes back. + .task(id: home.site.session.phase == .streaming) { await rules.load() } + // Back from the iPhone's Settings, the answer may have changed. + .task(id: scenePhase) { allowed = await Notifications.allowed() } + } + + /// This iPhone's own answer: asked here if never asked, or the way to + /// Settings if refused. + @ViewBuilder private var phone: some View { + switch allowed { + case .notDetermined?: + Section { + Button { + Task { + await Notifications.askAndRegister() + allowed = await Notifications.allowed() + } + } label: { + Label(L("Allow notifications on this iPhone"), systemImage: "bell.badge") + } + .accessibilityIdentifier("notify.allow") + } + case .denied?: + Section { + Text(L("Notifications for FTW are off in the iPhone's Settings.")) + if let url = Notifications.settingsURL { + Link(L("Open Settings"), destination: url) + } + } + default: + EmptyView() + } + } + + /// The box's own switch, or why there is none to show. + @ViewBuilder private var box: some View { + Section { + if !home.site.canConfigure { + Text(L("Notifications are chosen by this home's owner.")).foregroundStyle(Theme.ink2) + } else if rules.phase == .oldBox { + Text(AppWords.text("Your box doesn't have that yet — it may be running older software.")).foregroundStyle(Theme.ink2) + } else if rules.phase == .unknown { + Text(L(home.site.session.phase == .streaming ? "Reading your box…" : "Shown when your box is in reach.")) + .foregroundStyle(Theme.ink2) + } else { + Toggle(L("Notifications from your box"), isOn: Binding( + get: { rules.boxOn }, + set: { value in Task { await rules.setBoxOn(value) } } + )) + .accessibilityIdentifier("notify.box") + } + } footer: { + VStack(alignment: .leading, spacing: 4) { + if rules.phase == .ready { + Text(L("Your box decides when to send them, and every owner of this home gets the same. Notices about access come even with this off.")) + } + if let problem = rules.problem { + Text(problem).foregroundStyle(Theme.alarm) + } + } + } + } + + static func title(_ group: NotifyRules.Group) -> String { + switch group { + case .car: return L("The car") + case .home: return L("The box and its devices") + case .access: return L("Access") + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index f4b64bc..7fb7213 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -517,6 +517,8 @@ public final class SimulatedBox { /// Priced beside the route, never from the method, as the box does. static let routes: [(method: String, path: String, tier: Tier, op: String?)] = [ ("GET", "/api/status", .read, nil), + ("GET", "/api/notifications/rules", .read, nil), + ("PUT", "/api/notifications/rules", .configure, nil), ("GET", "/api/cloud", .configure, nil), ("DELETE", "/api/cloud", .configure, nil), ("GET", "/api/cloud/stats-policy", .configure, nil), @@ -636,6 +638,44 @@ public final class SimulatedBox { return [battery, car] } + /// The box's notification rules, as Core seeds them: off, apart from + /// FTW Cloud, access, which car and a device that does not follow. A + /// template rule the phone does not name rides along untouched. + public private(set) var notifyOn = false + public private(set) var notifyRules: [JSON] = [ + ["type": "driver_offline", "enabled": false, "threshold_s": 600, "priority": 4, "cooldown_s": 3600], + ["type": "charging.connected", "enabled": false, "priority": 3, "cooldown_s": 60], + ["type": "charging.session_complete", "enabled": false, "priority": 3], + ["type": "charging.interrupted", "enabled": false, "priority": 4, "cooldown_s": 3600], + ["type": "charging.goal_at_risk", "enabled": false, "priority": 4], + ["type": "update.installed", "enabled": false, "priority": 2], + ["type": "driver.offline", "enabled": false, "threshold_s": 600, "priority": 4, "cooldown_s": 3600], + ["type": "fuse.over_limit", "enabled": false, "threshold_s": 30, "priority": 5, "cooldown_s": 900], + ["type": "control.not_following", "enabled": true, "threshold_s": 600, "priority": 4, "cooldown_s": 3600], + ["type": "cloud.granted", "enabled": true, "priority": 3], + ["type": "cloud.revoked", "enabled": true, "priority": 3], + ["type": "agent.added", "enabled": true, "priority": 3], + ["type": "agent.request", "enabled": true, "priority": 4], + ["type": "agent.expiring", "enabled": true, "priority": 3], + ["type": "approval.key.set", "enabled": true, "priority": 3], + ["type": "owner.added", "enabled": true, "priority": 3], + ["type": "car.which", "enabled": true, "priority": 3], + ] + + private func notifyState() -> JSON { + .object([("enabled", .bool(notifyOn)), ("events", .array(notifyRules))]) + } + + /// The PUT: each entry replaces the rule of its type whole; types not + /// named keep what they had. + private func putNotifyRules(_ body: JSON?) { + if let on = body?["enabled"]?.bool { notifyOn = on } + for e in body?["events"]?.array ?? [] { + guard let type = e["type"]?.string else { continue } + if let i = notifyRules.firstIndex(where: { $0["type"]?.string == type }) { notifyRules[i] = e } else { notifyRules.append(e) } + } + } + private func cloudConsentState() -> JSON { [ "state": .string(cloudCopyOn ? "on" : "off"), "connected": true, @@ -673,6 +713,11 @@ public final class SimulatedBox { cloudTermsAccepted = false statisticsOn = false return (200, cloudConsentState()) + case ("GET", "/api/notifications/rules"): + return (200, notifyState()) + case ("PUT", "/api/notifications/rules"): + putNotifyRules(body) + return (200, notifyState()) case ("GET", "/api/status"): let perPhase = house.gridW / 3 return (200, [ diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index 829939d..5449ab2 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -24,6 +24,8 @@ public final class HomeModels { /// The box's answer to "Are we in control?", for the status line and /// the control sheet. public let control: ControlCheck + /// Which notifications the box sends, for Home's switches. + public let notify: NotifyRules /// `cache` is what the app left for its widgets. Each model opens on /// the sealed home file first, and on these records where it has none. @@ -38,6 +40,7 @@ public final class HomeModels { nodes = NodeModels(site: site, loadpoints: loadpoints, prices: prices, cache: cache) gridToday = GridTodayShare(site: site, history: nodes.history, prices: prices) control = ControlCheck(site: site) + notify = NotifyRules(site: site) // The nodes Now drew, so the next launch draws them in their places // before its first reading. site.keep { [weak site, weak loadpoints] home in diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index bb4f2ae..dc2c85c 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -33,6 +33,36 @@ public enum NodeWords { } static let sv: [String: String] = [ + // Notifications, under Home. + "Notifications": "Notiser", + "Allow notifications on this iPhone": "Tillåt notiser på den här iPhonen", + "Notifications for FTW are off in the iPhone's Settings.": "Notiser för FTW är avstängda i iPhonens inställningar.", + "Open Settings": "Öppna Inställningar", + "Notifications from your box": "Notiser från din box", + "Your box decides when to send them, and every owner of this home gets the same. Notices about access come even with this off.": "Boxen avgör när den skickar dem, och alla ägare av hemmet får samma. Notiser om åtkomst kommer även när det här är av.", + "The car": "Bilen", + "The box and its devices": "Boxen och enheterna", + "Access": "Åtkomst", + "Notifications are chosen by this home's owner.": "Notiserna väljs av hemmets ägare.", + "Shown when your box is in reach.": "Visas när boxen går att nå.", + "Reading your box…": "Läser från din box…", + "Not saved. Nothing changed on your box.": "Inte sparat. Inget ändrades på din box.", + "When the car is plugged in": "När bilen kopplas in", + "When the car finishes charging": "När bilen är färdigladdad", + "If the car may not be ready in time": "Om bilen kanske inte blir klar i tid", + "If charging stops before it is done": "Om laddningen avbryts innan den är klar", + "When the box asks which car is plugged in": "När boxen frågar vilken bil som är inkopplad", + "When your box updates itself": "När din box uppdaterar sig", + "If a device goes quiet": "Om en enhet tystnar", + "If the house draws more than the fuse allows": "Om huset drar mer än säkringen klarar", + "If a device does not do what FTW asks": "Om en enhet inte gör som FTW ber", + "When FTW Cloud is turned on": "När FTW Cloud slås på", + "When FTW Cloud is turned off": "När FTW Cloud stängs av", + "When an assistant or Sourceful gets access": "När en assistent eller Sourceful får åtkomst", + "When an assistant asks to change a setting": "När en assistent vill ändra en inställning", + "When an assistant's or Sourceful's access is about to end": "När en assistents eller Sourcefuls åtkomst snart upphör", + "When a phone sets up the key that approves access": "När en telefon sätter upp nyckeln som godkänner åtkomst", + "When a phone is paired as an owner": "När en telefon paras som ägare", "History and live readings": "Historik och livevärden", "Swipe up or down to move through time. Actions change the zoom.": "Svep uppåt eller nedåt för att flytta tiden. Åtgärder ändrar zoom.", "Zoom in": "Zooma in", diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NotifyRules.swift b/appleApp/FTWKit/Sources/FTWKit/State/NotifyRules.swift new file mode 100644 index 0000000..a6a9529 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/NotifyRules.swift @@ -0,0 +1,176 @@ +import Foundation +import FTWShared +import Observation + +/// Which notifications the box sends: its rules document, GET and PUT +/// /api/notifications/rules, read and written as the web app's settings do. +/// The rules are the box's, one set for the home, so a switch here turns a +/// kind on or off for every owner's phone. Only an owner may change them. +/// +/// A write sends every entry the box carries for a kind this phone knows, +/// whole and flipped only on `enabled`: the box replaces a rule wholesale, +/// and a partial entry would wipe the thresholds it seeded. A kind this +/// phone does not know is never sent, and the box keeps it as it was. +@Observable +@MainActor +public final class NotifyRules { + public enum Phase: Equatable, Sendable { + /// Not read yet. + case unknown + case ready + /// The box has no rules document: older software. + case oldBox + } + + /// The groups the switches stand in, by what a notification is about. + public enum Group: String, CaseIterable, Sendable { + case car, home, access + } + + /// The kinds this phone can name, in the catalogue's order. + /// `box.unreachable` is the relay's, and no rule gates it. + public nonisolated static let choices = PushKind.all.filter { $0 != PushKind.boxUnreachable } + + /// The kinds the box's document carries, in the catalogue's order. + public private(set) var kinds: [String] = [] + /// Whether each kind is on, as the switches show it. + public private(set) var on: [String: Bool] = [:] + /// The box's own switch for all of them. Security notices go out + /// without it. + public private(set) var boxOn = false + public private(set) var phase = Phase.unknown + public private(set) var saving = false + /// The last write or read failed, with a sentence to show. + public private(set) var problem: String? + + @ObservationIgnored private unowned let site: SiteModel + /// The box's entries, whole, as it last answered. + @ObservationIgnored private var events: [JSON] = [] + /// What the switches asked for since the last write went out. + @ObservationIgnored private var pending: (on: [String: Bool], boxOn: Bool)? + + public init(site: SiteModel) { + self.site = site + } + + /// Only an owner, over a box that serves its own API. + public var canChange: Bool { site.canConfigure && site.hasPassthrough } + + public func load() async { + guard canChange, site.session.phase == .streaming else { return } + do { + apply(try await site.callBox(.get, "/api/notifications/rules")) + problem = nil + } catch let e as BoxAPIError where e.status == 404 || e.code == "E_UNKNOWN_OP" { + phase = .oldBox + } catch let e as BoxAPIError { + problem = e.help + } catch { + problem = AppWords.text("Your box didn't answer. Still trying.") + } + } + + /// Turn one kind on or off. The switch moves at once, and any write + /// turns the box's own switch on: an owner who turns a kind on is + /// asking to hear it. + public func set(_ kind: String, _ value: Bool) async { + var next = pending?.on ?? on + next[kind] = value + on = next + boxOn = true + await write(on: next, boxOn: true) + } + + /// Turn every notification from the box on or off. + public func setBoxOn(_ value: Bool) async { + boxOn = value + await write(on: pending?.on ?? on, boxOn: value) + } + + /// One write at a time; switches flipped meanwhile go in the next. + private func write(on next: [String: Bool], boxOn value: Bool) async { + pending = (next, value) + guard !saving else { return } + saving = true + defer { saving = false } + while let want = pending { + pending = nil + do { + let answer = try await site.callBox(.put, "/api/notifications/rules", body: Self.body(events: events, on: want.on, boxOn: want.boxOn)) + apply(answer) + problem = nil + } catch { + // The switches go back to what the box holds. + pending = nil + problem = NodeWords.text("Not saved. Nothing changed on your box.") + apply(.object([("enabled", .bool(boxOnStored)), ("events", .array(events))])) + return + } + } + } + + /// The box's own switch as it last answered. + @ObservationIgnored private var boxOnStored = false + + /// The write's body. + nonisolated static func body(events: [JSON], on: [String: Bool], boxOn: Bool) -> JSON { + let out = events.compactMap { e -> JSON? in + guard let type = e["type"]?.string, choices.contains(type), let v = on[type] else { return nil } + return e.setting("enabled", .bool(v)) + } + return .object([("enabled", .bool(boxOn)), ("events", .array(out))]) + } + + private func apply(_ doc: JSON?) { + guard let doc, let list = doc["events"]?.array else { return } + events = list + boxOnStored = doc["enabled"]?.bool == true + let carried = Set(list.compactMap { $0["type"]?.string }) + kinds = Self.choices.filter(carried.contains) + var held = [String: Bool]() + for e in list { + if let type = e["type"]?.string { held[type] = e["enabled"]?.bool == true } + } + // A switch flipped while this write was out keeps its place. + if let pending { + on = held.merging(pending.on) { _, mine in mine } + boxOn = pending.boxOn + } else { + on = held + boxOn = boxOnStored + } + phase = .ready + } + + /// What a notification is about. + public nonisolated static func group(_ kind: String) -> Group { + if kind.hasPrefix("charging.") || kind == PushKind.carWhich { return .car } + if [PushKind.updateInstalled, PushKind.driverOffline, PushKind.fuseOverLimit, PushKind.controlNotFollowing].contains(kind) { return .home } + return .access + } + + /// What each switch says: the decision to hear it, in the web app's + /// words. The notification's own words are the catalogue's. + public nonisolated static func label(_ kind: String) -> String { + NodeWords.text(labels[kind] ?? kind) + } + + nonisolated static let labels: [String: String] = [ + PushKind.chargingConnected: "When the car is plugged in", + PushKind.chargingSessionComplete: "When the car finishes charging", + PushKind.chargingGoalAtRisk: "If the car may not be ready in time", + PushKind.chargingInterrupted: "If charging stops before it is done", + PushKind.carWhich: "When the box asks which car is plugged in", + PushKind.updateInstalled: "When your box updates itself", + PushKind.driverOffline: "If a device goes quiet", + PushKind.fuseOverLimit: "If the house draws more than the fuse allows", + PushKind.controlNotFollowing: "If a device does not do what FTW asks", + PushKind.cloudGranted: "When FTW Cloud is turned on", + PushKind.cloudRevoked: "When FTW Cloud is turned off", + PushKind.agentAdded: "When an assistant or Sourceful gets access", + PushKind.agentRequest: "When an assistant asks to change a setting", + PushKind.agentExpiring: "When an assistant's or Sourceful's access is about to end", + PushKind.approvalKeySet: "When a phone sets up the key that approves access", + PushKind.ownerAdded: "When a phone is paired as an owner", + ] +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift b/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift index 22236f0..c7c9e17 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift @@ -21,6 +21,9 @@ public enum PushKind { public static let updateInstalled = "update.installed" public static let driverOffline = "driver.offline" public static let fuseOverLimit = "fuse.over_limit" + /// A device that has not done what FTW asked for ten minutes, by the + /// box's own answer to "Are we in control?". + public static let controlNotFollowing = "control.not_following" /// The one alert the service writes itself: a box that cannot reach the /// service cannot sign one. public static let boxUnreachable = "box.unreachable" @@ -41,7 +44,7 @@ public enum PushKind { /// In the catalogue's order. public static let all = [ chargingConnected, chargingSessionComplete, chargingGoalAtRisk, chargingInterrupted, updateInstalled, - driverOffline, fuseOverLimit, boxUnreachable, cloudGranted, cloudRevoked, + driverOffline, fuseOverLimit, controlNotFollowing, boxUnreachable, cloudGranted, cloudRevoked, agentAdded, agentRequest, agentExpiring, approvalKeySet, ownerAdded, carWhich, ] diff --git a/appleApp/FTWKit/Sources/FTWShared/PushText.swift b/appleApp/FTWKit/Sources/FTWShared/PushText.swift index 9c3b52f..077310b 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PushText.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PushText.swift @@ -77,6 +77,8 @@ public enum PushText { PushKind.updateInstalled: Sentence(title: "Your box updated itself", body: "Now running {version}. Everything came back on its own.", without: "Everything came back on its own."), PushKind.driverOffline: Sentence(title: "A device went quiet", body: "{name} stopped answering.", without: "A device stopped answering."), PushKind.fuseOverLimit: Sentence(title: "The house is drawing too much", body: "{phase} is over the fuse rating.", without: "One phase is over the fuse rating."), + PushKind.controlNotFollowing: Sentence(title: "A device is not following FTW", body: "{name} does not do what FTW asks. Open FTW to see why.", + without: "A device does not do what FTW asks. Open FTW to see why."), PushKind.boxUnreachable: Sentence(title: "Your box is out of reach", body: "It stopped answering. Power cut, or the internet at home is down."), PushKind.cloudGranted: Sentence(title: "FTW Cloud is on", body: "Your box now shares a copy of its readings with FTW Cloud. You can turn it off in the app or on the box."), PushKind.cloudRevoked: Sentence(title: "FTW Cloud is off", body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy."), @@ -97,6 +99,8 @@ public enum PushText { PushKind.updateInstalled: Sentence(title: "Din box har uppdaterat sig", body: "Nu kör den {version}. Allt kom igång igen av sig självt.", without: "Allt kom igång igen av sig självt."), PushKind.driverOffline: Sentence(title: "En enhet har tystnat", body: "{name} har slutat svara.", without: "En enhet har slutat svara."), PushKind.fuseOverLimit: Sentence(title: "Huset drar för mycket ström", body: "{phase} drar mer än huvudsäkringen klarar.", without: "En fas drar mer än huvudsäkringen klarar."), + PushKind.controlNotFollowing: Sentence(title: "En enhet följer inte FTW", body: "{name} gör inte som FTW ber. Öppna FTW för att se varför.", + without: "En enhet gör inte som FTW ber. Öppna FTW för att se varför."), PushKind.boxUnreachable: Sentence(title: "Din box går inte att nå", body: "Den har slutat svara. Strömavbrott, eller så är internet hemma nere."), PushKind.cloudGranted: Sentence(title: "FTW Cloud är på", body: "Din box delar nu en kopia av sina mätvärden med FTW Cloud. Du kan stänga av det i appen eller på boxen."), PushKind.cloudRevoked: Sentence(title: "FTW Cloud är av", body: "Din box har slutat dela med FTW Cloud och bett den radera sin kopia."), diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NotifyRulesTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NotifyRulesTests.swift new file mode 100644 index 0000000..adabaf6 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/NotifyRulesTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import FTWDemo +@testable import FTWKit +import FTWShared + +/// Home › Notifications: the box's own rules, written as the web app writes +/// them (Fredrik, 5 Nov 2026: every notification choice in the iPhone app +/// too). +@Suite struct NotifyRulesTests { + @Test func aWriteSendsWholeEntriesForKindsThePhoneKnows() { + let events: [JSON] = [ + ["type": "driver_offline", "enabled": false, "threshold_s": 600], + ["type": "fuse.over_limit", "enabled": false, "threshold_s": 30, "cooldown_s": 900], + ["type": "some.future_kind", "enabled": true], + ] + let body = NotifyRules.body(events: events, on: ["fuse.over_limit": true, "driver_offline": true], boxOn: true) + #expect(body["enabled"]?.bool == true) + let sent = body["events"]?.array ?? [] + // The template rule and the kind this phone cannot name stay as the + // box holds them; the one it knows goes out whole. + #expect(sent.count == 1) + #expect(sent.first?["type"]?.string == "fuse.over_limit") + #expect(sent.first?["enabled"]?.bool == true) + #expect(sent.first?["threshold_s"]?.number == 30) + #expect(sent.first?["cooldown_s"]?.number == 900) + } + + @Test func everyChoiceHasAGroupALabelAndSwedish() { + #expect(!NotifyRules.choices.contains(PushKind.boxUnreachable)) + #expect(NotifyRules.choices.contains(PushKind.controlNotFollowing)) + for kind in NotifyRules.choices { + guard let en = NotifyRules.labels[kind] else { + Issue.record("\(kind) has no label") + continue + } + #expect(NodeWords.sv[en] != nil, "no Swedish for \"\(en)\"") + } + #expect(NotifyRules.group(PushKind.carWhich) == .car) + #expect(NotifyRules.group(PushKind.chargingGoalAtRisk) == .car) + #expect(NotifyRules.group(PushKind.controlNotFollowing) == .home) + #expect(NotifyRules.group(PushKind.ownerAdded) == .access) + AppWords.$language.withValue(.sv) { + #expect(NotifyRules.label(PushKind.controlNotFollowing) == "Om en enhet inte gör som FTW ber") + } + } + + @MainActor + @Test func theSwitchesReadAndWriteTheBoxsRules() async throws { + let rig = DemoRig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let box = try #require(rig.box) + await rig.run(1_500) + // The demo's clock moves only as the rig drives it, so each call + // runs beside the rig. + func drive(_ work: @escaping @MainActor () async -> Void) async { + let task = Task { @MainActor in await work() } + await rig.run(2_000) + await task.value + } + await drive { await home.notify.load() } + #expect(home.notify.phase == .ready) + #expect(home.notify.kinds == NotifyRules.choices) + #expect(home.notify.boxOn == false) + #expect(home.notify.on[PushKind.controlNotFollowing] == true) + #expect(home.notify.on[PushKind.chargingConnected] == false) + + // A kind turned on turns the box's own switch on too. + await drive { await home.notify.set(PushKind.chargingConnected, true) } + #expect(box.notifyOn) + #expect(home.notify.boxOn) + #expect(home.notify.on[PushKind.chargingConnected] == true) + let rule = { (type: String) in box.notifyRules.first { $0["type"]?.string == type } } + #expect(rule(PushKind.chargingConnected)?["enabled"]?.bool == true) + #expect(rule(PushKind.chargingConnected)?["cooldown_s"]?.number == 60) + #expect(rule(PushKind.fuseOverLimit)?["threshold_s"]?.number == 30) + #expect(rule("driver_offline")?["threshold_s"]?.number == 600) + + // And off again for the whole home. + await drive { await home.notify.setBoxOn(false) } + #expect(!box.notifyOn) + #expect(!home.notify.boxOn) + #expect(rule(PushKind.chargingConnected)?["enabled"]?.bool == true) + } +} diff --git a/protocol/push-catalogue.yaml b/protocol/push-catalogue.yaml index 3a56367..e428f57 100644 --- a/protocol/push-catalogue.yaml +++ b/protocol/push-catalogue.yaml @@ -44,6 +44,13 @@ events: # kettle must not page anyone. title: The house is drawing too much body: "{phase} is over the fuse rating." + - kind: control.not_following + # After the box's own threshold: ten minutes in which the box's answer + # to "Are we in control?" said a device does not do what FTW asked, as + # a battery that charges when told to discharge. A slow battery must not + # page anyone. Once until the device follows again. + title: A device is not following FTW + body: "{name} does not do what FTW asks. Open FTW to see why." - kind: box.unreachable # The one sentence the box cannot send about itself. The relay holds # this pre-encrypted and posts it only when the box has missed its