Vier Anläufe ohne Erfolg heisst: die Annahme, der Schreibvorgang komme an, trägt nicht mehr. Also wird sie geprüft, statt weiter darauf zu bauen. Drei Dinge am Schreibweg waren wirklich falsch. Die Reihenfolge der Kandidaten war nicht festgelegt. Bietet ein Merkmal beide Schreibarten an, sind beide Einträge gleichwertig - und `sorted` sichert für gleichwertige Einträge keine Reihenfolge zu. Ob mit oder ohne Bestätigung geschrieben wurde, entschied damit der Zufall, von Start zu Start womöglich verschieden. Jetzt bleibt die Fundreihenfolge erhalten. Ein Schreibvorgang ohne Bestätigung wird von iOS stillschweigend verworfen, wenn der Sendepuffer voll ist. Das wurde nirgends beachtet. Jetzt wartet, was nicht sofort rausgeht, auf die Bereitschaftsmeldung. Schreibfehler waren unsichtbar: für Schreibvorgänge mit Bestätigung gab es gar keine Rückmeldung. Jetzt werden sie gezählt und Fehler gemeldet, beides steht in der Diagnose. Dazu bessert die Sitzung selbst nach. Nach einem Stellbefehl wird geprüft, ob sich die Einstellungen der Box überhaupt geändert haben - an den Einstellungsbytes, nicht an Temperatur und Spannung, die ohnehin schwanken. Bleibt alles gleich, wird einmal mit der anderen Schreibart nachgesetzt. Nimmt die Box nur eine der beiden an, findet die App das jetzt allein. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
321 lines
12 KiB
Swift
321 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 = []
|
||
}
|
||
}
|
||
|
||
/// 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 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
|
||
}
|
||
}
|