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:
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Libelle für die Uhr: eine Blase, die dorthin wandert, wo das Fahrzeug
|
||||
/// **höher** steht – wie bei einer echten Wasserwaage.
|
||||
///
|
||||
/// Bewusst schlichter als am iPhone: Auf 40 mm Bildschirm ist der innere Ring
|
||||
/// (die Toleranz) die einzige Hilfslinie, die sich noch ablesen lässt.
|
||||
struct WatchBubble: View {
|
||||
let pitch: Double?
|
||||
let roll: Double?
|
||||
/// Bis zu welcher Neigung die Anzeige ausschlägt.
|
||||
var range: Double = 4
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let side = min(geometry.size.width, geometry.size.height)
|
||||
let radius = side / 2
|
||||
let bubble = side * 0.22
|
||||
let travel = radius - bubble / 2 - 2
|
||||
let tolerance = max(LevelState.levelTolerance / range * travel, bubble * 0.55)
|
||||
|
||||
ZStack {
|
||||
Circle().fill(Color.gray.opacity(0.25))
|
||||
Circle().strokeBorder(Color.gray.opacity(0.5), lineWidth: 1)
|
||||
Circle()
|
||||
.strokeBorder(isLevel ? Color.green : Color.gray.opacity(0.6),
|
||||
lineWidth: isLevel ? 2 : 1)
|
||||
.frame(width: tolerance * 2, height: tolerance * 2)
|
||||
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: bubble, height: bubble)
|
||||
// Positiver Pitch heisst: das Heck steht höher, die Blase
|
||||
// wandert nach oben – in der Ansicht also nach hinten.
|
||||
.offset(x: clamped(roll) / range * travel,
|
||||
y: -clamped(pitch) / range * travel)
|
||||
.animation(.spring(duration: 0.3), value: pitch)
|
||||
.animation(.spring(duration: 0.3), value: roll)
|
||||
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
|
||||
}
|
||||
.frame(width: side, height: side)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.accessibilityLabel(accessibilityText)
|
||||
}
|
||||
|
||||
/// Hält die Blase im Ring, auch wenn das Fahrzeug stark schräg steht.
|
||||
private func clamped(_ value: Double?) -> Double {
|
||||
guard let value else { return 0 }
|
||||
return min(max(value, -range), range)
|
||||
}
|
||||
|
||||
private var deviation: Double? {
|
||||
switch (pitch, roll) {
|
||||
case let (p?, r?): return max(abs(p), abs(r))
|
||||
case let (p?, nil): return abs(p)
|
||||
case let (nil, r?): return abs(r)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var isLevel: Bool { (deviation ?? .infinity) <= LevelState.levelTolerance }
|
||||
|
||||
/// Grün nur, wenn es wirklich eben ist – die Akzentfarbe der App ist selbst
|
||||
/// grün, sonst sähe schief aus wie eben.
|
||||
private var color: Color {
|
||||
guard let deviation else { return .gray }
|
||||
if deviation <= LevelState.levelTolerance { return .green }
|
||||
return deviation <= 2 ? .orange : .red
|
||||
}
|
||||
|
||||
private var accessibilityText: String {
|
||||
guard let pitch, let roll else { return "Keine Messwerte" }
|
||||
return String(format: "Längsneigung %.1f Grad, Querneigung %.1f Grad", pitch, roll)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user