Kühlbox: Stellbefehl passend zur Zonenzahl, Schalter ohne Rückfrage

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 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-30 14:27:28 +02:00
co-authored by Claude Opus 5
parent c3d708ae09
commit 3bece5b785
6 changed files with 40 additions and 19 deletions
@@ -241,7 +241,10 @@ struct AlpicoolState: Equatable {
p[10], p[11], p[12], p[13], // Kompressordrehzahlen 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 += [ data += [
p[18], // Sollwert rechts p[18], // Sollwert rechts
0, 0, 0, 0,
+6
View File
@@ -90,6 +90,8 @@ final class BMSSession: NSObject {
private var buffer: [UInt8] = [] private var buffer: [UInt8] = []
private var pollTimer: Timer? private var pollTimer: Timer?
private var lastResponse: Data? private var lastResponse: Data?
private var lastCommand: Data?
private var lastCommandAt: Date?
private var receivedByteCount = 0 private var receivedByteCount = 0
private var sentFrameCount = 0 private var sentFrameCount = 0
private var gattSummary: [String] = [] private var gattSummary: [String] = []
@@ -407,6 +409,8 @@ final class BMSSession: NSObject {
/// Schickt einen Stellbefehl und fragt kurz darauf den Zustand ab, damit /// Schickt einen Stellbefehl und fragt kurz darauf den Zustand ab, damit
/// die Anzeige dem Gerät folgt statt der Vermutung. /// die Anzeige dem Gerät folgt statt der Vermutung.
func sendControl(_ packet: Data) { func sendControl(_ packet: Data) {
lastCommand = packet
lastCommandAt = Date()
send(packet) send(packet)
// Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist. // Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist.
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
@@ -507,6 +511,8 @@ final class BMSSession: NSObject {
receivedBytes: receivedByteCount, receivedBytes: receivedByteCount,
lastSendAt: lastSendAt, lastSendAt: lastSendAt,
lastResponseHex: lastResponse.map { $0.map { String(format: "%02X", $0) }.joined(separator: " ") }, 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() updated: Date()
)) ))
} }
@@ -82,6 +82,10 @@ struct BMSDiagnostics: Hashable {
var receivedBytes: Int var receivedBytes: Int
var lastSendAt: Date? var lastSendAt: Date?
var lastResponseHex: String? 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 var updated: Date
struct Pair: Hashable { struct Pair: Hashable {
@@ -282,6 +282,21 @@ struct DeviceDetailView: View {
} }
.foregroundStyle(.secondary) .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 { if let hex = info.lastResponseHex {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Letzte Antwort") Text("Letzte Antwort")
+4 -18
View File
@@ -9,20 +9,15 @@ struct FridgeControls: View {
let state: AlpicoolState let state: AlpicoolState
@Environment(BluetoothManager.self) private var bluetooth @Environment(BluetoothManager.self) private var bluetooth
@State private var showPowerOffConfirmation = false
var body: some View { var body: some View {
Section { 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( Toggle("Eingeschaltet", isOn: Binding(
get: { state.isPoweredOn }, get: { state.isPoweredOn },
set: { wanted in set: { bluetooth.setFridgePower($0, for: device.id) }
// Ausschalten heisst: der Inhalt taut auf. Einmal nachfragen.
if wanted {
bluetooth.setFridgePower(true, for: device.id)
} else {
showPowerOffConfirmation = true
}
}
)) ))
Picker("Betriebsart", selection: Binding( Picker("Betriebsart", selection: Binding(
@@ -53,15 +48,6 @@ struct FridgeControls: View {
Text("Änderungen gehen direkt an die Box. Der angezeigte Stand kommt " Text("Änderungen gehen direkt an die Box. Der angezeigte Stand kommt "
+ "aus ihrer Antwort, nicht aus der Eingabe.") + "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 { private func targetStepper(zone: AlpicoolState.Zone, title: String, value: Int?) -> some View {
+7
View File
@@ -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 // 20 Byte noch in einen Schreibvorgang, aus dem langen nicht mehr. Ohne
// Aufteilen verwirft die Box ihn wortlos genau das war der Fehler. // Aufteilen verwirft die Box ihn wortlos genau das war der Fehler.
checkEqual("Stellbefehl aus dem kurzen Datensatz", offBytes.count, 20) 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 { guard let longPacket = padded.settingsCommand(poweredOn: true) else {
check("Stellbefehl aus dem langen Datensatz wird gebaut", false); exit(1) 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", checkEqual("kurze Pakete bleiben ungeteilt",
AlpicoolProtocol.chunks(AlpicoolProtocol.packet(.query), limit: 20).count, 1) AlpicoolProtocol.chunks(AlpicoolProtocol.packet(.query), limit: 20).count, 1)
checkEqual("Einschaltbyte im langen Befehl", [UInt8](longPacket)[5], 0x01) checkEqual("Einschaltbyte im langen Befehl", [UInt8](longPacket)[5], 0x01)
padded.zoneMode = .automatic
checkEqual("Hauptwert heisst dann schlicht Temperatur", checkEqual("Hauptwert heisst dann schlicht Temperatur",
padded.snapshot(deviceID: UUID(), rssi: nil) padded.snapshot(deviceID: UUID(), rssi: nil)
.metrics.first { $0.key == "temp_left" }?.label, "Temperatur") .metrics.first { $0.key == "temp_left" }?.label, "Temperatur")