forked from fritob/Camper-Monitor
Die Ursache, belegt statt vermutet. Im Referenzprojekt gibt es einen Bericht von genau dieser Box - einer Maentum/Plug-in Festival IceCube Dual (Gruni22/alpicool_ha_ble#20). Dort galt: Solltemperatur ändern geht, Ein- und Ausschalten nicht. Genau unser Bild. Der Grund ist nicht das Paket, sondern der Schreibvorgang. Die Box nimmt nur die 20 Nutzbytes der Standard-MTU je Schreibvorgang an. Der kurze Temperaturbefehl hat sieben Byte und geht durch, der Einstellungsblock hat 31 und wird abgelehnt. Wir haben bisher nach der ausgehandelten MTU aufgeteilt - und die ist unter iOS weit grösser als 20, also wurde nie aufgeteilt. Jetzt gilt für die Kühlbox die Grenze der Box, nicht die der Verbindung: 31 Byte gehen als 20 und 11 raus, mit der üblichen Pause dazwischen. Das mitgeschnittene Paket aus dem Bericht steht als Prüfung im Testlauf. Unsere Erzeugung trifft es byteweise - Längenbyte, Datenblock, rechte Zone, Prüfsumme -, es scheiterte also wirklich nur am Schreiben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
310 lines
12 KiB
Swift
310 lines
12 KiB
Swift
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)) }
|
||
|
||
/// Pause zwischen den Teilstücken eines aufgeteilten Pakets, damit das
|
||
/// Gerät sie wieder zusammensetzen kann.
|
||
static let chunkDelay: TimeInterval = 0.15
|
||
|
||
/// Wieviel die Box je Schreibvorgang annimmt.
|
||
///
|
||
/// Das sind die 20 Nutzbytes der Standard-MTU – unabhängig davon, was auf
|
||
/// der Verbindung ausgehandelt wurde. Ein längerer Schreibvorgang wird von
|
||
/// diesen Boxen abgelehnt; belegt an einer Maentum/Plug-in Festival
|
||
/// IceCube Dual, bei der genau deshalb das Ein- und Ausschalten scheiterte,
|
||
/// während der kurze Temperaturbefehl durchging
|
||
/// (Gruni22/alpicool_ha_ble#20). Das Ändern der Solltemperatur geht mit
|
||
/// sieben Byte durch, der Einstellungsblock mit 31 nicht.
|
||
static let maxWriteSize = 20
|
||
|
||
/// Zerlegt ein Paket in schreibbare Stücke.
|
||
///
|
||
/// Ohne ausgehandelte MTU nimmt BLE nur 20 Nutzbytes je Schreibvorgang an.
|
||
/// Der Einstellungsblock einer Kühlbox ist mit bis zu 31 Byte länger und
|
||
/// würde sonst stillschweigend verworfen.
|
||
static func chunks(_ data: Data, limit: Int) -> [Data] {
|
||
guard limit > 0 else { return [data] }
|
||
guard data.count > limit else { return [data] }
|
||
return stride(from: 0, to: data.count, by: limit).map { start in
|
||
data.subdata(in: start..<min(start + limit, data.count))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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] = []
|
||
|
||
/// Übersteuerung aus den Geräteeinstellungen.
|
||
var zoneMode: FridgeZoneMode = .automatic
|
||
|
||
/// Rohe Bytes der rechten Zone, für die Erkennung und die Diagnose.
|
||
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.
|
||
var detectedDualZone: Bool {
|
||
guard rightCurrent != nil, !rightZoneBytes.isEmpty else { return false }
|
||
return rightZoneBytes.contains { $0 != 0x00 } && rightZoneBytes.contains { $0 != 0xFF }
|
||
}
|
||
|
||
var isDualZone: Bool {
|
||
switch zoneMode {
|
||
case .automatic: return detectedDualZone
|
||
case .single: return false
|
||
case .dual: return 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])
|
||
rightZoneBytes = Array(p[18...26])
|
||
} else {
|
||
rightTarget = nil
|
||
rightCurrent = nil
|
||
rightZoneBytes = []
|
||
}
|
||
}
|
||
|
||
// 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
|
||
]
|
||
|
||
// 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 {
|
||
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
|
||
}
|
||
}
|