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")