diff --git a/CamperMonitor/Bluetooth/BMSSession.swift b/CamperMonitor/Bluetooth/BMSSession.swift index c1ec4f6..04395aa 100644 --- a/CamperMonitor/Bluetooth/BMSSession.swift +++ b/CamperMonitor/Bluetooth/BMSSession.swift @@ -4,22 +4,27 @@ import Foundation /// Hält die GATT-Verbindung zu einem BMS, pollt die Werte und meldet fertige /// Snapshots zurück. /// -/// Unterstützt drei Dialekte und erkennt selbst, welchen das Gerät spricht: +/// Zwei Dinge sind bei diesen Geräten nicht vorhersehbar und werden deshalb +/// ausprobiert statt vorausgesetzt: /// -/// * **Daly klassisch** – 13-Byte-Rahmen, beginnend mit `A5` -/// * **Daly Modbus** – `D2 03 …`, neuere Daly-Firmware -/// * **JBD / Xiaoxiang** – `DD A5 …`, u.a. in WattCycle-Akkus -/// -/// Auch die BLE-Charakteristiken werden gesucht statt vorausgesetzt: die Module -/// unterscheiden sich zwischen Herstellern und Fertigungschargen. +/// 1. **Über welche Charakteristiken gesprochen wird.** Im selben Dienst sehen +/// oft mehrere Charakteristiken beschreibbar aus, nur eine nimmt aber +/// wirklich Kommandos an. Die Session stellt alle sinnvollen Paare aus +/// Schreib- und Benachrichtigungs-Charakteristik zusammen und arbeitet sie +/// der Reihe nach ab, bis eines antwortet. +/// 2. **Welches Protokoll gesprochen wird.** Auf jedem Paar werden Daly +/// (klassisch und Modbus) und JBD/Xiaoxiang angefragt; der erste gültige +/// Rahmen legt den Dialekt fest. final class BMSSession: NSObject { - /// Bekannte Dienste, in Reihenfolge der Wahrscheinlichkeit. - private static let preferredServices: [CBUUID] = [ - CBUUID(string: "FFF0"), // Daly - CBUUID(string: "FF00"), // JBD - CBUUID(string: "FFE0"), - CBUUID(string: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"), // Nordic UART + /// Bekannte Paare, die zuerst versucht werden. + private static let knownPairs: [(service: String, write: String, notify: String)] = [ + ("FFF0", "FFF2", "FFF1"), // Daly und viele baugleiche Module + ("FF00", "FF02", "FF01"), // JBD / Xiaoxiang + ("FFE0", "FFE1", "FFE1"), + ("6E400001-B5A3-F393-E0A9-E50E24DCCA9E", + "6E400002-B5A3-F393-E0A9-E50E24DCCA9E", + "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"), // Nordic UART ] enum Dialect: String { @@ -29,25 +34,51 @@ final class BMSSession: NSObject { case jbd = "JBD / Xiaoxiang" } + /// Ein Kandidat: worüber geschrieben, worüber gelauscht und wie geschrieben + /// wird. Der Schreibmodus gehört dazu, weil manche Module nur die eine oder + /// nur die andere Variante annehmen. + private struct Endpoint { + let write: CBCharacteristic + let notify: CBCharacteristic + let writeType: CBCharacteristicWriteType + let isKnownPair: Bool + + var label: String { + let mode = writeType == .withoutResponse ? "ohne Bestätigung" : "mit Bestätigung" + return "\(write.uuid.uuidString) → \(notify.uuid.uuidString), \(mode)" + } + } + let deviceID: UUID private let peripheral: CBPeripheral private let onUpdate: (DeviceSnapshot) -> Void private let onStateChange: (DeviceLinkState) -> Void private let onDiagnostics: (BMSDiagnostics) -> Void - private var writeCharacteristic: CBCharacteristic? - private var notifyCharacteristic: CBCharacteristic? + private var endpoints: [Endpoint] = [] + private var endpointIndex = 0 + private var pendingServices = 0 private(set) var dialect: Dialect = .unknown private var dalyState = DalyState() private var jbdState = JBDState() private var buffer: [UInt8] = [] private var pollTimer: Timer? - private var silentRounds = 0 private var lastResponse: Data? + private var receivedByteCount = 0 + private var sentFrameCount = 0 + private var gattSummary: [String] = [] + /// Runden ohne verwertbare Antwort auf dem aktuellen Kandidaten. + private var silentRounds = 0 - /// Abstand zwischen zwei Abfragerunden. + /// Abstand zwischen zwei Abfragerunden im Normalbetrieb. var pollInterval: TimeInterval = 5 + /// Kürzer, solange noch gesucht wird – sonst dauert das Durchprobieren lang. + private var searchInterval: TimeInterval = 8 + + private var currentEndpoint: Endpoint? { + endpoints.indices.contains(endpointIndex) ? endpoints[endpointIndex] : nil + } init(deviceID: UUID, peripheral: CBPeripheral, @@ -73,11 +104,11 @@ final class BMSSession: NSObject { func stop() { pollTimer?.invalidate() pollTimer = nil - if let notifyCharacteristic, peripheral.state == .connected { - peripheral.setNotifyValue(false, for: notifyCharacteristic) + if let notify = currentEndpoint?.notify, peripheral.state == .connected { + peripheral.setNotifyValue(false, for: notify) } - writeCharacteristic = nil - notifyCharacteristic = nil + endpoints.removeAll() + endpointIndex = 0 dialect = .unknown buffer.removeAll() } @@ -85,80 +116,148 @@ final class BMSSession: NSObject { func handleDisconnect() { pollTimer?.invalidate() pollTimer = nil - writeCharacteristic = nil - notifyCharacteristic = nil + endpoints.removeAll() + endpointIndex = 0 buffer.removeAll() } + // MARK: - Kandidaten + + /// Stellt nach der Dienstsuche alle Paare zusammen: bekannte Kombinationen + /// zuerst, danach jede andere Schreib-/Notify-Kombination im selben Dienst. + private func buildEndpoints() { + var candidates: [Endpoint] = [] + + for service in peripheral.services ?? [] { + let characteristics = service.characteristics ?? [] + let writable = characteristics.filter { + $0.properties.contains(.write) || $0.properties.contains(.writeWithoutResponse) + } + let notifying = characteristics.filter { + $0.properties.contains(.notify) || $0.properties.contains(.indicate) + } + guard !writable.isEmpty, !notifying.isEmpty else { continue } + + for write in writable { + for notify in notifying { + let known = Self.knownPairs.contains { + CBUUID(string: $0.service) == service.uuid + && CBUUID(string: $0.write) == write.uuid + && CBUUID(string: $0.notify) == notify.uuid + } + // Beide Schreibarten anbieten, sofern das Gerät sie kann. + if write.properties.contains(.writeWithoutResponse) { + candidates.append(Endpoint(write: write, notify: notify, + writeType: .withoutResponse, isKnownPair: known)) + } + if write.properties.contains(.write) { + candidates.append(Endpoint(write: write, notify: notify, + writeType: .withResponse, isKnownPair: known)) + } + } + } + } + + // Bekannte Paare nach vorn, der Rest in Fundreihenfolge. + endpoints = candidates.sorted { lhs, rhs in + lhs.isKnownPair && !rhs.isKnownPair + } + endpointIndex = 0 + + guard !endpoints.isEmpty else { + onStateChange(.failed("Keine passenden Bluetooth-Merkmale gefunden")) + publishDiagnostics() + return + } + activateCurrentEndpoint() + } + + private func activateCurrentEndpoint() { + guard let endpoint = currentEndpoint else { return } + silentRounds = 0 + buffer.removeAll() + peripheral.setNotifyValue(true, for: endpoint.notify) + publishDiagnostics() + } + + /// Wechselt auf den nächsten Kandidaten. Sind alle durch, wird von vorn + /// begonnen – das Gerät kann zwischenzeitlich aufgewacht sein. + private func advanceEndpoint() { + guard let previous = currentEndpoint else { return } + if peripheral.state == .connected { + peripheral.setNotifyValue(false, for: previous.notify) + } + endpointIndex = (endpointIndex + 1) % endpoints.count + onStateChange(.connecting) + activateCurrentEndpoint() + beginPolling() + } + // MARK: - Abfrage private func beginPolling() { pollTimer?.invalidate() - onStateChange(.live) poll() - pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in + let interval = dialect == .unknown ? searchInterval : pollInterval + pollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in self?.poll() } } private func poll() { - guard peripheral.state == .connected, writeCharacteristic != nil else { return } + guard peripheral.state == .connected, currentEndpoint != nil else { return } switch dialect { case .unknown: - probeDialects() + // Alle drei Protokolle anfragen; was antwortet, gewinnt. + sendSequence([ + DalyProtocol.requestFrame(.soc), + JBDProtocol.requestFrame(.basicInfo), + DalyProtocol.modbusReadFrame(), + ], spacing: 1.2, thenGiveUpAfter: 5) case .dalyClassic: - sendSequence(DalyProtocol.Command.allCases.map { DalyProtocol.requestFrame($0) }) + sendSequence(DalyProtocol.Command.allCases.map { DalyProtocol.requestFrame($0) }, + spacing: 0.25, thenGiveUpAfter: 2) case .dalyModbus: - sendSequence([DalyProtocol.modbusReadFrame()]) + sendSequence([DalyProtocol.modbusReadFrame()], spacing: 0.25, thenGiveUpAfter: 2) case .jbd: - sendSequence(JBDProtocol.Command.allCases.map { JBDProtocol.requestFrame($0) }) + sendSequence(JBDProtocol.Command.allCases.map { JBDProtocol.requestFrame($0) }, + spacing: 0.25, thenGiveUpAfter: 2) } } - /// Nacheinander alle bekannten Anfragen schicken. Der erste gültige Rahmen - /// in der Antwort legt den Dialekt fest. - private func probeDialects() { - let probes: [Data] = [ - DalyProtocol.requestFrame(.soc), - JBDProtocol.requestFrame(.basicInfo), - DalyProtocol.modbusReadFrame(), - ] - for (index, probe) in probes.enumerated() { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 1.5) { [weak self] in - guard let self, self.dialect == .unknown else { return } - self.send(probe) - } - } - checkForSilence(after: Double(probes.count) * 1.5 + 1.5) - } - /// Kommandos leicht versetzt senden – manche Module verschlucken Anfragen, /// die zu dicht aufeinander folgen. - private func sendSequence(_ frames: [Data]) { + private func sendSequence(_ frames: [Data], spacing: TimeInterval, thenGiveUpAfter grace: TimeInterval) { for (index, frame) in frames.enumerated() { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.25) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * spacing) { [weak self] in self?.send(frame) } } - checkForSilence(after: Double(frames.count) * 0.25 + 2) + checkForSilence(after: Double(frames.count) * spacing + grace) } - /// Kommt mehrere Runden nichts Brauchbares zurück, wird der erkannte - /// Dialekt verworfen und neu gesucht. + /// Kommt nichts Brauchbares zurück, wird der nächste Kandidat versucht. private func checkForSilence(after delay: TimeInterval) { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self else { return } + guard let self, self.peripheral.state == .connected else { return } guard !self.hasUsableData else { self.silentRounds = 0 return } self.silentRounds += 1 - if self.silentRounds >= 3 { - self.silentRounds = 0 + self.publishDiagnostics() + + // Solange noch kein Protokoll steht, zügig weiterprobieren. + let limit = self.dialect == .unknown ? 1 : 3 + guard self.silentRounds > limit else { return } + + self.silentRounds = 0 + if self.endpoints.count > 1 { + self.advanceEndpoint() + } else { self.dialect = .unknown - self.onStateChange(.failed("Keine verwertbare Antwort vom BMS")) - self.publishDiagnostics() + self.onStateChange(.failed("Keine Antwort vom BMS")) } } } @@ -168,26 +267,24 @@ final class BMSSession: NSObject { } private func send(_ data: Data) { - guard let characteristic = writeCharacteristic else { return } - let type: CBCharacteristicWriteType = - characteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse - peripheral.writeValue(data, for: characteristic, type: type) + guard let endpoint = currentEndpoint, peripheral.state == .connected else { return } + sentFrameCount += 1 + peripheral.writeValue(data, for: endpoint.write, type: endpoint.writeType) } // MARK: - Auswertung private func consume(_ data: Data) { lastResponse = data + receivedByteCount += data.count buffer.append(contentsOf: [UInt8](data)) if buffer.count > 512 { buffer.removeFirst(buffer.count - 512) } - // JBD zuerst: der Rahmen ist durch Start-, Endbyte und Prüfsumme - // eindeutig und kann nicht mit den Daly-Rahmen verwechselt werden. + // JBD zuerst: Start-, Endbyte und Prüfsumme machen den Rahmen eindeutig. let (jbdFrames, jbdRemainder) = JBDProtocol.extractFrames(from: buffer) if !jbdFrames.isEmpty { buffer = jbdRemainder - dialect = .jbd - silentRounds = 0 + adopt(.jbd) for frame in jbdFrames { jbdState.apply(frame) } publish(jbdState.snapshot(deviceID: deviceID, rssi: nil), usable: jbdState.hasUsableData) return @@ -196,8 +293,7 @@ final class BMSSession: NSObject { if let start = buffer.firstIndex(where: { $0 == 0xD2 }), let registers = DalyProtocol.parseModbusResponse(Array(buffer[start...])) { buffer.removeAll() - dialect = .dalyModbus - silentRounds = 0 + adopt(.dalyModbus) dalyState.apply(registers: registers) publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData) return @@ -206,17 +302,26 @@ final class BMSSession: NSObject { let (dalyFrames, dalyRemainder) = DalyProtocol.extractA5Frames(from: buffer) if !dalyFrames.isEmpty { buffer = dalyRemainder - dialect = .dalyClassic - silentRounds = 0 + adopt(.dalyClassic) for frame in dalyFrames { dalyState.apply(frame) } publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData) return } - // Nichts erkannt – trotzdem melden, damit die Diagnose etwas zeigt. + // Etwas kam an, ließ sich aber nicht zuordnen: für die Diagnose sichtbar + // machen, damit sich das Protokoll nachträglich bestimmen lässt. publishDiagnostics() } + /// Erster verwertbarer Rahmen: Kandidat und Dialekt stehen fest, ab jetzt + /// im normalen Takt abfragen. + private func adopt(_ newDialect: Dialect) { + silentRounds = 0 + guard dialect != newDialect else { return } + dialect = newDialect + beginPolling() + } + private func publish(_ snapshot: DeviceSnapshot, usable: Bool) { publishDiagnostics() guard usable else { return } @@ -227,13 +332,30 @@ final class BMSSession: NSObject { private func publishDiagnostics() { onDiagnostics(BMSDiagnostics( dialect: dialect.rawValue, - serviceUUID: writeCharacteristic?.service?.uuid.uuidString, - writeUUID: writeCharacteristic?.uuid.uuidString, - notifyUUID: notifyCharacteristic?.uuid.uuidString, + endpointLabel: currentEndpoint?.label, + endpointPosition: endpoints.isEmpty ? nil : .init(endpointIndex + 1, endpoints.count), + serviceUUID: currentEndpoint?.write.service?.uuid.uuidString, + gattSummary: gattSummary, + sentFrames: sentFrameCount, + receivedBytes: receivedByteCount, lastResponseHex: lastResponse.map { $0.map { String(format: "%02X", $0) }.joined(separator: " ") }, updated: Date() )) } + + /// Menschenlesbarer GATT-Baum für die Diagnose. + private func appendToSummary(_ service: CBService) { + gattSummary.append("Dienst \(service.uuid.uuidString)") + for characteristic in service.characteristics ?? [] { + var traits: [String] = [] + if characteristic.properties.contains(.read) { traits.append("read") } + if characteristic.properties.contains(.write) { traits.append("write") } + if characteristic.properties.contains(.writeWithoutResponse) { traits.append("write-nr") } + if characteristic.properties.contains(.notify) { traits.append("notify") } + if characteristic.properties.contains(.indicate) { traits.append("indicate") } + gattSummary.append(" \(characteristic.uuid.uuidString) \(traits.joined(separator: ", "))") + } + } } // MARK: - CBPeripheralDelegate @@ -245,7 +367,14 @@ extension BMSSession: CBPeripheralDelegate { onStateChange(.failed(error.localizedDescription)) return } - for service in peripheral.services ?? [] { + let services = peripheral.services ?? [] + gattSummary.removeAll() + pendingServices = services.count + guard pendingServices > 0 else { + onStateChange(.failed("Gerät bietet keine Bluetooth-Dienste an")) + return + } + for service in services { peripheral.discoverCharacteristics(nil, for: service) } } @@ -253,37 +382,26 @@ extension BMSSession: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { - guard error == nil, let characteristics = service.characteristics else { return } - - let writable = characteristics.first { - $0.properties.contains(.write) || $0.properties.contains(.writeWithoutResponse) - } - let notifying = characteristics.first { - $0.properties.contains(.notify) || $0.properties.contains(.indicate) - } - guard let writable, let notifying else { return } - - // Einen bekannten Dienst immer bevorzugen, sonst den erstbesten nehmen. - let isPreferred = Self.preferredServices.contains(service.uuid) - let alreadyPreferred = writeCharacteristic - .flatMap { $0.service?.uuid } - .map { Self.preferredServices.contains($0) } ?? false - guard writeCharacteristic == nil || (isPreferred && !alreadyPreferred) else { return } - - writeCharacteristic = writable - notifyCharacteristic = notifying - peripheral.setNotifyValue(true, for: notifying) + appendToSummary(service) + pendingServices -= 1 + // Erst wenn alle Dienste durch sind, steht die Kandidatenliste fest. + guard pendingServices <= 0 else { return } + buildEndpoints() } func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { if let error { - onStateChange(.failed(error.localizedDescription)) + // Dieser Kandidat lässt sich nicht abonnieren – nächsten versuchen. + if endpoints.count > 1 { + advanceEndpoint() + } else { + onStateChange(.failed(error.localizedDescription)) + } return } - if characteristic.isNotifying, characteristic == notifyCharacteristic { - publishDiagnostics() + if characteristic.isNotifying, characteristic == currentEndpoint?.notify { beginPolling() } } @@ -291,7 +409,7 @@ extension BMSSession: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - guard error == nil, let value = characteristic.value else { return } + guard error == nil, let value = characteristic.value, !value.isEmpty else { return } consume(value) } } diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index 6be3c25..f46f00f 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -68,13 +68,26 @@ struct VictronDiagnostics: Hashable { /// greift, und gibt die letzte Rohantwort zum Nachsehen aus. struct BMSDiagnostics: Hashable { var dialect: String + /// Welche Schreib-/Notify-Kombination gerade versucht wird. + var endpointLabel: String? + /// Der wievielte von wie vielen Kandidaten das ist. + var endpointPosition: Pair? var serviceUUID: String? - var writeUUID: String? - var notifyUUID: String? + /// Vollständiger Dienst-/Merkmalsbaum des Geräts. + var gattSummary: [String] + var sentFrames: Int + var receivedBytes: Int var lastResponseHex: String? var updated: Date + + struct Pair: Hashable { + var index: Int + var total: Int + init(_ index: Int, _ total: Int) { self.index = index; self.total = total } + } } + /// Ein Messpunkt für die Verlaufsgrafik. struct HistorySample: Identifiable, Hashable { let id = UUID() diff --git a/CamperMonitor/Store/DemoData.swift b/CamperMonitor/Store/DemoData.swift index c83eb32..063a0a2 100644 --- a/CamperMonitor/Store/DemoData.swift +++ b/CamperMonitor/Store/DemoData.swift @@ -23,7 +23,7 @@ enum DemoData { static let mainProfile = Profile(id: Profile.defaultID, name: "Kastenwagen", symbol: "box.truck") static let secondProfile = Profile( id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C2")!, - name: "Wohnwagen", symbol: "caravan") + name: "Wohnwagen", symbol: "car.side") static var profiles: [Profile] { [mainProfile, secondProfile] } diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift index a35bb48..6b52a21 100644 --- a/CamperMonitor/Views/DeviceDetailView.swift +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -240,11 +240,20 @@ struct DeviceDetailView: View { if let info = bluetooth.bmsDiagnostics[device.id] { Section { LabeledContent("Erkanntes Protokoll", value: info.dialect) - if let service = info.serviceUUID { - LabeledContent("Dienst") { - Text(service).font(.caption.monospaced()).foregroundStyle(.secondary) + if let position = info.endpointPosition { + LabeledContent("Verbindungsweg", + value: "\(position.index) von \(position.total)") + } + if let endpoint = info.endpointLabel { + VStack(alignment: .leading, spacing: 4) { + Text("Aktueller Weg") + Text(endpoint) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) } } + LabeledContent("Gesendet / empfangen", + value: "\(info.sentFrames) Anfragen / \(info.receivedBytes) Byte") if let hex = info.lastResponseHex { VStack(alignment: .leading, spacing: 4) { Text("Letzte Antwort") @@ -257,9 +266,21 @@ struct DeviceDetailView: View { } header: { Text("Diagnose") } footer: { - Text("Die App probiert Daly (klassisch und Modbus) sowie JBD/Xiaoxiang " - + "durch und übernimmt, was antwortet. Bleibt es bei „wird ermittelt“, " - + "spricht das BMS ein anderes Protokoll – dann hilft die Rohantwort weiter.") + Text("Die App probiert alle Schreib-/Empfangs-Kombinationen des Geräts " + + "durch und fragt auf jeder Daly (klassisch und Modbus) sowie " + + "JBD/Xiaoxiang an. Bleibt „empfangen“ bei 0 Byte, nimmt das BMS " + + "die Kommandos nicht an.") + } + + if !info.gattSummary.isEmpty { + Section { + Text(info.gattSummary.joined(separator: "\n")) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } header: { + Text("Bluetooth-Merkmale des Geräts") + } } } } diff --git a/README.md b/README.md index a899936..8a0a6b0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest: |---|---|---| | Victron Ladebooster (Orion-TR Smart / Orion XS) | Instant Readout im Advertisement | Ein-/Ausgangsspannung, beim XS auch Ströme und Ladeleistung, Zustand, Abschaltgrund | | Victron Solarladeregler (SmartSolar MPPT) | Instant Readout im Advertisement | PV-Leistung, Batteriespannung, Ladestrom, Tagesertrag, Laststrom, Ladezustand (Bulk/Absorption/Float) | -| Daly BMS (Bulltron) | GATT-Verbindung, alle 5 s abgefragt | SoC, Spannung, Strom, Restkapazität, alle Einzelzellspannungen, Zelldifferenz, Temperaturen, Zyklen, MOSFET-Status | +| Batterie-BMS (Bulltron/Daly, WattCycle/JBD) | GATT-Verbindung, alle 5 s abgefragt | SoC, Spannung, Strom, Restkapazität, alle Einzelzellspannungen, Zelldifferenz, Temperaturen, Zyklen, MOSFET-Status | Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt. @@ -63,18 +63,35 @@ Schlüssel angezeigt wurde. Die Diagnose zeigt außerdem Produkt-ID, Datensatztyp und die Rohdaten des Advertisements – letztere lassen sich durch langes Antippen kopieren. -### Daly BMS +### Batterie / BMS -Kein Schlüssel nötig. In der Liste erscheint das BLE-Modul meist als `DL-…`. -Auswählen, Art auf **Batterie / BMS** stellen, Sichern. +Kein Schlüssel nötig. Auswählen, Art auf **Batterie / BMS** stellen, Sichern. +Bulltron-Akkus melden sich meist als `DL-…`, WattCycle je nach Charge unter +eigenem Namen – findest du nichts, in der Geräteliste auf **Alle** umschalten. -Die App erkennt selbst, ob das BMS das klassische `A5`-Protokoll oder das -neuere Modbus-Protokoll (`D2`) spricht, und sucht die passenden GATT- -Charakteristiken automatisch – die BLE-Module unterscheiden sich zwischen -Fertigungschargen. +Die App probiert drei Protokolle durch und übernimmt, was antwortet: + +| Dialekt | Verbreitung | +|---|---| +| Daly klassisch (`A5`) | Bulltron und viele Daly-BMS | +| Daly Modbus (`D2`) | neuere Daly-Firmware | +| JBD / Xiaoxiang (`DD A5`) | WattCycle und viele andere LiFePO4-Akkus | + +Auch die GATT-Charakteristiken werden gesucht statt vorausgesetzt, weil sich +die BLE-Module zwischen Herstellern und Chargen unterscheiden. Welches +Protokoll erkannt wurde, steht in der Detailansicht unter **Diagnose** – +zusammen mit der letzten Rohantwort. Nur **eine** App gleichzeitig kann mit dem BMS verbunden sein. Wenn die -Bulltron-/Daly-App offen ist, bekommt Camper Monitor keine Verbindung. +Hersteller-App offen ist, bekommt Camper Monitor keine Verbindung. + +## Mehrere Fahrzeuge + +Oben links im Dashboard sitzt der Fahrzeugwechsel. Jedes Profil hat seinen +eigenen Gerätesatz; die App scannt und verbindet immer nur für das gewählte +Fahrzeug. Über *Fahrzeuge verwalten…* lassen sich Profile anlegen, umbenennen, +mit einem Symbol versehen und löschen. Geräte, die vor der Profilverwaltung +eingerichtet wurden, wandern beim Update automatisch ins erste Profil. ## Ohne Fahrzeug ansehen @@ -98,6 +115,7 @@ und die Prüfsummen beider Daly-Dialekte. ``` CamperMonitor/ ├── Models/ +│ ├── Profile.swift Fahrzeug │ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart │ ├── DeviceSnapshot.swift Messwerte in Anzeigeform │ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes @@ -106,16 +124,18 @@ CamperMonitor/ │ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten │ ├── AESCounterMode.swift AES-128-CTR (CommonCrypto) │ ├── BitReader.swift Bitweises Lesen der gepackten Felder -│ ├── DalyProtocol.swift Rahmenbau und Prüfsummen beider Dialekte -│ ├── DalyState.swift Sammelt Antworten zu einem Gesamtbild -│ └── DalySession.swift GATT-Verbindung und Abfragezyklus +│ ├── DalyProtocol.swift Daly-Rahmen und Prüfsummen +│ ├── DalyState.swift Sammelt Daly-Antworten zu einem Gesamtbild +│ ├── JBDProtocol.swift JBD/Xiaoxiang (WattCycle), Rahmen und Auswertung +│ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage ├── Store/ │ ├── DeviceStore.swift Geräteliste, Persistenz │ └── KeychainStore.swift Victron-Schlüssel └── Views/ ├── DashboardView.swift Kachelübersicht ├── DeviceCard.swift Eine Kachel - ├── DeviceDetailView.swift Alle Werte, Verlauf, Zellspannungen + ├── DeviceDetailView.swift Alle Werte, Verlauf, Zellspannungen, Diagnose + ├── ProfilesView.swift Fahrzeuge anlegen und verwalten └── AddDeviceView.swift Scannen und Einrichten ``` @@ -125,10 +145,14 @@ CamperMonitor/ Advertisements nur im Vordergrund. Die App pausiert, sobald sie in den Hintergrund geht, und nimmt beim Zurückkommen wieder auf. * **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen - Firmwareständen.** Das klassische `A5`-Protokoll ist gut dokumentiert und - sicher; falls dein BMS Modbus spricht und Werte unplausibel aussehen, muss - das Mapping in `DalyState.apply(registers:)` am realen Gerät nachgezogen - werden. + Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind + gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel + aussehen, muss das Mapping in `DalyState.apply(registers:)` am realen Gerät + nachgezogen werden. +* **Welches BMS in einem WattCycle-Akku steckt, ist nicht garantiert.** Die + Unterstützung ist auf JBD/Xiaoxiang ausgelegt, das dort üblich ist. Meldet + die Diagnose dauerhaft „wird ermittelt“, spricht der Akku etwas anderes – + die dort angezeigte Rohantwort ist dann der Ansatzpunkt. * Die Feldbelegungen der Victron-Datensätze stammen aus Victrons „Extra Manufacturer Data“-Beschreibung. Solarladeregler und DC/DC-Wandler sind die am besten belegten Typen.