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:
@@ -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> <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)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,12 +27,18 @@ final class BMSSession: NSObject {
|
|||||||
"6E400003-B5A3-F393-E0A9-E50E24DCCA9E"), // Nordic UART
|
"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 {
|
enum Dialect: String {
|
||||||
case unknown = "wird ermittelt"
|
case unknown = "wird ermittelt"
|
||||||
case dalyClassic = "Daly (klassisch)"
|
case dalyClassic = "Daly (klassisch)"
|
||||||
case dalyModbus = "Daly (Modbus)"
|
case dalyModbus = "Daly (Modbus)"
|
||||||
case jbd = "JBD / Xiaoxiang"
|
case jbd = "JBD / Xiaoxiang"
|
||||||
case wattCycle = "WattCycle"
|
case wattCycle = "WattCycle"
|
||||||
|
case alpicool = "Alpicool-Kühlbox"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Freischalt-Charakteristik der WattCycle-Akkus. Liegt im selben Dienst
|
/// Freischalt-Charakteristik der WattCycle-Akkus. Liegt im selben Dienst
|
||||||
@@ -63,6 +69,8 @@ final class BMSSession: NSObject {
|
|||||||
private let onUpdate: (DeviceSnapshot) -> Void
|
private let onUpdate: (DeviceSnapshot) -> Void
|
||||||
private let onStateChange: (DeviceLinkState) -> Void
|
private let onStateChange: (DeviceLinkState) -> Void
|
||||||
private let onDiagnostics: (BMSDiagnostics) -> 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 endpoints: [Endpoint] = []
|
||||||
private var endpointIndex = 0
|
private var endpointIndex = 0
|
||||||
@@ -72,6 +80,9 @@ final class BMSSession: NSObject {
|
|||||||
private var dalyState = DalyState()
|
private var dalyState = DalyState()
|
||||||
private var jbdState = JBDState()
|
private var jbdState = JBDState()
|
||||||
private var wattCycleState = WattCycleState()
|
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 buffer: [UInt8] = []
|
||||||
private var pollTimer: Timer?
|
private var pollTimer: Timer?
|
||||||
private var lastResponse: Data?
|
private var lastResponse: Data?
|
||||||
@@ -165,7 +176,9 @@ final class BMSSession: NSObject {
|
|||||||
|
|
||||||
for write in writable where write.uuid != Self.wattCycleAuthUUID {
|
for write in writable where write.uuid != Self.wattCycleAuthUUID {
|
||||||
for notify in notifying {
|
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.service) == service.uuid
|
||||||
&& CBUUID(string: $0.write) == write.uuid
|
&& CBUUID(string: $0.write) == write.uuid
|
||||||
&& CBUUID(string: $0.notify) == notify.uuid
|
&& CBUUID(string: $0.notify) == notify.uuid
|
||||||
@@ -202,6 +215,7 @@ final class BMSSession: NSObject {
|
|||||||
silentRounds = 0
|
silentRounds = 0
|
||||||
isNotifyActive = false
|
isNotifyActive = false
|
||||||
didUnlock = false
|
didUnlock = false
|
||||||
|
didBind = false
|
||||||
buffer.removeAll()
|
buffer.removeAll()
|
||||||
activationToken += 1
|
activationToken += 1
|
||||||
let token = activationToken
|
let token = activationToken
|
||||||
@@ -279,6 +293,8 @@ final class BMSSession: NSObject {
|
|||||||
case .unknown:
|
case .unknown:
|
||||||
// Alle drei Protokolle anfragen; was antwortet, gewinnt.
|
// Alle drei Protokolle anfragen; was antwortet, gewinnt.
|
||||||
sendSequence([
|
sendSequence([
|
||||||
|
AlpicoolProtocol.packet(.bind),
|
||||||
|
AlpicoolProtocol.packet(.query),
|
||||||
WattCycleProtocol.requestFrame(.analog),
|
WattCycleProtocol.requestFrame(.analog),
|
||||||
DalyProtocol.requestFrame(.soc),
|
DalyProtocol.requestFrame(.soc),
|
||||||
JBDProtocol.requestFrame(.basicInfo),
|
JBDProtocol.requestFrame(.basicInfo),
|
||||||
@@ -299,6 +315,15 @@ final class BMSSession: NSObject {
|
|||||||
frames.append(WattCycleProtocol.requestFrame(.product))
|
frames.append(WattCycleProtocol.requestFrame(.product))
|
||||||
}
|
}
|
||||||
sendSequence(frames, spacing: 0.3, thenGiveUpAfter: 2)
|
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 {
|
private var hasUsableData: Bool {
|
||||||
dalyState.hasUsableData || jbdState.hasUsableData || wattCycleState.hasUsableData
|
dalyState.hasUsableData || jbdState.hasUsableData
|
||||||
|
|| wattCycleState.hasUsableData || alpicoolState.hasStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
private func send(_ data: Data) {
|
private func send(_ data: Data) {
|
||||||
@@ -352,6 +378,18 @@ final class BMSSession: NSObject {
|
|||||||
publishDiagnostics()
|
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
|
// MARK: - Auswertung
|
||||||
|
|
||||||
private func consume(_ data: Data) {
|
private func consume(_ data: Data) {
|
||||||
@@ -360,6 +398,17 @@ final class BMSSession: NSObject {
|
|||||||
buffer.append(contentsOf: [UInt8](data))
|
buffer.append(contentsOf: [UInt8](data))
|
||||||
if buffer.count > 512 { buffer.removeFirst(buffer.count - 512) }
|
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)
|
let (wattFrames, wattRemainder) = WattCycleProtocol.extractFrames(from: buffer)
|
||||||
if !wattFrames.isEmpty {
|
if !wattFrames.isEmpty {
|
||||||
buffer = wattRemainder
|
buffer = wattRemainder
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ struct Discovery: Identifiable, Hashable {
|
|||||||
var lastSeen: Date
|
var lastSeen: Date
|
||||||
var victronRecordType: UInt8?
|
var victronRecordType: UInt8?
|
||||||
var victronProductID: UInt16?
|
var victronProductID: UInt16?
|
||||||
var looksLikeDaly: Bool
|
var looksLikeSupported: Bool
|
||||||
|
|
||||||
var isVictron: Bool { victronRecordType != nil }
|
var isVictron: Bool { victronRecordType != nil }
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ struct Discovery: Identifiable, Hashable {
|
|||||||
if let type = victronRecordType {
|
if let type = victronRecordType {
|
||||||
return "Victron · " + Self.victronRecordName(type)
|
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"
|
return "Bluetooth-Gerät"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +110,8 @@ final class BluetoothManager: NSObject {
|
|||||||
private(set) var history: [UUID: [HistorySample]] = [:]
|
private(set) var history: [UUID: [HistorySample]] = [:]
|
||||||
private(set) var diagnostics: [UUID: VictronDiagnostics] = [:]
|
private(set) var diagnostics: [UUID: VictronDiagnostics] = [:]
|
||||||
private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
|
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 isBluetoothReady = false
|
||||||
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
|
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
|
||||||
|
|
||||||
@@ -152,6 +154,7 @@ final class BluetoothManager: NSObject {
|
|||||||
private func loadDemoData() {
|
private func loadDemoData() {
|
||||||
isBluetoothReady = true
|
isBluetoothReady = true
|
||||||
bluetoothStatusText = "Demo-Modus"
|
bluetoothStatusText = "Demo-Modus"
|
||||||
|
fridgeStates[DemoData.fridge.id] = DemoData.fridgeState
|
||||||
for snapshot in DemoData.snapshots() {
|
for snapshot in DemoData.snapshots() {
|
||||||
snapshots[snapshot.deviceID] = snapshot
|
snapshots[snapshot.deviceID] = snapshot
|
||||||
linkStates[snapshot.deviceID] = .live
|
linkStates[snapshot.deviceID] = .live
|
||||||
@@ -200,6 +203,7 @@ final class BluetoothManager: NSObject {
|
|||||||
history = history.filter { known.contains($0.key) }
|
history = history.filter { known.contains($0.key) }
|
||||||
diagnostics = diagnostics.filter { known.contains($0.key) }
|
diagnostics = diagnostics.filter { known.contains($0.key) }
|
||||||
bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
|
bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
|
||||||
|
fridgeStates = fridgeStates.filter { known.contains($0.key) }
|
||||||
connectManagedPeripherals()
|
connectManagedPeripherals()
|
||||||
restartScan()
|
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
|
// MARK: - Auswertung
|
||||||
|
|
||||||
private func record(_ snapshot: DeviceSnapshot) {
|
private func record(_ snapshot: DeviceSnapshot) {
|
||||||
@@ -339,7 +377,7 @@ final class BluetoothManager: NSObject {
|
|||||||
lastSeen: Date(),
|
lastSeen: Date(),
|
||||||
victronRecordType: nil,
|
victronRecordType: nil,
|
||||||
victronProductID: nil,
|
victronProductID: nil,
|
||||||
looksLikeDaly: false
|
looksLikeSupported: false
|
||||||
)
|
)
|
||||||
// Gleitender Mittelwert über die letzten Messungen.
|
// Gleitender Mittelwert über die letzten Messungen.
|
||||||
entry.rssi = existing.map { ($0.rssi * 3 + rssi) / 4 } ?? rssi
|
entry.rssi = existing.map { ($0.rssi * 3 + rssi) / 4 } ?? rssi
|
||||||
@@ -349,16 +387,20 @@ final class BluetoothManager: NSObject {
|
|||||||
entry.victronRecordType = envelope.recordType
|
entry.victronRecordType = envelope.recordType
|
||||||
entry.victronProductID = envelope.productID
|
entry.victronProductID = envelope.productID
|
||||||
}
|
}
|
||||||
entry.looksLikeDaly = Self.looksLikeDaly(name: entry.name)
|
entry.looksLikeSupported = Self.looksLikeSupported(name: entry.name)
|
||||||
pendingDiscoveries[peripheral.identifier] = entry
|
pendingDiscoveries[peripheral.identifier] = entry
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die BLE-Module der Daly-BMS melden sich meist als "DL-…" oder tragen
|
/// Grobe Namensprüfung für die Vorauswahl in der Geräteliste. Nur ein
|
||||||
/// "BMS" im Namen. Nur ein Hinweis für die Liste – auswählbar ist alles.
|
/// Hinweis – auswählbar ist über "Alle" weiterhin jedes Gerät.
|
||||||
private static func looksLikeDaly(name: String?) -> Bool {
|
private static func looksLikeSupported(name: String?) -> Bool {
|
||||||
guard let name = name?.lowercased() else { return false }
|
guard let name = name?.lowercased() else { return false }
|
||||||
return name.hasPrefix("dl-") || name.contains("daly") || name.contains("bms")
|
let needles = [
|
||||||
|| name.contains("bulltron") || name.hasPrefix("smart bms")
|
"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 },
|
onStateChange: { [weak self] state in self?.linkStates[device.id] = state },
|
||||||
onDiagnostics: { [weak self] info in self?.bmsDiagnostics[device.id] = info }
|
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
|
bmsSessions[peripheral.identifier] = session
|
||||||
session.start()
|
session.start()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
|||||||
case solarCharger
|
case solarCharger
|
||||||
case batteryMonitor
|
case batteryMonitor
|
||||||
case bms
|
case bms
|
||||||
|
case fridge
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
|||||||
case .solarCharger: return "Solarladeregler"
|
case .solarCharger: return "Solarladeregler"
|
||||||
case .batteryMonitor: return "Batteriemonitor"
|
case .batteryMonitor: return "Batteriemonitor"
|
||||||
case .bms: return "Batterie / BMS"
|
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 .solarCharger: return "sun.max"
|
||||||
case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
|
case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
|
||||||
case .bms: return "battery.100percent.bolt"
|
case .bms: return "battery.100percent.bolt"
|
||||||
|
case .fridge: return "refrigerator"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS
|
/// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS
|
||||||
/// braucht eine echte GATT-Verbindung.
|
/// braucht eine echte GATT-Verbindung.
|
||||||
var transport: DeviceTransport {
|
var transport: DeviceTransport {
|
||||||
self == .bms ? .connect : .advertisement
|
switch self {
|
||||||
|
case .bms, .fridge: return .connect
|
||||||
|
default: return .advertisement
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,22 @@ enum DemoData {
|
|||||||
name: "Solar Wohnwagen", role: .solarCharger, profileID: secondProfile.id,
|
name: "Solar Wohnwagen", role: .solarCharger, profileID: secondProfile.id,
|
||||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000C4")!)
|
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] {
|
static func snapshots() -> [DeviceSnapshot] {
|
||||||
var solarSnapshot = DeviceSnapshot(deviceID: solar.id, timestamp: Date(), rssi: -58)
|
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),
|
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.
|
/// Ein paar Stunden Verlauf, damit die Grafik etwas zeigt.
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ struct AddDeviceView: View {
|
|||||||
// Bewusst nicht nach Signalstärke sortieren: die schwankt im
|
// Bewusst nicht nach Signalstärke sortieren: die schwankt im
|
||||||
// Sekundentakt und die Liste würde unter dem Finger springen.
|
// Sekundentakt und die Liste würde unter dem Finger springen.
|
||||||
bluetooth.discoveries.values
|
bluetooth.discoveries.values
|
||||||
.filter { showsAllDevices || $0.isVictron || $0.looksLikeDaly }
|
.filter { showsAllDevices || $0.isVictron || $0.looksLikeSupported }
|
||||||
.sorted { lhs, rhs in
|
.sorted { lhs, rhs in
|
||||||
if lhs.isVictron != rhs.isVictron { return lhs.isVictron }
|
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
|
return lhs.firstSeen < rhs.firstSeen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,8 +61,9 @@ struct AddDeviceView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("Victron-Geräte werden automatisch erkannt. Damit sie hier "
|
Text("Victron-Geräte werden automatisch erkannt. Damit sie hier "
|
||||||
+ "auftauchen, muss „Instant Readout“ in VictronConnect aktiv sein. "
|
+ "auftauchen, muss „Instant Readout“ in VictronConnect aktiv sein. "
|
||||||
+ "Das BMS meldet sich meist als „DL-…“ – findest du es nicht, "
|
+ "Das BMS meldet sich meist als „DL-…“. Kühlboxen tragen oft "
|
||||||
+ "auf „Alle“ umschalten.")
|
+ "einen kryptischen Namen – findest du dein Gerät nicht, auf "
|
||||||
|
+ "„Alle“ umschalten.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Gerät für \(store.activeProfile?.name ?? "Camper")")
|
.navigationTitle("Gerät für \(store.activeProfile?.name ?? "Camper")")
|
||||||
@@ -90,9 +91,9 @@ struct AddDeviceView: View {
|
|||||||
private func row(for discovery: Discovery) -> some View {
|
private func row(for discovery: Discovery) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: discovery.isVictron ? "bolt.circle.fill"
|
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)
|
.font(.title3)
|
||||||
.foregroundStyle(discovery.isVictron || discovery.looksLikeDaly ? Color.accentColor : Color.secondary)
|
.foregroundStyle(discovery.isVictron || discovery.looksLikeSupported ? Color.accentColor : Color.secondary)
|
||||||
.frame(width: 28)
|
.frame(width: 28)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
@@ -192,7 +193,10 @@ private struct ConfigureDeviceView: View {
|
|||||||
case .batteryMonitor: role = .batteryMonitor
|
case .batteryMonitor: role = .batteryMonitor
|
||||||
default: role = .solarCharger
|
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
|
role = .bms
|
||||||
}
|
}
|
||||||
name = discovery.name?.isEmpty == false ? discovery.name! : role.title
|
name = discovery.name?.isEmpty == false ? discovery.name! : role.title
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ struct DeviceDetailView: View {
|
|||||||
List {
|
List {
|
||||||
statusSection
|
statusSection
|
||||||
|
|
||||||
|
if device.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
|
||||||
|
FridgeControls(device: device, state: fridge)
|
||||||
|
}
|
||||||
|
|
||||||
if let snapshot, !snapshot.metrics.isEmpty {
|
if let snapshot, !snapshot.metrics.isEmpty {
|
||||||
Section("Messwerte") {
|
Section("Messwerte") {
|
||||||
ForEach(snapshot.metrics) { metric in
|
ForEach(snapshot.metrics) { metric in
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 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) |
|
| 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 |
|
| 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.
|
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
|
Nur **eine** App gleichzeitig kann mit dem BMS verbunden sein. Wenn die
|
||||||
Hersteller-App offen ist, bekommt Camper Monitor keine Verbindung.
|
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
|
## Mehrere Fahrzeuge
|
||||||
|
|
||||||
Oben links im Dashboard sitzt der Fahrzeugwechsel. Jedes Profil hat seinen
|
Oben links im Dashboard sitzt der Fahrzeugwechsel. Jedes Profil hat seinen
|
||||||
@@ -141,6 +164,7 @@ CamperMonitor/
|
|||||||
│ ├── DalyState.swift Sammelt Daly-Antworten zu einem Gesamtbild
|
│ ├── DalyState.swift Sammelt Daly-Antworten zu einem Gesamtbild
|
||||||
│ ├── JBDProtocol.swift JBD/Xiaoxiang, Rahmen und Auswertung
|
│ ├── JBDProtocol.swift JBD/Xiaoxiang, Rahmen und Auswertung
|
||||||
│ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung
|
│ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung
|
||||||
|
│ ├── AlpicoolProtocol.swift Kühlboxen: Auswertung und Stellbefehle
|
||||||
│ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage
|
│ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage
|
||||||
├── Store/
|
├── Store/
|
||||||
│ ├── DeviceStore.swift Geräteliste, Persistenz
|
│ ├── DeviceStore.swift Geräteliste, Persistenz
|
||||||
@@ -150,6 +174,7 @@ CamperMonitor/
|
|||||||
├── DeviceCard.swift Eine Kachel
|
├── DeviceCard.swift Eine Kachel
|
||||||
├── DeviceDetailView.swift Alle Werte, Verlauf, Zellspannungen, Diagnose
|
├── DeviceDetailView.swift Alle Werte, Verlauf, Zellspannungen, Diagnose
|
||||||
├── ProfilesView.swift Fahrzeuge anlegen und verwalten
|
├── ProfilesView.swift Fahrzeuge anlegen und verwalten
|
||||||
|
├── FridgeControls.swift Bedienelemente der Kühlbox
|
||||||
└── AddDeviceView.swift Scannen und Einrichten
|
└── AddDeviceView.swift Scannen und Einrichten
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -413,5 +413,120 @@ var errorState = WattCycleState()
|
|||||||
for frame in WattCycleProtocol.extractFrames(from: errorFrame).frames { errorState.apply(frame) }
|
for frame in WattCycleProtocol.extractFrames(from: errorFrame).frames { errorState.apply(frame) }
|
||||||
checkEqual("Fehlerantwort liefert keine Werte", errorState.hasUsableData, false)
|
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.")
|
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
|
||||||
exit(failures == 0 ? 0 : 1)
|
exit(failures == 0 ? 0 : 1)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ swiftc -O -o "$OUT/tests" \
|
|||||||
CamperMonitor/Bluetooth/DalyState.swift \
|
CamperMonitor/Bluetooth/DalyState.swift \
|
||||||
CamperMonitor/Bluetooth/JBDProtocol.swift \
|
CamperMonitor/Bluetooth/JBDProtocol.swift \
|
||||||
CamperMonitor/Bluetooth/WattCycleProtocol.swift \
|
CamperMonitor/Bluetooth/WattCycleProtocol.swift \
|
||||||
|
CamperMonitor/Bluetooth/AlpicoolProtocol.swift \
|
||||||
CamperMonitor/Models/DeviceSnapshot.swift \
|
CamperMonitor/Models/DeviceSnapshot.swift \
|
||||||
CamperMonitor/Models/VictronCodes.swift \
|
CamperMonitor/Models/VictronCodes.swift \
|
||||||
CamperMonitor/Store/KeychainStore.swift \
|
CamperMonitor/Store/KeychainStore.swift \
|
||||||
|
|||||||
Reference in New Issue
Block a user