diff --git a/CamperMonitor/Bluetooth/AlpicoolProtocol.swift b/CamperMonitor/Bluetooth/AlpicoolProtocol.swift new file mode 100644 index 0000000..597603d --- /dev/null +++ b/CamperMonitor/Bluetooth/AlpicoolProtocol.swift @@ -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` 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 { + 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 + } +} diff --git a/CamperMonitor/Bluetooth/BMSSession.swift b/CamperMonitor/Bluetooth/BMSSession.swift index c74c5cd..bbcdd6f 100644 --- a/CamperMonitor/Bluetooth/BMSSession.swift +++ b/CamperMonitor/Bluetooth/BMSSession.swift @@ -27,12 +27,18 @@ final class BMSSession: NSObject { "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"), // Nordic UART ] + /// Die Kühlboxen liegen nicht in einem der bekannten Dienste, ihre + /// Charakteristiken sind aber eindeutig. + private static let alpicoolWriteUUID = CBUUID(string: "00001235-0000-1000-8000-00805F9B34FB") + private static let alpicoolNotifyUUID = CBUUID(string: "00001236-0000-1000-8000-00805F9B34FB") + enum Dialect: String { case unknown = "wird ermittelt" case dalyClassic = "Daly (klassisch)" case dalyModbus = "Daly (Modbus)" case jbd = "JBD / Xiaoxiang" case wattCycle = "WattCycle" + case alpicool = "Alpicool-Kühlbox" } /// Freischalt-Charakteristik der WattCycle-Akkus. Liegt im selben Dienst @@ -63,6 +69,8 @@ final class BMSSession: NSObject { private let onUpdate: (DeviceSnapshot) -> Void private let onStateChange: (DeviceLinkState) -> Void private let onDiagnostics: (BMSDiagnostics) -> Void + /// Meldet Änderungen am Kühlbox-Zustand, damit die Bedienelemente folgen. + var onFridgeState: ((AlpicoolState) -> Void)? private var endpoints: [Endpoint] = [] private var endpointIndex = 0 @@ -72,6 +80,9 @@ final class BMSSession: NSObject { private var dalyState = DalyState() private var jbdState = JBDState() private var wattCycleState = WattCycleState() + private(set) var alpicoolState = AlpicoolState() + /// Ob die Kühlbox in dieser Sitzung schon angemeldet wurde. + private var didBind = false private var buffer: [UInt8] = [] private var pollTimer: Timer? private var lastResponse: Data? @@ -165,7 +176,9 @@ final class BMSSession: NSObject { for write in writable where write.uuid != Self.wattCycleAuthUUID { for notify in notifying { - let known = Self.knownPairs.contains { + let isFridgePair = write.uuid == Self.alpicoolWriteUUID + && notify.uuid == Self.alpicoolNotifyUUID + let known = isFridgePair || Self.knownPairs.contains { CBUUID(string: $0.service) == service.uuid && CBUUID(string: $0.write) == write.uuid && CBUUID(string: $0.notify) == notify.uuid @@ -202,6 +215,7 @@ final class BMSSession: NSObject { silentRounds = 0 isNotifyActive = false didUnlock = false + didBind = false buffer.removeAll() activationToken += 1 let token = activationToken @@ -279,6 +293,8 @@ final class BMSSession: NSObject { case .unknown: // Alle drei Protokolle anfragen; was antwortet, gewinnt. sendSequence([ + AlpicoolProtocol.packet(.bind), + AlpicoolProtocol.packet(.query), WattCycleProtocol.requestFrame(.analog), DalyProtocol.requestFrame(.soc), JBDProtocol.requestFrame(.basicInfo), @@ -299,6 +315,15 @@ final class BMSSession: NSObject { frames.append(WattCycleProtocol.requestFrame(.product)) } sendSequence(frames, spacing: 0.3, thenGiveUpAfter: 2) + case .alpicool: + // Die Anmeldung gilt für die Dauer der Verbindung. + var frames: [Data] = [] + if !didBind { + frames.append(AlpicoolProtocol.packet(.bind)) + didBind = true + } + frames.append(AlpicoolProtocol.packet(.query)) + sendSequence(frames, spacing: 0.3, thenGiveUpAfter: 2) } } @@ -339,7 +364,8 @@ final class BMSSession: NSObject { } private var hasUsableData: Bool { - dalyState.hasUsableData || jbdState.hasUsableData || wattCycleState.hasUsableData + dalyState.hasUsableData || jbdState.hasUsableData + || wattCycleState.hasUsableData || alpicoolState.hasStatus } private func send(_ data: Data) { @@ -352,6 +378,18 @@ final class BMSSession: NSObject { publishDiagnostics() } + // MARK: - Steuern + + /// Schickt einen Stellbefehl und fragt kurz darauf den Zustand ab, damit + /// die Anzeige dem Gerät folgt statt der Vermutung. + func sendControl(_ packet: Data) { + send(packet) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in + guard let self, self.dialect == .alpicool else { return } + self.send(AlpicoolProtocol.packet(.query)) + } + } + // MARK: - Auswertung private func consume(_ data: Data) { @@ -360,6 +398,17 @@ final class BMSSession: NSObject { buffer.append(contentsOf: [UInt8](data)) if buffer.count > 512 { buffer.removeFirst(buffer.count - 512) } + let (fridgeFrames, fridgeRemainder) = AlpicoolProtocol.extractFrames(from: buffer) + if !fridgeFrames.isEmpty { + buffer = fridgeRemainder + adopt(.alpicool) + for frame in fridgeFrames { alpicoolState.apply(frame) } + onFridgeState?(alpicoolState) + publish(alpicoolState.snapshot(deviceID: deviceID, rssi: nil), + usable: alpicoolState.hasStatus) + return + } + let (wattFrames, wattRemainder) = WattCycleProtocol.extractFrames(from: buffer) if !wattFrames.isEmpty { buffer = wattRemainder diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index 63ee361..bc4e59a 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -14,7 +14,7 @@ struct Discovery: Identifiable, Hashable { var lastSeen: Date var victronRecordType: UInt8? var victronProductID: UInt16? - var looksLikeDaly: Bool + var looksLikeSupported: Bool var isVictron: Bool { victronRecordType != nil } @@ -27,7 +27,7 @@ struct Discovery: Identifiable, Hashable { if let type = victronRecordType { return "Victron · " + Self.victronRecordName(type) } - if looksLikeDaly { return "Sieht nach Daly BMS aus" } + if looksLikeSupported { return "Sieht nach BMS oder Kühlbox aus" } return "Bluetooth-Gerät" } @@ -110,6 +110,8 @@ final class BluetoothManager: NSObject { private(set) var history: [UUID: [HistorySample]] = [:] private(set) var diagnostics: [UUID: VictronDiagnostics] = [:] private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:] + /// Bedienzustand der Kühlboxen, damit die Schalter dem Gerät folgen. + private(set) var fridgeStates: [UUID: AlpicoolState] = [:] private(set) var isBluetoothReady = false private(set) var bluetoothStatusText = "Bluetooth wird gestartet…" @@ -152,6 +154,7 @@ final class BluetoothManager: NSObject { private func loadDemoData() { isBluetoothReady = true bluetoothStatusText = "Demo-Modus" + fridgeStates[DemoData.fridge.id] = DemoData.fridgeState for snapshot in DemoData.snapshots() { snapshots[snapshot.deviceID] = snapshot linkStates[snapshot.deviceID] = .live @@ -200,6 +203,7 @@ final class BluetoothManager: NSObject { history = history.filter { known.contains($0.key) } diagnostics = diagnostics.filter { known.contains($0.key) } bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) } + fridgeStates = fridgeStates.filter { known.contains($0.key) } connectManagedPeripherals() restartScan() } @@ -270,6 +274,40 @@ final class BluetoothManager: NSObject { } } + // MARK: - Kühlbox steuern + + private func fridgeSession(for deviceID: UUID) -> BMSSession? { + guard let device = store.devices.first(where: { $0.id == deviceID }) else { return nil } + return bmsSessions[device.peripheralID] + } + + /// Solltemperatur einer Zone setzen. + func setFridgeTarget(_ celsius: Int, zone: AlpicoolState.Zone, for deviceID: UUID) { + fridgeSession(for: deviceID)? + .sendControl(AlpicoolState.setTarget(zone: zone, to: celsius)) + } + + /// Kühlbox ein- oder ausschalten. + func setFridgePower(_ on: Bool, for deviceID: UUID) { + guard let session = fridgeSession(for: deviceID), + let packet = session.alpicoolState.settingsCommand(poweredOn: on) else { return } + session.sendControl(packet) + } + + /// Zwischen sparsamem und schnellem Kühlen umschalten. + func setFridgeEco(_ eco: Bool, for deviceID: UUID) { + guard let session = fridgeSession(for: deviceID), + let packet = session.alpicoolState.settingsCommand(eco: eco) else { return } + session.sendControl(packet) + } + + /// Bedienfeld der Box sperren oder freigeben. + func setFridgeLock(_ locked: Bool, for deviceID: UUID) { + guard let session = fridgeSession(for: deviceID), + let packet = session.alpicoolState.settingsCommand(locked: locked) else { return } + session.sendControl(packet) + } + // MARK: - Auswertung private func record(_ snapshot: DeviceSnapshot) { @@ -339,7 +377,7 @@ final class BluetoothManager: NSObject { lastSeen: Date(), victronRecordType: nil, victronProductID: nil, - looksLikeDaly: false + looksLikeSupported: false ) // Gleitender Mittelwert über die letzten Messungen. entry.rssi = existing.map { ($0.rssi * 3 + rssi) / 4 } ?? rssi @@ -349,16 +387,20 @@ final class BluetoothManager: NSObject { entry.victronRecordType = envelope.recordType entry.victronProductID = envelope.productID } - entry.looksLikeDaly = Self.looksLikeDaly(name: entry.name) + entry.looksLikeSupported = Self.looksLikeSupported(name: entry.name) pendingDiscoveries[peripheral.identifier] = entry } - /// Die BLE-Module der Daly-BMS melden sich meist als "DL-…" oder tragen - /// "BMS" im Namen. Nur ein Hinweis für die Liste – auswählbar ist alles. - private static func looksLikeDaly(name: String?) -> Bool { + /// Grobe Namensprüfung für die Vorauswahl in der Geräteliste. Nur ein + /// Hinweis – auswählbar ist über "Alle" weiterhin jedes Gerät. + private static func looksLikeSupported(name: String?) -> Bool { guard let name = name?.lowercased() else { return false } - return name.hasPrefix("dl-") || name.contains("daly") || name.contains("bms") - || name.contains("bulltron") || name.hasPrefix("smart bms") + let needles = [ + "daly", "bms", "bulltron", "wattcycle", // Batterien + "alpicool", "icecube", "ice cube", "fridge", "cool", // Kühlboxen + ] + return name.hasPrefix("dl-") || name.hasPrefix("wt") + || needles.contains { name.contains($0) } } } @@ -424,6 +466,7 @@ extension BluetoothManager: CBCentralManagerDelegate { onStateChange: { [weak self] state in self?.linkStates[device.id] = state }, onDiagnostics: { [weak self] info in self?.bmsDiagnostics[device.id] = info } ) + session.onFridgeState = { [weak self] state in self?.fridgeStates[device.id] = state } bmsSessions[peripheral.identifier] = session session.start() } diff --git a/CamperMonitor/Models/ConfiguredDevice.swift b/CamperMonitor/Models/ConfiguredDevice.swift index 0580756..65992ec 100644 --- a/CamperMonitor/Models/ConfiguredDevice.swift +++ b/CamperMonitor/Models/ConfiguredDevice.swift @@ -7,6 +7,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable { case solarCharger case batteryMonitor case bms + case fridge var id: String { rawValue } @@ -16,6 +17,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable { case .solarCharger: return "Solarladeregler" case .batteryMonitor: return "Batteriemonitor" case .bms: return "Batterie / BMS" + case .fridge: return "Kühlbox" } } @@ -25,13 +27,17 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable { case .solarCharger: return "sun.max" case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent" case .bms: return "battery.100percent.bolt" + case .fridge: return "refrigerator" } } /// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS /// braucht eine echte GATT-Verbindung. var transport: DeviceTransport { - self == .bms ? .connect : .advertisement + switch self { + case .bms, .fridge: return .connect + default: return .advertisement + } } } diff --git a/CamperMonitor/Store/DemoData.swift b/CamperMonitor/Store/DemoData.swift index 063a0a2..40209d5 100644 --- a/CamperMonitor/Store/DemoData.swift +++ b/CamperMonitor/Store/DemoData.swift @@ -47,7 +47,22 @@ enum DemoData { name: "Solar Wohnwagen", role: .solarCharger, profileID: secondProfile.id, peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000C4")!) - static var devices: [ConfiguredDevice] { [solar, booster, battery, caravanSolar] } + static let fridge = ConfiguredDevice( + id: UUID(uuidString: "00000000-0000-0000-0000-0000000000F0")!, + name: "Kühlbox", role: .fridge, profileID: Profile.defaultID, + peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000F1")!) + + static var devices: [ConfiguredDevice] { [solar, booster, battery, fridge, caravanSolar] } + + /// Zustand der Demo-Kühlbox: eingeschaltet, Eco, Soll 4 °C, ist 6 °C. + static var fridgeState: AlpicoolState { + var state = AlpicoolState() + state.apply(AlpicoolProtocol.Frame(command: 0x01, payload: [ + 0x00, 0x01, 0x01, 0x00, 0x04, 0x14, 0xE2, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x57, 0x0C, 0x07, + ])) + return state + } static func snapshots() -> [DeviceSnapshot] { var solarSnapshot = DeviceSnapshot(deviceID: solar.id, timestamp: Date(), rssi: -58) @@ -94,7 +109,8 @@ enum DemoData { Metric("battery_current", "Ladestrom", 4.4, unit: "A", precision: 1), ] - return [solarSnapshot, boosterSnapshot, batterySnapshot, caravanSnapshot] + return [solarSnapshot, boosterSnapshot, batterySnapshot, + fridgeState.snapshot(deviceID: fridge.id, rssi: -66), caravanSnapshot] } /// Ein paar Stunden Verlauf, damit die Grafik etwas zeigt. diff --git a/CamperMonitor/Views/AddDeviceView.swift b/CamperMonitor/Views/AddDeviceView.swift index 7a40b92..b8c27ea 100644 --- a/CamperMonitor/Views/AddDeviceView.swift +++ b/CamperMonitor/Views/AddDeviceView.swift @@ -16,10 +16,10 @@ struct AddDeviceView: View { // Bewusst nicht nach Signalstärke sortieren: die schwankt im // Sekundentakt und die Liste würde unter dem Finger springen. bluetooth.discoveries.values - .filter { showsAllDevices || $0.isVictron || $0.looksLikeDaly } + .filter { showsAllDevices || $0.isVictron || $0.looksLikeSupported } .sorted { lhs, rhs in if lhs.isVictron != rhs.isVictron { return lhs.isVictron } - if lhs.looksLikeDaly != rhs.looksLikeDaly { return lhs.looksLikeDaly } + if lhs.looksLikeSupported != rhs.looksLikeSupported { return lhs.looksLikeSupported } return lhs.firstSeen < rhs.firstSeen } } @@ -61,8 +61,9 @@ struct AddDeviceView: View { } footer: { Text("Victron-Geräte werden automatisch erkannt. Damit sie hier " + "auftauchen, muss „Instant Readout“ in VictronConnect aktiv sein. " - + "Das BMS meldet sich meist als „DL-…“ – findest du es nicht, " - + "auf „Alle“ umschalten.") + + "Das BMS meldet sich meist als „DL-…“. Kühlboxen tragen oft " + + "einen kryptischen Namen – findest du dein Gerät nicht, auf " + + "„Alle“ umschalten.") } } .navigationTitle("Gerät für \(store.activeProfile?.name ?? "Camper")") @@ -90,9 +91,9 @@ struct AddDeviceView: View { private func row(for discovery: Discovery) -> some View { HStack(spacing: 12) { Image(systemName: discovery.isVictron ? "bolt.circle.fill" - : discovery.looksLikeDaly ? "battery.100percent" : "dot.radiowaves.left.and.right") + : discovery.looksLikeSupported ? "battery.100percent" : "dot.radiowaves.left.and.right") .font(.title3) - .foregroundStyle(discovery.isVictron || discovery.looksLikeDaly ? Color.accentColor : Color.secondary) + .foregroundStyle(discovery.isVictron || discovery.looksLikeSupported ? Color.accentColor : Color.secondary) .frame(width: 28) VStack(alignment: .leading, spacing: 2) { @@ -192,7 +193,10 @@ private struct ConfigureDeviceView: View { case .batteryMonitor: role = .batteryMonitor default: role = .solarCharger } - } else if discovery.looksLikeDaly { + } else if let name = discovery.name?.lowercased(), + ["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) { + role = .fridge + } else if discovery.looksLikeSupported { role = .bms } name = discovery.name?.isEmpty == false ? discovery.name! : role.title diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift index 5f32bac..e3ec224 100644 --- a/CamperMonitor/Views/DeviceDetailView.swift +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -20,6 +20,10 @@ struct DeviceDetailView: View { List { statusSection + if device.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus { + FridgeControls(device: device, state: fridge) + } + if let snapshot, !snapshot.metrics.isEmpty { Section("Messwerte") { ForEach(snapshot.metrics) { metric in diff --git a/CamperMonitor/Views/FridgeControls.swift b/CamperMonitor/Views/FridgeControls.swift new file mode 100644 index 0000000..95810b2 --- /dev/null +++ b/CamperMonitor/Views/FridgeControls.swift @@ -0,0 +1,80 @@ +import SwiftUI + +/// Bedienelemente einer Alpicool-Kühlbox. +/// +/// Alle Schalter folgen dem Gerät, nicht der Vermutung: nach jedem Stellbefehl +/// fragt die Sitzung den Zustand neu ab, und die Ansicht zeigt, was zurückkam. +struct FridgeControls: View { + let device: ConfiguredDevice + let state: AlpicoolState + + @Environment(BluetoothManager.self) private var bluetooth + @State private var showPowerOffConfirmation = false + + var body: some View { + Section { + Toggle("Eingeschaltet", isOn: Binding( + get: { state.isPoweredOn }, + set: { wanted in + // Ausschalten heisst: der Inhalt taut auf. Einmal nachfragen. + if wanted { + bluetooth.setFridgePower(true, for: device.id) + } else { + showPowerOffConfirmation = true + } + } + )) + + Picker("Betriebsart", selection: Binding( + get: { state.isEco }, + set: { bluetooth.setFridgeEco($0, for: device.id) } + )) { + Text("Max").tag(false) + Text("Eco").tag(true) + } + .pickerStyle(.segmented) + .disabled(!state.isPoweredOn) + + targetStepper(zone: .left, + title: state.isDualZone ? "Soll links" : "Solltemperatur", + value: state.leftTarget) + + if state.isDualZone { + targetStepper(zone: .right, title: "Soll rechts", value: state.rightTarget) + } + + Toggle("Bedienfeld gesperrt", isOn: Binding( + get: { state.isLocked }, + set: { bluetooth.setFridgeLock($0, for: device.id) } + )) + } header: { + Text("Steuerung") + } footer: { + Text("Änderungen gehen direkt an die Box. Der angezeigte Stand kommt " + + "aus ihrer Antwort, nicht aus der Eingabe.") + } + .confirmationDialog("Kühlbox ausschalten?", + isPresented: $showPowerOffConfirmation, + titleVisibility: .visible) { + Button("Ausschalten", role: .destructive) { + bluetooth.setFridgePower(false, for: device.id) + } + } message: { + Text("Die Kühlung stoppt, bis sie wieder eingeschaltet wird.") + } + } + + private func targetStepper(zone: AlpicoolState.Zone, title: String, value: Int?) -> some View { + let range = state.targetRange + return Stepper(value: Binding( + get: { value ?? range.lowerBound }, + set: { bluetooth.setFridgeTarget($0, zone: zone, for: device.id) } + ), in: range) { + LabeledContent(title) { + Text(value.map { "\($0) \(state.unitSymbol)" } ?? "–") + .monospacedDigit() + } + } + .disabled(!state.isPoweredOn || value == nil) + } +} diff --git a/README.md b/README.md index 3e7d6c9..a0dd209 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest: | Victron Ladebooster (Orion-TR Smart / Orion XS) | Instant Readout im Advertisement | Ein-/Ausgangsspannung, beim XS auch Ströme und Ladeleistung, Zustand, Abschaltgrund | | Victron Solarladeregler (SmartSolar MPPT) | Instant Readout im Advertisement | PV-Leistung, Batteriespannung, Ladestrom, Tagesertrag, Laststrom, Ladezustand (Bulk/Absorption/Float) | | Batterie-BMS (Bulltron/Daly, WattCycle) | GATT-Verbindung, alle 5 s abgefragt | SoC, Spannung, Strom, Restkapazität, alle Einzelzellspannungen, Zelldifferenz, Temperaturen, Zyklen, MOSFET-Status | +| Kompressor-Kühlbox (Alpicool und Baugleiche) | GATT-Verbindung, lesen **und** stellen | Ist- und Solltemperatur je Zone, Betriebsart, Kompressorstatus, Bordspannung, Batterieanzeige | Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt. @@ -98,6 +99,28 @@ zusammen mit der letzten Rohantwort. Nur **eine** App gleichzeitig kann mit dem BMS verbunden sein. Wenn die Hersteller-App offen ist, bekommt Camper Monitor keine Verbindung. +### Kühlbox + +Die IceCube-Boxen von Plug-in Festivals sind umgelabelte Alpicool-Boxen – +erkennbar daran, dass der Hersteller die App „Alpicool T-Series“ nennt. Dasselbe +Protokoll sprechen auch BrassMonkey und Ocean Comfort. + +Beim Einrichten Art auf **Kühlbox** stellen. Die App meldet sich beim Verbinden +selbst an; steht **APP** im Display der Box, verlangt sie dabei einen +Tastendruck am Gerät. + +Neben den Messwerten gibt es hier als einzigem Gerät auch Bedienelemente: +Ein/Aus, Eco oder Max, Solltemperatur je Zone und die Bedienfeldsperre. Alle +Schalter zeigen den Stand, den die Box zurückmeldet – nach jedem Stellbefehl +wird ihr Zustand neu abgefragt, statt die Eingabe anzunehmen. Das Ausschalten +fragt einmal nach, weil dabei die Kühlung stoppt. + +Interessant für die Energieübersicht: Die Box misst die Bordspannung und meldet +sie mit, was einen Quervergleich zu Batterie und Solarregler erlaubt. + +Protokoll und Feldbelegung stammen aus +[Gruni22/alpicool_ha_ble](https://github.com/Gruni22/alpicool_ha_ble). + ## Mehrere Fahrzeuge Oben links im Dashboard sitzt der Fahrzeugwechsel. Jedes Profil hat seinen @@ -141,6 +164,7 @@ CamperMonitor/ │ ├── DalyState.swift Sammelt Daly-Antworten zu einem Gesamtbild │ ├── JBDProtocol.swift JBD/Xiaoxiang, Rahmen und Auswertung │ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung +│ ├── AlpicoolProtocol.swift Kühlboxen: Auswertung und Stellbefehle │ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage ├── Store/ │ ├── DeviceStore.swift Geräteliste, Persistenz @@ -150,6 +174,7 @@ CamperMonitor/ ├── DeviceCard.swift Eine Kachel ├── DeviceDetailView.swift Alle Werte, Verlauf, Zellspannungen, Diagnose ├── ProfilesView.swift Fahrzeuge anlegen und verwalten + ├── FridgeControls.swift Bedienelemente der Kühlbox └── AddDeviceView.swift Scannen und Einrichten ``` diff --git a/Tests/main.swift b/Tests/main.swift index c10890b..e9d52e2 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -413,5 +413,120 @@ var errorState = WattCycleState() for frame in WattCycleProtocol.extractFrames(from: errorFrame).frames { errorState.apply(frame) } checkEqual("Fehlerantwort liefert keine Werte", errorState.hasUsableData, false) +// MARK: 9 – Alpicool-Kühlbox (Plug-in Festivals IceCube u.a.) +print("\nAlpicool-Protokoll") + +// Die beiden festen Pakete aus der Referenzimplementierung; sie belegen +// zugleich, dass die allgemeine Prüfsummenregel für sie gilt. +checkEqual("Anmeldepaket", [UInt8](AlpicoolProtocol.packet(.bind)), + [0xFE, 0xFE, 0x03, 0x00, 0x01, 0xFF]) +checkEqual("Abfragepaket", [UInt8](AlpicoolProtocol.packet(.query)), + [0xFE, 0xFE, 0x03, 0x01, 0x02, 0x00]) + +func alpicoolResponse(command: UInt8, payload: [UInt8]) -> [UInt8] { + var packet: [UInt8] = [0xFE, 0xFE, UInt8(payload.count + 3), command] + payload + let sum = AlpicoolProtocol.checksum(packet) + packet += [UInt8(sum >> 8), UInt8(sum & 0xFF)] + return packet +} + +// Einzonen-Box: aus? nein · an · Eco · Soll 4 °C · ist 6 °C · 87 % · 12,7 V +var fridgePayload: [UInt8] = [ + 0x00, // Bedienfeld frei + 0x01, // eingeschaltet + 0x01, // Eco + 0x00, // Batteriewächter niedrig + 0x04, // Soll 4 °C + 0x14, // Grenze oben 20 °C + 0xE2, // Grenze unten -30 °C + 0x01, // Rückschaltdifferenz + 0x00, // Anlaufverzögerung + 0x00, // Celsius + 0x00, 0x00, 0x00, 0x00, + 0x06, // Ist 6 °C + 0x57, // 87 % + 0x0C, 0x07, // 12,7 V +] +let fridgeStream = alpicoolResponse(command: 0x01, payload: fridgePayload) +let (fridgeFrames, fridgeRest) = AlpicoolProtocol.extractFrames(from: fridgeStream) +checkEqual("Statusrahmen erkannt", fridgeFrames.count, 1) +checkEqual("nichts bleibt übrig", fridgeRest.count, 0) + +var fridge = AlpicoolState() +for frame in fridgeFrames { fridge.apply(frame) } +checkEqual("eingeschaltet", fridge.isPoweredOn, true) +checkEqual("Eco-Modus", fridge.isEco, true) +checkEqual("Isttemperatur", fridge.leftCurrent, 6) +checkEqual("Solltemperatur", fridge.leftTarget, 4) +checkEqual("negative Untergrenze als Zweierkomplement", fridge.temperatureMin, -30) +checkEqual("Bordspannung aus Vor- und Nachkommabyte", fridge.batteryVolts.map(round2), 12.7) +checkEqual("Batterieanzeige", fridge.batteryPercent, 87) +checkEqual("Einzonen-Box", fridge.isDualZone, false) +checkEqual("Sollbereich aus den gemeldeten Grenzen", + fridge.targetRange, -30...20) + +let fridgeSnapshot = fridge.snapshot(deviceID: UUID(), rssi: nil) +checkEqual("Isttemperatur ist der Hauptwert", fridgeSnapshot.primaryMetric?.key, "temp_left") +checkEqual("Zustand", fridgeSnapshot.state, "Eco") + +// Stellbefehl: Solltemperatur, auch negativ +checkEqual("Solltemperatur setzen", + [UInt8](AlpicoolState.setTarget(zone: .left, to: 4)), + [0xFE, 0xFE, 0x04, 0x05, 0x04, 0x02, 0x09]) +let belowZero = [UInt8](AlpicoolState.setTarget(zone: .left, to: -18)) +checkEqual("negative Solltemperatur als Zweierkomplement", belowZero[4], 0xEE) +checkEqual("rechte Zone nutzt ein eigenes Kommando", + [UInt8](AlpicoolState.setTarget(zone: .right, to: 4))[3], 0x06) + +// Ein/Aus verändert genau ein Byte im Einstellungsblock, der Rest bleibt. +guard let offPacket = fridge.settingsCommand(poweredOn: false) else { + check("Ausschaltbefehl wird gebaut", false); exit(1) +} +let offBytes = [UInt8](offPacket) +checkEqual("Ausschalten nutzt das Einstellungskommando", offBytes[3], 0x02) +checkEqual("Ein-/Aus-Byte wird gesetzt", offBytes[5], 0x00) +checkEqual("Betriebsart bleibt unangetastet", offBytes[6], 0x01) +checkEqual("Solltemperatur bleibt unangetastet", offBytes[8], 0x04) +let offSum = AlpicoolProtocol.checksum(Array(offBytes[0..<(offBytes.count - 2)])) +checkEqual("Prüfsumme des Stellbefehls", + UInt16(offBytes[offBytes.count - 2]) << 8 | UInt16(offBytes[offBytes.count - 1]), + offSum) + +// Ohne bekannten Zustand darf kein Einstellungsblock entstehen – sonst +// überschriebe er die Einstellungen der Box mit Nullen. +checkEqual("kein Stellbefehl ohne Status", + AlpicoolState().settingsCommand(poweredOn: true) == nil, true) + +// Zweizonen-Box +var dualPayload = fridgePayload +dualPayload += [0xFE, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xFB, 0x01] +var dual = AlpicoolState() +for frame in AlpicoolProtocol.extractFrames(from: + alpicoolResponse(command: 0x01, payload: dualPayload)).frames { + dual.apply(frame) +} +checkEqual("Zweizonen-Box erkannt", dual.isDualZone, true) +checkEqual("Soll rechts negativ", dual.rightTarget, -2) +checkEqual("Ist rechts negativ", dual.rightCurrent, -5) +checkEqual("Zustand meldet laufenden Kompressor", + dual.snapshot(deviceID: UUID(), rssi: nil).state, "Kühlt (Eco)") + +// Echo und Status in einer Benachrichtigung: die Box antwortet auf +// Stellbefehle mit zwei aneinandergehängten Paketen. +let echoThenStatus = alpicoolResponse(command: 0x02, payload: [0x01]) + fridgeStream +let (bothFrames, _) = AlpicoolProtocol.extractFrames(from: echoThenStatus) +checkEqual("beide Pakete werden gefunden", bothFrames.count, 2) +var afterEcho = AlpicoolState() +for frame in bothFrames { afterEcho.apply(frame) } +checkEqual("das Echo verändert den Zustand nicht", afterEcho.leftCurrent, 6) + +// Kaputte Prüfsumme und angefangene Pakete +var brokenFridge = fridgeStream +brokenFridge[brokenFridge.count - 1] ^= 0xFF +checkEqual("falsche Prüfsumme wird verworfen", + AlpicoolProtocol.extractFrames(from: brokenFridge).frames.count, 0) +checkEqual("angefangenes Paket wird aufgehoben", + AlpicoolProtocol.extractFrames(from: Array(fridgeStream.prefix(10))).frames.count, 0) + print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.") exit(failures == 0 ? 0 : 1) diff --git a/run-tests.sh b/run-tests.sh index d3bc5ac..b5aa811 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -12,6 +12,7 @@ swiftc -O -o "$OUT/tests" \ CamperMonitor/Bluetooth/DalyState.swift \ CamperMonitor/Bluetooth/JBDProtocol.swift \ CamperMonitor/Bluetooth/WattCycleProtocol.swift \ + CamperMonitor/Bluetooth/AlpicoolProtocol.swift \ CamperMonitor/Models/DeviceSnapshot.swift \ CamperMonitor/Models/VictronCodes.swift \ CamperMonitor/Store/KeychainStore.swift \