diff --git a/CamperMonitor/Bluetooth/AlpicoolProtocol.swift b/CamperMonitor/Bluetooth/AlpicoolProtocol.swift index 6a9c872..ed24f0b 100644 --- a/CamperMonitor/Bluetooth/AlpicoolProtocol.swift +++ b/CamperMonitor/Bluetooth/AlpicoolProtocol.swift @@ -92,6 +92,9 @@ enum AlpicoolProtocol { static func signed(_ byte: UInt8) -> Int { Int(Int8(bitPattern: byte)) } + /// Womit diese Boxen einen nicht vorhandenen Fühler melden. + static let missingSensorReading = -128 + /// Pause zwischen den Teilstücken eines aufgeteilten Pakets, damit das /// Gerät sie wieder zusammensetzen kann. static let chunkDelay: TimeInterval = 0.15 @@ -154,16 +157,24 @@ struct AlpicoolState: Equatable { /// Übersteuerung aus den Geräteeinstellungen. var zoneMode: FridgeZoneMode = .automatic - /// Rohe Bytes der rechten Zone, für die Erkennung und die Diagnose. + /// Die Einstellungsbytes der rechten Zone, für die Erkennung und die + /// Diagnose. Ohne den Messwert – der wird getrennt beurteilt. var rightZoneBytes: [UInt8] = [] /// Ob die Box wirklich eine zweite Zone hat. /// /// Die Nutzlastlänge allein taugt nicht: Einzonen-Boxen senden den langen - /// Datensatz teils mit, füllen den zweiten Block aber mit Nullen. Als - /// zweizonig gilt deshalb nur, wo dieser Block überhaupt etwas enthält. + /// Datensatz teils mit und füllen den zweiten Block auf. Zwei Anzeichen + /// verraten das. Erstens meldet die Box für den fehlenden zweiten Fühler + /// -128, den üblichen Platzhalter. Zweitens stehen die Einstellungen der + /// rechten Zone dann auf lauter Nullen oder lauter 0xFF. + /// + /// Das ist keine Frage der Anzeige allein: der Stellbefehl fällt für eine + /// Box mit zwei Zonen länger aus, und die falsche Länge wird verworfen. var detectedDualZone: Bool { - guard rightCurrent != nil, !rightZoneBytes.isEmpty else { return false } + guard let current = rightCurrent, + current != AlpicoolProtocol.missingSensorReading else { return false } + guard !rightZoneBytes.isEmpty else { return false } return rightZoneBytes.contains { $0 != 0x00 } && rightZoneBytes.contains { $0 != 0xFF } } @@ -213,7 +224,7 @@ struct AlpicoolState: Equatable { rightTarget = AlpicoolProtocol.signed(p[18]) rightCurrent = AlpicoolProtocol.signed(p[26]) runningStatus = Int(p[27]) - rightZoneBytes = Array(p[18...26]) + rightZoneBytes = Array(p[18...25]) } else { rightTarget = nil rightCurrent = nil @@ -263,12 +274,12 @@ struct AlpicoolState: Equatable { p[10], p[11], p[12], p[13], // Kompressordrehzahlen ] - // 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 { + // Der zweite Block gehört nur an den Befehl, wenn die Box wirklich + // zwei Zonen hat. Eine Einzonen-Box sendet den langen Datensatz teils + // trotzdem – nimmt aber nur den kurzen Befehl an. Stimmt die Erkennung + // im Einzelfall nicht, lässt sie sich in den Geräteeinstellungen von + // Hand festlegen. + if isDualZone, p.count >= 28 { data += [ p[18], // Sollwert rechts 0, 0, diff --git a/Tests/main.swift b/Tests/main.swift index 32df4b6..cc5ac8a 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -530,13 +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) -// 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) +// Eine Einzonen-Box, die den langen Datensatz nur auffüllt, bekommt den +// kurzen Befehl – den langen nimmt sie nicht an. +checkEqual("aufgefüllter Datensatz heisst trotzdem kurzer Stellbefehl", + 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) } @@ -584,6 +583,47 @@ let expectedOff = Data([ ]) checkEqual("Ausschaltbefehl der IceCube Dual stimmt byteweise", iceCube.settingsCommand(poweredOn: false), expectedOff) +// Eine echte Box aus dem Feld: 30 Byte Nutzlast, aber nur ein Fühler. Sie +// füllt den zweiten Block mit Nullen auf und meldet für den fehlenden +// zweiten Fühler -128. Der Stellbefehl muss trotzdem der kurze sein - der +// lange wurde von ihr wortlos verworfen, und genau daran scheiterte das +// Ein- und Ausschalten. +let paddedIceCubePayload: [UInt8] = [ + 0x00, // Bedienfeld frei + 0x01, // eingeschaltet + 0x01, // Eco + 0x02, // Batteriewächter hoch + 0x09, // Soll 9 + 0x14, 0xEC, // Grenzen 20 / -20 + 0x02, 0x00, 0x00, // Rückschaltdifferenz, Verzögerung, Celsius + 0x00, 0x00, 0xFD, 0x00, // Kompressordrehzahlen + 0x17, // Ist 23 + 0x64, // 100 % + 0x0E, 0x03, // 14,3 V + 0x00, 0x00, 0x00, 0x00, // rechte Zone: nur Füllung + 0x00, 0x00, 0x00, 0x00, + 0x80, // kein zweiter Fühler + 0x00, // Kompressor aus + 0x01, 0x00, // was darüber hinaus kommt +] +var single = AlpicoolState() +for frame in AlpicoolProtocol.extractFrames(from: + alpicoolResponse(command: 0x01, payload: paddedIceCubePayload)).frames { + single.apply(frame) +} +checkEqual("30 Byte Nutzlast werden gelesen", single.leftCurrent, 23) +checkEqual("fehlender zweiter Fühler heisst eine Zone", single.isDualZone, false) +checkEqual("und damit der kurze Stellbefehl", + single.settingsCommand(poweredOn: false)?.count, 20) +checkEqual("der in einen Schreibvorgang passt", + AlpicoolProtocol.chunks(single.settingsCommand(poweredOn: false)!, + limit: AlpicoolProtocol.maxWriteSize).count, 1) +// Von Hand lässt sich die Erkennung übersteuern, falls sie danebenliegt. +single.zoneMode = .dual +checkEqual("von Hand erzwungen wird er wieder lang", + single.settingsCommand(poweredOn: false)?.count, 31) +single.zoneMode = .automatic + // Ob ein Stellbefehl gewirkt hat, wird an den Einstellungsbytes abgelesen - // nicht an den Messwerten, die ohnehin schwanken. let fingerprintBefore = iceCube.settingsFingerprint