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 /// 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 = 8 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 buffer.removeAll() peripheral.setNotifyValue(true, for: endpoint.notify) publishDiagnostics() } /// 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: 1.2, thenGiveUpAfter: 5) 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 peripheral.writeValue(data, for: endpoint.write, type: endpoint.writeType) } // 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, gattSummary: gattSummary, sentFrames: sentFrameCount, receivedBytes: receivedByteCount, 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 { beginPolling() } } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard error == nil, let value = characteristic.value, !value.isEmpty else { return } consume(value) } }