Files
Camper-Monitor/CamperMonitor/Bluetooth/AlpicoolProtocol.swift
T
BiasFandClaude Opus 5 5fec4a55dd Kühlbox nur verbinden, während man sie ansieht
Jede Verbindung meldet sich am Display der Box an – sie piept, und wer
gerade davorsteht, wird gestört. Dauerhaft verbunden zu sein ist dort
also nicht unsichtbar wie beim BMS, sondern lästig.

Die Kühlbox wird deshalb nur noch verbunden, solange ihre Ansicht offen
ist: am iPhone über die Detailansicht, an der Uhr über einen eigenen
Befehl, den sie beim Öffnen und Schliessen schickt. Ein Stellbefehl geht
weiterhin immer durch – ist die Box nicht verbunden, wird er gemerkt und
löst den Verbindungsaufbau aus.

Abgeriegelt sind alle drei Wege, über die bisher verbunden wurde: beim
Start, im Takt des Wiederverbindens, und – das war das eigentliche Loch –
sobald das Gerät in den Werbedaten auftaucht. Da die App dauerhaft
scannt, hätte allein dieser Weg die Box weiter angefunkt. Dass sie in
Reichweite ist, ist kein Grund, sie anzufassen.

Auf der Übersicht steht dafür der zuletzt gestellte Stand statt der
Messwerte: Sollwert, Ein/Aus, Eco oder Max, dazu wann er gestellt wurde.
Das bleibt richtig, auch wenn es von gestern ist – ein Sollwert ändert
sich nur, wenn jemand ihn ändert. Eine Innentemperatur von gestern sähe
dagegen aus wie eine von jetzt, und man würde ihr glauben. Genau das
sichern die neuen Prüfungen ab: Hauptwert ist der Sollwert, gemessene
Temperatur und Bordspannung kommen nicht vor.

Der Stand liegt in den Einstellungen (FridgeSettings) mit einem
Zeitstempel, der „zuletzt geändert" bedeutet und nicht „zuletzt gesehen" –
sonst behauptete die Kachel Frische, wo sich nichts getan hat. Die Uhr
bekommt dasselbe Bild: WatchFridge trägt jetzt ein Kennzeichen isLive.

BMS und Neigungsmesser bleiben dauerhaft verbunden. Sie stören nicht, und
ihre Werte will man laufend sehen.

Nicht nachgezogen ist die Android-App – dort verbindet der
BluetoothManager weiterhin dauerhaft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:00:10 +02:00

345 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)) }
/// 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
/// 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
/// 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 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 let current = rightCurrent,
current != AlpicoolProtocol.missingSensorReading else { return false }
guard !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...25])
} else {
rightTarget = nil
rightCurrent = nil
rightZoneBytes = []
}
}
/// Der Stand, der die Verbindung überdauert ohne Messwerte.
var settings: FridgeSettings? {
guard hasStatus else { return nil }
return FridgeSettings(isPoweredOn: isPoweredOn,
isEco: isEco,
isLocked: isLocked,
isDualZone: isDualZone,
usesFahrenheit: usesFahrenheit,
leftTarget: leftTarget,
rightTarget: isDualZone ? rightTarget : nil,
updated: Date())
}
/// Die Bytes, die ein Stellbefehl ändert.
///
/// Messwerte gehören nicht dazu: Temperatur und Spannung schwanken
/// ohnehin, an ihnen liesse sich nicht ablesen, ob ein Befehl gewirkt hat.
var settingsFingerprint: [UInt8] {
guard lastPayload.count >= 18 else { return [] }
var bytes = [lastPayload[0], lastPayload[1], lastPayload[2], lastPayload[4]]
if lastPayload.count >= 28 { bytes.append(lastPayload[18]) }
return bytes
}
// 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 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,
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
}
}