Files
Camper-Monitor/CamperMonitor/Bluetooth/BMSSession.swift
T
2026-08-30 10:36:52 +02:00

298 lines
10 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.
///
/// 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)
}
}