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
@@ -0,0 +1,94 @@
import SwiftUI
/// Nivellierung auf der Uhr: Libelle, Zahlen, und was zu tun ist.
struct WatchLevelView: View {
/// Nur für den Einstieg angezeigt wird immer der Stand aus dem letzten
/// Datensatz, sonst bliebe die Ansicht auf dem Wert vom Öffnen stehen.
let device: WatchDevice
@Environment(PhoneLink.self) private var link
@State private var isCalibrating = false
private var current: WatchDevice { link.payload?.levelDevice ?? device }
private var state: LevelState { current.level ?? LevelState() }
var body: some View {
ScrollView {
VStack(spacing: 10) {
WatchBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: 110)
HStack(spacing: 14) {
angle("Längs", state.pitch)
angle("Quer", state.roll)
}
Text(state.instruction ?? "Keine Werte")
.font(.footnote.weight(.medium))
.multilineTextAlignment(.center)
.foregroundStyle(state.isLevel ? .green : .primary)
if let wedges = wedgeText {
Text(wedges)
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
NavigationLink {
WatchAlignView(deviceID: current.id)
} label: {
Label("Ausrichten", systemImage: "car.side.rear.and.collision.and.car.side.front")
}
.buttonStyle(.borderedProminent)
Button("Kalibrieren", systemImage: "scope") { isCalibrating = true }
.font(.footnote)
if state.isKnownUncalibrated {
Text("Noch nicht kalibriert die Anzeige zeigt die Lage des "
+ "Sensors, nicht die des Fahrzeugs.")
.font(.caption2)
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
}
}
.padding(.horizontal, 4)
}
.navigationTitle("Nivellierung")
.confirmationDialog("Auf aktuelle Lage kalibrieren?",
isPresented: $isCalibrating, titleVisibility: .visible) {
Button("Kalibrieren") { link.send(.calibrateLevel(device: current.id)) }
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Nur sinnvoll, wenn das Fahrzeug jetzt eben steht.")
}
}
private func angle(_ title: String, _ value: Double?) -> some View {
VStack(spacing: 0) {
Text(value.map { String(format: "%.1f°", $0) } ?? "")
.font(.title3.weight(.semibold))
.monospacedDigit()
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
/// Wie hoch die Auffahrkeile sein müssen reine Geometrie, und nur zu
/// haben, wenn im Fahrzeugprofil Spurweite und Radstand stehen.
private var wedgeText: String? {
guard let profile = link.payload?.profile else { return nil }
var parts: [String] = []
if let roll = state.roll, let width = profile.trackWidth,
let wedge = LevelingWedge.across(roll: roll, trackWidth: width) {
parts.append(String(format: "%.0f cm %@", wedge.heightInCentimetres, wedge.side.text))
}
if let pitch = state.pitch, let base = profile.wheelbase,
let wedge = LevelingWedge.along(pitch: pitch, wheelbase: base) {
parts.append(String(format: "%.0f cm %@", wedge.heightInCentimetres, wedge.side.text))
}
return parts.isEmpty ? nil : "Keile: " + parts.joined(separator: ", ")
}
}