forked from fritob/Camper-Monitor
Apple Watch: Übersicht, Ausrichten mit Vibration, Kühlbox am Handgelenk
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>
This commit is contained in:
@@ -949,5 +949,142 @@ let missing = SensorOrientation.identity.apply(pitch: nil, roll: 2)
|
||||
checkEqual("fehlende Werte bleiben leer", missing.pitch == nil, true)
|
||||
checkEqual("vorhandene Werte kommen durch", missing.roll, 2)
|
||||
|
||||
// MARK: 13 – Strecke zur Apple Watch
|
||||
|
||||
print("\nApple Watch – Datensatz und Befehle")
|
||||
|
||||
let watchProfile = Profile(name: "Kastenwagen", symbol: "box.truck",
|
||||
trackWidth: 1.85, wheelbase: 3.50)
|
||||
let watchLevelID = UUID()
|
||||
let watchFridgeID = UUID()
|
||||
let watchBoosterID = UUID()
|
||||
|
||||
var watchLevel = LevelState()
|
||||
watchLevel.pitch = 1.8
|
||||
watchLevel.roll = -0.9
|
||||
watchLevel.pitchOffset = 0.4
|
||||
watchLevel.rollOffset = -0.2
|
||||
|
||||
// Feste Zeitstempel, damit der Vergleich unten wirklich den Inhalt prüft: über
|
||||
// die Strecke bleibt die Millisekunde stehen, ein `Date()` wäre feiner.
|
||||
let watchStamp = Date(timeIntervalSince1970: 1_699_999_999)
|
||||
|
||||
var watchLevelSnapshot = watchLevel.snapshot(deviceID: watchLevelID, rssi: -70)
|
||||
watchLevelSnapshot.timestamp = watchStamp
|
||||
|
||||
var boosterSnapshot = DeviceSnapshot(deviceID: watchBoosterID, timestamp: watchStamp, rssi: -71)
|
||||
boosterSnapshot.state = "Aus"
|
||||
boosterSnapshot.offReasons = ["Keine Eingangsspannung"]
|
||||
boosterSnapshot.metrics = [
|
||||
Metric("output_voltage", "Ausgang", 14.09, unit: "V", precision: 2, primary: true),
|
||||
Metric("input_voltage", "Eingang", 12.42, unit: "V", precision: 2),
|
||||
]
|
||||
|
||||
let watchFridge = WatchFridge(isPoweredOn: true, isEco: true, isLocked: false,
|
||||
isDualZone: false, unitSymbol: "°C",
|
||||
leftTarget: 4, leftCurrent: 6,
|
||||
rightTarget: nil, rightCurrent: nil,
|
||||
minTarget: -20, maxTarget: 20, batteryVolts: 12.7)
|
||||
|
||||
let watchPayload = WatchPayload(
|
||||
generatedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
profile: watchProfile,
|
||||
isRadioReady: true,
|
||||
radioStatus: "Bereit",
|
||||
devices: [
|
||||
WatchDevice(id: watchLevelID, name: "Nivellierung", role: .leveling,
|
||||
link: .live, snapshot: watchLevelSnapshot,
|
||||
level: watchLevel, fridge: nil),
|
||||
WatchDevice(id: watchBoosterID, name: "Ladebooster", role: .chargeBooster,
|
||||
link: .failed("Nicht gefunden"), snapshot: boosterSnapshot,
|
||||
level: nil, fridge: nil),
|
||||
WatchDevice(id: watchFridgeID, name: "Kühlbox", role: .fridge,
|
||||
link: .live, snapshot: nil, level: nil, fridge: watchFridge),
|
||||
])
|
||||
|
||||
// Der ganze Weg: verpacken, als WatchConnectivity-Wörterbuch lesen, vergleichen.
|
||||
if let message = try? watchPayload.message(),
|
||||
let restored = WatchPayload.decode(from: message) {
|
||||
checkEqual("Datensatz kommt unverändert an", restored, watchPayload)
|
||||
checkEqual("Zeitstempel überlebt das Verpacken",
|
||||
restored.generatedAt.timeIntervalSince1970, 1_700_000_000)
|
||||
checkEqual("Fehlerklartext bleibt am Gerät",
|
||||
restored.devices[1].link, .failed("Nicht gefunden"))
|
||||
checkEqual("Neigung wird gefunden", restored.levelDevice?.id, watchLevelID)
|
||||
checkEqual("Kühlbox wird gefunden", restored.fridgeDevice?.id, watchFridgeID)
|
||||
checkEqual("Sollwert der Box überlebt", restored.fridgeDevice?.fridge?.leftTarget, 4)
|
||||
checkEqual("Fahrzeugmasse kommen mit", restored.profile.trackWidth, 1.85)
|
||||
} else {
|
||||
check("Datensatz lässt sich verpacken und wieder lesen", false)
|
||||
}
|
||||
|
||||
// Eine Uhr mit älterer App darf einen neueren Datensatz nicht halb verstehen.
|
||||
var futurePayload = watchPayload
|
||||
futurePayload.version = WatchPayload.currentVersion + 1
|
||||
if let message = try? futurePayload.message() {
|
||||
checkEqual("fremde Formatversion wird abgelehnt",
|
||||
WatchPayload.decode(from: message) == nil, true)
|
||||
} else {
|
||||
check("fremde Formatversion lässt sich verpacken", false)
|
||||
}
|
||||
|
||||
// Zeitpunkte reisen als Millisekunden seit 1970 in einer Fliesskommazahl. Auf
|
||||
// die Millisekunde genau kommen sie zurück, aufs letzte Bit nicht – deshalb
|
||||
// vergleicht der Test oben feste Zeitstempel und nicht ein `Date()`.
|
||||
var preciseSnapshot = boosterSnapshot
|
||||
preciseSnapshot.timestamp = Date(timeIntervalSince1970: 1_699_999_999.123_456)
|
||||
var precisePayload = watchPayload
|
||||
precisePayload.devices[1].snapshot = preciseSnapshot
|
||||
if let message = try? precisePayload.message(),
|
||||
let restored = WatchPayload.decode(from: message)?.devices[1].snapshot?.timestamp {
|
||||
checkEqual("feiner Zeitstempel kommt auf die Millisekunde genau zurück",
|
||||
abs(restored.timeIntervalSince1970 - 1_699_999_999.123_456) < 0.001, true)
|
||||
} else {
|
||||
check("Datensatz mit feinem Zeitstempel lässt sich verpacken", false)
|
||||
}
|
||||
|
||||
checkEqual("leeres Wörterbuch ergibt keinen Datensatz",
|
||||
WatchPayload.decode(from: [:]) == nil, true)
|
||||
checkEqual("Unsinn im Nutzdatenfeld ergibt keinen Datensatz",
|
||||
WatchPayload.decode(from: [WatchLink.payloadKey: Data([0x00, 0x01])]) == nil, true)
|
||||
|
||||
// Der Vergleich entscheidet, ob überhaupt gefunkt wird: der Zeitstempel allein
|
||||
// darf keinen Funkverkehr auslösen, ein geänderter Messwert schon.
|
||||
var laterPayload = watchPayload
|
||||
laterPayload.generatedAt = watchPayload.generatedAt.addingTimeInterval(30)
|
||||
checkEqual("gleicher Inhalt, neuer Zeitstempel gilt als unverändert",
|
||||
watchPayload.hasSameContent(as: laterPayload), true)
|
||||
|
||||
var changedPayload = laterPayload
|
||||
changedPayload.devices[2].fridge?.leftTarget = 5
|
||||
checkEqual("geänderter Sollwert gilt als Änderung",
|
||||
watchPayload.hasSameContent(as: changedPayload), false)
|
||||
|
||||
// Befehle in die Gegenrichtung.
|
||||
let commands: [WatchCommand] = [
|
||||
.hello(live: true),
|
||||
.hello(live: false),
|
||||
.fridgePower(device: watchFridgeID, on: false),
|
||||
.fridgeEco(device: watchFridgeID, eco: true),
|
||||
.fridgeLock(device: watchFridgeID, locked: true),
|
||||
.fridgeTarget(device: watchFridgeID, zone: .right, value: -3),
|
||||
.calibrateLevel(device: watchLevelID),
|
||||
]
|
||||
for command in commands {
|
||||
guard let message = try? command.message(),
|
||||
let restored = WatchCommand.decode(from: message) else {
|
||||
check("Befehl \(command) lässt sich verpacken", false)
|
||||
continue
|
||||
}
|
||||
checkEqual("Befehl kommt unverändert an", restored, command)
|
||||
}
|
||||
|
||||
checkEqual("Anmeldung ohne Hinschauen verlängert den schnellen Takt nicht",
|
||||
WatchCommand.hello(live: false).wantsLiveUpdates, false)
|
||||
checkEqual("wer stellt, schaut auch hin",
|
||||
WatchCommand.fridgePower(device: watchFridgeID, on: true).wantsLiveUpdates, true)
|
||||
checkEqual("ein Datensatz ist kein Befehl",
|
||||
WatchCommand.decode(from: (try? watchPayload.message()) ?? [:]) == nil, true)
|
||||
|
||||
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
|
||||
exit(failures == 0 ? 0 : 1)
|
||||
|
||||
Reference in New Issue
Block a user