Kompressor-Kühlboxen unterstützen, mit Steuerung
Die IceCube-Boxen von Plug-in Festivals sind umgelabelte Alpicool-Boxen – der Hersteller verweist selbst auf die App "Alpicool T-Series". Bestätigt am Gerät: die Charakteristiken 00001235 und 00001236 tauchen im GATT-Baum auf. Dasselbe Protokoll sprechen BrassMonkey und Ocean Comfort. AlpicoolProtocol baut und liest die Rahmen (FE FE, Länge, Kommando, Daten, Bytesummen-Prüfsumme) und wertet die Statusantwort aus: Ist- und Solltemperatur je Zone, Betriebsart, Kompressorstatus, Bedienfeldsperre, Bordspannung und Batterieanzeige. Zweizonen-Boxen werden an der Nutzlastlänge erkannt. Auf Stellbefehle antwortet die Box mit Echo und Status in einer einzigen Benachrichtigung, weshalb der Rahmenleser weitersucht statt nach dem ersten Treffer abzubrechen; das Echo läuft mangels Statuslänge ins Leere. Als bisher einziges Gerät ist die Box auch stellbar: Ein/Aus, Eco/Max, Solltemperatur je Zone und Bedienfeldsperre. Nach jedem Stellbefehl wird der Zustand neu abgefragt, sodass die Schalter dem Gerät folgen und nicht der Eingabe. Ausschalten fragt einmal nach, weil dabei die Kühlung stoppt. Ein/Aus und Betriebsart brauchen den kompletten Einstellungsblock: er wird aus dem letzten Status neu aufgebaut und nur im betroffenen Byte geändert, sonst überschriebe die Box eigene Einstellungen mit Nullen. Ohne bekannten Status entsteht gar kein Stellbefehl. Die Namensprüfung in der Geräteliste hiess bisher looksLikeDaly und hätte Kühlboxen aus der Vorauswahl geworfen; sie deckt jetzt alle unterstützten Geräte ab und belegt die Art beim Einrichten vor. Protokoll und Feldbelegung nach Gruni22/alpicool_ha_ble. Die festen Anmelde- und Abfragepakete der Referenz sind byteweise abgesichert und belegen zugleich, dass die allgemeine Prüfsummenregel auch für sie gilt. 139 Prüfungen laufen durch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user