diff --git a/CamperMonitor/Bluetooth/AlpicoolProtocol.swift b/CamperMonitor/Bluetooth/AlpicoolProtocol.swift index 44158b8..c8580ea 100644 --- a/CamperMonitor/Bluetooth/AlpicoolProtocol.swift +++ b/CamperMonitor/Bluetooth/AlpicoolProtocol.swift @@ -241,10 +241,12 @@ struct AlpicoolState: Equatable { p[10], p[11], p[12], p[13], // Kompressordrehzahlen ] - // Der zweite Block gehört nur an den Befehl, wenn die Box wirklich - // zwei Zonen hat. Einzonen-Boxen senden den langen Datensatz teils - // mit, nehmen den langen Befehl aber nicht an. - if isDualZone, p.count >= 28 { + // Der zweite Block gehört an den Befehl, sobald die Box ihn auch + // gemeldet hat – unabhängig davon, ob die Anzeige eine zweite Zone + // zeigt. Die Box erwartet den Befehl in der Länge, in der sie selbst + // antwortet; ein kürzerer wird verworfen, auch wenn der zweite Block + // nur Nullen enthält. + if p.count >= 28 { data += [ p[18], // Sollwert rechts 0, 0, diff --git a/CamperMonitor/Bluetooth/BMSSession.swift b/CamperMonitor/Bluetooth/BMSSession.swift index 0d6c169..1665e21 100644 --- a/CamperMonitor/Bluetooth/BMSSession.swift +++ b/CamperMonitor/Bluetooth/BMSSession.swift @@ -91,6 +91,10 @@ final class BMSSession: NSObject { } /// 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? @@ -234,6 +238,7 @@ final class BMSSession: NSObject { isNotifyActive = false didUnlock = false didBind = false + bindAcknowledged = false buffer.removeAll() activationToken += 1 let token = activationToken @@ -438,6 +443,21 @@ final class BMSSession: NSObject { 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 @@ -495,6 +515,9 @@ final class BMSSession: NSObject { 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) @@ -574,6 +597,7 @@ final class BMSSession: NSObject { serviceUUID: currentEndpoint?.write.service?.uuid.uuidString, isConnected: peripheral.state == .connected, isNotifyActive: isNotifyActive, + isBound: dialect == .alpicool ? bindAcknowledged : nil, gattSummary: gattSummary, sentFrames: sentFrameCount, receivedBytes: receivedByteCount, diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index 92384a7..4e175e7 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -79,6 +79,8 @@ struct BMSDiagnostics: Hashable { var isConnected: Bool /// Ob das Gerät das Abonnieren der Notify-Charakteristik bestätigt hat. var isNotifyActive: Bool + /// Nur bei Kühlboxen: ob die Box die Anmeldung beantwortet hat. + var isBound: Bool? /// Vollständiger Dienst-/Merkmalsbaum des Geräts. var gattSummary: [String] var sentFrames: Int diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift index e80575b..7bd7bc0 100644 --- a/CamperMonitor/Views/DeviceDetailView.swift +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -300,6 +300,13 @@ struct DeviceDetailView: View { systemImage: info.isNotifyActive ? "checkmark.circle" : "xmark.circle") .foregroundStyle(info.isNotifyActive ? .green : .orange) } + if let isBound = info.isBound { + LabeledContent("Angemeldet") { + Label(isBound ? "ja" : "nein", + systemImage: isBound ? "checkmark.circle" : "xmark.circle") + .foregroundStyle(isBound ? .green : .orange) + } + } LabeledContent("Gesendet / empfangen", value: "\(info.sentFrames) Anfragen / \(info.receivedBytes) Byte") if let lastSendAt = info.lastSendAt { diff --git a/Tests/main.swift b/Tests/main.swift index 8498e1c..001a1fc 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -530,12 +530,13 @@ checkEqual("leerer zweiter Block gilt nicht als zweite Zone", padded.isDualZone, // 20 Byte noch in einen Schreibvorgang, aus dem langen nicht mehr. Ohne // Aufteilen verwirft die Box ihn wortlos – genau das war der Fehler. checkEqual("Stellbefehl aus dem kurzen Datensatz", offBytes.count, 20) -// Eine als einzonig erkannte Box bekommt auch nur den kurzen Befehl, -// obwohl sie den langen Datensatz sendet. -checkEqual("kurzer Stellbefehl bei einzoniger Box", - padded.settingsCommand(poweredOn: false)?.count, 20) +// Der Befehl folgt der Länge, in der die Box selbst antwortet – nicht dem, +// was die Anzeige aus dem leeren zweiten Block macht. Eine Box, die den +// langen Datensatz sendet, nimmt nur den langen Befehl an. +checkEqual("langer Datensatz heisst langer Stellbefehl", + padded.settingsCommand(poweredOn: false)?.count, 31) +checkEqual("die Anzeige bleibt trotzdem einzonig", padded.isDualZone, false) -padded.zoneMode = .dual guard let longPacket = padded.settingsCommand(poweredOn: true) else { check("Stellbefehl aus dem langen Datensatz wird gebaut", false); exit(1) }