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
+60 -5
View File
@@ -15,6 +15,8 @@ struct Discovery: Identifiable, Hashable {
var victronRecordType: UInt8?
var victronProductID: UInt16?
var looksLikeSupported: Bool
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
var isLevelSensor = false
var isVictron: Bool { victronRecordType != nil }
@@ -27,6 +29,7 @@ struct Discovery: Identifiable, Hashable {
if let type = victronRecordType {
return "Victron · " + Self.victronRecordName(type)
}
if isLevelSensor { return "VanAlign Neigungsmesser" }
if looksLikeSupported { return "Sieht nach BMS oder Kühlbox aus" }
return "Bluetooth-Gerät"
}
@@ -134,6 +137,7 @@ final class BluetoothManager: NSObject {
private(set) var diagnostics: [UUID: VictronDiagnostics] = [:]
private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
private(set) var levelStates: [UUID: LevelState] = [:]
private(set) var isBluetoothReady = false
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
@@ -163,6 +167,7 @@ final class BluetoothManager: NSObject {
private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID
private var bmsSessions: [UUID: BMSSession] = [:]
private var levelSessions: [UUID: LevelSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
private var reconnectTimer: DispatchSourceTimer?
private var discoveryFlushTimer: DispatchSourceTimer?
@@ -207,6 +212,7 @@ final class BluetoothManager: NSObject {
isBluetoothReady = true
bluetoothStatusText = "Demo-Modus"
fridgeStates[DemoData.fridge.id] = DemoData.fridgeState
levelStates[DemoData.level.id] = DemoData.levelState
for snapshot in DemoData.snapshots() {
snapshots[snapshot.deviceID] = snapshot
linkStates[snapshot.deviceID] = .live
@@ -248,6 +254,7 @@ final class BluetoothManager: NSObject {
diagnostics = diagnostics.filter { known.contains($0.key) }
bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
fridgeStates = fridgeStates.filter { known.contains($0.key) }
levelStates = levelStates.filter { known.contains($0.key) }
queue.async { self.applyConfiguration(devices) }
}
@@ -302,6 +309,21 @@ final class BluetoothManager: NSObject {
}
}
/// Setzt die aktuelle Lage des Fahrzeugs als neue Null.
func calibrateLevel(for deviceID: UUID) {
queue.async { self.levelSession(for: deviceID)?.calibrate() }
}
/// Verwirft die Kalibrierung.
func resetLevelCalibration(for deviceID: UUID) {
queue.async { self.levelSession(for: deviceID)?.resetCalibration() }
}
private func levelSession(for deviceID: UUID) -> LevelSession? {
guard let entry = managed.values.first(where: { $0.id == deviceID }) else { return nil }
return levelSessions[entry.peripheralID]
}
private func session(for deviceID: UUID) -> BMSSession? {
guard let entry = managed.values.first(where: { $0.id == deviceID }) else { return nil }
return bmsSessions[entry.peripheralID]
@@ -317,10 +339,12 @@ final class BluetoothManager: NSObject {
for (peripheralID, session) in bmsSessions where !wanted.contains(peripheralID) {
session.stop()
bmsSessions[peripheralID] = nil
if let peripheral = connectedPeripherals[peripheralID] {
central?.cancelPeripheralConnection(peripheral)
connectedPeripherals[peripheralID] = nil
}
disconnect(peripheralID)
}
for (peripheralID, session) in levelSessions where !wanted.contains(peripheralID) {
session.stop()
levelSessions[peripheralID] = nil
disconnect(peripheralID)
}
// Zoneneinstellung an bestehende Sitzungen weiterreichen.
for device in devices {
@@ -332,6 +356,12 @@ final class BluetoothManager: NSObject {
startScanning()
}
private func disconnect(_ peripheralID: UUID) {
guard let peripheral = connectedPeripherals[peripheralID] else { return }
central?.cancelPeripheralConnection(peripheral)
connectedPeripherals[peripheralID] = nil
}
private func setDiscovering(_ wanted: Bool) {
guard discovering != wanted else { return }
discovering = wanted
@@ -356,8 +386,10 @@ final class BluetoothManager: NSObject {
reconnectTimer?.cancel(); reconnectTimer = nil
discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
for (_, session) in bmsSessions { session.stop() }
for (_, session) in levelSessions { session.stop() }
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
bmsSessions.removeAll()
levelSessions.removeAll()
connectedPeripherals.removeAll()
}
@@ -514,7 +546,10 @@ final class BluetoothManager: NSObject {
entry.victronRecordType = envelope.recordType
entry.victronProductID = envelope.productID
}
entry.looksLikeSupported = Self.looksLikeSupported(name: entry.name)
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
entry.isLevelSensor = services.contains(LevelSession.serviceUUID)
entry.looksLikeSupported = entry.isLevelSensor
|| Self.looksLikeSupported(name: entry.name)
pendingDiscoveries[peripheral.identifier] = entry
}
@@ -589,6 +624,24 @@ extension BluetoothManager: CBCentralManagerDelegate {
guard let device = managed[peripheral.identifier] else { return }
connectedPeripherals[peripheral.identifier] = peripheral
if device.role == .leveling {
let session = LevelSession(
deviceID: device.id,
peripheral: peripheral,
queue: queue,
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
onStateChange: { [weak self] state in
self?.publish { self?.linkStates[device.id] = state }
},
onLevelState: { [weak self] state in
self?.publish { self?.levelStates[device.id] = state }
}
)
levelSessions[peripheral.identifier] = session
session.start()
return
}
let session = BMSSession(
deviceID: device.id,
peripheral: peripheral,
@@ -624,6 +677,8 @@ extension BluetoothManager: CBCentralManagerDelegate {
error: Error?) {
bmsSessions[peripheral.identifier]?.handleDisconnect()
bmsSessions[peripheral.identifier] = nil
levelSessions[peripheral.identifier]?.handleDisconnect()
levelSessions[peripheral.identifier] = nil
connectedPeripherals[peripheral.identifier] = nil
if let device = managed[peripheral.identifier] {