From 3bece5b785ee685fc7c8359c0e63a8e4bd80ad95 Mon Sep 17 00:00:00 2001 From: BiasF Date: Sun, 30 Aug 2026 14:27:28 +0200 Subject: [PATCH] =?UTF-8?q?K=C3=BChlbox:=20Stellbefehl=20passend=20zur=20Z?= =?UTF-8?q?onenzahl,=20Schalter=20ohne=20R=C3=BCckfrage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ausschalten blieb wirkungslos. Zwei Ursachen, beide behoben. Die Länge des Einstellungsblocks hing an der Länge des empfangenen Datensatzes statt an der Zonenzahl. Einzonen-Boxen, die den langen Datensatz trotzdem senden, bekamen so einen 25-Byte-Block für zwei Zonen und nahmen ihn offenbar nicht an. Jetzt entscheidet die erkannte Zonenzahl: eine Zone ergibt 20 Byte und passt damit ohne Aufteilen in einen Schreibvorgang. Der Bestätigungsdialog beim Ausschalten hing an einer Section, und dort zeigt SwiftUI ihn nicht zuverlässig an - der Schalter tat dann gar nichts. Ein und Aus laufen jetzt beide direkt über denselben Weg, ohne Rückfrage. Die Diagnose zeigt zusätzlich den zuletzt abgeschickten Stellbefehl mit Zeitstempel, damit sich unterscheiden lässt, ob ein Befehl das Gerät nicht erreicht oder von ihm verworfen wird. Co-Authored-By: Claude Opus 5 --- .../Bluetooth/AlpicoolProtocol.swift | 5 ++++- CamperMonitor/Bluetooth/BMSSession.swift | 6 +++++ .../Bluetooth/BluetoothManager.swift | 4 ++++ CamperMonitor/Views/DeviceDetailView.swift | 15 +++++++++++++ CamperMonitor/Views/FridgeControls.swift | 22 ++++--------------- Tests/main.swift | 7 ++++++ 6 files changed, 40 insertions(+), 19 deletions(-) diff --git a/CamperMonitor/Bluetooth/AlpicoolProtocol.swift b/CamperMonitor/Bluetooth/AlpicoolProtocol.swift index 5a5372a..44158b8 100644 --- a/CamperMonitor/Bluetooth/AlpicoolProtocol.swift +++ b/CamperMonitor/Bluetooth/AlpicoolProtocol.swift @@ -241,7 +241,10 @@ struct AlpicoolState: Equatable { p[10], p[11], p[12], p[13], // Kompressordrehzahlen ] - if p.count >= 28 { + // 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 { data += [ p[18], // Sollwert rechts 0, 0, diff --git a/CamperMonitor/Bluetooth/BMSSession.swift b/CamperMonitor/Bluetooth/BMSSession.swift index f789467..f7fe346 100644 --- a/CamperMonitor/Bluetooth/BMSSession.swift +++ b/CamperMonitor/Bluetooth/BMSSession.swift @@ -90,6 +90,8 @@ final class BMSSession: NSObject { private var buffer: [UInt8] = [] private var pollTimer: Timer? private var lastResponse: Data? + private var lastCommand: Data? + private var lastCommandAt: Date? private var receivedByteCount = 0 private var sentFrameCount = 0 private var gattSummary: [String] = [] @@ -407,6 +409,8 @@ final class BMSSession: NSObject { /// 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() send(packet) // Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist. DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in @@ -507,6 +511,8 @@ final class BMSSession: NSObject { 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() )) } diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index 5f0ce50..168bf02 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -82,6 +82,10 @@ struct BMSDiagnostics: Hashable { var receivedBytes: Int var lastSendAt: Date? var lastResponseHex: String? + /// Zuletzt abgeschickter Stellbefehl, damit sich prüfen lässt, ob er das + /// Gerät überhaupt erreicht hat. + var lastCommandHex: String? + var lastCommandAt: Date? var updated: Date struct Pair: Hashable { diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift index e4fccb3..a959693 100644 --- a/CamperMonitor/Views/DeviceDetailView.swift +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -282,6 +282,21 @@ struct DeviceDetailView: View { } .foregroundStyle(.secondary) } + if let command = info.lastCommandHex { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Letzter Stellbefehl") + Spacer() + if let at = info.lastCommandAt { + Text(at, style: .relative).foregroundStyle(.secondary) + } + } + Text(command) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } if let hex = info.lastResponseHex { VStack(alignment: .leading, spacing: 4) { Text("Letzte Antwort") diff --git a/CamperMonitor/Views/FridgeControls.swift b/CamperMonitor/Views/FridgeControls.swift index 95810b2..d29da4d 100644 --- a/CamperMonitor/Views/FridgeControls.swift +++ b/CamperMonitor/Views/FridgeControls.swift @@ -9,20 +9,15 @@ struct FridgeControls: View { let state: AlpicoolState @Environment(BluetoothManager.self) private var bluetooth - @State private var showPowerOffConfirmation = false var body: some View { Section { + // Ohne Rückfrage: ein Bestätigungsdialog an einer Section wird von + // SwiftUI nicht zuverlässig angezeigt, und der Schalter blieb dann + // wirkungslos. Der Weg ist jetzt für Ein und Aus derselbe. Toggle("Eingeschaltet", isOn: Binding( get: { state.isPoweredOn }, - set: { wanted in - // Ausschalten heisst: der Inhalt taut auf. Einmal nachfragen. - if wanted { - bluetooth.setFridgePower(true, for: device.id) - } else { - showPowerOffConfirmation = true - } - } + set: { bluetooth.setFridgePower($0, for: device.id) } )) Picker("Betriebsart", selection: Binding( @@ -53,15 +48,6 @@ struct FridgeControls: View { Text("Änderungen gehen direkt an die Box. Der angezeigte Stand kommt " + "aus ihrer Antwort, nicht aus der Eingabe.") } - .confirmationDialog("Kühlbox ausschalten?", - isPresented: $showPowerOffConfirmation, - titleVisibility: .visible) { - Button("Ausschalten", role: .destructive) { - bluetooth.setFridgePower(false, for: device.id) - } - } message: { - Text("Die Kühlung stoppt, bis sie wieder eingeschaltet wird.") - } } private func targetStepper(zone: AlpicoolState.Zone, title: String, value: Int?) -> some View { diff --git a/Tests/main.swift b/Tests/main.swift index 9d505e7..bc5b030 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -530,6 +530,12 @@ 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) + +padded.zoneMode = .dual guard let longPacket = padded.settingsCommand(poweredOn: true) else { check("Stellbefehl aus dem langen Datensatz wird gebaut", false); exit(1) } @@ -543,6 +549,7 @@ checkEqual("zusammengesetzt ergibt sich das Original", checkEqual("kurze Pakete bleiben ungeteilt", AlpicoolProtocol.chunks(AlpicoolProtocol.packet(.query), limit: 20).count, 1) checkEqual("Einschaltbyte im langen Befehl", [UInt8](longPacket)[5], 0x01) +padded.zoneMode = .automatic checkEqual("Hauptwert heisst dann schlicht Temperatur", padded.snapshot(deviceID: UUID(), rssi: nil) .metrics.first { $0.key == "temp_left" }?.label, "Temperatur")