From 568662fd5e1faa7ae8e6106962bce3068b7a1732 Mon Sep 17 00:00:00 2001 From: BiasF Date: Sun, 30 Aug 2026 23:51:38 +0200 Subject: [PATCH] =?UTF-8?q?K=C3=BChlbox:=20Stellbefehl=20in=20der=20L?= =?UTF-8?q?=C3=A4nge,=20die=20die=20Box=20erwartet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diesmal gegen die Referenzimplementierung geprüft statt geraten. Rahmen, Längenbyte, Prüfsumme, Anmelde- und Abfragepaket stimmen dort byteweise mit unseren überein. Ein Unterschied blieb: die Länge des Einstellungs- blocks. Ich hatte sie an die Zonenerkennung gehängt. Sendet eine Box den langen Datensatz, füllt den zweiten Block aber mit Nullen, galt sie als einzonig und bekam den kurzen Befehl - während sie den langen erwartet. Die Box verwirft ihn dann wortlos, und Ein- und Ausschalten blieb wirkungslos. Die Referenz hängt die Länge an die Antwort der Box, nicht an eine Deutung davon. Jetzt auch hier. Für die Anzeige bleibt die Erkennung unverändert: ein leerer zweiter Block wird weiterhin nicht als zweite Zone gezeigt. Zweitens die Anmeldung. Abfragen beantwortet die Box teils auch unangemeldet, Stellbefehle nicht. Ob die Anmeldung je bestätigt wurde, wurde bisher nicht verfolgt. Jetzt schon: fehlt die Bestätigung, wird vor dem Stellbefehl noch einmal angemeldet. In der Diagnose steht der Stand als eigene Zeile. Co-Authored-By: Claude Opus 5 --- .../Bluetooth/AlpicoolProtocol.swift | 10 ++++---- CamperMonitor/Bluetooth/BMSSession.swift | 24 +++++++++++++++++++ .../Bluetooth/BluetoothManager.swift | 2 ++ CamperMonitor/Views/DeviceDetailView.swift | 7 ++++++ Tests/main.swift | 11 +++++---- 5 files changed, 45 insertions(+), 9 deletions(-) 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) }