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:
BiasF
2026-08-31 15:20:16 +02:00
co-authored by Claude Opus 5
parent 32659ff0b9
commit 6612b07fd5
31 changed files with 1904 additions and 101 deletions
+137
View File
@@ -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)