import Foundation /// Was zwischen iPhone und Uhr hin- und hergeht. /// /// Die Aufteilung folgt dem, was jedes Gerät hergibt: /// /// * **Den Neigungsmesser funkt die Uhr selbst an** (`WatchLevelRadio`). Er /// bewirbt seinen Dienst, ist also ohne Einrichtung auffindbar, und er ist /// unverschlüsselt. Damit steht die Nivellierung am Handgelenk auch dann, /// wenn das iPhone in der Tasche schläft – und das ist der Fall, für den man /// die Uhr überhaupt anschaut. /// * **Alles andere kommt über das iPhone.** BMS und Kühlbox lassen nur eine /// Verbindung zu; eine mitlesende Uhr nähme dem iPhone die Verbindung weg, /// statt sie zu ergänzen. Und die Victron-Schlüssel liegen in der Keychain /// des iPhones – sie auf die Uhr zu kopieren hiesse, sie ein zweites Mal /// aufzubewahren, ohne dass der zweite Ort etwas brächte. /// /// Übertragen werden deshalb die Messwerte der übrigen Geräte, dazu Fahrzeug /// und Einbaulage des Sensors: Beides braucht die Uhr, um aus den Rohwinkeln /// des Neigungsmessers dasselbe zu machen wie das iPhone. enum WatchLink { /// Schlüssel im WatchConnectivity-Wörterbuch. Übertragen wird jeweils ein /// JSON-`Data`, nicht ein aufgedröseltes Wörterbuch: die Typen unten sind /// damit die einzige Stelle, an der das Format steht. static let payloadKey = "payload" static let commandKey = "command" /// Wie lange ein „ich schaue gerade hin“ der Uhr gilt, ohne erneuert zu /// werden. Bricht die Verbindung ab oder wandert die App auf der Uhr in den /// Hintergrund, hört das iPhone von selbst wieder auf, im Sekundentakt zu /// senden. static let liveLease: TimeInterval = 12 /// Sendetakt, solange die Uhr hinschaut. Der Neigungsmesser wird zweimal je /// Sekunde abgefragt – schneller zu senden brächte nichts. static let liveInterval: TimeInterval = 0.5 /// Sendetakt sonst. Geht als Anwendungskontext raus, den watchOS auch dann /// noch zustellt, wenn die App auf der Uhr gerade nicht läuft. static let idleInterval: TimeInterval = 2 } /// Der gesamte Stand eines Fahrzeugs, so wie ihn die Uhr anzeigt. struct WatchPayload: Codable, Equatable, Sendable { /// Steigt, wenn sich das Format ändert. Eine Uhr mit älterer App bekommt /// sonst Werte, die sie falsch versteht – lieber sagt sie „App aktualisieren“. static let currentVersion = 1 var version = WatchPayload.currentVersion var generatedAt: Date /// Das am iPhone gewählte Fahrzeug. var profile: Profile /// Ob das iPhone gerade überhaupt funken kann. var isRadioReady: Bool /// Klartext dazu, falls nicht. var radioStatus: String var devices: [WatchDevice] /// Nach der Rolle gesucht, nicht nach vorhandenen Werten: Ein /// Neigungsmesser, der gerade nicht antwortet, gehört trotzdem an seinen /// Platz – sonst rutschte er beim Verbindungsabriss in die Geräteliste. var levelDevice: WatchDevice? { devices.first { $0.role == .leveling } } // var fridgeDevice: WatchDevice? { devices.first { $0.role == .fridge } } /// Ob sich inhaltlich nichts geändert hat. Der Zeitstempel zählt dabei /// nicht mit: sonst gälte jeder Datensatz als neu und die Uhr bekäme rund /// um die Uhr Funkverkehr, auch wenn das Fahrzeug still steht. func hasSameContent(as other: WatchPayload) -> Bool { var mine = self, theirs = other mine.generatedAt = .distantPast theirs.generatedAt = .distantPast return mine == theirs } /// Wie alt der Stand ist. Auf der Uhr die wichtigste Angabe überhaupt: /// alles hier ist Weitergereichtes, und eine abgerissene Strecke zum /// iPhone sieht sonst aus wie ein stillstehendes Fahrzeug. func age(now: Date = Date()) -> TimeInterval { now.timeIntervalSince(generatedAt) } } /// Ein Gerät mitsamt seinem letzten Messwertsatz. struct WatchDevice: Codable, Equatable, Identifiable, Sendable { var id: UUID var name: String var role: DeviceRole var link: DeviceLinkState var snapshot: DeviceSnapshot? /// Nur beim Neigungsmesser belegt. var level: LevelState? /// Ebenfalls nur dort: wie der Sensor im Fahrzeug sitzt. Die Uhr funkt /// den Neigungsmesser selbst an und braucht die Zuordnung deshalb auch – /// eingestellt wird sie weiterhin nur am iPhone. var orientation: SensorOrientation? /// Nur bei der Kühlbox belegt. //var fridge: WatchFridge? var isLive: Bool { link == .live && !(snapshot?.isStale ?? true) } } /// Zustand und Grenzen der Kühlbox, so weit die Uhr sie braucht. /// /// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit /// drin, den auf die Uhr zu schicken nichts brächte. /*struct WatchFridge: Codable, Equatable, Sendable { /// Ob die Box gerade wirklich verbunden ist. /// /// Sie wird nur verbunden, während jemand ihre Ansicht offen hat – jede /// Verbindung meldet sich an ihrem Display an. Sonst stehen hier die /// zuletzt gestellten Werte, und die Messwerte fehlen. var isLive: Bool /// Wann der Stand zuletzt von der Box kam. var updated: Date var isPoweredOn: Bool var isEco: Bool var isLocked: Bool var isDualZone: Bool var unitSymbol: String var leftTarget: Int? var leftCurrent: Int? var rightTarget: Int? var rightCurrent: Int? var minTarget: Int var maxTarget: Int var batteryVolts: Double? var targetRange: ClosedRange { minTarget < maxTarget ? minTarget...maxTarget : -30...20 } }*/ /// Was die Uhr das iPhone tun lässt. /// /// Gestellt wird nur, was auch am iPhone gestellt werden kann; die Uhr bekommt /// keine eigenen Fähigkeiten. Das Ergebnis liest sie wie jeder andere Wert aus /// dem nächsten Datensatz – bestätigt wird also das Gerät, nicht der Tastendruck. enum WatchCommand: Codable, Equatable, Sendable { /// Die Uhr schaut gerade hin und möchte den schnellen Takt. case hello(live: Bool) //case fridgePower(device: UUID, on: Bool) //case fridgeEco(device: UUID, eco: Bool) //case fridgeLock(device: UUID, locked: Bool) //case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int) /// Die Kühlbox-Ansicht auf der Uhr wurde geöffnet oder geschlossen. Das /// iPhone verbindet die Box daraufhin – oder gibt sie wieder frei. //case fridgeSession(device: UUID, wanted: Bool) /// Ob der Befehl den schnellen Sendetakt verlängert. var wantsLiveUpdates: Bool { if case .hello(let live) = self { return live } // Wer stellt, schaut auch hin. return true } } /* /// Spiegelt `AlpicoolState.Zone`, ohne das Protokoll auf die Uhr zu ziehen. enum WatchFridgeZone: String, Codable, Equatable, Sendable { case left, right } // MARK: - Verpacken extension WatchFridge { /// Aus dem zuletzt gestellten Stand, ohne Messwerte. init(_ settings: FridgeSettings) { self.init(isLive: false, updated: settings.updated, isPoweredOn: settings.isPoweredOn, isEco: settings.isEco, isLocked: settings.isLocked, isDualZone: settings.isDualZone, unitSymbol: settings.unitSymbol, leftTarget: settings.leftTarget, leftCurrent: nil, rightTarget: settings.rightTarget, rightCurrent: nil, minTarget: settings.usesFahrenheit ? -22 : -30, maxTarget: settings.usesFahrenheit ? 68 : 20, batteryVolts: nil) } }*/ extension WatchPayload { func encoded() throws -> Data { try WatchCoder.encoder.encode(self) } /// Aus einem WatchConnectivity-Wörterbuch zurücklesen. Fremde oder zu neue /// Datensätze ergeben nil statt eines halb gefüllten Zustands. static func decode(from message: [String: Any]) -> WatchPayload? { guard let data = message[WatchLink.payloadKey] as? Data, let payload = try? WatchCoder.decoder.decode(WatchPayload.self, from: data), payload.version == WatchPayload.currentVersion else { return nil } return payload } func message() throws -> [String: Any] { [WatchLink.payloadKey: try encoded()] } } extension WatchCommand { func message() throws -> [String: Any] { [WatchLink.commandKey: try WatchCoder.encoder.encode(self)] } static func decode(from message: [String: Any]) -> WatchCommand? { guard let data = message[WatchLink.commandKey] as? Data else { return nil } return try? WatchCoder.decoder.decode(WatchCommand.self, from: data) } } /// Feste Einstellungen für beide Richtungen. Ohne das würde ein Datum auf der /// einen Seite anders geschrieben als auf der anderen gelesen. enum WatchCoder { static let encoder: JSONEncoder = { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .millisecondsSince1970 return encoder }() static let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .millisecondsSince1970 return decoder }() }