This commit is contained in:
BiasF
2026-08-30 10:36:52 +02:00
commit fdcc644828
30 changed files with 4152 additions and 0 deletions
+297
View File
@@ -0,0 +1,297 @@
import CoreBluetooth
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:
///
/// * **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.
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
]
enum Dialect: String {
case unknown = "wird ermittelt"
case dalyClassic = "Daly (klassisch)"
case dalyModbus = "Daly (Modbus)"
case jbd = "JBD / Xiaoxiang"
}
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(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?
/// Abstand zwischen zwei Abfragerunden.
var pollInterval: TimeInterval = 5
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 notifyCharacteristic, peripheral.state == .connected {
peripheral.setNotifyValue(false, for: notifyCharacteristic)
}
writeCharacteristic = nil
notifyCharacteristic = nil
dialect = .unknown
buffer.removeAll()
}
func handleDisconnect() {
pollTimer?.invalidate()
pollTimer = nil
writeCharacteristic = nil
notifyCharacteristic = nil
buffer.removeAll()
}
// MARK: - Abfrage
private func beginPolling() {
pollTimer?.invalidate()
onStateChange(.live)
poll()
pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
self?.poll()
}
}
private func poll() {
guard peripheral.state == .connected, writeCharacteristic != nil else { return }
switch dialect {
case .unknown:
probeDialects()
case .dalyClassic:
sendSequence(DalyProtocol.Command.allCases.map { DalyProtocol.requestFrame($0) })
case .dalyModbus:
sendSequence([DalyProtocol.modbusReadFrame()])
case .jbd:
sendSequence(JBDProtocol.Command.allCases.map { JBDProtocol.requestFrame($0) })
}
}
/// 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]) {
for (index, frame) in frames.enumerated() {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.25) { [weak self] in
self?.send(frame)
}
}
checkForSilence(after: Double(frames.count) * 0.25 + 2)
}
/// Kommt mehrere Runden nichts Brauchbares zurück, wird der erkannte
/// Dialekt verworfen und neu gesucht.
private func checkForSilence(after delay: TimeInterval) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self else { return }
guard !self.hasUsableData else {
self.silentRounds = 0
return
}
self.silentRounds += 1
if self.silentRounds >= 3 {
self.silentRounds = 0
self.dialect = .unknown
self.onStateChange(.failed("Keine verwertbare Antwort vom BMS"))
self.publishDiagnostics()
}
}
}
private var hasUsableData: Bool {
dalyState.hasUsableData || jbdState.hasUsableData
}
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)
}
// MARK: - Auswertung
private func consume(_ data: Data) {
lastResponse = data
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.
let (jbdFrames, jbdRemainder) = JBDProtocol.extractFrames(from: buffer)
if !jbdFrames.isEmpty {
buffer = jbdRemainder
dialect = .jbd
silentRounds = 0
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()
dialect = .dalyModbus
silentRounds = 0
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
dialect = .dalyClassic
silentRounds = 0
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.
publishDiagnostics()
}
private func publish(_ snapshot: DeviceSnapshot, usable: Bool) {
publishDiagnostics()
guard usable else { return }
onStateChange(.live)
onUpdate(snapshot)
}
private func publishDiagnostics() {
onDiagnostics(BMSDiagnostics(
dialect: dialect.rawValue,
serviceUUID: writeCharacteristic?.service?.uuid.uuidString,
writeUUID: writeCharacteristic?.uuid.uuidString,
notifyUUID: notifyCharacteristic?.uuid.uuidString,
lastResponseHex: lastResponse.map { $0.map { String(format: "%02X", $0) }.joined(separator: " ") },
updated: Date()
))
}
}
// MARK: - CBPeripheralDelegate
extension BMSSession: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
onStateChange(.failed(error.localizedDescription))
return
}
for service in peripheral.services ?? [] {
peripheral.discoverCharacteristics(nil, for: service)
}
}
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)
}
func peripheral(_ peripheral: CBPeripheral,
didUpdateNotificationStateFor characteristic: CBCharacteristic,
error: Error?) {
if let error {
onStateChange(.failed(error.localizedDescription))
return
}
if characteristic.isNotifying, characteristic == notifyCharacteristic {
publishDiagnostics()
beginPolling()
}
}
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard error == nil, let value = characteristic.value else { return }
consume(value)
}
}