Die Ursache, belegt statt vermutet. Im Referenzprojekt gibt es einen Bericht von genau dieser Box - einer Maentum/Plug-in Festival IceCube Dual (Gruni22/alpicool_ha_ble#20). Dort galt: Solltemperatur ändern geht, Ein- und Ausschalten nicht. Genau unser Bild. Der Grund ist nicht das Paket, sondern der Schreibvorgang. Die Box nimmt nur die 20 Nutzbytes der Standard-MTU je Schreibvorgang an. Der kurze Temperaturbefehl hat sieben Byte und geht durch, der Einstellungsblock hat 31 und wird abgelehnt. Wir haben bisher nach der ausgehandelten MTU aufgeteilt - und die ist unter iOS weit grösser als 20, also wurde nie aufgeteilt. Jetzt gilt für die Kühlbox die Grenze der Box, nicht die der Verbindung: 31 Byte gehen als 20 und 11 raus, mit der üblichen Pause dazwischen. Das mitgeschnittene Paket aus dem Bericht steht als Prüfung im Testlauf. Unsere Erzeugung trifft es byteweise - Längenbyte, Datenblock, rechte Zone, Prüfsumme -, es scheiterte also wirklich nur am Schreiben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
693 lines
28 KiB
Swift
693 lines
28 KiB
Swift
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)
|
||
}
|
||
}
|