diff --git a/CamperMonitor/Store/DeviceStore.swift b/CamperMonitor/Store/DeviceStore.swift index ecdade4..4f6c93e 100644 --- a/CamperMonitor/Store/DeviceStore.swift +++ b/CamperMonitor/Store/DeviceStore.swift @@ -9,6 +9,8 @@ final class DeviceStore { private static let devicesKey = "configuredDevices" private static let profilesKey = "profiles" private static let activeProfileKey = "activeProfileID" + /// Hierhin wird gerettet, was sich nicht lesen liess – siehe `loadDevices`. + private static let unreadableDevicesKey = "configuredDevices.unreadable" /// Alle Geräte über alle Profile hinweg. private(set) var devices: [ConfiguredDevice] = [] @@ -16,6 +18,11 @@ final class DeviceStore { private(set) var activeProfileID: UUID = Profile.defaultID + /// Ob beim Start Geräte im Speicher lagen, die sich nicht lesen liessen. + /// Ihre Rohdaten sind aufgehoben, statt beim nächsten Speichern + /// überschrieben zu werden. + private(set) var hasUnreadableDevices = false + /// Zwischenspeicher, damit nicht bei jedem Advertisement die Keychain /// befragt wird – das passiert bis zu mehrmals pro Sekunde. @ObservationIgnored private var keyCache: [UUID: [UInt8]] = [:] @@ -151,9 +158,8 @@ final class DeviceStore { profiles = [.initial] } - if let data = defaults.data(forKey: Self.devicesKey), - let decoded = try? JSONDecoder().decode([ConfiguredDevice].self, from: data) { - devices = decoded + if let data = defaults.data(forKey: Self.devicesKey) { + devices = loadDevices(from: data) } if let raw = defaults.string(forKey: Self.activeProfileKey), @@ -171,6 +177,42 @@ final class DeviceStore { } } + /// Liest die Geräteliste so verlustarm wie möglich. + /// + /// Der einfache Weg – die ganze Liste auf einmal – scheitert vollständig, + /// sobald ein einziges Gerät nicht lesbar ist: etwa weil eine neuere + /// Fassung der App ein Feld hinzugefügt hat, das die ältere nicht kennt, + /// oder umgekehrt. Die Liste wäre dann leer, und das nächste Speichern + /// schriebe diese Leere über den Bestand. Genau so gehen Einrichtungen + /// verloren, ohne dass jemand etwas löscht. + /// + /// Deshalb zweistufig: erst die ganze Liste, und wenn das misslingt, jedes + /// Gerät für sich. Was dabei übrig bleibt, wird behalten; die Rohdaten + /// wandern zusätzlich in einen eigenen Schlüssel, damit sich der Bestand + /// später noch untersuchen lässt. + private func loadDevices(from data: Data) -> [ConfiguredDevice] { + let decoder = JSONDecoder() + if let decoded = try? decoder.decode([ConfiguredDevice].self, from: data) { + return decoded + } + + let salvaged = (try? decoder.decode([Salvage].self, from: data))? + .compactMap(\.device) ?? [] + UserDefaults.standard.set(data, forKey: Self.unreadableDevicesKey) + hasUnreadableDevices = true + return salvaged + } + + /// Hülle, die ein einzelnes unlesbares Gerät verschluckt, statt die ganze + /// Liste scheitern zu lassen. + private struct Salvage: Decodable { + let device: ConfiguredDevice? + + init(from decoder: Decoder) throws { + device = try? ConfiguredDevice(from: decoder) + } + } + private func save() { let defaults = UserDefaults.standard if let data = try? JSONEncoder().encode(devices) { diff --git a/README.md b/README.md index 1a947c2..66b2dbb 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,19 @@ Vorzeichen. Der Assistent unter *Sensor → Einbaulage* klärt das durch zwei Kippbewegungen: einmal die Front nach unten, einmal die linke Seite. Aus der Reaktion ergibt sich die Zuordnung – geraten wird nichts. +Dabei wird auch eine **Verdrehung um die Hochachse** mitgemessen, also der +Fall, dass der Sensor schräg statt längs im Fahrzeug klebt. Ohne diese +Korrektur verteilt sich eine reine Querneigung auf beide Achsen: Das Fahrzeug +kippt zur Seite, und die Längsanzeige kippt sichtbar mit – bei 20° Verdrehung +mit gut einem Drittel des Werts. Der Winkel steht im Ergebnis des Assistenten +(„um 20° verdreht") und wird von da an herausgerechnet. + +Beim **Kalibrieren** liesse sich das nicht ermitteln, und zwar grundsätzlich +nicht: Es misst eine einzige Lage und zieht sie als Nullpunkt ab. Eine Drehung +um die Hochachse steckt darin nicht – eben sieht in jeder Verdrehung gleich +aus. Dafür braucht es zwei Kippbewegungen in bekannte Richtungen, und genau die +macht der Einbaulage-Assistent. + **Danach kalibrieren:** Fahrzeug eben stellen, dann *Auf aktuelle Lage kalibrieren*. Ohne das zeigt die Anzeige die Lage des Sensors, nicht die des Fahrzeugs – je nachdem, wie schief er eingebaut ist. diff --git a/Shared/Models/SensorOrientation.swift b/Shared/Models/SensorOrientation.swift index 2c63043..4f13848 100644 --- a/Shared/Models/SensorOrientation.swift +++ b/Shared/Models/SensorOrientation.swift @@ -23,16 +23,62 @@ struct SensorOrientation: Codable, Equatable, Hashable, Sendable { 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 - return (longitudinal.map { invertLongitudinal ? -$0 : $0 }, - lateral.map { invertLateral ? -$0 : $0 }) + 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 { @@ -41,6 +87,7 @@ struct SensorOrientation: Codable, Equatable, Hashable, Sendable { 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: ", ") } } @@ -59,6 +106,9 @@ enum OrientationDetection { /// 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 @@ -130,6 +180,24 @@ enum OrientationDetection { // 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) } } diff --git a/Tests/main.swift b/Tests/main.swift index 544015e..016a18f 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -970,6 +970,95 @@ let untouched = SensorOrientation.identity.apply(pitch: 1.5, roll: -0.5) checkEqual("unveränderte Lage lässt längs stehen", untouched.pitch, 1.5) checkEqual("unveränderte Lage lässt quer stehen", untouched.roll, -0.5) +// Gespeicherte Einrichtungen müssen ältere Fassungen überleben. Ein neues Feld +// darf das Lesen nicht scheitern lassen – sonst ist die Geräteliste leer, und +// das nächste Speichern schreibt diese Leere über den Bestand. +print("\nGespeicherte Geräte lesen") + +func decodeDevice(_ json: String) -> ConfiguredDevice? { + try? JSONDecoder().decode(ConfiguredDevice.self, from: Data(json.utf8)) +} + +let deviceBeforeTwist = """ +{"role":"leveling","profileID":"00000000-0000-0000-0000-00000000C001", + "name":"Nivellierung","id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B5", + "advertisedName":"vanalign","fridgeZoneMode":"automatic", + "peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DA", + "sensorOrientation":{"invertLongitudinal":false,"invertLateral":true, + "longitudinalSource":"roll"}} +""" +let restoredDevice = decodeDevice(deviceBeforeTwist) +checkEqual("Gerät ohne twist lässt sich weiterhin lesen", restoredDevice?.name, "Nivellierung") +checkEqual("die bekannten Felder der Einbaulage bleiben erhalten", + restoredDevice?.sensorOrientation.longitudinalSource, .roll) +checkEqual("das neue Feld fällt auf null zurück", restoredDevice?.sensorOrientation.twist, 0) + +let deviceWithoutOrientation = """ +{"role":"bms","profileID":"00000000-0000-0000-0000-00000000C001","name":"Bulltron", + "id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B6", + "peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DB"} +""" +checkEqual("Gerät ganz ohne Einbaulage ebenso", + decodeDevice(deviceWithoutOrientation)?.sensorOrientation, .identity) + +checkEqual("eine unbrauchbare Einbaulage bleibt ein Fehler", + decodeDevice(""" +{"role":"bms","profileID":"00000000-0000-0000-0000-00000000C001","name":"Kaputt", + "id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B7", + "peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DC", + "sensorOrientation":{"longitudinalSource":"quer"}} +""") == nil, true) + +// Schräg eingebauter Sensor: um 20 Grad um die Hochachse verdreht. Eine reine +// Kippbewegung nach vorne verteilt sich dann auf beide Sensorachsen. +let twistAngle = 20.0 +func twisted(_ longitudinal: Double, _ lateral: Double) -> OrientationDetection.Reading { + // Umgekehrter Weg zu SensorOrientation.apply: aus Fahrzeugwerten wird das, + // was ein verdrehter Sensor melden würde. + let a = twistAngle * .pi / 180 + return OrientationDetection.Reading(pitch: longitudinal * cos(a) - lateral * sin(a), + roll: longitudinal * sin(a) + lateral * cos(a)) +} + +let noseTwisted = twisted(10, 0) // Front nach unten, sauber um die Querachse +let sideTwisted = twisted(0, 10) // linke Seite nach unten + +if case .success(let skew) = detect(nose: noseTwisted, side: sideTwisted) { + checkEqual("Verdrehung wird erkannt", (skew.twist * 10).rounded() / 10, 20.0) + checkEqual("Achsen bleiben dabei zugeordnet", skew.longitudinalSource, .pitch) + + // Die Probe: zurückgerechnet muss aus der Frontbewegung reine Längsneigung + // werden und aus der Seitenbewegung reine Querneigung. + let front = skew.apply(pitch: noseTwisted.pitch, roll: noseTwisted.roll) + checkEqual("verdreht gemessene Frontbewegung wird reine Längsneigung", + front.pitch.map { ($0 * 10).rounded() / 10 }, 10.0) + checkEqual("und hat keine Querneigung mehr", + front.roll.map { abs($0) < 0.001 }, true) + + let side = skew.apply(pitch: sideTwisted.pitch, roll: sideTwisted.roll) + checkEqual("verdreht gemessene Seitenbewegung wird reine Querneigung", + side.roll.map { ($0 * 10).rounded() / 10 }, 10.0) + checkEqual("und hat keine Längsneigung mehr", + side.pitch.map { abs($0) < 0.001 }, true) +} else { + check("verdrehter Einbau wird ausgewertet", false) +} + +// Genau das, was vorher fehlte: ohne Korrektur schlägt eine reine Querneigung +// auf die Längsanzeige durch – bei 20 Grad Verdrehung mit gut einem Drittel. +let uncorrected = SensorOrientation.identity.apply(pitch: sideTwisted.pitch, + roll: sideTwisted.roll) +checkEqual("ohne Korrektur kippt die Längsanzeige mit", + uncorrected.pitch.map { ($0 * 10).rounded() / 10 }, -3.4) + +// Ein bisschen Wackeln ist keine Verdrehung. +if case .success(let steady) = detect(nose: Reading(pitch: 10, roll: 0.2), + side: Reading(pitch: 0.2, roll: 10)) { + checkEqual("kleiner Rest gilt als Rauschen, nicht als Verdrehung", steady.twist, 0) +} else { + check("saubere Kippbewegung wird ausgewertet", false) +} + let missing = SensorOrientation.identity.apply(pitch: nil, roll: 2) checkEqual("fehlende Werte bleiben leer", missing.pitch == nil, true) checkEqual("vorhandene Werte kommen durch", missing.roll, 2)