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
@@ -0,0 +1,249 @@
import Foundation
/// Protokoll der Alpicool-Kompressorkühlboxen. Dieselbe Elektronik steckt
/// unter anderem in den IceCube-Boxen von Plug-in Festivals sowie in Modellen
/// von BrassMonkey und Ocean Comfort.
///
/// Gesprochen wird über zwei Charakteristiken: geschrieben auf `00001235-`,
/// Antworten kommen über `00001236-`.
///
/// Rahmenaufbau in beide Richtungen:
///
/// FE FE <Länge> <Kommando> <Daten> <Prüfsumme 2 Byte>
///
/// `Länge` zählt Kommando, Daten und Prüfsumme, die Gesamtlänge ist also
/// `3 + Länge`. Die Prüfsumme ist die Summe aller vorangehenden Bytes,
/// höherwertiges Byte zuerst.
///
/// Vor der ersten Abfrage muss einmal `BIND` geschickt werden. Steht APP im
/// Display der Box, verlangt sie dabei einen Tastendruck am Gerät.
///
/// Feldbelegung nach Gruni22/alpicool_ha_ble.
enum AlpicoolProtocol {
static let header: [UInt8] = [0xFE, 0xFE]
enum Command: UInt8 {
case bind = 0x00
case query = 0x01
case set = 0x02
case reset = 0x04
case setLeft = 0x05
case setRight = 0x06
}
/// Summe aller Bytes, auf 16 Bit beschnitten.
static func checksum(_ bytes: [UInt8]) -> UInt16 {
UInt16(truncatingIfNeeded: bytes.reduce(UInt32(0)) { $0 + UInt32($1) })
}
static func packet(_ command: Command, data: [UInt8] = []) -> Data {
var packet = header
packet.append(UInt8(data.count + 3)) // Kommando + Daten + Prüfsumme
packet.append(command.rawValue)
packet.append(contentsOf: data)
let sum = checksum(packet)
packet.append(UInt8(sum >> 8))
packet.append(UInt8(sum & 0xFF))
return Data(packet)
}
struct Frame {
let command: UInt8
/// Nutzdaten ohne Kommando und ohne Prüfsumme.
let payload: [UInt8]
}
/// Sucht vollständige Rahmen im Puffer.
///
/// Auf Stellbefehle antwortet die Box mit zwei Paketen in einer einzigen
/// Benachrichtigung: erst ein Echo des Befehls, dann der volle Status.
/// Deshalb wird in der Schleife weitergesucht, statt nach dem ersten
/// Treffer abzubrechen.
static func extractFrames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) {
var frames: [Frame] = []
var index = 0
var consumed = 0
while index + 3 <= buffer.count {
guard buffer[index] == 0xFE, buffer[index + 1] == 0xFE else {
index += 1
continue
}
let total = 3 + Int(buffer[index + 2])
guard total >= 6, total <= 128 else { index += 1; continue }
guard index + total <= buffer.count else { break } // Rest abwarten
let packet = Array(buffer[index..<(index + total)])
let expected = checksum(Array(packet[0..<(total - 2)]))
let actual = UInt16(packet[total - 2]) << 8 | UInt16(packet[total - 1])
guard expected == actual else {
index += 1
continue
}
frames.append(Frame(command: packet[3],
payload: Array(packet[4..<(total - 2)])))
index += total
consumed = index
}
let keepFrom = max(consumed, max(0, buffer.count - 128))
return (frames, Array(buffer[keepFrom...]))
}
static func signed(_ byte: UInt8) -> Int { Int(Int8(bitPattern: byte)) }
}
/// Zustand einer Kühlbox Messwerte und die Einstellungen, die sich ändern
/// lassen.
struct AlpicoolState: Equatable {
var isLocked = false
var isPoweredOn = true
/// 0 = Max, 1 = Eco.
var runMode = 0
var batterySaver = 0
var leftTarget: Int?
var leftCurrent: Int?
var rightTarget: Int?
var rightCurrent: Int?
var temperatureMin: Int?
var temperatureMax: Int?
var startDelayMinutes: Int?
var returnDifference: Int?
/// 0 = °C, 1 = °F.
var unit = 0
var runningStatus: Int?
var batteryPercent: Int?
var batteryVolts: Double?
/// Die vollständige Nutzlast der letzten Statusantwort. Stellbefehle für
/// Ein/Aus und Betriebsart schicken den gesamten Einstellungsblock zurück,
/// deshalb wird er aufgehoben.
var lastPayload: [UInt8] = []
var isDualZone: Bool { rightCurrent != nil }
var isEco: Bool { runMode == 1 }
var usesFahrenheit: Bool { unit == 1 }
var hasStatus: Bool { !lastPayload.isEmpty }
var unitSymbol: String { usesFahrenheit ? "°F" : "°C" }
/// Grenzen für den Sollwert. Meldet die Box keine brauchbaren, gelten
/// die üblichen Werte der Baureihe.
var targetRange: ClosedRange<Int> {
let low = temperatureMin ?? (usesFahrenheit ? -22 : -30)
let high = temperatureMax ?? (usesFahrenheit ? 68 : 20)
return low < high ? low...high : (usesFahrenheit ? -22...68 : -30...20)
}
mutating func apply(_ frame: AlpicoolProtocol.Frame) {
// Nur Statusantworten auswerten; das Echo eines Stellbefehls ist kurz.
guard frame.command == AlpicoolProtocol.Command.query.rawValue,
frame.payload.count >= 18 else { return }
let p = frame.payload
lastPayload = p
isLocked = p[0] != 0
isPoweredOn = p[1] != 0
runMode = Int(p[2])
batterySaver = Int(p[3])
leftTarget = AlpicoolProtocol.signed(p[4])
temperatureMax = AlpicoolProtocol.signed(p[5])
temperatureMin = AlpicoolProtocol.signed(p[6])
returnDifference = AlpicoolProtocol.signed(p[7])
startDelayMinutes = Int(p[8])
unit = Int(p[9])
leftCurrent = AlpicoolProtocol.signed(p[14])
batteryPercent = Int(p[15])
batteryVolts = Double(p[16]) + Double(p[17]) / 10
if p.count >= 28 {
rightTarget = AlpicoolProtocol.signed(p[18])
rightCurrent = AlpicoolProtocol.signed(p[26])
runningStatus = Int(p[27])
}
}
// MARK: - Stellbefehle
static func setTarget(zone: Zone, to value: Int) -> Data {
AlpicoolProtocol.packet(zone == .left ? .setLeft : .setRight,
data: [UInt8(bitPattern: Int8(clamping: value))])
}
enum Zone { case left, right }
/// Baut den Einstellungsblock neu auf und ändert darin einzelne Bytes.
/// Ein Teil-Update gibt es bei diesem Kommando nicht die Box erwartet
/// den kompletten Block, sonst überschreibt sie Einstellungen mit Nullen.
func settingsCommand(poweredOn: Bool? = nil,
eco: Bool? = nil,
locked: Bool? = nil) -> Data? {
guard lastPayload.count >= 18 else { return nil }
let p = lastPayload
var data: [UInt8] = [
locked.map { $0 ? 1 : 0 } ?? p[0],
poweredOn.map { $0 ? 1 : 0 } ?? p[1],
eco.map { $0 ? 1 : 0 } ?? p[2],
p[3], // Batteriewächter
p[4], // Sollwert links
p[5], p[6], // Grenzen
p[7], // Rückschaltdifferenz
p[8], // Anlaufverzögerung
p[9], // Einheit
p[10], p[11], p[12], p[13], // Kompressordrehzahlen
]
if p.count >= 28 {
data += [
p[18], // Sollwert rechts
0, 0,
p[21], // Rückschaltdifferenz rechts
p[22], p[23], p[24], p[25],
0, 0, 0,
]
}
return AlpicoolProtocol.packet(.set, data: data)
}
// MARK: - Anzeige
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
let unit = unitSymbol
var metrics: [Metric] = [
Metric("temp_left", isDualZone ? "Temperatur links" : "Temperatur",
leftCurrent.map(Double.init), unit: unit, precision: 0, primary: true),
Metric("target_left", isDualZone ? "Soll links" : "Solltemperatur",
leftTarget.map(Double.init), unit: unit, precision: 0),
]
if isDualZone {
metrics.append(Metric("temp_right", "Temperatur rechts",
rightCurrent.map(Double.init), unit: unit, precision: 0))
metrics.append(Metric("target_right", "Soll rechts",
rightTarget.map(Double.init), unit: unit, precision: 0))
}
metrics.append(Metric("supply_voltage", "Bordspannung", batteryVolts, unit: "V", precision: 1))
metrics.append(Metric("battery_percent", "Batterieanzeige",
batteryPercent.map(Double.init), unit: "%", precision: 0))
snapshot.metrics = metrics
if !isPoweredOn {
snapshot.state = "Aus"
} else if runningStatus == 1 {
snapshot.state = isEco ? "Kühlt (Eco)" : "Kühlt (Max)"
} else {
snapshot.state = isEco ? "Eco" : "Max"
}
var notes: [String] = []
if isLocked { notes.append("Bedienfeld gesperrt") }
snapshot.offReasons = notes
return snapshot
}
}