BMS: alle Schreib-/Empfangs-Kombinationen durchprobieren

Die WattCycle-Batterie meldete sich am FFF0-Dienst, nahm aber keine
Kommandos an. Grund: dort ist FFF1 die erste beschreibbare
Charakteristik, sie sieht schreibbar aus und bleibt trotzdem stumm –
Kommandos gehören auf FFF2.

Statt die richtige Charakteristik zu raten, stellt BMSSession jetzt alle
Paare aus schreibbarer und benachrichtigender Charakteristik zusammen,
jeweils mit und ohne Schreibbestätigung, und arbeitet sie ab, bis eines
antwortet. Bekannte Paare (FFF2/FFF1, FF02/FF01, Nordic UART) kommen
zuerst dran; ein Kandidat, der sich nicht abonnieren lässt, wird sofort
übersprungen. Auf jedem Weg werden weiterhin Daly klassisch, Daly Modbus
und JBD angefragt.

Die Diagnose zeigt dazu den vollständigen GATT-Baum des Geräts, den
gerade versuchten Weg samt Position in der Kandidatenliste sowie Zähler
für gesendete Anfragen und empfangene Bytes. Damit lässt sich
unterscheiden, ob das BMS die Kommandos gar nicht annimmt oder ein
unbekanntes Protokoll spricht.

Nebenbei: "caravan" existiert nicht als SF-Symbol und ließ SwiftUI
stillschweigend auf Text zurückfallen – in den Demodaten ersetzt.
README auf Profile und WattCycle/JBD nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-30 11:31:16 +02:00
co-authored by Claude Opus 5
parent fdcc644828
commit b0e7f057b6
5 changed files with 300 additions and 124 deletions
+216 -98
View File
@@ -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)
}
}
+15 -2
View File
@@ -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()
+1 -1
View File
@@ -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] }
+27 -6
View File
@@ -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")
}
}
}
}
+41 -17
View File
@@ -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.