Files
Camper-Monitor/CamperMonitor/Bluetooth/BMSSession.swift
T
BiasFandClaude Opus 5 b0e7f057b6 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>
2026-08-30 11:31:16 +02:00

416 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CoreBluetooth
import Foundation
/// Hält die GATT-Verbindung zu einem BMS, pollt die Werte und meldet fertige
/// Snapshots zurück.
///
/// Zwei Dinge sind bei diesen Geräten nicht vorhersehbar und werden deshalb
/// ausprobiert statt vorausgesetzt:
///
/// 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 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 {
case unknown = "wird ermittelt"
case dalyClassic = "Daly (klassisch)"
case dalyModbus = "Daly (Modbus)"
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 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 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 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,
onUpdate: @escaping (DeviceSnapshot) -> Void,
onStateChange: @escaping (DeviceLinkState) -> Void,
onDiagnostics: @escaping (BMSDiagnostics) -> Void) {
self.deviceID = deviceID
self.peripheral = peripheral
self.onUpdate = onUpdate
self.onStateChange = onStateChange
self.onDiagnostics = onDiagnostics
super.init()
peripheral.delegate = self
}
// MARK: - Lebenszyklus
func start() {
onStateChange(.connecting)
peripheral.discoverServices(nil)
}
func stop() {
pollTimer?.invalidate()
pollTimer = nil
if let notify = currentEndpoint?.notify, peripheral.state == .connected {
peripheral.setNotifyValue(false, for: notify)
}
endpoints.removeAll()
endpointIndex = 0
dialect = .unknown
buffer.removeAll()
}
func handleDisconnect() {
pollTimer?.invalidate()
pollTimer = 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()
poll()
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, currentEndpoint != nil else { return }
switch dialect {
case .unknown:
// 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) },
spacing: 0.25, thenGiveUpAfter: 2)
case .dalyModbus:
sendSequence([DalyProtocol.modbusReadFrame()], spacing: 0.25, thenGiveUpAfter: 2)
case .jbd:
sendSequence(JBDProtocol.Command.allCases.map { JBDProtocol.requestFrame($0) },
spacing: 0.25, thenGiveUpAfter: 2)
}
}
/// Kommandos leicht versetzt senden manche Module verschlucken Anfragen,
/// die zu dicht aufeinander folgen.
private func sendSequence(_ frames: [Data], spacing: TimeInterval, thenGiveUpAfter grace: TimeInterval) {
for (index, frame) in frames.enumerated() {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * spacing) { [weak self] in
self?.send(frame)
}
}
checkForSilence(after: Double(frames.count) * spacing + grace)
}
/// 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, self.peripheral.state == .connected else { return }
guard !self.hasUsableData else {
self.silentRounds = 0
return
}
self.silentRounds += 1
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 Antwort vom BMS"))
}
}
}
private var hasUsableData: Bool {
dalyState.hasUsableData || jbdState.hasUsableData
}
private func send(_ data: Data) {
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: Start-, Endbyte und Prüfsumme machen den Rahmen eindeutig.
let (jbdFrames, jbdRemainder) = JBDProtocol.extractFrames(from: buffer)
if !jbdFrames.isEmpty {
buffer = jbdRemainder
adopt(.jbd)
for frame in jbdFrames { jbdState.apply(frame) }
publish(jbdState.snapshot(deviceID: deviceID, rssi: nil), usable: jbdState.hasUsableData)
return
}
if let start = buffer.firstIndex(where: { $0 == 0xD2 }),
let registers = DalyProtocol.parseModbusResponse(Array(buffer[start...])) {
buffer.removeAll()
adopt(.dalyModbus)
dalyState.apply(registers: registers)
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
return
}
let (dalyFrames, dalyRemainder) = DalyProtocol.extractA5Frames(from: buffer)
if !dalyFrames.isEmpty {
buffer = dalyRemainder
adopt(.dalyClassic)
for frame in dalyFrames { dalyState.apply(frame) }
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
return
}
// 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 }
onStateChange(.live)
onUpdate(snapshot)
}
private func publishDiagnostics() {
onDiagnostics(BMSDiagnostics(
dialect: dialect.rawValue,
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
extension BMSSession: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
onStateChange(.failed(error.localizedDescription))
return
}
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)
}
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
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 {
// 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 == currentEndpoint?.notify {
beginPolling()
}
}
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard error == nil, let value = characteristic.value, !value.isEmpty else { return }
consume(value)
}
}