forked from fritob/Camper-Monitor
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user