forked from fritob/Camper-Monitor
Neues Target CamperMonitorWatch, eingebettet in die iPhone-App. Die Uhr zeigt je Gerät den Hauptwert, die Nivellierung mit Libelle und Keilhöhen, den Ausrichtungs-Assistenten und stellt die Kühlbox. Die Uhr funkt nicht selbst, obwohl watchOS das könnte. BMS, Kühlbox und Neigungsmesser lassen jeweils nur eine Verbindung zu – eine mitlesende Uhr würde dem iPhone die Verbindung wegnehmen, statt sie zu ergänzen. Und die Victron-Schlüssel liegen in der Keychain des iPhones; sie ein zweites Mal auf der Uhr aufzubewahren brächte nichts. Das iPhone bleibt also das Funkgerät und reicht fertige Messwerte über WatchConnectivity weiter. Gesendet wird in zwei Takten: ein halber Sekundentakt als Nachricht, solange die Watch-App im Vordergrund ist und das über eine Frist meldet, sonst alle zwei Sekunden als Anwendungskontext und nur bei Änderungen. Läuft die Frist ab, hört das iPhone von selbst wieder auf. Solange die Uhr hinschaut, hält die App das Funkgerät auch im Hintergrund am Leben (UIBackgroundModes in Config/CamperMonitor-Info.plist) – beim Rangieren liegt das iPhone sonst mit dunklem Bildschirm in der Halterung und die Anzeige am Handgelenk wäre genau dann tot. Die verbundenen Geräte liefern dabei weiter, die Victron-Werbedaten nicht: ungefiltertes Suchen lässt iOS im Hintergrund nicht zu. Der plattformneutrale Modellcode wandert nach Shared/ und wird in beide Targets übersetzt; LevelState ist dafür aus VanAlignProtocol.swift herausgelöst. Datensatz und Befehle stehen in Shared/WatchLink und sind in run-tests.sh abgesichert: Rundlauf, Schutz gegen fremde Formatversion, Vergleich ohne Zeitstempel und die Auflösung der Zeitangaben. Geprüft sind der Bau beider Targets samt Einbetten und der Testlauf; auf echter Hardware noch nicht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
186 lines
7.2 KiB
Swift
186 lines
7.2 KiB
Swift
import Foundation
|
||
|
||
/// Was zwischen iPhone und Uhr hin- und hergeht.
|
||
///
|
||
/// Die Uhr funkt **nicht** selbst zu den Geräten im Fahrzeug, obwohl watchOS
|
||
/// das könnte. Zwei Gründe sprechen dagegen, und beide sind hart:
|
||
///
|
||
/// * BMS, Kühlbox und Neigungsmesser lassen jeweils nur **eine** Verbindung zu.
|
||
/// Eine mitlesende Uhr würde dem iPhone die Verbindung wegnehmen, statt sie
|
||
/// zu ergänzen.
|
||
/// * Die Victron-Schlüssel liegen in der Keychain des iPhones. Die Uhr hat ihre
|
||
/// eigene; die Schlüssel dorthin zu kopieren hiesse, sie ein zweites Mal
|
||
/// aufzubewahren, ohne dass ein zweiter Aufbewahrungsort etwas brächte.
|
||
///
|
||
/// Das iPhone bleibt also das Funkgerät und schickt fertige Messwerte weiter,
|
||
/// die Uhr ist Anzeige und Fernbedienung.
|
||
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?
|
||
/// 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 {
|
||
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<Int> {
|
||
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)
|
||
case calibrateLevel(device: UUID)
|
||
|
||
/// 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 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
|
||
}()
|
||
}
|