Files
Camper-Monitor/Shared/Models/AlignmentAssistant.swift
T
BiasFandClaude Opus 5 6612b07fd5 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>
2026-08-31 15:20:16 +02:00

184 lines
6.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// Hilft beim Ausrichten des Fahrzeugs während des Rangierens.
///
/// Bewusst **ohne** Positionsbestimmung: Aus einem MEMS-Beschleunigungssensor
/// lässt sich keine brauchbare Strecke ableiten, weil der Fehler beim
/// zweifachen Integrieren quadratisch mit der Zeit wächst. Beim Rangieren im
/// Schritttempo gehen die tatsächlichen Beschleunigungen ohnehin im Rauschen
/// unter.
///
/// Gebraucht wird das auch gar nicht. Die Frage beim Einparken lautet nie „wo
/// stehe ich“, sondern „wird es besser oder schlechter, und wo war es am
/// besten“. Beides steckt bereits im zeitlichen Verlauf der Neigung ganz
/// ohne Annahmen über das Gelände.
struct AlignmentAssistant {
struct Sample: Equatable {
let time: Date
let pitch: Double
let roll: Double
/// Gesamtabweichung von der Waagerechten.
var deviation: Double { (pitch * pitch + roll * roll).squareRoot() }
}
/// Wie lange zurückgeschaut wird.
static let memory: TimeInterval = 90
/// Ab dieser Verbesserung lohnt der Hinweis auf einen früheren Punkt.
static let worthGoingBack = 0.2
/// Unterhalb dieser Änderung gilt die Lage als unverändert.
static let trendThreshold = 0.08
private(set) var samples: [Sample] = []
enum Trend: Equatable {
case improving
case worsening
case steady
case unknown
var text: String {
switch self {
case .improving: return "Wird besser"
case .worsening: return "Wird schlechter"
case .steady: return "Bleibt gleich"
case .unknown: return "Messe…"
}
}
var symbol: String {
switch self {
case .improving: return "arrow.down.right.circle.fill"
case .worsening: return "arrow.up.right.circle.fill"
case .steady: return "equal.circle.fill"
case .unknown: return "hourglass.circle"
}
}
}
mutating func add(pitch: Double, roll: Double, at time: Date = Date()) {
samples.append(Sample(time: time, pitch: pitch, roll: roll))
let cutoff = time.addingTimeInterval(-Self.memory)
samples.removeAll { $0.time < cutoff }
}
mutating func reset() {
samples.removeAll()
}
var current: Sample? { samples.last }
/// Der flachste Punkt, den wir gesehen haben.
var best: Sample? {
samples.min { $0.deviation < $1.deviation }
}
/// Ob und wie stark sich die Lage gerade ändert.
///
/// Verglichen wird das jüngste Drittel mit dem davorliegenden. Einzelne
/// Messwerte wären zu unruhig; das Fahrzeug wippt beim Rangieren.
var trend: Trend {
guard samples.count >= 6 else { return .unknown }
let recent = samples.suffix(3)
let previous = samples.dropLast(3).suffix(3)
guard !previous.isEmpty else { return .unknown }
let now = recent.map(\.deviation).reduce(0, +) / Double(recent.count)
let before = previous.map(\.deviation).reduce(0, +) / Double(previous.count)
let change = now - before
if abs(change) < Self.trendThreshold { return .steady }
return change < 0 ? .improving : .worsening
}
/// Wieviel besser der beste Punkt gegenüber jetzt war nil, wenn es sich
/// nicht lohnt oder wir gerade selbst am besten Punkt stehen.
var improvementAtBest: Double? {
guard let current, let best, best.time < current.time else { return nil }
let gain = current.deviation - best.deviation
return gain >= Self.worthGoingBack ? gain : nil
}
/// Wie lange der beste Punkt zurückliegt.
var timeSinceBest: TimeInterval? {
guard improvementAtBest != nil, let current, let best else { return nil }
return current.time.timeIntervalSince(best.time)
}
/// Was der Fahrer jetzt tun soll.
var advice: String {
guard let current else { return "Warte auf Messwerte…" }
if current.deviation <= LevelState.levelTolerance {
return "Steht eben anhalten"
}
if let seconds = timeSinceBest {
return String(format: "Vor %.0f s stand es besser ein Stück zurück",
seconds.rounded())
}
switch trend {
case .improving: return "Wird besser weiter so"
case .worsening: return "Wird schlechter andere Richtung"
case .steady: return "Ändert sich kaum andere Richtung versuchen"
case .unknown: return "Langsam weiterfahren"
}
}
var hasReachedTarget: Bool {
(current?.deviation ?? .infinity) <= LevelState.levelTolerance
}
}
/// Wie hoch ein Auffahrkeil sein muss, um eine Neigung auszugleichen.
///
/// Rein geometrisch und damit exakt: Höhe = tan(Winkel) × Abstand der Achsen
/// beziehungsweise der Räder.
struct LevelingWedge: Equatable {
/// Wo der Keil hin muss.
let side: Side
/// Höhe in Metern.
let height: Double
/// Der zugrundeliegende Winkel in Grad.
let angle: Double
enum Side: Equatable {
case front, rear, left, right
var text: String {
switch self {
case .front: return "vorne"
case .rear: return "hinten"
case .left: return "links"
case .right: return "rechts"
}
}
}
var heightInCentimetres: Double { height * 100 }
/// Quer: die tieferliegende Seite muss angehoben werden. Positiver Roll
/// heisst, dass rechts höher steht der Keil gehört also nach links.
static func across(roll: Double, trackWidth: Double) -> LevelingWedge? {
wedge(angle: roll, distance: trackWidth,
whenPositive: .left, whenNegative: .right)
}
/// Längs: positiver Pitch heisst, dass das Heck höher steht der Keil
/// gehört unter die Vorderräder.
static func along(pitch: Double, wheelbase: Double) -> LevelingWedge? {
wedge(angle: pitch, distance: wheelbase,
whenPositive: .front, whenNegative: .rear)
}
private static func wedge(angle: Double,
distance: Double,
whenPositive: Side,
whenNegative: Side) -> LevelingWedge? {
guard distance > 0, abs(angle) > LevelState.levelTolerance else { return nil }
let height = tan(abs(angle) * .pi / 180) * distance
return LevelingWedge(side: angle > 0 ? whenPositive : whenNegative,
height: height,
angle: abs(angle))
}
}