Neigungsmesser VanAlign einbinden

Das ESPHome-Projekt VanAlign Pro misst über einen MPU6050 die Längs- und
Querneigung des Fahrzeugs und stellt sie über Bluetooth bereit. Damit
lässt sich der Camper beim Parken ausrichten.

Der Neigungsmesser bewirbt seinen Dienst, wird beim Einrichten also
sicher erkannt und die Geräteart vorbelegt. Anders als bei den übrigen
verbundenen Geräten gibt es hier kein Rahmenprotokoll: jede Messgrösse
liegt in einer eigenen Charakteristik als Float. LevelSession ist deshalb
eine eigene, deutlich einfachere Sitzungsart neben BMSSession - ohne
Protokollerkennung und ohne Kandidatensuche.

Bevorzugt werden die Werte abonniert; bietet das Gerät das nicht an, wird
im Takt abgefragt. Damit läuft die App auch mit der alten Firmware 1.0,
die nur Lesen kennt.

Die Detailansicht zeigt eine Libelle: die Blase wandert dorthin, wo das
Fahrzeug höher steht. Grün bis 0,5 Grad, orange bis zwei, darüber rot -
die Akzentfarbe der App ist selbst grün, "schief" hätte sonst genauso
ausgesehen wie "eben". Die Ringe sind ein echter Massstab (Toleranz und
zwei Grad), sonst sagt die Blasenlage nichts über die verbleibende
Abweichung. Dazu die Ansage in Worten, welche Seite höher steht.

Die Winkel werden fest little-endian gelesen. Die Web-Oberfläche des
Ursprungsprojekts probiert zusätzlich die umgekehrte Reihenfolge, falls
die erste unplausibel wirkt. Das ist nicht nur unnötig, sondern
schädlich: ein vertauschter Float von 4,25 Grad liest sich als etwa 0,0
und wirkt damit völlig plausibel - der Fehler bliebe unbemerkt. Ein Test
hält das fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-30 15:05:34 +02:00
co-authored by Claude Opus 5
parent 1ad476a963
commit 1b9486aad4
11 changed files with 724 additions and 9 deletions
+74
View File
@@ -590,5 +590,79 @@ checkEqual("falsche Prüfsumme wird verworfen",
checkEqual("angefangenes Paket wird aufgehoben",
AlpicoolProtocol.extractFrames(from: Array(fridgeStream.prefix(10))).frames.count, 0)
// MARK: 10 VanAlign Neigungsmesser
print("\nVanAlign-Neigungsmesser")
/// So legt der ESP32 einen Float ab: memcpy, also little-endian.
func floatBytes(_ value: Float, littleEndian: Bool = true) -> [UInt8] {
let raw = value.bitPattern
let bytes = (0..<4).map { UInt8((raw >> UInt32(8 * $0)) & 0xFF) }
return littleEndian ? bytes : bytes.reversed()
}
checkEqual("positiver Winkel",
VanAlignProtocol.angle(from: Data(floatBytes(2.5))).map(round2), 2.5)
checkEqual("negativer Winkel",
VanAlignProtocol.angle(from: Data(floatBytes(-3.75))).map(round2), -3.75)
checkEqual("Null",
VanAlignProtocol.angle(from: Data(floatBytes(0))).map(round2), 0)
checkEqual("zu kurze Daten liefern nichts",
VanAlignProtocol.angle(from: Data([0x01, 0x02])) == nil, true)
// Vertauschte Bytes ergeben etwas völlig anderes und sind nicht als Fehler
// erkennbar - ein vertauschtes 4,25 liest sich als etwa 0. Genau deshalb wird
// gar nicht erst geraten, sondern fest little-endian gelesen.
check("vertauschte Bytes ergeben nicht denselben Winkel",
VanAlignProtocol.angle(from: Data(floatBytes(4.25, littleEndian: false)))
.map { abs($0 - 4.25) > 0.1 } ?? true)
checkEqual("unplausibel grosser Winkel wird verworfen",
VanAlignProtocol.angle(from: Data(floatBytes(5000))) == nil, true)
// NaN meldet die Firmware, solange der Sensor keine Werte liefert.
checkEqual("NaN gilt nicht als Messwert",
VanAlignProtocol.angle(from: Data(floatBytes(Float.nan))) == nil, true)
let offsetData = Data(floatBytes(1.5) + floatBytes(-2.25))
let offsets = VanAlignProtocol.offsets(from: offsetData)
checkEqual("Offset längs", offsets?.pitch.rounded(), 2)
checkEqual("Offset quer", offsets.map { ($0.roll * 100).rounded() / 100 }, -2.25)
checkEqual("halbe Offset-Daten liefern nichts",
VanAlignProtocol.offsets(from: Data(floatBytes(1.5))) == nil, true)
checkEqual("Kalibrierbefehl", [UInt8](VanAlignProtocol.calibrateCommand), [0x01])
checkEqual("Rücksetzbefehl", [UInt8](VanAlignProtocol.resetCommand), [0x00])
// Zustand und Handlungsanweisung
var level = LevelState()
level.pitch = 0.2; level.roll = -0.3
checkEqual("kleine Abweichung gilt als eben", level.isLevel, true)
checkEqual("Ansage bei ebenem Stand", level.instruction, "Steht eben")
level.pitch = 2.4; level.roll = 0.1
checkEqual("Längsneigung bricht die Waage", level.isLevel, false)
checkEqual("Ansage nennt die hohe Seite", level.instruction, "Heck steht höher")
checkEqual("grösste Abweichung", level.largestDeviation.map(round2), 2.4)
level.pitch = -1.0; level.roll = -2.0
checkEqual("beide Achsen in der Ansage", level.instruction,
"Front steht höher, links steht höher")
checkEqual("grösste Abweichung über beide Achsen", level.largestDeviation.map(round2), 2.0)
checkEqual("ohne Offsets gilt das Gerät als unkalibriert", level.isCalibrated, false)
level.pitchOffset = 0; level.rollOffset = 0
checkEqual("Nulloffsets sind keine Kalibrierung", level.isCalibrated, false)
level.pitchOffset = 1.2
checkEqual("ein gesetzter Offset genügt", level.isCalibrated, true)
let levelSnapshot = level.snapshot(deviceID: UUID(), rssi: nil)
checkEqual("Längsneigung ist der Hauptwert", levelSnapshot.primaryMetric?.key, "pitch")
checkEqual("Messwerte beider Achsen", levelSnapshot.metrics.count, 2)
var uncalibrated = LevelState()
uncalibrated.pitch = 0; uncalibrated.roll = 0
checkEqual("fehlende Kalibrierung wird gemeldet",
uncalibrated.snapshot(deviceID: UUID(), rssi: nil).offReasons.first,
"Nicht kalibriert")
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
exit(failures == 0 ? 0 : 1)