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
+135
View File
@@ -0,0 +1,135 @@
import Foundation
/// Wie der Neigungsmesser im Fahrzeug sitzt.
///
/// Der Sensor kann quer eingebaut, gedreht oder kopfüber montiert sein. Dann
/// stimmen seine Achsen nicht mit denen des Fahrzeugs überein: was er als
/// Längsneigung meldet, ist womöglich die Querneigung, und Vorzeichen können
/// vertauscht sein.
///
/// Statt das raten zu lassen, ermittelt der Einrichtungsassistent die Lage
/// durch zwei definierte Kippbewegungen.
struct SensorOrientation: Codable, Equatable, Hashable, Sendable {
/// Welche Achse des Sensors die Längsneigung des Fahrzeugs liefert.
enum Source: String, Codable, Hashable, Sendable {
case pitch
case roll
}
/// Woher die Längsneigung kommt. Die Querneigung kommt aus der jeweils
/// anderen Achse.
var longitudinalSource: Source = .pitch
var invertLongitudinal = false
var invertLateral = false
static let identity = SensorOrientation()
var isIdentity: Bool { self == .identity }
/// Rechnet Sensorwerte in Fahrzeugwerte um.
func apply(pitch: Double?, roll: Double?) -> (pitch: Double?, roll: Double?) {
let longitudinal = longitudinalSource == .pitch ? pitch : roll
let lateral = longitudinalSource == .pitch ? roll : pitch
return (longitudinal.map { invertLongitudinal ? -$0 : $0 },
lateral.map { invertLateral ? -$0 : $0 })
}
var summary: String {
if isIdentity { return "Achsen unverändert" }
var parts: [String] = []
if longitudinalSource == .roll { parts.append("Achsen getauscht") }
if invertLongitudinal { parts.append("längs umgekehrt") }
if invertLateral { parts.append("quer umgekehrt") }
return parts.joined(separator: ", ")
}
}
/// Wertet die Kippbewegungen des Einrichtungsassistenten aus.
///
/// Zweimal wird gekippt: einmal die Front nach unten, einmal die linke Seite
/// nach unten. Welche Sensorachse sich dabei jeweils bewegt und in welche
/// Richtung, ergibt die Einbaulage.
enum OrientationDetection {
/// So weit muss gekippt werden, damit die Bewegung eindeutig ist.
/// Darunter wäre nicht zu unterscheiden, ob überhaupt gekippt wurde.
static let minimumTilt = 5.0
/// Soviel deutlicher muss die gewinnende Deutung sein als die andere.
static let ambiguityMargin = 1.3
struct Reading: Equatable {
let pitch: Double
let roll: Double
static func - (lhs: Reading, rhs: Reading) -> Reading {
Reading(pitch: lhs.pitch - rhs.pitch, roll: lhs.roll - rhs.roll)
}
}
enum Failure: Error, Equatable {
/// Es wurde zu wenig oder gar nicht gekippt.
case tooLittleMovement
/// Beide Achsen haben sich ähnlich stark bewegt dann lässt sich
/// nicht sagen, welche gemeint war.
case ambiguous
var message: String {
switch self {
case .tooLittleMovement:
return "Zu wenig Bewegung. Deutlicher kippen, mindestens eine "
+ "Handbreit."
case .ambiguous:
return "Beide Schritte haben dieselbe Achse bewegt. Im ersten "
+ "Schritt nach vorne kippen, im zweiten zur Seite."
}
}
}
/// Ermittelt aus beiden Kippbewegungen die Einbaulage.
///
/// Beide Bewegungen werden gemeinsam beurteilt. Kippt man von Hand zur
/// Seite, geht die andere Achse fast immer ein Stück mit jede Bewegung
/// für sich betrachtet sähe das nach schrägem Kippen aus. Im Paar ist die
/// Zuordnung trotzdem eindeutig: es gewinnt die Deutung, die beide
/// Bewegungen zusammen am besten erklärt.
///
/// - Parameters:
/// - nose: Änderung beim Kippen der Front nach unten.
/// - side: Änderung beim Kippen der linken Seite nach unten.
static func orientation(nose: Reading,
side: Reading) -> Result<SensorOrientation, Failure> {
guard max(abs(nose.pitch), abs(nose.roll)) >= minimumTilt,
max(abs(side.pitch), abs(side.roll)) >= minimumTilt else {
return .failure(.tooLittleMovement)
}
// Zwei mögliche Deutungen, bewertet danach, wieviel Bewegung sie
// erklären: die erste Achse längs und die zweite quer oder umgekehrt.
let pitchIsLongitudinal = abs(nose.pitch) + abs(side.roll)
let rollIsLongitudinal = abs(nose.roll) + abs(side.pitch)
let winner = max(pitchIsLongitudinal, rollIsLongitudinal)
let loser = min(pitchIsLongitudinal, rollIsLongitudinal)
// Liegen beide Deutungen dicht beieinander, ist die Zuordnung wirklich
// nicht zu treffen etwa wenn zweimal um dieselbe Achse gekippt wurde.
guard winner >= ambiguityMargin * loser else { return .failure(.ambiguous) }
var orientation = SensorOrientation()
orientation.longitudinalSource = pitchIsLongitudinal > rollIsLongitudinal ? .pitch : .roll
let longitudinal = orientation.longitudinalSource == .pitch ? nose.pitch : nose.roll
let lateral = orientation.longitudinalSource == .pitch ? side.roll : side.pitch
// Die zugeordnete Achse muss in ihrem Schritt auch deutlich gewandert
// sein, sonst stünde das Vorzeichen auf wackligem Grund.
guard abs(longitudinal) >= minimumTilt, abs(lateral) >= minimumTilt else {
return .failure(.tooLittleMovement)
}
// Front nach unten heisst: das Heck steht höher, die Längsneigung des
// Fahrzeugs ist also positiv.
orientation.invertLongitudinal = longitudinal < 0
// Linke Seite nach unten heisst: rechts steht höher, die Querneigung
// ist positiv.
orientation.invertLateral = lateral < 0
return .success(orientation)
}
}