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>
184 lines
6.5 KiB
Swift
184 lines
6.5 KiB
Swift
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))
|
||
}
|
||
}
|