Kühlbox: Stellbefehl in der Länge, die die Box erwartet

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 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-30 23:51:38 +02:00
co-authored by Claude Opus 5
parent 270c701ad6
commit 568662fd5e
5 changed files with 45 additions and 9 deletions
@@ -241,10 +241,12 @@ struct AlpicoolState: Equatable {
p[10], p[11], p[12], p[13], // Kompressordrehzahlen p[10], p[11], p[12], p[13], // Kompressordrehzahlen
] ]
// Der zweite Block gehört nur an den Befehl, wenn die Box wirklich // Der zweite Block gehört an den Befehl, sobald die Box ihn auch
// zwei Zonen hat. Einzonen-Boxen senden den langen Datensatz teils // gemeldet hat unabhängig davon, ob die Anzeige eine zweite Zone
// mit, nehmen den langen Befehl aber nicht an. // zeigt. Die Box erwartet den Befehl in der Länge, in der sie selbst
if isDualZone, p.count >= 28 { // antwortet; ein kürzerer wird verworfen, auch wenn der zweite Block
// nur Nullen enthält.
if p.count >= 28 {
data += [ data += [
p[18], // Sollwert rechts p[18], // Sollwert rechts
0, 0, 0, 0,
+24
View File
@@ -91,6 +91,10 @@ final class BMSSession: NSObject {
} }
/// Ob die Kühlbox in dieser Sitzung schon angemeldet wurde. /// Ob die Kühlbox in dieser Sitzung schon angemeldet wurde.
private var didBind = false 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 buffer: [UInt8] = []
private var pollTimer: DispatchSourceTimer? private var pollTimer: DispatchSourceTimer?
private var lastResponse: Data? private var lastResponse: Data?
@@ -234,6 +238,7 @@ final class BMSSession: NSObject {
isNotifyActive = false isNotifyActive = false
didUnlock = false didUnlock = false
didBind = false didBind = false
bindAcknowledged = false
buffer.removeAll() buffer.removeAll()
activationToken += 1 activationToken += 1
let token = activationToken let token = activationToken
@@ -438,6 +443,21 @@ final class BMSSession: NSObject {
return 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) send(packet)
// Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist. // Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist.
queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
@@ -495,6 +515,9 @@ final class BMSSession: NSObject {
guard !frames.isEmpty else { return false } guard !frames.isEmpty else { return false }
buffer = remainder buffer = remainder
adopt(.alpicool) adopt(.alpicool)
if frames.contains(where: { $0.command == AlpicoolProtocol.Command.bind.rawValue }) {
bindAcknowledged = true
}
for frame in frames { alpicoolState.apply(frame) } for frame in frames { alpicoolState.apply(frame) }
alpicoolState.zoneMode = fridgeZoneMode alpicoolState.zoneMode = fridgeZoneMode
onFridgeState?(alpicoolState) onFridgeState?(alpicoolState)
@@ -574,6 +597,7 @@ final class BMSSession: NSObject {
serviceUUID: currentEndpoint?.write.service?.uuid.uuidString, serviceUUID: currentEndpoint?.write.service?.uuid.uuidString,
isConnected: peripheral.state == .connected, isConnected: peripheral.state == .connected,
isNotifyActive: isNotifyActive, isNotifyActive: isNotifyActive,
isBound: dialect == .alpicool ? bindAcknowledged : nil,
gattSummary: gattSummary, gattSummary: gattSummary,
sentFrames: sentFrameCount, sentFrames: sentFrameCount,
receivedBytes: receivedByteCount, receivedBytes: receivedByteCount,
@@ -79,6 +79,8 @@ struct BMSDiagnostics: Hashable {
var isConnected: Bool var isConnected: Bool
/// Ob das Gerät das Abonnieren der Notify-Charakteristik bestätigt hat. /// Ob das Gerät das Abonnieren der Notify-Charakteristik bestätigt hat.
var isNotifyActive: Bool var isNotifyActive: Bool
/// Nur bei Kühlboxen: ob die Box die Anmeldung beantwortet hat.
var isBound: Bool?
/// Vollständiger Dienst-/Merkmalsbaum des Geräts. /// Vollständiger Dienst-/Merkmalsbaum des Geräts.
var gattSummary: [String] var gattSummary: [String]
var sentFrames: Int var sentFrames: Int
@@ -300,6 +300,13 @@ struct DeviceDetailView: View {
systemImage: info.isNotifyActive ? "checkmark.circle" : "xmark.circle") systemImage: info.isNotifyActive ? "checkmark.circle" : "xmark.circle")
.foregroundStyle(info.isNotifyActive ? .green : .orange) .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", LabeledContent("Gesendet / empfangen",
value: "\(info.sentFrames) Anfragen / \(info.receivedBytes) Byte") value: "\(info.sentFrames) Anfragen / \(info.receivedBytes) Byte")
if let lastSendAt = info.lastSendAt { if let lastSendAt = info.lastSendAt {
+6 -5
View File
@@ -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 // 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, // Der Befehl folgt der Länge, in der die Box selbst antwortet nicht dem,
// obwohl sie den langen Datensatz sendet. // was die Anzeige aus dem leeren zweiten Block macht. Eine Box, die den
checkEqual("kurzer Stellbefehl bei einzoniger Box", // langen Datensatz sendet, nimmt nur den langen Befehl an.
padded.settingsCommand(poweredOn: false)?.count, 20) 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 { 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)
} }