forked from fritob/Camper-Monitor
Die Suche nach dem richtigen Verbindungsweg blieb auf dem ersten Kandidaten stehen: gesendet wurde erst, nachdem iOS das Abonnieren der Notify-Charakteristik bestätigt hat. Bestätigt ein Modul das nie – bei der WattCycle-Batterie auf FFF1 der Fall –, wurde weder je eine Anfrage geschickt noch zum nächsten Kandidaten gewechselt. Die Diagnose zeigte dauerhaft "1 von 16" und "0 Anfragen / 0 Byte". Die Aktivierung eines Kandidaten hat jetzt ein Zeitlimit von vier Sekunden. Läuft es ab, geht es zum nächsten; ist es der einzige Weg, wird trotzdem gesendet – manche Module antworten auch ohne bestätigtes Abonnement. Ein Token verhindert, dass verspätete Rückmeldungen eines bereits verworfenen Kandidaten den aktuellen durcheinanderbringen. Damit sich Fortschritt überhaupt beobachten lässt, wird die Diagnose jetzt bei jeder gesendeten Anfrage aktualisiert statt nur am Ende einer Runde, und zeigt zusätzlich Verbindungszustand, ob der Empfang abonniert ist, und wann zuletzt gesendet wurde. Die Suche ist enger getaktet, damit alle Wege in gut zwei Minuten durch sind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
447 lines
17 KiB
Swift
447 lines
17 KiB
Swift
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
|
||
/// Zählt hoch, sobald ein Kandidat aktiviert wird. Späte Rückmeldungen
|
||
/// eines bereits verworfenen Kandidaten lassen sich so ignorieren.
|
||
private var activationToken = 0
|
||
private var isNotifyActive = false
|
||
private var lastSendAt: Date?
|
||
|
||
/// 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 = 6
|
||
|
||
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
|
||
isNotifyActive = false
|
||
buffer.removeAll()
|
||
activationToken += 1
|
||
let token = activationToken
|
||
|
||
peripheral.setNotifyValue(true, for: endpoint.notify)
|
||
publishDiagnostics()
|
||
|
||
// Manche Module bestätigen das Abonnieren nie. Ohne Zeitlimit bliebe
|
||
// die Suche hier für immer stehen, ohne je etwas zu senden.
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in
|
||
guard let self, self.activationToken == token, !self.isNotifyActive else { return }
|
||
if self.endpoints.count > 1 {
|
||
self.advanceEndpoint()
|
||
} else {
|
||
// Einziger Weg – trotzdem versuchen zu senden, vielleicht
|
||
// antwortet das Gerät auch ohne bestätigtes Abonnement.
|
||
self.beginPolling()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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: 0.6, thenGiveUpAfter: 3)
|
||
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
|
||
lastSendAt = Date()
|
||
peripheral.writeValue(data, for: endpoint.write, type: endpoint.writeType)
|
||
// Sofort melden, sonst sieht die Diagnose sekundenlang nach Stillstand
|
||
// aus, obwohl gerade gesucht wird.
|
||
publishDiagnostics()
|
||
}
|
||
|
||
// 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,
|
||
isConnected: peripheral.state == .connected,
|
||
isNotifyActive: isNotifyActive,
|
||
gattSummary: gattSummary,
|
||
sentFrames: sentFrameCount,
|
||
receivedBytes: receivedByteCount,
|
||
lastSendAt: lastSendAt,
|
||
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 {
|
||
isNotifyActive = true
|
||
publishDiagnostics()
|
||
beginPolling()
|
||
}
|
||
}
|
||
|
||
func peripheral(_ peripheral: CBPeripheral,
|
||
didUpdateValueFor characteristic: CBCharacteristic,
|
||
error: Error?) {
|
||
guard error == nil, let value = characteristic.value, !value.isEmpty else { return }
|
||
consume(value)
|
||
}
|
||
}
|