Kompressor-Kühlboxen unterstützen, mit Steuerung

Die IceCube-Boxen von Plug-in Festivals sind umgelabelte Alpicool-Boxen –
der Hersteller verweist selbst auf die App "Alpicool T-Series". Bestätigt
am Gerät: die Charakteristiken 00001235 und 00001236 tauchen im GATT-Baum
auf. Dasselbe Protokoll sprechen BrassMonkey und Ocean Comfort.

AlpicoolProtocol baut und liest die Rahmen (FE FE, Länge, Kommando,
Daten, Bytesummen-Prüfsumme) und wertet die Statusantwort aus: Ist- und
Solltemperatur je Zone, Betriebsart, Kompressorstatus, Bedienfeldsperre,
Bordspannung und Batterieanzeige. Zweizonen-Boxen werden an der
Nutzlastlänge erkannt. Auf Stellbefehle antwortet die Box mit Echo und
Status in einer einzigen Benachrichtigung, weshalb der Rahmenleser
weitersucht statt nach dem ersten Treffer abzubrechen; das Echo läuft
mangels Statuslänge ins Leere.

Als bisher einziges Gerät ist die Box auch stellbar: Ein/Aus, Eco/Max,
Solltemperatur je Zone und Bedienfeldsperre. Nach jedem Stellbefehl wird
der Zustand neu abgefragt, sodass die Schalter dem Gerät folgen und nicht
der Eingabe. Ausschalten fragt einmal nach, weil dabei die Kühlung
stoppt. Ein/Aus und Betriebsart brauchen den kompletten
Einstellungsblock: er wird aus dem letzten Status neu aufgebaut und nur
im betroffenen Byte geändert, sonst überschriebe die Box eigene
Einstellungen mit Nullen. Ohne bekannten Status entsteht gar kein
Stellbefehl.

Die Namensprüfung in der Geräteliste hiess bisher looksLikeDaly und
hätte Kühlboxen aus der Vorauswahl geworfen; sie deckt jetzt alle
unterstützten Geräte ab und belegt die Art beim Einrichten vor.

Protokoll und Feldbelegung nach Gruni22/alpicool_ha_ble. Die festen
Anmelde- und Abfragepakete der Referenz sind byteweise abgesichert und
belegen zugleich, dass die allgemeine Prüfsummenregel auch für sie gilt.
139 Prüfungen laufen durch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-30 14:10:25 +02:00
co-authored by Claude Opus 5
parent 091243a1d8
commit 45d1dc087d
11 changed files with 613 additions and 21 deletions
+115
View File
@@ -413,5 +413,120 @@ var errorState = WattCycleState()
for frame in WattCycleProtocol.extractFrames(from: errorFrame).frames { errorState.apply(frame) }
checkEqual("Fehlerantwort liefert keine Werte", errorState.hasUsableData, false)
// MARK: 9 Alpicool-Kühlbox (Plug-in Festivals IceCube u.a.)
print("\nAlpicool-Protokoll")
// Die beiden festen Pakete aus der Referenzimplementierung; sie belegen
// zugleich, dass die allgemeine Prüfsummenregel für sie gilt.
checkEqual("Anmeldepaket", [UInt8](AlpicoolProtocol.packet(.bind)),
[0xFE, 0xFE, 0x03, 0x00, 0x01, 0xFF])
checkEqual("Abfragepaket", [UInt8](AlpicoolProtocol.packet(.query)),
[0xFE, 0xFE, 0x03, 0x01, 0x02, 0x00])
func alpicoolResponse(command: UInt8, payload: [UInt8]) -> [UInt8] {
var packet: [UInt8] = [0xFE, 0xFE, UInt8(payload.count + 3), command] + payload
let sum = AlpicoolProtocol.checksum(packet)
packet += [UInt8(sum >> 8), UInt8(sum & 0xFF)]
return packet
}
// Einzonen-Box: aus? nein · an · Eco · Soll 4 °C · ist 6 °C · 87 % · 12,7 V
var fridgePayload: [UInt8] = [
0x00, // Bedienfeld frei
0x01, // eingeschaltet
0x01, // Eco
0x00, // Batteriewächter niedrig
0x04, // Soll 4 °C
0x14, // Grenze oben 20 °C
0xE2, // Grenze unten -30 °C
0x01, // Rückschaltdifferenz
0x00, // Anlaufverzögerung
0x00, // Celsius
0x00, 0x00, 0x00, 0x00,
0x06, // Ist 6 °C
0x57, // 87 %
0x0C, 0x07, // 12,7 V
]
let fridgeStream = alpicoolResponse(command: 0x01, payload: fridgePayload)
let (fridgeFrames, fridgeRest) = AlpicoolProtocol.extractFrames(from: fridgeStream)
checkEqual("Statusrahmen erkannt", fridgeFrames.count, 1)
checkEqual("nichts bleibt übrig", fridgeRest.count, 0)
var fridge = AlpicoolState()
for frame in fridgeFrames { fridge.apply(frame) }
checkEqual("eingeschaltet", fridge.isPoweredOn, true)
checkEqual("Eco-Modus", fridge.isEco, true)
checkEqual("Isttemperatur", fridge.leftCurrent, 6)
checkEqual("Solltemperatur", fridge.leftTarget, 4)
checkEqual("negative Untergrenze als Zweierkomplement", fridge.temperatureMin, -30)
checkEqual("Bordspannung aus Vor- und Nachkommabyte", fridge.batteryVolts.map(round2), 12.7)
checkEqual("Batterieanzeige", fridge.batteryPercent, 87)
checkEqual("Einzonen-Box", fridge.isDualZone, false)
checkEqual("Sollbereich aus den gemeldeten Grenzen",
fridge.targetRange, -30...20)
let fridgeSnapshot = fridge.snapshot(deviceID: UUID(), rssi: nil)
checkEqual("Isttemperatur ist der Hauptwert", fridgeSnapshot.primaryMetric?.key, "temp_left")
checkEqual("Zustand", fridgeSnapshot.state, "Eco")
// Stellbefehl: Solltemperatur, auch negativ
checkEqual("Solltemperatur setzen",
[UInt8](AlpicoolState.setTarget(zone: .left, to: 4)),
[0xFE, 0xFE, 0x04, 0x05, 0x04, 0x02, 0x09])
let belowZero = [UInt8](AlpicoolState.setTarget(zone: .left, to: -18))
checkEqual("negative Solltemperatur als Zweierkomplement", belowZero[4], 0xEE)
checkEqual("rechte Zone nutzt ein eigenes Kommando",
[UInt8](AlpicoolState.setTarget(zone: .right, to: 4))[3], 0x06)
// Ein/Aus verändert genau ein Byte im Einstellungsblock, der Rest bleibt.
guard let offPacket = fridge.settingsCommand(poweredOn: false) else {
check("Ausschaltbefehl wird gebaut", false); exit(1)
}
let offBytes = [UInt8](offPacket)
checkEqual("Ausschalten nutzt das Einstellungskommando", offBytes[3], 0x02)
checkEqual("Ein-/Aus-Byte wird gesetzt", offBytes[5], 0x00)
checkEqual("Betriebsart bleibt unangetastet", offBytes[6], 0x01)
checkEqual("Solltemperatur bleibt unangetastet", offBytes[8], 0x04)
let offSum = AlpicoolProtocol.checksum(Array(offBytes[0..<(offBytes.count - 2)]))
checkEqual("Prüfsumme des Stellbefehls",
UInt16(offBytes[offBytes.count - 2]) << 8 | UInt16(offBytes[offBytes.count - 1]),
offSum)
// Ohne bekannten Zustand darf kein Einstellungsblock entstehen sonst
// überschriebe er die Einstellungen der Box mit Nullen.
checkEqual("kein Stellbefehl ohne Status",
AlpicoolState().settingsCommand(poweredOn: true) == nil, true)
// Zweizonen-Box
var dualPayload = fridgePayload
dualPayload += [0xFE, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xFB, 0x01]
var dual = AlpicoolState()
for frame in AlpicoolProtocol.extractFrames(from:
alpicoolResponse(command: 0x01, payload: dualPayload)).frames {
dual.apply(frame)
}
checkEqual("Zweizonen-Box erkannt", dual.isDualZone, true)
checkEqual("Soll rechts negativ", dual.rightTarget, -2)
checkEqual("Ist rechts negativ", dual.rightCurrent, -5)
checkEqual("Zustand meldet laufenden Kompressor",
dual.snapshot(deviceID: UUID(), rssi: nil).state, "Kühlt (Eco)")
// Echo und Status in einer Benachrichtigung: die Box antwortet auf
// Stellbefehle mit zwei aneinandergehängten Paketen.
let echoThenStatus = alpicoolResponse(command: 0x02, payload: [0x01]) + fridgeStream
let (bothFrames, _) = AlpicoolProtocol.extractFrames(from: echoThenStatus)
checkEqual("beide Pakete werden gefunden", bothFrames.count, 2)
var afterEcho = AlpicoolState()
for frame in bothFrames { afterEcho.apply(frame) }
checkEqual("das Echo verändert den Zustand nicht", afterEcho.leftCurrent, 6)
// Kaputte Prüfsumme und angefangene Pakete
var brokenFridge = fridgeStream
brokenFridge[brokenFridge.count - 1] ^= 0xFF
checkEqual("falsche Prüfsumme wird verworfen",
AlpicoolProtocol.extractFrames(from: brokenFridge).frames.count, 0)
checkEqual("angefangenes Paket wird aufgehoben",
AlpicoolProtocol.extractFrames(from: Array(fridgeStream.prefix(10))).frames.count, 0)
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
exit(failures == 0 ? 0 : 1)