first commit
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
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 jedes einzelne Rad unterlegt werden muss.
|
||||
///
|
||||
/// Getrennte Angaben wie „rechts 8 cm" und „vorne 4 cm" beschreiben dasselbe
|
||||
/// Fahrzeug und lassen sich nicht getrennt ausführen: Unter „rechts" liegen
|
||||
/// zwei Räder, unter „vorne" auch, und zwei davon sind dieselben. Gefragt ist
|
||||
/// deshalb die Höhe **je Rad**.
|
||||
///
|
||||
/// Das ist reine Geometrie und damit exakt. Das Fahrzeug steht auf vier
|
||||
/// Punkten; deren Höhen ergeben sich aus Spurweite, Radstand und den beiden
|
||||
/// Winkeln. Angehoben wird auf die Höhe des höchsten Rades – das bleibt
|
||||
/// liegen, die anderen bekommen die Differenz.
|
||||
struct LevelingLift: Equatable {
|
||||
|
||||
enum Wheel: CaseIterable, Hashable {
|
||||
case frontLeft, frontRight, rearLeft, rearRight
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .frontLeft: return "vorne links"
|
||||
case .frontRight: return "vorne rechts"
|
||||
case .rearLeft: return "hinten links"
|
||||
case .rearRight: return "hinten rechts"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Höhen in Metern, je Rad. Das höchste Rad steht auf 0.
|
||||
let frontLeft: Double
|
||||
let frontRight: Double
|
||||
let rearLeft: Double
|
||||
let rearRight: Double
|
||||
|
||||
func height(_ wheel: Wheel) -> Double {
|
||||
switch wheel {
|
||||
case .frontLeft: return frontLeft
|
||||
case .frontRight: return frontRight
|
||||
case .rearLeft: return rearLeft
|
||||
case .rearRight: return rearRight
|
||||
}
|
||||
}
|
||||
|
||||
func centimetres(_ wheel: Wheel) -> Double { height(wheel) * 100 }
|
||||
|
||||
/// Das am tiefsten stehende Rad – dort liegt die grösste Höhe.
|
||||
var deepest: Wheel {
|
||||
Wheel.allCases.max { height($0) < height($1) } ?? .frontLeft
|
||||
}
|
||||
|
||||
var maximumCentimetres: Double { centimetres(deepest) }
|
||||
|
||||
/// Unter einem Zentimeter lohnt kein Keil; so genau steht kein Fahrzeug,
|
||||
/// und so genau misst der Sensor auch nicht.
|
||||
var isNegligible: Bool { maximumCentimetres < 1 }
|
||||
|
||||
/// `pitch` positiv heisst, das Heck steht höher; `roll` positiv, die rechte
|
||||
/// Seite steht höher. Ohne Fahrzeugmasse gibt es nichts zu rechnen.
|
||||
static func compute(pitch: Double,
|
||||
roll: Double,
|
||||
trackWidth: Double,
|
||||
wheelbase: Double) -> LevelingLift? {
|
||||
guard trackWidth > 0, wheelbase > 0 else { return nil }
|
||||
|
||||
// Wie viel höher die rechte Seite und wie viel höher das Heck steht.
|
||||
// Vorzeichen inbegriffen: tan(-2°) ist negativ, dann steht links höher.
|
||||
let rightRise = tan(roll * .pi / 180) * trackWidth
|
||||
let rearRise = tan(pitch * .pi / 180) * wheelbase
|
||||
|
||||
// Höhen der vier Aufstandspunkte, bezogen auf vorne links.
|
||||
let heights: [Wheel: Double] = [
|
||||
.frontLeft: 0,
|
||||
.frontRight: rightRise,
|
||||
.rearLeft: rearRise,
|
||||
.rearRight: rightRise + rearRise,
|
||||
]
|
||||
let top = heights.values.max() ?? 0
|
||||
|
||||
return LevelingLift(frontLeft: top - heights[.frontLeft]!,
|
||||
frontRight: top - heights[.frontRight]!,
|
||||
rearLeft: top - heights[.rearLeft]!,
|
||||
rearRight: top - heights[.rearRight]!)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import Foundation
|
||||
|
||||
/// Welche Rolle ein Gerät im Camper spielt. Bestimmt Icon, Sortierung und
|
||||
/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
|
||||
enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
//case chargeBooster
|
||||
//case solarCharger
|
||||
//case batteryMonitor
|
||||
//case bms
|
||||
//case fridge
|
||||
case leveling
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
//case .chargeBooster: return "Ladebooster"
|
||||
//case .solarCharger: return "Solarladeregler"
|
||||
//case .batteryMonitor: return "Batteriemonitor"
|
||||
//case .bms: return "Batterie / BMS"
|
||||
//case .fridge: return "Kühlbox"
|
||||
case .leveling: return "Nivellierung"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
//case .chargeBooster: return "bolt.car"
|
||||
//case .solarCharger: return "sun.max"
|
||||
//case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
|
||||
//case .bms: return "battery.100percent.bolt"
|
||||
//case .fridge: return "refrigerator"
|
||||
case .leveling: return "level"
|
||||
}
|
||||
}
|
||||
|
||||
/// Es gibt keine Geräte mehr, die nur auf Anforderung verbunden werden –
|
||||
/// der Neigungsmesser bleibt immer verbunden.
|
||||
var connectsOnDemand: Bool { false }
|
||||
/*/// Ob nur verbunden wird, während die Ansicht des Geräts offen ist.
|
||||
///
|
||||
/// Die Kühlbox meldet jede Verbindung an ihrem Display an und verlangt je
|
||||
/// nach Modell sogar einen Tastendruck; dauerhaft verbunden zu sein ist
|
||||
/// dort also nicht unsichtbar, sondern lästig. Ihre Werte ändern sich
|
||||
/// ausserdem langsam, und was auf der Übersicht zählt – der Sollwert –
|
||||
/// ändert sich überhaupt nur, wenn jemand ihn ändert.
|
||||
///
|
||||
/// BMS und Neigungsmesser bleiben dagegen verbunden: Sie stören nicht, und
|
||||
/// ihre Werte will man laufend sehen.
|
||||
var connectsOnDemand: Bool { self == .fridge }*/
|
||||
|
||||
/// Der Neigungsmesser braucht eine echte GATT-Verbindung, um Werte zu liefern.
|
||||
var transport: DeviceTransport {
|
||||
return .connect
|
||||
}
|
||||
/*/// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS
|
||||
/// braucht eine echte GATT-Verbindung.
|
||||
var transport: DeviceTransport {
|
||||
switch self {
|
||||
case .bms, .fridge, .leveling: return .connect
|
||||
default: return .advertisement
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
enum DeviceTransport: Sendable {
|
||||
/// Passives Mitlesen der BLE-Werbedaten (Victron Instant Readout).
|
||||
case advertisement
|
||||
/// Verbindungsaufbau, Kommando schreiben, Antwort per Notify lesen (Daly).
|
||||
case connect
|
||||
}
|
||||
|
||||
/*/// Wie viele Kühlzonen eine Box hat. Manche Einzonen-Boxen senden den langen
|
||||
/// Datensatz einer Doppelzonen-Box mit, weshalb die Erkennung übersteuerbar ist.
|
||||
enum FridgeZoneMode: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case automatic
|
||||
case single
|
||||
case dual
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .automatic: return "Automatisch"
|
||||
case .single: return "Eine Zone"
|
||||
case .dual: return "Zwei Zonen"
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
/// Ein vom Nutzer eingerichtetes Gerät. Der Victron-Schlüssel liegt nicht hier,
|
||||
/// sondern in der Keychain (siehe `KeychainStore`).
|
||||
struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var role: DeviceRole
|
||||
/// Zu welchem Fahrzeug das Gerät gehört.
|
||||
var profileID: UUID
|
||||
/// CoreBluetooth-Identifier des Peripherals. Pro iPhone stabil, aber nicht
|
||||
/// geräteübergreifend – deshalb wird beim Einrichten neu gescannt.
|
||||
var peripheralID: UUID
|
||||
/// Zuletzt gesehener Advertised Name, nur zur Wiedererkennung in der UI.
|
||||
var advertisedName: String?
|
||||
/// Nur für Kühlboxen.
|
||||
//var fridgeZoneMode: FridgeZoneMode = .automatic
|
||||
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
|
||||
var sensorOrientation = SensorOrientation.identity
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
name: String,
|
||||
role: DeviceRole,
|
||||
profileID: UUID,
|
||||
peripheralID: UUID,
|
||||
advertisedName: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.role = role
|
||||
self.profileID = profileID
|
||||
self.peripheralID = peripheralID
|
||||
self.advertisedName = advertisedName
|
||||
}
|
||||
|
||||
/// Geräte, die vor der Profilverwaltung angelegt wurden, haben noch keine
|
||||
/// Zuordnung – die landen im Standardprofil.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
role = try container.decode(DeviceRole.self, forKey: .role)
|
||||
profileID = try container.decodeIfPresent(UUID.self, forKey: .profileID)
|
||||
?? Profile.defaultID
|
||||
peripheralID = try container.decode(UUID.self, forKey: .peripheralID)
|
||||
advertisedName = try container.decodeIfPresent(String.self, forKey: .advertisedName)
|
||||
//fridgeZoneMode = try container.decodeIfPresent(FridgeZoneMode.self, forKey: .fridgeZoneMode)
|
||||
// ?? .automatic
|
||||
sensorOrientation = try container.decodeIfPresent(SensorOrientation.self,
|
||||
forKey: .sensorOrientation) ?? .identity
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine einzelne Messgrösse in einer bereits formatierten Form.
|
||||
struct Metric: Identifiable, Hashable, Codable, Sendable {
|
||||
let key: String
|
||||
let label: String
|
||||
let value: Double?
|
||||
let unit: String
|
||||
/// Nachkommastellen für die Anzeige.
|
||||
let precision: Int
|
||||
/// Wird auf der Kachel gross dargestellt.
|
||||
let isPrimary: Bool
|
||||
|
||||
var id: String { key }
|
||||
|
||||
init(_ key: String,
|
||||
_ label: String,
|
||||
_ value: Double?,
|
||||
unit: String,
|
||||
precision: Int = 2,
|
||||
primary: Bool = false) {
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.value = value
|
||||
self.unit = unit
|
||||
self.precision = precision
|
||||
self.isPrimary = primary
|
||||
}
|
||||
|
||||
var formatted: String {
|
||||
guard let value else { return "–" }
|
||||
return String(format: "%.\(precision)f", value)
|
||||
}
|
||||
|
||||
var formattedWithUnit: String {
|
||||
guard value != nil else { return "–" }
|
||||
return unit.isEmpty ? formatted : "\(formatted) \(unit)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Der komplette, zuletzt empfangene Zustand eines Geräts.
|
||||
struct DeviceSnapshot: Identifiable, Codable, Equatable, Sendable {
|
||||
var id: UUID { deviceID }
|
||||
var deviceID: UUID
|
||||
var timestamp: Date
|
||||
var metrics: [Metric] = []
|
||||
/// z.B. "Bulk", "Float", "Aus"
|
||||
var state: String?
|
||||
/// Klartext einer aktiven Störung, sonst nil.
|
||||
var fault: String?
|
||||
/// Grund, warum das Gerät gerade nicht lädt (Victron Off-Reason).
|
||||
var offReasons: [String] = []
|
||||
var rssi: Int?
|
||||
/// Einzelzellspannungen in Volt (nur BMS).
|
||||
var cellVoltages: [Double] = []
|
||||
/// Temperaturfühler in °C (nur BMS).
|
||||
var temperatures: [Double] = []
|
||||
/// Feste Angaben des Geräts, etwa Modell oder Seriennummer.
|
||||
var info: [InfoItem] = []
|
||||
|
||||
struct InfoItem: Identifiable, Hashable, Codable, Sendable {
|
||||
let label: String
|
||||
let value: String
|
||||
var id: String { label }
|
||||
}
|
||||
|
||||
var primaryMetric: Metric? {
|
||||
metrics.first(where: \.isPrimary) ?? metrics.first
|
||||
}
|
||||
|
||||
var age: TimeInterval { Date().timeIntervalSince(timestamp) }
|
||||
|
||||
/// Werte älter als eine Minute gelten als veraltet – Victron sendet etwa
|
||||
/// jede Sekunde, das Daly wird alle paar Sekunden gepollt.
|
||||
var isStale: Bool { age > 60 }
|
||||
}
|
||||
|
||||
/// Verbindungszustand für die UI.
|
||||
enum DeviceLinkState: Equatable, Codable, Sendable {
|
||||
case idle
|
||||
case searching
|
||||
case connecting
|
||||
case live
|
||||
case needsKey
|
||||
case failed(String)
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .idle: return "Inaktiv"
|
||||
case .searching: return "Suche…"
|
||||
case .connecting: return "Verbinde…"
|
||||
case .live: return "Live"
|
||||
case .needsKey: return "Schlüssel fehlt"
|
||||
case .failed(let m): return m
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
/// Zustand des Neigungsmessers.
|
||||
struct LevelState: Equatable, Codable, Sendable {
|
||||
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
|
||||
/// Bereits auf die Einbaulage des Sensors umgerechnet.
|
||||
var pitch: Double?
|
||||
/// Querneigung: positiv bedeutet, die rechte Seite steht höher.
|
||||
var roll: Double?
|
||||
/// Wie der Sensor selbst meldet – nötig, um die Einbaulage zu bestimmen.
|
||||
var rawPitch: Double?
|
||||
var rawRoll: Double?
|
||||
var pitchOffset: Double?
|
||||
var rollOffset: Double?
|
||||
|
||||
var hasReading: Bool { pitch != nil || roll != nil }
|
||||
|
||||
/// Ob kalibriert wurde – oder nil, wenn das Gerät es nicht verrät.
|
||||
///
|
||||
/// Nicht jede Firmware stellt die Offsets bereit. Fehlen sie, heisst das
|
||||
/// „unbekannt“ und nicht „nicht kalibriert“: eine Warnung, die sich nie
|
||||
/// abstellen lässt, ist schlimmer als keine.
|
||||
var calibrationState: Bool? {
|
||||
guard let pitchOffset, let rollOffset else { return nil }
|
||||
return abs(pitchOffset) > 0.001 || abs(rollOffset) > 0.001
|
||||
}
|
||||
|
||||
/// Nur wahr, wenn es auch belegt ist.
|
||||
var isCalibrated: Bool { calibrationState == true }
|
||||
|
||||
/// Nur wahr, wenn das Gerät ausdrücklich Nulloffsets meldet.
|
||||
var isKnownUncalibrated: Bool { calibrationState == false }
|
||||
|
||||
/// Bis hierhin gilt das Fahrzeug als eben genug.
|
||||
static let levelTolerance = 0.5
|
||||
|
||||
var isLevel: Bool {
|
||||
guard let pitch, let roll else { return false }
|
||||
return abs(pitch) <= Self.levelTolerance && abs(roll) <= Self.levelTolerance
|
||||
}
|
||||
|
||||
/// Die grössere der beiden Abweichungen – das ist die, die man zuerst
|
||||
/// ausgleichen will.
|
||||
var largestDeviation: 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Was zu tun ist, in Worten. Beim Ausrichten schaut man aufs Handy,
|
||||
/// nicht auf Vorzeichen.
|
||||
var instruction: String? {
|
||||
guard let pitch, let roll else { return nil }
|
||||
if isLevel { return "Steht eben" }
|
||||
var parts: [String] = []
|
||||
if abs(pitch) > Self.levelTolerance {
|
||||
parts.append(pitch > 0 ? "Heck steht höher" : "Front steht höher")
|
||||
}
|
||||
if abs(roll) > Self.levelTolerance {
|
||||
parts.append(roll > 0 ? "rechts steht höher" : "links steht höher")
|
||||
}
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
|
||||
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
|
||||
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
|
||||
snapshot.metrics = [
|
||||
Metric("pitch", "Längsneigung", pitch, unit: "°", precision: 1, primary: true),
|
||||
Metric("roll", "Querneigung", roll, unit: "°", precision: 1),
|
||||
]
|
||||
snapshot.state = instruction
|
||||
if isKnownUncalibrated {
|
||||
snapshot.offReasons = ["Nicht kalibriert"]
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
/// Ein Fahrzeug. Jedes Profil hat seinen eigenen Satz Geräte; die App zeigt und
|
||||
/// funkt immer nur für das gerade gewählte.
|
||||
struct Profile: Identifiable, Codable, Hashable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
/// SF-Symbol für die Auswahl im Dashboard.
|
||||
var symbol: String
|
||||
|
||||
/// Spurweite in Metern – für die Berechnung der Auffahrkeile quer.
|
||||
var trackWidth: Double?
|
||||
/// Radstand in Metern – dasselbe längs.
|
||||
var wheelbase: Double?
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
name: String,
|
||||
symbol: String = "box.truck",
|
||||
trackWidth: Double? = nil,
|
||||
wheelbase: Double? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.symbol = symbol
|
||||
self.trackWidth = trackWidth
|
||||
self.wheelbase = wheelbase
|
||||
}
|
||||
|
||||
/// Profile aus der Zeit vor den Fahrzeugmassen haben noch keine.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
symbol = try container.decode(String.self, forKey: .symbol)
|
||||
trackWidth = try container.decodeIfPresent(Double.self, forKey: .trackWidth)
|
||||
wheelbase = try container.decodeIfPresent(Double.self, forKey: .wheelbase)
|
||||
}
|
||||
|
||||
/// Profil, dem Geräte aus der Zeit vor der Profilverwaltung zugeordnet
|
||||
/// werden. Feste Kennung, damit die Zuordnung beim Update erhalten bleibt.
|
||||
static let defaultID = UUID(uuidString: "00000000-0000-0000-0000-00000000C001")!
|
||||
|
||||
static var initial: Profile {
|
||||
Profile(id: defaultID, name: "Mein Camper")
|
||||
}
|
||||
|
||||
/// Auswahl für die Profilbearbeitung.
|
||||
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft – ein nicht
|
||||
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
|
||||
static let symbols = [
|
||||
"box.truck", "truck.pickup.side", "bus", "bus.doubledecker",
|
||||
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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
|
||||
|
||||
/// Verdrehung des Sensors um die Hochachse, in Grad – der Rest, den der
|
||||
/// Achsentausch nicht abdeckt.
|
||||
///
|
||||
/// Sitzt der Sensor schräg im Fahrzeug, verteilt sich eine reine
|
||||
/// Querneigung auf beide Sensorachsen: Das Fahrzeug kippt zur Seite, die
|
||||
/// Anzeige meldet zusätzlich Längsneigung. Achsentausch und Vorzeichen
|
||||
/// helfen dagegen nicht, die springen in 90°-Schritten.
|
||||
///
|
||||
/// Der Wert wird beim Bestimmen der Einbaulage mitgemessen und kostet
|
||||
/// keinen zusätzlichen Handgriff. Beim **Kalibrieren** liesse er sich
|
||||
/// nicht ermitteln: Das misst eine einzige Lage und zieht sie als Nullpunkt
|
||||
/// ab – eine Drehung um die Hochachse ist darin nicht enthalten, denn eben
|
||||
/// sieht in jeder Verdrehung gleich aus.
|
||||
var twist: Double = 0
|
||||
|
||||
static let identity = SensorOrientation()
|
||||
|
||||
init() {}
|
||||
|
||||
/// Von Hand geschrieben, nicht von Swift erzeugt – und das ist der Punkt.
|
||||
///
|
||||
/// Die erzeugte Fassung verlangt beim Lesen jedes Feld; Standardwerte im
|
||||
/// Code zählen dabei nicht. Eine Einbaulage, die vor `twist` gespeichert
|
||||
/// wurde, liesse sich damit nicht mehr lesen, das Gerät dazu ebenso wenig,
|
||||
/// und die Geräteliste bliebe leer. Jedes neue Feld gehört deshalb hier
|
||||
/// hinein, mit `decodeIfPresent` und einem Rückfallwert.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
longitudinalSource = try container.decodeIfPresent(Source.self,
|
||||
forKey: .longitudinalSource) ?? .pitch
|
||||
invertLongitudinal = try container.decodeIfPresent(Bool.self,
|
||||
forKey: .invertLongitudinal) ?? false
|
||||
invertLateral = try container.decodeIfPresent(Bool.self, forKey: .invertLateral) ?? false
|
||||
twist = try container.decodeIfPresent(Double.self, forKey: .twist) ?? 0
|
||||
}
|
||||
|
||||
var isIdentity: Bool { self == .identity }
|
||||
|
||||
/// Rechnet Sensorwerte in Fahrzeugwerte um.
|
||||
///
|
||||
/// Zwei Schritte, in dieser Reihenfolge: erst die grobe Zuordnung der
|
||||
/// Achsen samt Vorzeichen, dann die Verdrehung zurückdrehen. Für kleine
|
||||
/// Winkel verhält sich das Wertepaar wie ein Vektor in der Ebene – genau
|
||||
/// deshalb lässt sich die Verdrehung überhaupt herausrechnen.
|
||||
func apply(pitch: Double?, roll: Double?) -> (pitch: Double?, roll: Double?) {
|
||||
let longitudinal = longitudinalSource == .pitch ? pitch : roll
|
||||
let lateral = longitudinalSource == .pitch ? roll : pitch
|
||||
let mappedLongitudinal = longitudinal.map { invertLongitudinal ? -$0 : $0 }
|
||||
let mappedLateral = lateral.map { invertLateral ? -$0 : $0 }
|
||||
|
||||
guard twist != 0, let long = mappedLongitudinal, let lat = mappedLateral else {
|
||||
return (mappedLongitudinal, mappedLateral)
|
||||
}
|
||||
let angle = twist * .pi / 180
|
||||
return (long * cos(angle) + lat * sin(angle),
|
||||
-long * sin(angle) + lat * cos(angle))
|
||||
}
|
||||
|
||||
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") }
|
||||
if twist != 0 { parts.append(String(format: "um %.0f° verdreht", twist)) }
|
||||
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
|
||||
|
||||
/// Ab hier gilt eine Verdrehung als echt und nicht als Wackeln der Hand.
|
||||
static let minimumTwist = 2.0
|
||||
|
||||
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
|
||||
|
||||
// Was nach dem Achsentausch noch übrig ist, ist die Verdrehung um die
|
||||
// Hochachse. Beim Kippen der Front nach unten dürfte sich nur die
|
||||
// Längsneigung ändern; wandert die Querneigung mit, sitzt der Sensor
|
||||
// schräg – und zwar um genau diesen Winkel.
|
||||
//
|
||||
// Gemessen wird an der Frontbewegung, nicht an der Seitenbewegung: Die
|
||||
// Front lässt sich am Fahrzeug genauer treffen, und beide Schritte
|
||||
// gemeinsam auszuwerten brächte hier nichts, weil eine Verdrehung auf
|
||||
// beide gleich wirkt.
|
||||
let corrected = orientation.apply(pitch: nose.pitch, roll: nose.roll)
|
||||
if let long = corrected.pitch, let lat = corrected.roll {
|
||||
let residual = atan2(lat, long) * 180 / .pi
|
||||
// Unter zwei Grad ist es Messrauschen. Zwei Kippbewegungen von Hand
|
||||
// sind nicht genauer, und eine erfundene Verdrehung wäre schlimmer
|
||||
// als keine.
|
||||
orientation.twist = abs(residual) >= minimumTwist ? residual : 0
|
||||
}
|
||||
return .success(orientation)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user