298 lines
10 KiB
Swift
298 lines
10 KiB
Swift
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)
|
||
}
|
||
}
|