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` 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 /// 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.. { 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 } }