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 ] /// Die Kühlboxen liegen nicht in einem der bekannten Dienste, ihre /// Charakteristiken sind aber eindeutig. private static let alpicoolWriteUUID = CBUUID(string: "00001235-0000-1000-8000-00805F9B34FB") private static let alpicoolNotifyUUID = CBUUID(string: "00001236-0000-1000-8000-00805F9B34FB") enum Dialect: String { case unknown = "wird ermittelt" case dalyClassic = "Daly (klassisch)" case dalyModbus = "Daly (Modbus)" case jbd = "JBD / Xiaoxiang" case wattCycle = "WattCycle" case alpicool = "Alpicool-Kühlbox" } /// Freischalt-Charakteristik der WattCycle-Akkus. Liegt im selben Dienst /// wie Schreiben und Empfangen und muss vor der ersten Abfrage beschrieben /// werden, sonst bleibt der Akku stumm. private static let wattCycleAuthUUID = CBUUID(string: "FFFA") /// 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 /// Falls vorhanden, wird hierauf vor der ersten Abfrage freigeschaltet. let auth: CBCharacteristic? let writeType: CBCharacteristicWriteType let isKnownPair: Bool var label: String { let mode = writeType == .withoutResponse ? "ohne Bestätigung" : "mit Bestätigung" let unlock = auth == nil ? "" : ", Freischaltung über \(auth!.uuid.uuidString)" return "\(write.uuid.uuidString) → \(notify.uuid.uuidString), \(mode)\(unlock)" } } let deviceID: UUID /// Die Queue, auf der CoreBluetooth arbeitet. Alle Zeitgeber und /// verzögerten Aufrufe laufen darauf, damit der Zustand dieser Klasse nur /// von einem Thread aus angefasst wird. private let queue: DispatchQueue private let peripheral: CBPeripheral private let onUpdate: (DeviceSnapshot) -> Void private let onStateChange: (DeviceLinkState) -> Void private let onDiagnostics: (BMSDiagnostics) -> Void /// Meldet Änderungen am Kühlbox-Zustand, damit die Bedienelemente folgen. var onFridgeState: ((AlpicoolState) -> 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 wattCycleState = WattCycleState() private(set) var alpicoolState = AlpicoolState() /// Aus den Geräteeinstellungen; übersteuert die automatische Erkennung. var fridgeZoneMode: FridgeZoneMode = .automatic { didSet { alpicoolState.zoneMode = fridgeZoneMode } } /// Ob die Kühlbox in dieser Sitzung schon angemeldet wurde. private var didBind = false /// Ob die Box die Anmeldung auch beantwortet hat. Abfragen nimmt sie /// teils auch unangemeldet an, Stellbefehle nicht – deshalb wird vor /// einem Befehl notfalls noch einmal angemeldet. private(set) var bindAcknowledged = false private var buffer: [UInt8] = [] private var pollTimer: DispatchSourceTimer? private var lastResponse: Data? private var lastCommand: Data? private var lastCommandAt: Date? /// Stellbefehle, die kamen, bevor der Kanal stand. Sie jetzt schon zu /// senden hiesse, sie an einen womöglich falschen Kandidaten zu schicken; /// sie fallen zu lassen hiesse, ein Tippen zu verschlucken. private var waitingControls: [(packet: Data, queuedAt: Date)] = [] /// So lange darf ein Befehl warten, bevor er verfällt. private let controlLifetime: TimeInterval = 30 private var receivedByteCount = 0 private var sentFrameCount = 0 private var gattSummary: [String] = [] /// Runden ohne verwertbare Antwort auf dem aktuellen Kandidaten. private var silentRounds = 0 /// Zählt hoch, sobald ein Kandidat aktiviert wird. Späte Rückmeldungen /// eines bereits verworfenen Kandidaten lassen sich so ignorieren. private var activationToken = 0 private var isNotifyActive = false /// Ob auf diesem Kandidaten schon freigeschaltet wurde. private var didUnlock = false private var lastSendAt: Date? /// 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 = 6 private var currentEndpoint: Endpoint? { endpoints.indices.contains(endpointIndex) ? endpoints[endpointIndex] : nil } init(deviceID: UUID, peripheral: CBPeripheral, queue: DispatchQueue, onUpdate: @escaping (DeviceSnapshot) -> Void, onStateChange: @escaping (DeviceLinkState) -> Void, onDiagnostics: @escaping (BMSDiagnostics) -> Void) { self.deviceID = deviceID self.queue = queue 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?.cancel() 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?.cancel() 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 } // Eine Freischalt-Charakteristik im selben Dienst gehört zum // Kandidaten dazu; sie zu beschreiben schadet den anderen // Protokollen nicht, für WattCycle ist sie zwingend. let auth = characteristics.first { $0.uuid == Self.wattCycleAuthUUID && ($0.properties.contains(.write) || $0.properties.contains(.writeWithoutResponse)) } for write in writable where write.uuid != Self.wattCycleAuthUUID { for notify in notifying { let isFridgePair = write.uuid == Self.alpicoolWriteUUID && notify.uuid == Self.alpicoolNotifyUUID let known = isFridgePair || 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, auth: auth, writeType: .withoutResponse, isKnownPair: known)) } if write.properties.contains(.write) { candidates.append(Endpoint(write: write, notify: notify, auth: auth, 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 isNotifyActive = false didUnlock = false didBind = false bindAcknowledged = false buffer.removeAll() activationToken += 1 let token = activationToken peripheral.setNotifyValue(true, for: endpoint.notify) publishDiagnostics() // Manche Module bestätigen das Abonnieren nie. Ohne Zeitlimit bliebe // die Suche hier für immer stehen, ohne je etwas zu senden. queue.asyncAfter(deadline: .now() + 4) { [weak self] in guard let self, self.activationToken == token, !self.isNotifyActive else { return } if self.endpoints.count > 1 { self.advanceEndpoint() } else { // Einziger Weg – trotzdem versuchen zu senden, vielleicht // antwortet das Gerät auch ohne bestätigtes Abonnement. self.beginPolling() } } } /// 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 /// WattCycle verlangt vor der ersten Abfrage ein „HiLink“ auf der /// Freischalt-Charakteristik, und danach eine kurze Pause. Die Referenz /// wartet ~200 ms nach dem Abo und ~300 ms nach der Freischaltung. private func unlockThenPoll() { guard let endpoint = currentEndpoint, let auth = endpoint.auth else { beginPolling() return } let token = activationToken queue.asyncAfter(deadline: .now() + 0.2) { [weak self] in guard let self, self.activationToken == token, self.peripheral.state == .connected else { return } let type: CBCharacteristicWriteType = auth.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse self.peripheral.writeValue(WattCycleProtocol.authPayload, for: auth, type: type) self.didUnlock = true self.publishDiagnostics() queue.asyncAfter(deadline: .now() + 0.3) { [weak self] in guard let self, self.activationToken == token else { return } self.beginPolling() } } } private func beginPolling() { pollTimer?.cancel() flushWaitingControls() poll() let interval = dialect == .unknown ? searchInterval : pollInterval let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + interval, repeating: interval) timer.setEventHandler { [weak self] in self?.poll() } timer.resume() pollTimer = timer } private func poll() { guard peripheral.state == .connected, currentEndpoint != nil else { return } switch dialect { case .unknown: // Alle drei Protokolle anfragen; was antwortet, gewinnt. sendSequence([ AlpicoolProtocol.packet(.bind), AlpicoolProtocol.packet(.query), WattCycleProtocol.requestFrame(.analog), DalyProtocol.requestFrame(.soc), JBDProtocol.requestFrame(.basicInfo), DalyProtocol.modbusReadFrame(), ], spacing: 0.6, thenGiveUpAfter: 3) 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) case .wattCycle: // Modell und Seriennummer ändern sich nie – nur einmal abfragen. var frames = [WattCycleProtocol.requestFrame(.analog)] if !wattCycleState.hasProductInfo { frames.append(WattCycleProtocol.requestFrame(.product)) } sendSequence(frames, spacing: 0.3, thenGiveUpAfter: 2) case .alpicool: // Die Anmeldung gilt für die Dauer der Verbindung. var frames: [Data] = [] if !didBind { frames.append(AlpicoolProtocol.packet(.bind)) didBind = true } frames.append(AlpicoolProtocol.packet(.query)) sendSequence(frames, spacing: 0.3, 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() { queue.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) { queue.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 || wattCycleState.hasUsableData || alpicoolState.hasStatus } private func send(_ data: Data) { guard let endpoint = currentEndpoint, peripheral.state == .connected else { return } sentFrameCount += 1 lastSendAt = Date() let pieces = AlpicoolProtocol.chunks(data, limit: writeLimit(for: endpoint)) for (index, piece) in pieces.enumerated() { guard index > 0 else { peripheral.writeValue(piece, for: endpoint.write, type: endpoint.writeType) continue } queue.asyncAfter( deadline: .now() + Double(index) * AlpicoolProtocol.chunkDelay ) { [weak self] in guard let self, self.peripheral.state == .connected, let current = self.currentEndpoint else { return } self.peripheral.writeValue(piece, for: current.write, type: current.writeType) } } // Sofort melden, sonst sieht die Diagnose sekundenlang nach Stillstand // aus, obwohl gerade gesucht wird. publishDiagnostics() } /// Wieviel je Schreibvorgang rausgeht. /// /// Grundsätzlich das, was die Verbindung hergibt. Die Kühlboxen nehmen /// aber nur die 20 Byte der Standard-MTU an, auch wenn iOS eine grössere /// aushandelt und damit weit mehr erlauben würde. Ohne diese Grenze ginge /// der Einstellungsblock als ein Schreibvorgang raus – und die Box würde /// ihn ablehnen, während die kurzen Befehle durchgehen. private func writeLimit(for endpoint: Endpoint) -> Int { let negotiated = peripheral.maximumWriteValueLength(for: endpoint.writeType) guard dialect == .alpicool else { return negotiated } return min(negotiated, AlpicoolProtocol.maxWriteSize) } // MARK: - Steuern /// Schickt einen Stellbefehl und fragt kurz darauf den Zustand ab, damit /// die Anzeige dem Gerät folgt statt der Vermutung. func sendControl(_ packet: Data) { lastCommand = packet lastCommandAt = Date() // Erst wenn der Dialekt steht, ist auch der richtige Kanal bekannt. guard dialect == .alpicool, currentEndpoint != nil, peripheral.state == .connected else { waitingControls.append((packet, Date())) if waitingControls.count > 4 { waitingControls.removeFirst(waitingControls.count - 4) } publishDiagnostics() return } // Hat die Box die Anmeldung nie beantwortet, wird sie hier nachgeholt. // Ein Stellbefehl an eine unangemeldete Box wird sonst verworfen. guard bindAcknowledged else { send(AlpicoolProtocol.packet(.bind)) queue.asyncAfter(deadline: .now() + 0.4) { [weak self] in guard let self else { return } self.send(packet) self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in guard let self, self.dialect == .alpicool else { return } self.send(AlpicoolProtocol.packet(.query)) } } return } send(packet) // Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist. queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in guard let self, self.dialect == .alpicool else { return } self.send(AlpicoolProtocol.packet(.query)) } } /// Schickt raus, was während des Verbindungsaufbaus aufgelaufen ist. private func flushWaitingControls() { guard dialect == .alpicool, !waitingControls.isEmpty else { return } let due = waitingControls.filter { Date().timeIntervalSince($0.queuedAt) < controlLifetime } waitingControls.removeAll() for (index, entry) in due.enumerated() { queue.asyncAfter(deadline: .now() + Double(index) * 0.4) { [weak self] in self?.sendControl(entry.packet) } } } // 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) } // Steht der Dialekt fest, nur noch diesen prüfen. Bei jeder Antwort // alle fünf Parser durchzugehen belastet die Funk-Queue ohne Nutzen. switch dialect { case .alpicool: consumeAlpicool(); return case .wattCycle: consumeWattCycle(); return case .jbd: consumeJBD(); return case .dalyModbus: consumeDalyModbus(); return case .dalyClassic: consumeDalyClassic(); return case .unknown: break } if consumeAlpicool() { return } if consumeWattCycle() { return } if consumeJBD() { return } if consumeDalyModbus() { return } if consumeDalyClassic() { return } // Etwas kam an, ließ sich aber nicht zuordnen: für die Diagnose // sichtbar machen, damit sich das Protokoll bestimmen lässt. publishDiagnostics() } @discardableResult private func consumeAlpicool() -> Bool { let (frames, remainder) = AlpicoolProtocol.extractFrames(from: buffer) guard !frames.isEmpty else { return false } buffer = remainder adopt(.alpicool) if frames.contains(where: { $0.command == AlpicoolProtocol.Command.bind.rawValue }) { bindAcknowledged = true } for frame in frames { alpicoolState.apply(frame) } alpicoolState.zoneMode = fridgeZoneMode onFridgeState?(alpicoolState) publish(alpicoolState.snapshot(deviceID: deviceID, rssi: nil), usable: alpicoolState.hasStatus) return true } @discardableResult private func consumeWattCycle() -> Bool { let (frames, remainder) = WattCycleProtocol.extractFrames(from: buffer) guard !frames.isEmpty else { return false } buffer = remainder adopt(.wattCycle) for frame in frames { wattCycleState.apply(frame) } publish(wattCycleState.snapshot(deviceID: deviceID, rssi: nil), usable: wattCycleState.hasUsableData) return true } /// JBD: Start-, Endbyte und Prüfsumme machen den Rahmen eindeutig. @discardableResult private func consumeJBD() -> Bool { let (frames, remainder) = JBDProtocol.extractFrames(from: buffer) guard !frames.isEmpty else { return false } buffer = remainder adopt(.jbd) for frame in frames { jbdState.apply(frame) } publish(jbdState.snapshot(deviceID: deviceID, rssi: nil), usable: jbdState.hasUsableData) return true } @discardableResult private func consumeDalyModbus() -> Bool { guard let start = buffer.firstIndex(where: { $0 == 0xD2 }), let registers = DalyProtocol.parseModbusResponse(Array(buffer[start...])) else { return false } buffer.removeAll() adopt(.dalyModbus) dalyState.apply(registers: registers) publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData) return true } @discardableResult private func consumeDalyClassic() -> Bool { let (frames, remainder) = DalyProtocol.extractA5Frames(from: buffer) guard !frames.isEmpty else { return false } buffer = remainder adopt(.dalyClassic) for frame in frames { dalyState.apply(frame) } publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData) return true } /// 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, isConnected: peripheral.state == .connected, isNotifyActive: isNotifyActive, isBound: dialect == .alpicool ? bindAcknowledged : nil, gattSummary: gattSummary, sentFrames: sentFrameCount, receivedBytes: receivedByteCount, lastSendAt: lastSendAt, lastResponseHex: lastResponse.map { $0.map { String(format: "%02X", $0) }.joined(separator: " ") }, lastCommandHex: lastCommand.map { $0.map { String(format: "%02X", $0) }.joined(separator: " ") }, lastCommandAt: lastCommandAt, 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 { isNotifyActive = true publishDiagnostics() unlockThenPoll() } } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { guard error == nil, let value = characteristic.value, !value.isEmpty else { return } consume(value) } }