Dein Befund grenzt es ein: die Solltemperatur lässt sich stellen, also kommen Schreibvorgänge an. Es scheitert nur am Einstellungsblock. Das mehrfache Piepen war meine Schuld. Beantwortet die Box die Anmeldung nicht, habe ich sie vor jedem Stellbefehl erneut angemeldet - und die Box quittiert jede Anmeldung mit einem Ton. Jetzt geschieht das höchstens einmal je Verbindung. Weiter komme ich nicht ohne die Antwort deiner Box. Ihre Länge entscheidet darüber, wie lang der Stellbefehl sein muss, und sie steht jetzt als "Statusdaten der Box" in der Diagnose. Dazu ein Knopf, der alles zusammen in die Zwischenablage legt - Merkmalsbaum, Weg, Schreibart, letzten Befehl und letzte Antwort. Das abzutippen wäre zuviel verlangt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
784 lines
32 KiB
Swift
784 lines
32 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
|
||
/// Ob vor einem Stellbefehl schon einmal nachgemeldet wurde. Jedes Mal
|
||
/// anzumelden lässt die Box bei jedem Tastendruck erneut piepen.
|
||
private var didRebindForControl = 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?
|
||
/// Was noch rausgeschrieben werden muss.
|
||
///
|
||
/// Ein Schreibvorgang ohne Bestätigung wird von iOS stillschweigend
|
||
/// verworfen, wenn der Sendepuffer gerade voll ist. Deshalb wird nur
|
||
/// geschrieben, solange iOS bereit ist, und der Rest wartet auf die
|
||
/// Rückmeldung.
|
||
private var outbox: [Data] = []
|
||
/// Fehler des letzten bestätigten Schreibvorgangs, für die Diagnose.
|
||
private var lastWriteError: String?
|
||
/// Wieviele Schreibvorgänge das Gerät bestätigt hat.
|
||
private var confirmedWrites = 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 = 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. Die
|
||
// Fundreihenfolge muss dabei erhalten bleiben: `sorted` allein
|
||
// garantiert das nicht, und dann entschiede der Zufall, ob mit oder
|
||
// ohne Bestätigung geschrieben wird.
|
||
endpoints = candidates.enumerated()
|
||
.sorted { lhs, rhs in
|
||
lhs.element.isKnownPair == rhs.element.isKnownPair
|
||
? lhs.offset < rhs.offset
|
||
: lhs.element.isKnownPair
|
||
}
|
||
.map(\.element)
|
||
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
|
||
didRebindForControl = 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 { enqueue(piece); continue }
|
||
queue.asyncAfter(
|
||
deadline: .now() + Double(index) * AlpicoolProtocol.chunkDelay
|
||
) { [weak self] in
|
||
self?.enqueue(piece)
|
||
}
|
||
}
|
||
|
||
// Sofort melden, sonst sieht die Diagnose sekundenlang nach Stillstand
|
||
// aus, obwohl gerade gesucht wird.
|
||
publishDiagnostics()
|
||
}
|
||
|
||
private func enqueue(_ piece: Data) {
|
||
outbox.append(piece)
|
||
drainOutbox()
|
||
}
|
||
|
||
/// Schreibt, solange iOS Schreibvorgänge annimmt.
|
||
private func drainOutbox() {
|
||
guard let endpoint = currentEndpoint, peripheral.state == .connected else {
|
||
outbox.removeAll()
|
||
return
|
||
}
|
||
while !outbox.isEmpty {
|
||
if endpoint.writeType == .withoutResponse, !peripheral.canSendWriteWithoutResponse {
|
||
// Der Rest geht raus, sobald iOS sich wieder meldet.
|
||
return
|
||
}
|
||
peripheral.writeValue(outbox.removeFirst(),
|
||
for: endpoint.write, type: endpoint.writeType)
|
||
}
|
||
}
|
||
|
||
/// 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 einmal je
|
||
// Verbindung nachgeholt. Ein Stellbefehl an eine unangemeldete Box
|
||
// wird sonst womöglich verworfen - jedes Mal anzumelden lässt die Box
|
||
// aber bei jedem Tastendruck zusätzlich piepen.
|
||
guard bindAcknowledged || didRebindForControl else {
|
||
didRebindForControl = true
|
||
send(AlpicoolProtocol.packet(.bind))
|
||
queue.asyncAfter(deadline: .now() + 0.4) { [weak self] in
|
||
self?.deliverControl(packet, attempt: 0)
|
||
}
|
||
return
|
||
}
|
||
|
||
deliverControl(packet, attempt: 0)
|
||
}
|
||
|
||
/// Schickt den Befehl und prüft, ob er gewirkt hat.
|
||
///
|
||
/// Manche Module nehmen nur eine der beiden Schreibarten an und melden das
|
||
/// nicht – der Befehl verschwindet dann lautlos. Bleiben die Einstellungen
|
||
/// der Box unverändert, wird deshalb einmal mit der anderen Art nachgesetzt.
|
||
private func deliverControl(_ packet: Data, attempt: Int) {
|
||
let before = alpicoolState.settingsFingerprint
|
||
let token = activationToken
|
||
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))
|
||
}
|
||
|
||
guard attempt == 0 else { return }
|
||
queue.asyncAfter(deadline: .now() + 3.5) { [weak self] in
|
||
guard let self, self.activationToken == token,
|
||
self.dialect == .alpicool, self.peripheral.state == .connected,
|
||
self.alpicoolState.settingsFingerprint == before,
|
||
let index = self.alternateWriteTypeIndex() else { return }
|
||
self.endpointIndex = index
|
||
self.publishDiagnostics()
|
||
self.deliverControl(packet, attempt: 1)
|
||
}
|
||
}
|
||
|
||
/// Derselbe Kanal, nur mit der anderen Schreibart.
|
||
private func alternateWriteTypeIndex() -> Int? {
|
||
guard let current = currentEndpoint else { return nil }
|
||
return endpoints.firstIndex {
|
||
$0.write.uuid == current.write.uuid
|
||
&& $0.notify.uuid == current.notify.uuid
|
||
&& $0.writeType != current.writeType
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
confirmedWrites: confirmedWrites,
|
||
lastWriteError: lastWriteError,
|
||
fridgePayloadHex: alpicoolState.lastPayload.isEmpty ? nil
|
||
: alpicoolState.lastPayload.map { String(format: "%02X", $0) }.joined(separator: " "),
|
||
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)
|
||
}
|
||
|
||
/// Nur bei Schreibvorgängen mit Bestätigung. Ohne Bestätigung meldet iOS
|
||
/// nichts zurück – auch keinen Fehler.
|
||
func peripheral(_ peripheral: CBPeripheral,
|
||
didWriteValueFor characteristic: CBCharacteristic,
|
||
error: Error?) {
|
||
if let error {
|
||
lastWriteError = error.localizedDescription
|
||
} else {
|
||
lastWriteError = nil
|
||
confirmedWrites += 1
|
||
}
|
||
publishDiagnostics()
|
||
}
|
||
|
||
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
||
drainOutbox()
|
||
}
|
||
}
|