forked from fritob/Camper-Monitor
Einbaulage: Verdrehung um die Hochachse messen – und Geräte dabei nicht verlieren
Sitzt der Sensor schräg statt längs im Fahrzeug, 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. Achsentausch und Vorzeichen halfen dagegen nicht, die
springen in 90°-Schritten.
SensorOrientation bekommt deshalb "twist", einen stufenlosen Winkel. Der
Einbaulage-Assistent misst ihn ohne zusätzlichen Handgriff mit: Beim
Kippen der Front nach unten dürfte sich nur die Längsneigung ändern;
wandert die Querneigung mit, ist das der gesuchte Winkel. Unter zwei Grad
gilt es als Wackeln der Hand. Angezeigt wird er in der Zusammenfassung
("um 20° verdreht"), herausgerechnet bei jeder Anzeige.
Beim Kalibrieren liesse sich das grundsätzlich nicht ermitteln: 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.
Das neue Feld hat beim Aufspielen sämtliche eingerichteten Geräte
gekostet, und der Grund gehört hierher: Swifts erzeugte Codable-Umsetzung
verlangt beim Lesen jedes Feld, Standardwerte im Code zählen nicht.
Gespeicherte Einbaulagen hatten kein "twist", also scheiterte das Lesen
der Einbaulage, damit des Geräts, damit der ganzen Liste – und das
nächste Speichern schrieb die leere Liste über den Bestand.
Zwei Vorkehrungen dagegen:
* SensorOrientation liest jetzt von Hand und nachsichtig, mit
decodeIfPresent und Rückfallwerten, wie Profile und ConfiguredDevice es
längst tun. Jedes künftige Feld gehört dort hinein.
* DeviceStore liest die Liste zweistufig: scheitert sie als Ganzes, wird
jedes Gerät einzeln versucht und behalten, was lesbar ist. Die
Rohdaten wandern zusätzlich in einen eigenen Schlüssel, statt beim
nächsten Speichern überschrieben zu werden.
Beides in run-tests.sh abgesichert: die Erkennung und Rückrechnung der
Verdrehung samt Gegenprobe ohne Korrektur, und das Lesen gespeicherter
Geräte ohne "twist", ganz ohne Einbaulage und mit unbrauchbarer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user