diff --git a/CamperMonitor/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/CamperMonitor/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..554608c Binary files /dev/null and b/CamperMonitor/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/CamperMonitor/Assets.xcassets/AppIcon.appiconset/Contents.json b/CamperMonitor/Assets.xcassets/AppIcon.appiconset/Contents.json index 2d72247..b773ff2 100644 --- a/CamperMonitor/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/CamperMonitor/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 } } diff --git a/CamperMonitor/Bluetooth/AlpicoolProtocol.swift b/CamperMonitor/Bluetooth/AlpicoolProtocol.swift index 597603d..5a5372a 100644 --- a/CamperMonitor/Bluetooth/AlpicoolProtocol.swift +++ b/CamperMonitor/Bluetooth/AlpicoolProtocol.swift @@ -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.. 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) diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index bc4e59a..5f0ce50 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -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() } diff --git a/CamperMonitor/Models/ConfiguredDevice.swift b/CamperMonitor/Models/ConfiguredDevice.swift index 65992ec..37e6cb0 100644 --- a/CamperMonitor/Models/ConfiguredDevice.swift +++ b/CamperMonitor/Models/ConfiguredDevice.swift @@ -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 } } diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift index e3ec224..e4fccb3 100644 --- a/CamperMonitor/Views/DeviceDetailView.swift +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -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()) diff --git a/Tests/main.swift b/Tests/main.swift index e9d52e2..9d505e7 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -506,11 +506,66 @@ for frame in AlpicoolProtocol.extractFrames(from: dual.apply(frame) } 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("Ist rechts negativ", dual.rightCurrent, -5) checkEqual("Zustand meldet laufenden Kompressor", 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 // Stellbefehle mit zwei aneinandergehängten Paketen. let echoThenStatus = alpicoolResponse(command: 0x02, payload: [0x01]) + fridgeStream diff --git a/run-tests.sh b/run-tests.sh index b5aa811..1508856 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -14,6 +14,8 @@ swiftc -O -o "$OUT/tests" \ CamperMonitor/Bluetooth/WattCycleProtocol.swift \ CamperMonitor/Bluetooth/AlpicoolProtocol.swift \ CamperMonitor/Models/DeviceSnapshot.swift \ + CamperMonitor/Models/ConfiguredDevice.swift \ + CamperMonitor/Models/Profile.swift \ CamperMonitor/Models/VictronCodes.swift \ CamperMonitor/Store/KeychainStore.swift \ Tests/main.swift