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) } }