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:
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 }
|
||||
}
|
||||
|
||||
@@ -91,6 +91,23 @@ enum AlpicoolProtocol {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -123,7 +140,29 @@ struct AlpicoolState: Equatable {
|
||||
/// deshalb wird er aufgehoben.
|
||||
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 usesFahrenheit: Bool { unit == 1 }
|
||||
var hasStatus: Bool { !lastPayload.isEmpty }
|
||||
@@ -163,6 +202,11 @@ struct AlpicoolState: Equatable {
|
||||
rightTarget = AlpicoolProtocol.signed(p[18])
|
||||
rightCurrent = AlpicoolProtocol.signed(p[26])
|
||||
runningStatus = Int(p[27])
|
||||
rightZoneBytes = Array(p[18...26])
|
||||
} else {
|
||||
rightTarget = nil
|
||||
rightCurrent = nil
|
||||
rightZoneBytes = []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,10 @@ final class BMSSession: NSObject {
|
||||
private var jbdState = JBDState()
|
||||
private var wattCycleState = WattCycleState()
|
||||
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.
|
||||
private var didBind = false
|
||||
private var buffer: [UInt8] = []
|
||||
@@ -372,7 +376,27 @@ final class BMSSession: NSObject {
|
||||
guard let endpoint = currentEndpoint, peripheral.state == .connected else { return }
|
||||
sentFrameCount += 1
|
||||
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
|
||||
// aus, obwohl gerade gesucht wird.
|
||||
publishDiagnostics()
|
||||
@@ -384,7 +408,8 @@ final class BMSSession: NSObject {
|
||||
/// 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
|
||||
// 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 }
|
||||
self.send(AlpicoolProtocol.packet(.query))
|
||||
}
|
||||
@@ -403,6 +428,7 @@ final class BMSSession: NSObject {
|
||||
buffer = fridgeRemainder
|
||||
adopt(.alpicool)
|
||||
for frame in fridgeFrames { alpicoolState.apply(frame) }
|
||||
alpicoolState.zoneMode = fridgeZoneMode
|
||||
onFridgeState?(alpicoolState)
|
||||
publish(alpicoolState.snapshot(deviceID: deviceID, rssi: nil),
|
||||
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
|
||||
|
||||
private func fridgeSession(for deviceID: UUID) -> BMSSession? {
|
||||
@@ -467,6 +474,7 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
||||
onDiagnostics: { [weak self] info in self?.bmsDiagnostics[device.id] = info }
|
||||
)
|
||||
session.onFridgeState = { [weak self] state in self?.fridgeStates[device.id] = state }
|
||||
session.fridgeZoneMode = device.fridgeZoneMode
|
||||
bmsSessions[peripheral.identifier] = session
|
||||
session.start()
|
||||
}
|
||||
|
||||
@@ -48,6 +48,24 @@ enum DeviceTransport: Sendable {
|
||||
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,
|
||||
/// sondern in der Keychain (siehe `KeychainStore`).
|
||||
struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
||||
@@ -61,6 +79,8 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
||||
var peripheralID: UUID
|
||||
/// Zuletzt gesehener Advertised Name, nur zur Wiedererkennung in der UI.
|
||||
var advertisedName: String?
|
||||
/// Nur für Kühlboxen.
|
||||
var fridgeZoneMode: FridgeZoneMode = .automatic
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
name: String,
|
||||
@@ -87,5 +107,7 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
||||
?? Profile.defaultID
|
||||
peripheralID = try container.decode(UUID.self, forKey: .peripheralID)
|
||||
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
|
||||
|| editedName == device.name)
|
||||
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") {
|
||||
Text(device.peripheralID.uuidString.prefix(8) + "…")
|
||||
.font(.caption.monospaced())
|
||||
|
||||
Reference in New Issue
Block a user