forked from fritob/Camper-Monitor
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:
@@ -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] {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
/// Hält die Verbindung zum Neigungsmesser.
|
||||
///
|
||||
/// Deutlich einfacher als die BMS-Sitzung: Dienst und Charakteristiken sind
|
||||
/// bekannt, es gibt kein Rahmenprotokoll und keine Protokollerkennung. Jede
|
||||
/// Messgrösse liegt in einer eigenen Charakteristik.
|
||||
///
|
||||
/// Bevorzugt werden die Werte abonniert – die Firmware ab v1.1 schickt sie
|
||||
/// dann von selbst, sobald sie sich ändern. Ältere Firmware bietet nur
|
||||
/// Lesen an; dafür bleibt das Abfragen im Takt als Rückfallebene, damit die
|
||||
/// App auch ohne Neuaufspielen funktioniert.
|
||||
final class LevelSession: NSObject {
|
||||
|
||||
static let serviceUUID = CBUUID(string: VanAlignProtocol.serviceUUID)
|
||||
private static let pitchUUID = CBUUID(string: VanAlignProtocol.pitchUUID)
|
||||
private static let rollUUID = CBUUID(string: VanAlignProtocol.rollUUID)
|
||||
private static let offsetsUUID = CBUUID(string: VanAlignProtocol.offsetsUUID)
|
||||
private static let calibrateUUID = CBUUID(string: VanAlignProtocol.calibrateUUID)
|
||||
|
||||
let deviceID: UUID
|
||||
private let queue: DispatchQueue
|
||||
private let peripheral: CBPeripheral
|
||||
private let onUpdate: (DeviceSnapshot) -> Void
|
||||
private let onStateChange: (DeviceLinkState) -> Void
|
||||
private let onLevelState: (LevelState) -> Void
|
||||
|
||||
private var pitchCharacteristic: CBCharacteristic?
|
||||
private var rollCharacteristic: CBCharacteristic?
|
||||
private var offsetsCharacteristic: CBCharacteristic?
|
||||
private var calibrateCharacteristic: CBCharacteristic?
|
||||
|
||||
private var state = LevelState()
|
||||
private var pollTimer: DispatchSourceTimer?
|
||||
/// Nur nötig, solange das Gerät die Werte nicht von selbst schickt.
|
||||
private var needsPolling = false
|
||||
|
||||
/// Beim Ausrichten schaut man dauernd aufs Gerät, deshalb dichter als bei
|
||||
/// den übrigen Geräten.
|
||||
var pollInterval: TimeInterval = 0.5
|
||||
|
||||
init(deviceID: UUID,
|
||||
peripheral: CBPeripheral,
|
||||
queue: DispatchQueue,
|
||||
onUpdate: @escaping (DeviceSnapshot) -> Void,
|
||||
onStateChange: @escaping (DeviceLinkState) -> Void,
|
||||
onLevelState: @escaping (LevelState) -> Void) {
|
||||
self.deviceID = deviceID
|
||||
self.queue = queue
|
||||
self.peripheral = peripheral
|
||||
self.onUpdate = onUpdate
|
||||
self.onStateChange = onStateChange
|
||||
self.onLevelState = onLevelState
|
||||
super.init()
|
||||
peripheral.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - Lebenszyklus
|
||||
|
||||
func start() {
|
||||
onStateChange(.connecting)
|
||||
peripheral.discoverServices([Self.serviceUUID])
|
||||
}
|
||||
|
||||
func stop() {
|
||||
pollTimer?.cancel()
|
||||
pollTimer = nil
|
||||
if peripheral.state == .connected {
|
||||
for characteristic in [pitchCharacteristic, rollCharacteristic] {
|
||||
guard let characteristic, characteristic.isNotifying else { continue }
|
||||
peripheral.setNotifyValue(false, for: characteristic)
|
||||
}
|
||||
}
|
||||
pitchCharacteristic = nil
|
||||
rollCharacteristic = nil
|
||||
offsetsCharacteristic = nil
|
||||
calibrateCharacteristic = nil
|
||||
}
|
||||
|
||||
func handleDisconnect() {
|
||||
pollTimer?.cancel()
|
||||
pollTimer = nil
|
||||
pitchCharacteristic = nil
|
||||
rollCharacteristic = nil
|
||||
offsetsCharacteristic = nil
|
||||
calibrateCharacteristic = nil
|
||||
}
|
||||
|
||||
// MARK: - Kalibrieren
|
||||
|
||||
/// Setzt die aktuelle Lage als neue Null.
|
||||
func calibrate() {
|
||||
write(VanAlignProtocol.calibrateCommand)
|
||||
}
|
||||
|
||||
/// Verwirft die Kalibrierung.
|
||||
func resetCalibration() {
|
||||
write(VanAlignProtocol.resetCommand)
|
||||
}
|
||||
|
||||
private func write(_ data: Data) {
|
||||
guard let characteristic = calibrateCharacteristic, peripheral.state == .connected else {
|
||||
return
|
||||
}
|
||||
let type: CBCharacteristicWriteType =
|
||||
characteristic.properties.contains(.write) ? .withResponse : .withoutResponse
|
||||
peripheral.writeValue(data, for: characteristic, type: type)
|
||||
// Die Offsets erst nach dem Rechnen im Gerät zurücklesen.
|
||||
queue.asyncAfter(deadline: .now() + 0.4) { [weak self] in
|
||||
self?.readOffsets()
|
||||
}
|
||||
}
|
||||
|
||||
private func readOffsets() {
|
||||
guard let offsetsCharacteristic, peripheral.state == .connected else { return }
|
||||
peripheral.readValue(for: offsetsCharacteristic)
|
||||
}
|
||||
|
||||
// MARK: - Abfrage
|
||||
|
||||
private func startPollingIfNeeded() {
|
||||
guard needsPolling, pollTimer == nil else { return }
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now(), repeating: pollInterval)
|
||||
timer.setEventHandler { [weak self] in
|
||||
guard let self, self.peripheral.state == .connected else { return }
|
||||
if let pitchCharacteristic = self.pitchCharacteristic {
|
||||
self.peripheral.readValue(for: pitchCharacteristic)
|
||||
}
|
||||
if let rollCharacteristic = self.rollCharacteristic {
|
||||
self.peripheral.readValue(for: rollCharacteristic)
|
||||
}
|
||||
}
|
||||
timer.resume()
|
||||
pollTimer = timer
|
||||
}
|
||||
|
||||
private func publish() {
|
||||
guard state.hasReading else { return }
|
||||
onStateChange(.live)
|
||||
onLevelState(state)
|
||||
onUpdate(state.snapshot(deviceID: deviceID, rssi: nil))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBPeripheralDelegate
|
||||
|
||||
extension LevelSession: CBPeripheralDelegate {
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
if let error {
|
||||
onStateChange(.failed(error.localizedDescription))
|
||||
return
|
||||
}
|
||||
guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
|
||||
onStateChange(.failed("Neigungsmesser-Dienst nicht gefunden"))
|
||||
return
|
||||
}
|
||||
peripheral.discoverCharacteristics(
|
||||
[Self.pitchUUID, Self.rollUUID, Self.offsetsUUID, Self.calibrateUUID],
|
||||
for: service
|
||||
)
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral,
|
||||
didDiscoverCharacteristicsFor service: CBService,
|
||||
error: Error?) {
|
||||
guard error == nil, let characteristics = service.characteristics else {
|
||||
onStateChange(.failed(error?.localizedDescription ?? "Keine Merkmale gefunden"))
|
||||
return
|
||||
}
|
||||
|
||||
for characteristic in characteristics {
|
||||
switch characteristic.uuid {
|
||||
case Self.pitchUUID: pitchCharacteristic = characteristic
|
||||
case Self.rollUUID: rollCharacteristic = characteristic
|
||||
case Self.offsetsUUID: offsetsCharacteristic = characteristic
|
||||
case Self.calibrateUUID: calibrateCharacteristic = characteristic
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
guard pitchCharacteristic != nil || rollCharacteristic != nil else {
|
||||
onStateChange(.failed("Neigungswerte nicht gefunden"))
|
||||
return
|
||||
}
|
||||
|
||||
// Abonnieren, wo möglich; sonst im Takt abfragen.
|
||||
var subscribed = false
|
||||
for characteristic in [pitchCharacteristic, rollCharacteristic] {
|
||||
guard let characteristic else { continue }
|
||||
if characteristic.properties.contains(.notify) {
|
||||
peripheral.setNotifyValue(true, for: characteristic)
|
||||
subscribed = true
|
||||
}
|
||||
}
|
||||
needsPolling = !subscribed
|
||||
startPollingIfNeeded()
|
||||
|
||||
// Einmal alles lesen, damit sofort etwas dasteht.
|
||||
for characteristic in [pitchCharacteristic, rollCharacteristic, offsetsCharacteristic] {
|
||||
guard let characteristic, characteristic.properties.contains(.read) else { continue }
|
||||
peripheral.readValue(for: characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral,
|
||||
didUpdateValueFor characteristic: CBCharacteristic,
|
||||
error: Error?) {
|
||||
guard error == nil, let value = characteristic.value else { return }
|
||||
|
||||
switch characteristic.uuid {
|
||||
case Self.pitchUUID:
|
||||
state.pitch = VanAlignProtocol.angle(from: value)
|
||||
case Self.rollUUID:
|
||||
state.roll = VanAlignProtocol.angle(from: value)
|
||||
case Self.offsetsUUID:
|
||||
if let offsets = VanAlignProtocol.offsets(from: value) {
|
||||
state.pitchOffset = offsets.pitch
|
||||
state.rollOffset = offsets.roll
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
publish()
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral,
|
||||
didUpdateNotificationStateFor characteristic: CBCharacteristic,
|
||||
error: Error?) {
|
||||
// Lässt sich nicht abonnieren, dann eben abfragen.
|
||||
if error != nil {
|
||||
needsPolling = true
|
||||
startPollingIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
|
||||
/// Neigungsmesser „VanAlign Pro“ – ein ESP32 mit MPU6050, der Längs- und
|
||||
/// Querneigung des Fahrzeugs über Bluetooth bereitstellt.
|
||||
///
|
||||
/// Anders als die übrigen Geräte gibt es hier kein Rahmenprotokoll: Jede
|
||||
/// Messgrösse liegt in einer eigenen Charakteristik als 32-Bit-Float.
|
||||
enum VanAlignProtocol {
|
||||
|
||||
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
|
||||
static let serviceUUID = "2A24B789-7AAB-4535-AF3E-EE76A35CC42D"
|
||||
|
||||
static let pitchUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233424"
|
||||
static let rollUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233425"
|
||||
/// Zwei Floats: die gespeicherten Kalibrier-Offsets.
|
||||
static let offsetsUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233426"
|
||||
/// Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage.
|
||||
static let calibrateUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427"
|
||||
|
||||
static let calibrateCommand = Data([0x01])
|
||||
static let resetCommand = Data([0x00])
|
||||
|
||||
/// Liest einen Winkel aus vier Bytes, little-endian.
|
||||
///
|
||||
/// Die Firmware legt den Float per `memcpy` ab, und der ESP32 ist
|
||||
/// little-endian – die Reihenfolge steht also fest. Die Web-Oberfläche des
|
||||
/// Ursprungsprojekts probiert zusätzlich die umgekehrte Reihenfolge, falls
|
||||
/// die erste unplausibel aussieht. Das ist nicht nur unnötig, sondern
|
||||
/// schädlich: ein vertauschter Float von 4,25° ergibt gelesen etwa 0,0 und
|
||||
/// wirkt damit völlig plausibel. Ein Vorzeichen- oder Wertfehler bliebe so
|
||||
/// unbemerkt.
|
||||
static func angle(from data: Data) -> Double? {
|
||||
guard data.count >= 4 else { return nil }
|
||||
var raw: UInt32 = 0
|
||||
for (index, byte) in data.prefix(4).enumerated() {
|
||||
raw |= UInt32(byte) << UInt32(8 * index)
|
||||
}
|
||||
let value = Float(bitPattern: raw)
|
||||
// NAN meldet die Firmware, solange der Sensor nichts liefert.
|
||||
guard value.isFinite, abs(value) <= 180 else { return nil }
|
||||
return Double(value)
|
||||
}
|
||||
|
||||
/// Die beiden gespeicherten Offsets.
|
||||
static func offsets(from data: Data) -> (pitch: Double, roll: Double)? {
|
||||
guard data.count >= 8,
|
||||
let pitch = angle(from: data.prefix(4)),
|
||||
let roll = angle(from: data.dropFirst(4).prefix(4)) else { return nil }
|
||||
return (pitch, roll)
|
||||
}
|
||||
}
|
||||
|
||||
/// Zustand des Neigungsmessers.
|
||||
struct LevelState: Equatable {
|
||||
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
|
||||
var pitch: Double?
|
||||
/// Querneigung: positiv bedeutet, die rechte Seite steht höher.
|
||||
var roll: Double?
|
||||
var pitchOffset: Double?
|
||||
var rollOffset: Double?
|
||||
|
||||
var hasReading: Bool { pitch != nil || roll != nil }
|
||||
|
||||
/// Als kalibriert gilt, wo ein Offset hinterlegt ist.
|
||||
var isCalibrated: Bool {
|
||||
guard let pitchOffset, let rollOffset else { return false }
|
||||
return abs(pitchOffset) > 0.001 || abs(rollOffset) > 0.001
|
||||
}
|
||||
|
||||
/// 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 !isCalibrated {
|
||||
snapshot.offReasons = ["Nicht kalibriert"]
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case batteryMonitor
|
||||
case bms
|
||||
case fridge
|
||||
case leveling
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -18,6 +19,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case .batteryMonitor: return "Batteriemonitor"
|
||||
case .bms: return "Batterie / BMS"
|
||||
case .fridge: return "Kühlbox"
|
||||
case .leveling: return "Nivellierung"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +30,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
|
||||
case .bms: return "battery.100percent.bolt"
|
||||
case .fridge: return "refrigerator"
|
||||
case .leveling: return "level"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +38,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
/// braucht eine echte GATT-Verbindung.
|
||||
var transport: DeviceTransport {
|
||||
switch self {
|
||||
case .bms, .fridge: return .connect
|
||||
case .bms, .fridge, .leveling: return .connect
|
||||
default: return .advertisement
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,24 @@ enum DemoData {
|
||||
name: "Kühlbox", role: .fridge, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000F1")!)
|
||||
|
||||
static var devices: [ConfiguredDevice] { [solar, booster, battery, fridge, caravanSolar] }
|
||||
static let level = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000E0")!,
|
||||
name: "Nivellierung", role: .leveling, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000E1")!)
|
||||
|
||||
static var devices: [ConfiguredDevice] {
|
||||
[solar, booster, battery, fridge, level, caravanSolar]
|
||||
}
|
||||
|
||||
/// Leicht schräg stehend, damit die Libelle etwas zu zeigen hat.
|
||||
static var levelState: LevelState {
|
||||
var state = LevelState()
|
||||
state.pitch = 1.8
|
||||
state.roll = -0.9
|
||||
state.pitchOffset = 0.4
|
||||
state.rollOffset = -0.2
|
||||
return state
|
||||
}
|
||||
|
||||
/// Zustand der Demo-Kühlbox: eingeschaltet, Eco, Soll 4 °C, ist 6 °C.
|
||||
static var fridgeState: AlpicoolState {
|
||||
@@ -110,7 +127,9 @@ enum DemoData {
|
||||
]
|
||||
|
||||
return [solarSnapshot, boosterSnapshot, batterySnapshot,
|
||||
fridgeState.snapshot(deviceID: fridge.id, rssi: -66), caravanSnapshot]
|
||||
fridgeState.snapshot(deviceID: fridge.id, rssi: -66),
|
||||
levelState.snapshot(deviceID: level.id, rssi: -70),
|
||||
caravanSnapshot]
|
||||
}
|
||||
|
||||
/// Ein paar Stunden Verlauf, damit die Grafik etwas zeigt.
|
||||
|
||||
@@ -193,6 +193,8 @@ private struct ConfigureDeviceView: View {
|
||||
case .batteryMonitor: role = .batteryMonitor
|
||||
default: role = .solarCharger
|
||||
}
|
||||
} else if discovery.isLevelSensor {
|
||||
role = .leveling
|
||||
} else if let name = discovery.name?.lowercased(),
|
||||
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
|
||||
role = .fridge
|
||||
@@ -202,7 +204,9 @@ private struct ConfigureDeviceView: View {
|
||||
// Funknamen wie "WTaEaAA25342229" taugen nicht als Anzeigename. Die
|
||||
// Art des Geräts ist der bessere Vorschlag; der Funkname steht
|
||||
// ohnehin darüber unter "Gefunden als".
|
||||
if let advertised = discovery.name, advertised.count <= 20,
|
||||
if discovery.isLevelSensor {
|
||||
name = "Nivellierung"
|
||||
} else if let advertised = discovery.name, advertised.count <= 20,
|
||||
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
|
||||
name = advertised
|
||||
} else {
|
||||
|
||||
@@ -30,6 +30,11 @@ struct DeviceDetailView: View {
|
||||
FridgeControls(device: currentDevice, state: fridge)
|
||||
}
|
||||
|
||||
if currentDevice.role == .leveling {
|
||||
LevelControls(device: currentDevice,
|
||||
state: bluetooth.levelStates[device.id] ?? LevelState())
|
||||
}
|
||||
|
||||
if let snapshot, !snapshot.metrics.isEmpty {
|
||||
Section("Messwerte") {
|
||||
ForEach(snapshot.metrics) { metric in
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Grafische Libelle: eine Blase, die zeigt, wohin das Fahrzeug hängt.
|
||||
///
|
||||
/// Die Blase wandert dorthin, wo das Fahrzeug **höher** steht – so, wie sich
|
||||
/// eine echte Wasserwaage verhält. Wer sie mittig haben will, muss also die
|
||||
/// Gegenseite anheben.
|
||||
struct LevelBubble: View {
|
||||
let pitch: Double?
|
||||
let roll: Double?
|
||||
/// Bis zu welcher Neigung die Anzeige ausschlägt.
|
||||
var range: Double = 6
|
||||
|
||||
private var isLevel: Bool {
|
||||
guard let pitch, let roll else { return false }
|
||||
return abs(pitch) <= LevelState.levelTolerance
|
||||
&& abs(roll) <= LevelState.levelTolerance
|
||||
}
|
||||
|
||||
/// Grün nur, wenn es wirklich eben ist. Die Akzentfarbe der App ist selbst
|
||||
/// grün – damit sähe "schief" genauso aus wie "eben".
|
||||
private var bubbleColor: Color {
|
||||
guard let deviation = maxDeviation else { return .secondary }
|
||||
if deviation <= LevelState.levelTolerance { return .green }
|
||||
if deviation <= 2 { return .orange }
|
||||
return .red
|
||||
}
|
||||
|
||||
private var maxDeviation: 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
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let side = min(geometry.size.width, geometry.size.height)
|
||||
let radius = side / 2
|
||||
let bubble = side * 0.16
|
||||
// Die Blase darf den Rand nicht verlassen, auch bei starker Neigung.
|
||||
let travel = radius - bubble / 2 - 4
|
||||
|
||||
let offsetX = clamped(roll) / range * travel
|
||||
let offsetY = clamped(pitch) / range * travel
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(.secondarySystemFill))
|
||||
Circle()
|
||||
.strokeBorder(Color.secondary.opacity(0.35), lineWidth: 1)
|
||||
|
||||
// Ringe als echter Maßstab: der innere markiert die Toleranz,
|
||||
// der mittlere zwei Grad. Ohne Maßstab sagt die Blasenlage
|
||||
// nichts darüber, wie weit es noch ist.
|
||||
let toleranceRadius = max(LevelState.levelTolerance / range * travel, bubble * 0.6)
|
||||
Circle()
|
||||
.strokeBorder(isLevel ? Color.green : Color.secondary.opacity(0.5),
|
||||
lineWidth: isLevel ? 2 : 1)
|
||||
.frame(width: toleranceRadius * 2, height: toleranceRadius * 2)
|
||||
Circle()
|
||||
.strokeBorder(Color.secondary.opacity(0.3), lineWidth: 1)
|
||||
.frame(width: 2.0 / range * travel * 2, height: 2.0 / range * travel * 2)
|
||||
|
||||
Path { path in
|
||||
path.move(to: CGPoint(x: radius - travel, y: radius))
|
||||
path.addLine(to: CGPoint(x: radius + travel, y: radius))
|
||||
path.move(to: CGPoint(x: radius, y: radius - travel))
|
||||
path.addLine(to: CGPoint(x: radius, y: radius + travel))
|
||||
}
|
||||
.stroke(Color.secondary.opacity(0.25), lineWidth: 1)
|
||||
|
||||
Circle()
|
||||
.fill(bubbleColor)
|
||||
.frame(width: bubble, height: bubble)
|
||||
// Positiver Pitch heisst: das Heck steht höher, die Blase
|
||||
// wandert also nach oben – in der Ansicht nach hinten.
|
||||
.offset(x: offsetX, y: -offsetY)
|
||||
.animation(.spring(duration: 0.35), value: offsetX)
|
||||
.animation(.spring(duration: 0.35), value: offsetY)
|
||||
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
|
||||
}
|
||||
.frame(width: side, height: side)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.accessibilityLabel(accessibilityText)
|
||||
}
|
||||
|
||||
private func clamped(_ value: Double?) -> Double {
|
||||
guard let value else { return 0 }
|
||||
return min(max(value, -range), range)
|
||||
}
|
||||
|
||||
private var accessibilityText: String {
|
||||
guard let pitch, let roll else { return "Keine Messwerte" }
|
||||
return String(format: "Längsneigung %.1f Grad, Querneigung %.1f Grad", pitch, roll)
|
||||
}
|
||||
}
|
||||
|
||||
/// Anzeige und Kalibrierung des Neigungsmessers.
|
||||
struct LevelControls: View {
|
||||
let device: ConfiguredDevice
|
||||
let state: LevelState
|
||||
|
||||
@Environment(BluetoothManager.self) private var bluetooth
|
||||
@State private var showResetConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
VStack(spacing: 16) {
|
||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(maxHeight: 220)
|
||||
|
||||
if let instruction = state.instruction {
|
||||
Label(instruction,
|
||||
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||
.font(.headline)
|
||||
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
HStack(spacing: 24) {
|
||||
reading("Längs", state.pitch)
|
||||
reading("Quer", state.roll)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Auf aktuelle Lage kalibrieren", systemImage: "scope") {
|
||||
bluetooth.calibrateLevel(for: device.id)
|
||||
}
|
||||
if state.isCalibrated {
|
||||
Button("Kalibrierung zurücksetzen", systemImage: "arrow.uturn.backward", role: .destructive) {
|
||||
showResetConfirmation = true
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Kalibrierung")
|
||||
} footer: {
|
||||
if state.isCalibrated, let pitchOffset = state.pitchOffset,
|
||||
let rollOffset = state.rollOffset {
|
||||
Text(String(format: "Nullpunkt liegt bei %.1f° längs und %.1f° quer. "
|
||||
+ "Zum Kalibrieren das Fahrzeug eben stellen und dann tippen.",
|
||||
pitchOffset, rollOffset))
|
||||
} else {
|
||||
Text("Noch nicht kalibriert – die Anzeige zeigt die Lage des Sensors, "
|
||||
+ "nicht die des Fahrzeugs. Fahrzeug eben stellen und dann kalibrieren.")
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Kalibrierung zurücksetzen?",
|
||||
isPresented: $showResetConfirmation,
|
||||
titleVisibility: .visible) {
|
||||
Button("Zurücksetzen", role: .destructive) {
|
||||
bluetooth.resetLevelCalibration(for: device.id)
|
||||
}
|
||||
} message: {
|
||||
Text("Die Anzeige zeigt danach wieder die Lage des Sensors.")
|
||||
}
|
||||
}
|
||||
|
||||
private func reading(_ title: String, _ value: Double?) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(value.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.font(.title2.weight(.semibold).monospacedDigit())
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest:
|
||||
| Victron Solarladeregler (SmartSolar MPPT) | Instant Readout im Advertisement | PV-Leistung, Batteriespannung, Ladestrom, Tagesertrag, Laststrom, Ladezustand (Bulk/Absorption/Float) |
|
||||
| Batterie-BMS (Bulltron/Daly, WattCycle) | GATT-Verbindung, alle 5 s abgefragt | SoC, Spannung, Strom, Restkapazität, alle Einzelzellspannungen, Zelldifferenz, Temperaturen, Zyklen, MOSFET-Status |
|
||||
| Kompressor-Kühlbox (Alpicool und Baugleiche) | GATT-Verbindung, lesen **und** stellen | Ist- und Solltemperatur je Zone, Betriebsart, Kompressorstatus, Bordspannung, Batterieanzeige |
|
||||
| Neigungsmesser (VanAlign Pro) | GATT-Verbindung | Längs- und Querneigung als Libelle, Kalibrierung aus der App |
|
||||
|
||||
Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt.
|
||||
|
||||
@@ -121,6 +122,26 @@ sie mit, was einen Quervergleich zu Batterie und Solarregler erlaubt.
|
||||
Protokoll und Feldbelegung stammen aus
|
||||
[Gruni22/alpicool_ha_ble](https://github.com/Gruni22/alpicool_ha_ble).
|
||||
|
||||
### Nivellierung
|
||||
|
||||
Der Neigungsmesser [VanAlign Pro](https://github.com/) ist ein ESP32 mit
|
||||
MPU6050, der Längs- und Querneigung über Bluetooth bereitstellt. Er bewirbt
|
||||
seinen Dienst, wird beim Einrichten also sicher erkannt und die Art
|
||||
vorbelegt.
|
||||
|
||||
Die Detailansicht zeigt eine Libelle: die Blase wandert dorthin, wo das
|
||||
Fahrzeug höher steht, wie bei einer echten Wasserwaage. Grün heißt eben
|
||||
(bis 0,5°), orange bis zwei Grad, darüber rot. Dazu steht in Worten, welche
|
||||
Seite höher steht.
|
||||
|
||||
**Vor der ersten Nutzung 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.
|
||||
|
||||
Die App kommt auch mit der alten Firmware 1.0 zurecht: bietet das Gerät kein
|
||||
`notify` an, werden die Werte im Takt abgefragt. Kalibrieren aus der App
|
||||
braucht allerdings Firmware 1.1.
|
||||
|
||||
## Mehrere Fahrzeuge
|
||||
|
||||
Oben links im Dashboard sitzt der Fahrzeugwechsel. Jedes Profil hat seinen
|
||||
@@ -165,6 +186,8 @@ CamperMonitor/
|
||||
│ ├── JBDProtocol.swift JBD/Xiaoxiang, Rahmen und Auswertung
|
||||
│ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung
|
||||
│ ├── AlpicoolProtocol.swift Kühlboxen: Auswertung und Stellbefehle
|
||||
│ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung
|
||||
│ ├── LevelSession.swift Verbindung zum Neigungsmesser
|
||||
│ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage
|
||||
├── Store/
|
||||
│ ├── DeviceStore.swift Geräteliste, Persistenz
|
||||
@@ -175,6 +198,7 @@ CamperMonitor/
|
||||
├── DeviceDetailView.swift Alle Werte, Verlauf, Zellspannungen, Diagnose
|
||||
├── ProfilesView.swift Fahrzeuge anlegen und verwalten
|
||||
├── FridgeControls.swift Bedienelemente der Kühlbox
|
||||
├── LevelView.swift Libelle und Kalibrierung
|
||||
└── AddDeviceView.swift Scannen und Einrichten
|
||||
```
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,7 @@ swiftc -O -o "$OUT/tests" \
|
||||
CamperMonitor/Bluetooth/JBDProtocol.swift \
|
||||
CamperMonitor/Bluetooth/WattCycleProtocol.swift \
|
||||
CamperMonitor/Bluetooth/AlpicoolProtocol.swift \
|
||||
CamperMonitor/Bluetooth/VanAlignProtocol.swift \
|
||||
CamperMonitor/Models/DeviceSnapshot.swift \
|
||||
CamperMonitor/Models/ConfiguredDevice.swift \
|
||||
CamperMonitor/Models/Profile.swift \
|
||||
|
||||
Reference in New Issue
Block a user