Kühlbox: Zonen richtig erkennen, Stellbefehle nicht mehr abschneiden

Zwei Fehler am Gerät gefunden.

Einzonen-Boxen als Doppelzone erkannt: Manche senden den langen
Datensatz einer Zweizonen-Box mit, füllen den zweiten Block aber mit
Nullen. Die Nutzlastlänge allein taugt deshalb nicht als Kriterium. Als
zweizonig gilt jetzt nur, wo dieser Block überhaupt Inhalt hat, und in
den Geräteeinstellungen lässt sich die Erkennung auf "Eine Zone" oder
"Zwei Zonen" festnageln.

Einschalten reagierte nicht: Ein/Aus und Betriebsart schicken den
kompletten Einstellungsblock, der mit dem langen Datensatz auf 31 Byte
kommt. Ohne ausgehandelte MTU nimmt BLE aber nur 20 Nutzbytes je
Schreibvorgang an, der Rest wurde stillschweigend verworfen. Die
Solltemperatur ging als kurzes Paket durch, alles Längere nicht. Lange
Pakete werden jetzt anhand von maximumWriteValueLength aufgeteilt und
versetzt geschrieben; die Statusabfrage danach wartet entsprechend
länger. Aus dem kurzen Datensatz ist der Befehl übrigens genau 20 Byte
groß und hätte gerade noch gepasst - deshalb trat es nur an dieser Box
auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-30 14:21:28 +02:00
co-authored by Claude Opus 5
parent 45d1dc087d
commit daea73d649
9 changed files with 183 additions and 4 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -1,4 +1,11 @@
{ {
"images" : [ { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : { "author" : "xcode", "version" : 1 } "info" : { "author" : "xcode", "version" : 1 }
} }
+45 -1
View File
@@ -91,6 +91,23 @@ enum AlpicoolProtocol {
} }
static func signed(_ byte: UInt8) -> Int { Int(Int8(bitPattern: byte)) } static func signed(_ byte: UInt8) -> Int { Int(Int8(bitPattern: byte)) }
/// Pause zwischen den Teilstücken eines aufgeteilten Pakets, damit das
/// Gerät sie wieder zusammensetzen kann.
static let chunkDelay: TimeInterval = 0.15
/// Zerlegt ein Paket in schreibbare Stücke.
///
/// Ohne ausgehandelte MTU nimmt BLE nur 20 Nutzbytes je Schreibvorgang an.
/// Der Einstellungsblock einer Kühlbox ist mit bis zu 31 Byte länger und
/// würde sonst stillschweigend verworfen.
static func chunks(_ data: Data, limit: Int) -> [Data] {
guard limit > 0 else { return [data] }
guard data.count > limit else { return [data] }
return stride(from: 0, to: data.count, by: limit).map { start in
data.subdata(in: start..<min(start + limit, data.count))
}
}
} }
/// Zustand einer Kühlbox Messwerte und die Einstellungen, die sich ändern /// Zustand einer Kühlbox Messwerte und die Einstellungen, die sich ändern
@@ -123,7 +140,29 @@ struct AlpicoolState: Equatable {
/// deshalb wird er aufgehoben. /// deshalb wird er aufgehoben.
var lastPayload: [UInt8] = [] var lastPayload: [UInt8] = []
var isDualZone: Bool { rightCurrent != nil } /// Übersteuerung aus den Geräteeinstellungen.
var zoneMode: FridgeZoneMode = .automatic
/// Rohe Bytes der rechten Zone, für die Erkennung und die Diagnose.
var rightZoneBytes: [UInt8] = []
/// Ob die Box wirklich eine zweite Zone hat.
///
/// Die Nutzlastlänge allein taugt nicht: Einzonen-Boxen senden den langen
/// Datensatz teils mit, füllen den zweiten Block aber mit Nullen. Als
/// zweizonig gilt deshalb nur, wo dieser Block überhaupt etwas enthält.
var detectedDualZone: Bool {
guard rightCurrent != nil, !rightZoneBytes.isEmpty else { return false }
return rightZoneBytes.contains { $0 != 0x00 } && rightZoneBytes.contains { $0 != 0xFF }
}
var isDualZone: Bool {
switch zoneMode {
case .automatic: return detectedDualZone
case .single: return false
case .dual: return rightCurrent != nil
}
}
var isEco: Bool { runMode == 1 } var isEco: Bool { runMode == 1 }
var usesFahrenheit: Bool { unit == 1 } var usesFahrenheit: Bool { unit == 1 }
var hasStatus: Bool { !lastPayload.isEmpty } var hasStatus: Bool { !lastPayload.isEmpty }
@@ -163,6 +202,11 @@ struct AlpicoolState: Equatable {
rightTarget = AlpicoolProtocol.signed(p[18]) rightTarget = AlpicoolProtocol.signed(p[18])
rightCurrent = AlpicoolProtocol.signed(p[26]) rightCurrent = AlpicoolProtocol.signed(p[26])
runningStatus = Int(p[27]) runningStatus = Int(p[27])
rightZoneBytes = Array(p[18...26])
} else {
rightTarget = nil
rightCurrent = nil
rightZoneBytes = []
} }
} }
+28 -2
View File
@@ -81,6 +81,10 @@ final class BMSSession: NSObject {
private var jbdState = JBDState() private var jbdState = JBDState()
private var wattCycleState = WattCycleState() private var wattCycleState = WattCycleState()
private(set) var alpicoolState = AlpicoolState() private(set) var alpicoolState = AlpicoolState()
/// Aus den Geräteeinstellungen; übersteuert die automatische Erkennung.
var fridgeZoneMode: FridgeZoneMode = .automatic {
didSet { alpicoolState.zoneMode = fridgeZoneMode }
}
/// Ob die Kühlbox in dieser Sitzung schon angemeldet wurde. /// Ob die Kühlbox in dieser Sitzung schon angemeldet wurde.
private var didBind = false private var didBind = false
private var buffer: [UInt8] = [] private var buffer: [UInt8] = []
@@ -372,7 +376,27 @@ final class BMSSession: NSObject {
guard let endpoint = currentEndpoint, peripheral.state == .connected else { return } guard let endpoint = currentEndpoint, peripheral.state == .connected else { return }
sentFrameCount += 1 sentFrameCount += 1
lastSendAt = Date() lastSendAt = Date()
peripheral.writeValue(data, for: endpoint.write, type: endpoint.writeType)
// Was in einen Schreibvorgang passt, hängt an der ausgehandelten MTU.
// Längeres muss aufgeteilt werden, sonst verwirft das Gerät es
// wortlos der Einstellungsblock einer Kühlbox ist so ein Fall.
let limit = peripheral.maximumWriteValueLength(for: endpoint.writeType)
let pieces = AlpicoolProtocol.chunks(data, limit: limit)
for (index, piece) in pieces.enumerated() {
guard index > 0 else {
peripheral.writeValue(piece, for: endpoint.write, type: endpoint.writeType)
continue
}
DispatchQueue.main.asyncAfter(
deadline: .now() + Double(index) * AlpicoolProtocol.chunkDelay
) { [weak self] in
guard let self, self.peripheral.state == .connected,
let current = self.currentEndpoint else { return }
self.peripheral.writeValue(piece, for: current.write, type: current.writeType)
}
}
// Sofort melden, sonst sieht die Diagnose sekundenlang nach Stillstand // Sofort melden, sonst sieht die Diagnose sekundenlang nach Stillstand
// aus, obwohl gerade gesucht wird. // aus, obwohl gerade gesucht wird.
publishDiagnostics() publishDiagnostics()
@@ -384,7 +408,8 @@ final class BMSSession: NSObject {
/// die Anzeige dem Gerät folgt statt der Vermutung. /// die Anzeige dem Gerät folgt statt der Vermutung.
func sendControl(_ packet: Data) { func sendControl(_ packet: Data) {
send(packet) send(packet)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in // Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist.
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
guard let self, self.dialect == .alpicool else { return } guard let self, self.dialect == .alpicool else { return }
self.send(AlpicoolProtocol.packet(.query)) self.send(AlpicoolProtocol.packet(.query))
} }
@@ -403,6 +428,7 @@ final class BMSSession: NSObject {
buffer = fridgeRemainder buffer = fridgeRemainder
adopt(.alpicool) adopt(.alpicool)
for frame in fridgeFrames { alpicoolState.apply(frame) } for frame in fridgeFrames { alpicoolState.apply(frame) }
alpicoolState.zoneMode = fridgeZoneMode
onFridgeState?(alpicoolState) onFridgeState?(alpicoolState)
publish(alpicoolState.snapshot(deviceID: deviceID, rssi: nil), publish(alpicoolState.snapshot(deviceID: deviceID, rssi: nil),
usable: alpicoolState.hasStatus) usable: alpicoolState.hasStatus)
@@ -274,6 +274,13 @@ final class BluetoothManager: NSObject {
} }
} }
/// Nach einer Änderung der Zoneneinstellung aufrufen.
func updateFridgeZoneMode(for device: ConfiguredDevice) {
guard let session = bmsSessions[device.peripheralID] else { return }
session.fridgeZoneMode = device.fridgeZoneMode
fridgeStates[device.id] = session.alpicoolState
}
// MARK: - Kühlbox steuern // MARK: - Kühlbox steuern
private func fridgeSession(for deviceID: UUID) -> BMSSession? { private func fridgeSession(for deviceID: UUID) -> BMSSession? {
@@ -467,6 +474,7 @@ extension BluetoothManager: CBCentralManagerDelegate {
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 } session.onFridgeState = { [weak self] state in self?.fridgeStates[device.id] = state }
session.fridgeZoneMode = device.fridgeZoneMode
bmsSessions[peripheral.identifier] = session bmsSessions[peripheral.identifier] = session
session.start() session.start()
} }
@@ -48,6 +48,24 @@ enum DeviceTransport: Sendable {
case connect case connect
} }
/// Wie viele Kühlzonen eine Box hat. Manche Einzonen-Boxen senden den langen
/// Datensatz einer Doppelzonen-Box mit, weshalb die Erkennung übersteuerbar ist.
enum FridgeZoneMode: String, Codable, CaseIterable, Identifiable, Sendable {
case automatic
case single
case dual
var id: String { rawValue }
var title: String {
switch self {
case .automatic: return "Automatisch"
case .single: return "Eine Zone"
case .dual: return "Zwei Zonen"
}
}
}
/// Ein vom Nutzer eingerichtetes Gerät. Der Victron-Schlüssel liegt nicht hier, /// Ein vom Nutzer eingerichtetes Gerät. Der Victron-Schlüssel liegt nicht hier,
/// sondern in der Keychain (siehe `KeychainStore`). /// sondern in der Keychain (siehe `KeychainStore`).
struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable { struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
@@ -61,6 +79,8 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
var peripheralID: UUID var peripheralID: UUID
/// Zuletzt gesehener Advertised Name, nur zur Wiedererkennung in der UI. /// Zuletzt gesehener Advertised Name, nur zur Wiedererkennung in der UI.
var advertisedName: String? var advertisedName: String?
/// Nur für Kühlboxen.
var fridgeZoneMode: FridgeZoneMode = .automatic
init(id: UUID = UUID(), init(id: UUID = UUID(),
name: String, name: String,
@@ -87,5 +107,7 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
?? Profile.defaultID ?? Profile.defaultID
peripheralID = try container.decode(UUID.self, forKey: .peripheralID) peripheralID = try container.decode(UUID.self, forKey: .peripheralID)
advertisedName = try container.decodeIfPresent(String.self, forKey: .advertisedName) advertisedName = try container.decodeIfPresent(String.self, forKey: .advertisedName)
fridgeZoneMode = try container.decodeIfPresent(FridgeZoneMode.self, forKey: .fridgeZoneMode)
?? .automatic
} }
} }
@@ -323,6 +323,21 @@ struct DeviceDetailView: View {
.disabled(editedName.trimmingCharacters(in: .whitespaces).isEmpty .disabled(editedName.trimmingCharacters(in: .whitespaces).isEmpty
|| editedName == device.name) || editedName == device.name)
LabeledContent("Typ", value: device.role.title) LabeledContent("Typ", value: device.role.title)
if device.role == .fridge {
Picker("Kühlzonen", selection: Binding(
get: { device.fridgeZoneMode },
set: { mode in
var updated = device
updated.fridgeZoneMode = mode
store.update(updated)
bluetooth.updateFridgeZoneMode(for: updated)
}
)) {
ForEach(FridgeZoneMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
}
LabeledContent("Bluetooth-ID") { LabeledContent("Bluetooth-ID") {
Text(device.peripheralID.uuidString.prefix(8) + "") Text(device.peripheralID.uuidString.prefix(8) + "")
.font(.caption.monospaced()) .font(.caption.monospaced())
+55
View File
@@ -506,11 +506,66 @@ for frame in AlpicoolProtocol.extractFrames(from:
dual.apply(frame) dual.apply(frame)
} }
checkEqual("Zweizonen-Box erkannt", dual.isDualZone, true) checkEqual("Zweizonen-Box erkannt", dual.isDualZone, true)
dual.zoneMode = .single
checkEqual("auch eine echte Zweizonen-Box laesst sich auf eine reduzieren",
dual.isDualZone, false)
dual.zoneMode = .automatic
checkEqual("Soll rechts negativ", dual.rightTarget, -2) checkEqual("Soll rechts negativ", dual.rightTarget, -2)
checkEqual("Ist rechts negativ", dual.rightCurrent, -5) checkEqual("Ist rechts negativ", dual.rightCurrent, -5)
checkEqual("Zustand meldet laufenden Kompressor", checkEqual("Zustand meldet laufenden Kompressor",
dual.snapshot(deviceID: UUID(), rssi: nil).state, "Kühlt (Eco)") dual.snapshot(deviceID: UUID(), rssi: nil).state, "Kühlt (Eco)")
// Einzonen-Box, die den langen Datensatz trotzdem sendet: der zweite Block
// ist dann leer und darf nicht als zweite Zone durchgehen.
var paddedPayload = fridgePayload
paddedPayload += [UInt8](repeating: 0, count: 10)
var padded = AlpicoolState()
for frame in AlpicoolProtocol.extractFrames(from:
alpicoolResponse(command: 0x01, payload: paddedPayload)).frames {
padded.apply(frame)
}
checkEqual("leerer zweiter Block gilt nicht als zweite Zone", padded.isDualZone, false)
// Schreibgrenze: ein Stellbefehl aus dem kurzen Datensatz passt mit genau
// 20 Byte noch in einen Schreibvorgang, aus dem langen nicht mehr. Ohne
// Aufteilen verwirft die Box ihn wortlos genau das war der Fehler.
checkEqual("Stellbefehl aus dem kurzen Datensatz", offBytes.count, 20)
guard let longPacket = padded.settingsCommand(poweredOn: true) else {
check("Stellbefehl aus dem langen Datensatz wird gebaut", false); exit(1)
}
check("aus dem langen Datensatz wird er zu lang für einen Schreibvorgang",
longPacket.count > 20, "-> \(longPacket.count) Byte")
let pieces = AlpicoolProtocol.chunks(longPacket, limit: 20)
checkEqual("wird in zwei Stücke geteilt", pieces.count, 2)
checkEqual("kein Stück überschreitet die Grenze", pieces.allSatisfy { $0.count <= 20 }, true)
checkEqual("zusammengesetzt ergibt sich das Original",
[UInt8](pieces.reduce(Data(), +)), [UInt8](longPacket))
checkEqual("kurze Pakete bleiben ungeteilt",
AlpicoolProtocol.chunks(AlpicoolProtocol.packet(.query), limit: 20).count, 1)
checkEqual("Einschaltbyte im langen Befehl", [UInt8](longPacket)[5], 0x01)
checkEqual("Hauptwert heisst dann schlicht Temperatur",
padded.snapshot(deviceID: UUID(), rssi: nil)
.metrics.first { $0.key == "temp_left" }?.label, "Temperatur")
checkEqual("keine Werte der rechten Zone in den Messwerten",
padded.snapshot(deviceID: UUID(), rssi: nil)
.metrics.contains { $0.key == "temp_right" }, false)
// Dasselbe mit 0xFF gefülltem zweiten Block.
var ffPayload = fridgePayload
ffPayload += [UInt8](repeating: 0xFF, count: 10)
var ffState = AlpicoolState()
for frame in AlpicoolProtocol.extractFrames(from:
alpicoolResponse(command: 0x01, payload: ffPayload)).frames {
ffState.apply(frame)
}
checkEqual("mit 0xFF gefuellter Block gilt nicht als zweite Zone", ffState.isDualZone, false)
// Handeinstellung schlaegt die Erkennung in beide Richtungen.
padded.zoneMode = .dual
checkEqual("Handeinstellung erzwingt zwei Zonen", padded.isDualZone, true)
padded.zoneMode = .single
checkEqual("Handeinstellung erzwingt eine Zone", padded.isDualZone, false)
// Echo und Status in einer Benachrichtigung: die Box antwortet auf // Echo und Status in einer Benachrichtigung: die Box antwortet auf
// Stellbefehle mit zwei aneinandergehängten Paketen. // Stellbefehle mit zwei aneinandergehängten Paketen.
let echoThenStatus = alpicoolResponse(command: 0x02, payload: [0x01]) + fridgeStream let echoThenStatus = alpicoolResponse(command: 0x02, payload: [0x01]) + fridgeStream
+2
View File
@@ -14,6 +14,8 @@ swiftc -O -o "$OUT/tests" \
CamperMonitor/Bluetooth/WattCycleProtocol.swift \ CamperMonitor/Bluetooth/WattCycleProtocol.swift \
CamperMonitor/Bluetooth/AlpicoolProtocol.swift \ CamperMonitor/Bluetooth/AlpicoolProtocol.swift \
CamperMonitor/Models/DeviceSnapshot.swift \ CamperMonitor/Models/DeviceSnapshot.swift \
CamperMonitor/Models/ConfiguredDevice.swift \
CamperMonitor/Models/Profile.swift \
CamperMonitor/Models/VictronCodes.swift \ CamperMonitor/Models/VictronCodes.swift \
CamperMonitor/Store/KeychainStore.swift \ CamperMonitor/Store/KeychainStore.swift \
Tests/main.swift Tests/main.swift