diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index bf85cd7..18e3283 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -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] { diff --git a/CamperMonitor/Bluetooth/LevelSession.swift b/CamperMonitor/Bluetooth/LevelSession.swift new file mode 100644 index 0000000..6b010b4 --- /dev/null +++ b/CamperMonitor/Bluetooth/LevelSession.swift @@ -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() + } + } +} diff --git a/CamperMonitor/Bluetooth/VanAlignProtocol.swift b/CamperMonitor/Bluetooth/VanAlignProtocol.swift new file mode 100644 index 0000000..2b81d1b --- /dev/null +++ b/CamperMonitor/Bluetooth/VanAlignProtocol.swift @@ -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 + } +} diff --git a/CamperMonitor/Models/ConfiguredDevice.swift b/CamperMonitor/Models/ConfiguredDevice.swift index 37e6cb0..4e513f3 100644 --- a/CamperMonitor/Models/ConfiguredDevice.swift +++ b/CamperMonitor/Models/ConfiguredDevice.swift @@ -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 } } diff --git a/CamperMonitor/Store/DemoData.swift b/CamperMonitor/Store/DemoData.swift index 40209d5..caaedd3 100644 --- a/CamperMonitor/Store/DemoData.swift +++ b/CamperMonitor/Store/DemoData.swift @@ -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. diff --git a/CamperMonitor/Views/AddDeviceView.swift b/CamperMonitor/Views/AddDeviceView.swift index b63653f..cb98135 100644 --- a/CamperMonitor/Views/AddDeviceView.swift +++ b/CamperMonitor/Views/AddDeviceView.swift @@ -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 { diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift index 61a9a1a..cab3f63 100644 --- a/CamperMonitor/Views/DeviceDetailView.swift +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -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 diff --git a/CamperMonitor/Views/LevelView.swift b/CamperMonitor/Views/LevelView.swift new file mode 100644 index 0000000..663ff4b --- /dev/null +++ b/CamperMonitor/Views/LevelView.swift @@ -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) + } + } +} diff --git a/README.md b/README.md index a0dd209..dc21404 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/Tests/main.swift b/Tests/main.swift index bc5b030..7d8caba 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -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) diff --git a/run-tests.sh b/run-tests.sh index 1508856..420bbb6 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -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 \