diff --git a/CamperMonitor/Bluetooth/BMSSession.swift b/CamperMonitor/Bluetooth/BMSSession.swift index 24842c1..c74c5cd 100644 --- a/CamperMonitor/Bluetooth/BMSSession.swift +++ b/CamperMonitor/Bluetooth/BMSSession.swift @@ -32,20 +32,29 @@ final class BMSSession: NSObject { case dalyClassic = "Daly (klassisch)" case dalyModbus = "Daly (Modbus)" case jbd = "JBD / Xiaoxiang" + case wattCycle = "WattCycle" } + /// Freischalt-Charakteristik der WattCycle-Akkus. Liegt im selben Dienst + /// wie Schreiben und Empfangen und muss vor der ersten Abfrage beschrieben + /// werden, sonst bleibt der Akku stumm. + private static let wattCycleAuthUUID = CBUUID(string: "FFFA") + /// Ein Kandidat: worüber geschrieben, worüber gelauscht und wie geschrieben /// wird. Der Schreibmodus gehört dazu, weil manche Module nur die eine oder /// nur die andere Variante annehmen. private struct Endpoint { let write: CBCharacteristic let notify: CBCharacteristic + /// Falls vorhanden, wird hierauf vor der ersten Abfrage freigeschaltet. + let auth: CBCharacteristic? let writeType: CBCharacteristicWriteType let isKnownPair: Bool var label: String { let mode = writeType == .withoutResponse ? "ohne Bestätigung" : "mit Bestätigung" - return "\(write.uuid.uuidString) → \(notify.uuid.uuidString), \(mode)" + let unlock = auth == nil ? "" : ", Freischaltung über \(auth!.uuid.uuidString)" + return "\(write.uuid.uuidString) → \(notify.uuid.uuidString), \(mode)\(unlock)" } } @@ -62,6 +71,7 @@ final class BMSSession: NSObject { private(set) var dialect: Dialect = .unknown private var dalyState = DalyState() private var jbdState = JBDState() + private var wattCycleState = WattCycleState() private var buffer: [UInt8] = [] private var pollTimer: Timer? private var lastResponse: Data? @@ -74,6 +84,8 @@ final class BMSSession: NSObject { /// eines bereits verworfenen Kandidaten lassen sich so ignorieren. private var activationToken = 0 private var isNotifyActive = false + /// Ob auf diesem Kandidaten schon freigeschaltet wurde. + private var didUnlock = false private var lastSendAt: Date? /// Abstand zwischen zwei Abfragerunden im Normalbetrieb. @@ -143,7 +155,15 @@ final class BMSSession: NSObject { } guard !writable.isEmpty, !notifying.isEmpty else { continue } - for write in writable { + // Eine Freischalt-Charakteristik im selben Dienst gehört zum + // Kandidaten dazu; sie zu beschreiben schadet den anderen + // Protokollen nicht, für WattCycle ist sie zwingend. + let auth = characteristics.first { + $0.uuid == Self.wattCycleAuthUUID + && ($0.properties.contains(.write) || $0.properties.contains(.writeWithoutResponse)) + } + + for write in writable where write.uuid != Self.wattCycleAuthUUID { for notify in notifying { let known = Self.knownPairs.contains { CBUUID(string: $0.service) == service.uuid @@ -152,11 +172,11 @@ final class BMSSession: NSObject { } // Beide Schreibarten anbieten, sofern das Gerät sie kann. if write.properties.contains(.writeWithoutResponse) { - candidates.append(Endpoint(write: write, notify: notify, + candidates.append(Endpoint(write: write, notify: notify, auth: auth, writeType: .withoutResponse, isKnownPair: known)) } if write.properties.contains(.write) { - candidates.append(Endpoint(write: write, notify: notify, + candidates.append(Endpoint(write: write, notify: notify, auth: auth, writeType: .withResponse, isKnownPair: known)) } } @@ -181,6 +201,7 @@ final class BMSSession: NSObject { guard let endpoint = currentEndpoint else { return } silentRounds = 0 isNotifyActive = false + didUnlock = false buffer.removeAll() activationToken += 1 let token = activationToken @@ -217,6 +238,31 @@ final class BMSSession: NSObject { // MARK: - Abfrage + /// WattCycle verlangt vor der ersten Abfrage ein „HiLink“ auf der + /// Freischalt-Charakteristik, und danach eine kurze Pause. Die Referenz + /// wartet ~200 ms nach dem Abo und ~300 ms nach der Freischaltung. + private func unlockThenPoll() { + guard let endpoint = currentEndpoint, let auth = endpoint.auth else { + beginPolling() + return + } + let token = activationToken + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + guard let self, self.activationToken == token, + self.peripheral.state == .connected else { return } + let type: CBCharacteristicWriteType = + auth.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse + self.peripheral.writeValue(WattCycleProtocol.authPayload, for: auth, type: type) + self.didUnlock = true + self.publishDiagnostics() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + guard let self, self.activationToken == token else { return } + self.beginPolling() + } + } + } + private func beginPolling() { pollTimer?.invalidate() poll() @@ -233,6 +279,7 @@ final class BMSSession: NSObject { case .unknown: // Alle drei Protokolle anfragen; was antwortet, gewinnt. sendSequence([ + WattCycleProtocol.requestFrame(.analog), DalyProtocol.requestFrame(.soc), JBDProtocol.requestFrame(.basicInfo), DalyProtocol.modbusReadFrame(), @@ -245,6 +292,13 @@ final class BMSSession: NSObject { case .jbd: sendSequence(JBDProtocol.Command.allCases.map { JBDProtocol.requestFrame($0) }, spacing: 0.25, thenGiveUpAfter: 2) + case .wattCycle: + // Modell und Seriennummer ändern sich nie – nur einmal abfragen. + var frames = [WattCycleProtocol.requestFrame(.analog)] + if !wattCycleState.hasProductInfo { + frames.append(WattCycleProtocol.requestFrame(.product)) + } + sendSequence(frames, spacing: 0.3, thenGiveUpAfter: 2) } } @@ -285,7 +339,7 @@ final class BMSSession: NSObject { } private var hasUsableData: Bool { - dalyState.hasUsableData || jbdState.hasUsableData + dalyState.hasUsableData || jbdState.hasUsableData || wattCycleState.hasUsableData } private func send(_ data: Data) { @@ -306,7 +360,17 @@ final class BMSSession: NSObject { buffer.append(contentsOf: [UInt8](data)) if buffer.count > 512 { buffer.removeFirst(buffer.count - 512) } - // JBD zuerst: Start-, Endbyte und Prüfsumme machen den Rahmen eindeutig. + let (wattFrames, wattRemainder) = WattCycleProtocol.extractFrames(from: buffer) + if !wattFrames.isEmpty { + buffer = wattRemainder + adopt(.wattCycle) + for frame in wattFrames { wattCycleState.apply(frame) } + publish(wattCycleState.snapshot(deviceID: deviceID, rssi: nil), + usable: wattCycleState.hasUsableData) + return + } + + // JBD als Nächstes: Start-, Endbyte und Prüfsumme machen den Rahmen eindeutig. let (jbdFrames, jbdRemainder) = JBDProtocol.extractFrames(from: buffer) if !jbdFrames.isEmpty { buffer = jbdRemainder @@ -433,7 +497,7 @@ extension BMSSession: CBPeripheralDelegate { if characteristic.isNotifying, characteristic == currentEndpoint?.notify { isNotifyActive = true publishDiagnostics() - beginPolling() + unlockThenPoll() } } diff --git a/CamperMonitor/Bluetooth/WattCycleProtocol.swift b/CamperMonitor/Bluetooth/WattCycleProtocol.swift new file mode 100644 index 0000000..c3804fc --- /dev/null +++ b/CamperMonitor/Bluetooth/WattCycleProtocol.swift @@ -0,0 +1,272 @@ +import Foundation + +/// Protokoll der WattCycle-BLE-Akkus. +/// +/// Weder Daly noch JBD, sondern ein eigenes Modbus-artiges Format. Zwei +/// Besonderheiten: +/// +/// * Vor der ersten Abfrage muss der ASCII-Text `HiLink` auf eine eigene +/// Freischalt-Charakteristik (`FFFA`) geschrieben werden. Ohne das bleibt +/// der Akku auf jede Anfrage stumm. +/// * Anfragen gehen auf `FFF2`, Antworten kommen über `FFF1`. +/// +/// Rahmenaufbau: +/// +/// Anfrage (11 Byte): +/// 1E 00 01 03 00 00 0D +/// Antwort: +/// 7E +/// 0D +/// +/// Die Prüfsumme ist der übliche Modbus-CRC16 über alles vor der Prüfsumme, +/// höherwertiges Byte zuerst. Nachgerechnet gegen die Tabellenvariante der +/// Referenzimplementierung (frabnet/esphome-wattcycle-ble). +enum WattCycleProtocol { + + static let frameHeadRequest: UInt8 = 0x1E + static let frameHeadResponse: UInt8 = 0x7E + static let frameTail: UInt8 = 0x0D + static let functionRead: UInt8 = 0x03 + static let functionError: UInt8 = 0x86 + + /// Der Freischalt-Text, der vor der ersten Abfrage geschrieben wird. + static let authPayload = Data("HiLink".utf8) + + enum Datapoint: UInt16 { + case analog = 0x008C // Messwerte + case product = 0x0092 // Modell, Hersteller, Seriennummer + } + + static func requestFrame(_ datapoint: Datapoint) -> Data { + var frame: [UInt8] = [ + frameHeadRequest, + 0x00, // Version + 0x01, // Adresse + functionRead, + UInt8(datapoint.rawValue >> 8), + UInt8(datapoint.rawValue & 0xFF), + 0x00, 0x00, // Anzahl: 0 liefert den ganzen Datensatz + ] + let crc = DalyProtocol.crc16Modbus(frame) + frame.append(UInt8(crc >> 8)) + frame.append(UInt8(crc & 0xFF)) + frame.append(frameTail) + return Data(frame) + } + + struct Frame { + let function: UInt8 + let datapoint: UInt16 + let payload: [UInt8] + + var isError: Bool { function == functionError } + } + + /// Sucht vollständige, prüfsummenkorrekte Antwortrahmen im Puffer. + static func extractFrames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) { + var frames: [Frame] = [] + var index = 0 + var consumed = 0 + + while index + 11 <= buffer.count { + guard buffer[index] == frameHeadResponse else { + index += 1 + continue + } + let dataLength = Int(buffer[index + 6]) << 8 | Int(buffer[index + 7]) + let total = dataLength + 11 + guard total <= 512 else { index += 1; continue } + guard index + total <= buffer.count else { break } // Rest abwarten + + let frame = Array(buffer[index..<(index + total)]) + guard frame[total - 1] == frameTail else { + index += 1 + continue + } + let expected = DalyProtocol.crc16Modbus(Array(frame[0..<(total - 3)])) + let actual = UInt16(frame[total - 3]) << 8 | UInt16(frame[total - 2]) + guard expected == actual else { + index += 1 + continue + } + frames.append(Frame( + function: frame[3], + datapoint: UInt16(frame[4]) << 8 | UInt16(frame[5]), + payload: Array(frame[8..<(8 + dataLength)]) + )) + index += total + consumed = index + } + let keepFrom = max(consumed, max(0, buffer.count - 256)) + return (frames, Array(buffer[keepFrom...])) + } + + /// Temperaturen kommen in Zehntel-Kelvin. + static func temperature(_ raw: UInt16) -> Double { + (Double(raw) - 2730) / 10 + } + + /// Der Strom hat ein eigenes Format: Bit 15 ist das Vorzeichen, Bit 14 gibt + /// an, ob der Rest in Zehntel-Ampere zu lesen ist, der Rest ist der Betrag. + static func current(high: UInt8, low: UInt8) -> Double { + let isNegative = high & 0x80 != 0 + let hasDecimal = high & 0x40 != 0 + let magnitude = Double(Int(low) | (Int(high & 0x3F) << 8)) + let value = hasDecimal ? magnitude / 10 : magnitude + return isNegative ? -value : value + } +} + +/// Sammelt die Antworten eines WattCycle-Akkus. +struct WattCycleState { + var cellVolts: [Double] = [] + var mosTemperature: Double? + var pcbTemperature: Double? + var cellTemperatures: [Double] = [] + var current: Double? + var voltage: Double? + var remainingAh: Double? + var totalAh: Double? + var designAh: Double? + var cycles: Int? + var soc: Double? + + var model: String? + var manufacturer: String? + var serial: String? + + var hasUsableData: Bool { voltage != nil || soc != nil || !cellVolts.isEmpty } + var hasProductInfo: Bool { model != nil || manufacturer != nil || serial != nil } + + mutating func apply(_ frame: WattCycleProtocol.Frame) { + guard !frame.isError else { return } + switch WattCycleProtocol.Datapoint(rawValue: frame.datapoint) { + case .analog: applyAnalog(frame.payload) + case .product: applyProduct(frame.payload) + case nil: break + } + } + + /// Der Messwert-Datensatz ist selbstbeschreibend: erst die Zellenanzahl, + /// dann die Zellspannungen, dann die Fühleranzahl und so weiter. Die + /// Feldlängen stehen also nicht fest und werden mitgelesen. + private mutating func applyAnalog(_ data: [UInt8]) { + var offset = 0 + + func readUInt16() -> UInt16? { + guard offset + 1 < data.count else { return nil } + defer { offset += 2 } + return UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) + } + func readUInt8() -> UInt8? { + guard offset < data.count else { return nil } + defer { offset += 1 } + return data[offset] + } + + guard let cellCount = readUInt8() else { return } + var cells: [Double] = [] + for _ in 0..= 2 else { return } + guard let mos = readUInt16(), let pcb = readUInt16() else { return } + mosTemperature = WattCycleProtocol.temperature(mos) + pcbTemperature = WattCycleProtocol.temperature(pcb) + + var probes: [Double] = [] + for _ in 0..<(Int(temperatureCount) - 2) { + guard let raw = readUInt16() else { return } + probes.append(WattCycleProtocol.temperature(raw)) + } + cellTemperatures = probes + + guard offset + 1 < data.count else { return } + current = WattCycleProtocol.current(high: data[offset], low: data[offset + 1]) + offset += 2 + + guard let voltageRaw = readUInt16() else { return } + voltage = Double(voltageRaw) / 100 + + guard let remaining = readUInt16(), + let total = readUInt16(), + let cycleCount = readUInt16(), + let design = readUInt16(), + let charge = readUInt16() else { return } + remainingAh = Double(remaining) / 10 + totalAh = Double(total) / 10 + cycles = Int(cycleCount) + designAh = Double(design) / 10 + soc = Double(charge) + } + + /// Drei ASCII-Felder à 20 Byte. + private mutating func applyProduct(_ data: [UInt8]) { + guard data.count >= 60 else { return } + func text(_ range: Range) -> String? { + let value = String(decoding: data[range], as: UTF8.self) + .trimmingCharacters(in: CharacterSet(charactersIn: "\0 ")) + return value.isEmpty ? nil : value + } + model = text(0..<20) + manufacturer = text(20..<40) + serial = text(40..<60) + } + + func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot { + var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi) + + var metrics: [Metric] = [ + Metric("soc", "Ladezustand", soc, unit: "%", precision: 0, primary: true), + Metric("voltage", "Spannung", voltage, unit: "V", precision: 2), + Metric("current", "Strom", current, unit: "A", precision: 1), + ] + if let voltage, let current { + metrics.append(Metric("power", "Leistung", voltage * current, unit: "W", precision: 0)) + } + metrics.append(Metric("capacity", "Restkapazität", remainingAh, unit: "Ah", precision: 1)) + if let totalAh { + metrics.append(Metric("capacity_total", "Kapazität geladen", totalAh, unit: "Ah", precision: 1)) + } + if let designAh { + metrics.append(Metric("capacity_design", "Nennkapazität", designAh, unit: "Ah", precision: 1)) + } + if let maxV = cellVolts.max(), let minV = cellVolts.min() { + metrics.append(Metric("cell_delta", "Zell-Differenz", + (maxV - minV) * 1000, unit: "mV", precision: 0)) + metrics.append(Metric("cell_max", "Höchste Zelle", maxV, unit: "V", precision: 3)) + metrics.append(Metric("cell_min", "Niedrigste Zelle", minV, unit: "V", precision: 3)) + } + if let mosTemperature { + metrics.append(Metric("temp_mos", "Temperatur MOSFET", mosTemperature, unit: "°C", precision: 1)) + } + if let pcbTemperature { + metrics.append(Metric("temp_pcb", "Temperatur Platine", pcbTemperature, unit: "°C", precision: 1)) + } + if let cycles { + metrics.append(Metric("cycles", "Ladezyklen", Double(cycles), unit: "", precision: 0)) + } + snapshot.metrics = metrics + + snapshot.cellVoltages = cellVolts + snapshot.temperatures = cellTemperatures + + if let current { + if current > 0.3 { snapshot.state = "Lädt" } + else if current < -0.3 { snapshot.state = "Entlädt" } + else { snapshot.state = "Ruhend" } + } + + var info: [DeviceSnapshot.InfoItem] = [] + if let model { info.append(.init(label: "Modell / Firmware", value: model)) } + if let manufacturer { info.append(.init(label: "Hersteller", value: manufacturer)) } + if let serial { info.append(.init(label: "Seriennummer", value: serial)) } + snapshot.info = info + + return snapshot + } +} diff --git a/CamperMonitor/Models/DeviceSnapshot.swift b/CamperMonitor/Models/DeviceSnapshot.swift index abc4c61..80b01ef 100644 --- a/CamperMonitor/Models/DeviceSnapshot.swift +++ b/CamperMonitor/Models/DeviceSnapshot.swift @@ -55,6 +55,14 @@ struct DeviceSnapshot: Identifiable, Sendable { var cellVoltages: [Double] = [] /// Temperaturfühler in °C (nur BMS). var temperatures: [Double] = [] + /// Feste Angaben des Geräts, etwa Modell oder Seriennummer. + var info: [InfoItem] = [] + + struct InfoItem: Identifiable, Hashable, Sendable { + let label: String + let value: String + var id: String { label } + } var primaryMetric: Metric? { metrics.first(where: \.isPrimary) ?? metrics.first diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift index 54ceb64..5f32bac 100644 --- a/CamperMonitor/Views/DeviceDetailView.swift +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -53,6 +53,14 @@ struct DeviceDetailView: View { cellSection(snapshot.cellVoltages) } + if let snapshot, !snapshot.info.isEmpty { + Section("Gerät") { + ForEach(snapshot.info) { item in + LabeledContent(item.label, value: item.value) + } + } + } + if let snapshot, snapshot.temperatures.count > 1 { Section("Temperaturen") { ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in diff --git a/README.md b/README.md index 8a0a6b0..3e7d6c9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,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 Solarladeregler (SmartSolar MPPT) | Instant Readout im Advertisement | PV-Leistung, Batteriespannung, Ladestrom, Tagesertrag, Laststrom, Ladezustand (Bulk/Absorption/Float) | -| Batterie-BMS (Bulltron/Daly, WattCycle/JBD) | 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 | Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt. @@ -75,7 +75,20 @@ Die App probiert drei Protokolle durch und übernimmt, was antwortet: |---|---| | Daly klassisch (`A5`) | Bulltron und viele Daly-BMS | | Daly Modbus (`D2`) | neuere Daly-Firmware | -| JBD / Xiaoxiang (`DD A5`) | WattCycle und viele andere LiFePO4-Akkus | +| JBD / Xiaoxiang (`DD A5`) | viele LiFePO4-Akkus mit eigener App | +| WattCycle (`1E`/`7E`) | WattCycle-Bluetooth-Serie | + +WattCycle-Akkus verlangen eine Besonderheit: vor der ersten Abfrage muss der +Text `HiLink` auf eine eigene Freischalt-Charakteristik (`FFFA`) geschrieben +werden, sonst bleiben sie auf jede Anfrage stumm. Die App macht das +automatisch, sobald ein Gerät diese Charakteristik anbietet. Modell, +Hersteller und Seriennummer liest sie einmalig mit aus und zeigt sie in der +Detailansicht unter *Gerät*. + +Protokoll und Feldbelegung stammen aus +[frabnet/esphome-wattcycle-ble](https://github.com/frabnet/esphome-wattcycle-ble); +die Prüfsummen-Variante von dort ist gegen den klassischen Modbus-CRC +nachgerechnet, die Anfragerahmen sind byteweise in `run-tests.sh` abgesichert. Auch die GATT-Charakteristiken werden gesucht statt vorausgesetzt, weil sich die BLE-Module zwischen Herstellern und Chargen unterscheiden. Welches @@ -126,7 +139,8 @@ CamperMonitor/ │ ├── BitReader.swift Bitweises Lesen der gepackten Felder │ ├── DalyProtocol.swift Daly-Rahmen und Prüfsummen │ ├── DalyState.swift Sammelt Daly-Antworten zu einem Gesamtbild -│ ├── JBDProtocol.swift JBD/Xiaoxiang (WattCycle), Rahmen und Auswertung +│ ├── JBDProtocol.swift JBD/Xiaoxiang, Rahmen und Auswertung +│ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung │ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage ├── Store/ │ ├── DeviceStore.swift Geräteliste, Persistenz @@ -149,10 +163,10 @@ CamperMonitor/ gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel aussehen, muss das Mapping in `DalyState.apply(registers:)` am realen Gerät nachgezogen werden. -* **Welches BMS in einem WattCycle-Akku steckt, ist nicht garantiert.** Die - Unterstützung ist auf JBD/Xiaoxiang ausgelegt, das dort üblich ist. Meldet - die Diagnose dauerhaft „wird ermittelt“, spricht der Akku etwas anderes – - die dort angezeigte Rohantwort ist dann der Ansatzpunkt. +* **Die Feldbelegung der BMS-Datensätze ist nicht an jedem Modell geprüft.** + Meldet die Diagnose dauerhaft „wird ermittelt“, spricht der Akku ein + Protokoll, das die App nicht kennt – die dort angezeigte Rohantwort ist dann + der Ansatzpunkt. * Die Feldbelegungen der Victron-Datensätze stammen aus Victrons „Extra Manufacturer Data“-Beschreibung. Solarladeregler und DC/DC-Wandler sind die am besten belegten Typen. diff --git a/Tests/main.swift b/Tests/main.swift index fb01e3b..c10890b 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -312,5 +312,106 @@ let halfJBD = Array(jbdStream.prefix(jbdStream.count - 4)) checkEqual("nur der vollständige Rahmen wird ausgewertet", JBDProtocol.extractFrames(from: halfJBD).frames.count, 1) +// MARK: 8 – WattCycle +print("\nWattCycle-Protokoll") + +// Die Anfragerahmen sind gegen die Referenzimplementierung nachgerechnet +// (frabnet/esphome-wattcycle-ble): CRC16 über die ersten acht Byte. +checkEqual("Anfragerahmen Messwerte (DP 0x008C)", + [UInt8](WattCycleProtocol.requestFrame(.analog)), + [0x1E, 0x00, 0x01, 0x03, 0x00, 0x8C, 0x00, 0x00, 0xB1, 0x44, 0x0D]) +checkEqual("Anfragerahmen Produktdaten (DP 0x0092)", + [UInt8](WattCycleProtocol.requestFrame(.product)), + [0x1E, 0x00, 0x01, 0x03, 0x00, 0x92, 0x00, 0x00, 0xB7, 0x24, 0x0D]) +checkEqual("Freischalttext ist \"HiLink\"", + [UInt8](WattCycleProtocol.authPayload), + [0x48, 0x69, 0x4C, 0x69, 0x6E, 0x6B]) + +/// Baut eine Antwort, wie der Akku sie schickt. +func wattResponse(datapoint: UInt16, payload: [UInt8]) -> [UInt8] { + var frame: [UInt8] = [0x7E, 0x00, 0x01, 0x03, + UInt8(datapoint >> 8), UInt8(datapoint & 0xFF), + UInt8(payload.count >> 8), UInt8(payload.count & 0xFF)] + frame += payload + let crc = DalyProtocol.crc16Modbus(frame) + frame += [UInt8(crc >> 8), UInt8(crc & 0xFF), 0x0D] + return frame +} + +// 4 Zellen, 4 Fühler (MOSFET, Platine, 2 Zellfühler), 13,28 V, -25,4 A, 76 % +var analog: [UInt8] = [0x04] +analog += [0x0C, 0xFE, 0x0D, 0x12, 0x0D, 0x00, 0x0C, 0xF8] // 3326/3346/3328/3320 mV +analog += [0x04] // vier Fühler +analog += [0x0B, 0x8E] // MOSFET 2958 -> 22,8 °C +analog += [0x0B, 0x84] // Platine 2948 -> 21,8 °C +analog += [0x0B, 0x7A, 0x0B, 0x70] // Zellen 20,8 / 19,8 °C +analog += [0xC0, 0xFE] // Strom: negativ, Zehntel, 254 -> -25,4 A +analog += [0x05, 0x30] // 1328 -> 13,28 V +analog += [0x05, 0xF0] // Rest 1520 -> 152,0 Ah +analog += [0x07, 0xD0] // Geladen 2000 -> 200,0 Ah +analog += [0x00, 0x2F] // 47 Zyklen +analog += [0x07, 0xD0] // Nennkapazität 200,0 Ah +analog += [0x00, 0x4C] // 76 % + +var product = [UInt8](repeating: 0, count: 60) +for (index, byte) in Array("WC-12100BT v1.4".utf8).enumerated() { product[index] = byte } +for (index, byte) in Array("WattCycle".utf8).enumerated() { product[20 + index] = byte } +for (index, byte) in Array("WTaEaAA25342229".utf8).enumerated() { product[40 + index] = byte } + +var wattStream = wattResponse(datapoint: 0x008C, payload: analog) +wattStream += wattResponse(datapoint: 0x0092, payload: product) + +let (wattFrames, wattRest) = WattCycleProtocol.extractFrames(from: wattStream) +checkEqual("beide Rahmen erkannt", wattFrames.count, 2) +checkEqual("nichts bleibt übrig", wattRest.count, 0) + +var watt = WattCycleState() +for frame in wattFrames { watt.apply(frame) } +let wattSnapshot = watt.snapshot(deviceID: UUID(), rssi: nil) +func wattValue(_ key: String) -> Double? { wattSnapshot.metrics.first { $0.key == key }?.value } +checkEqual("Spannung", wattValue("voltage").map(round2), 13.28) +checkEqual("Entladestrom aus dem Sonderformat", wattValue("current").map(round2), -25.4) +checkEqual("Ladezustand", wattValue("soc"), 76) +checkEqual("Restkapazität", wattValue("capacity").map(round2), 152.0) +checkEqual("geladene Kapazität", wattValue("capacity_total").map(round2), 200.0) +checkEqual("Nennkapazität", wattValue("capacity_design").map(round2), 200.0) +checkEqual("Ladezyklen", wattValue("cycles"), 47) +checkEqual("vier Zellspannungen", wattSnapshot.cellVoltages.count, 4) +checkEqual("höchste Zelle", wattValue("cell_max"), 3.346) +checkEqual("Zell-Differenz", wattValue("cell_delta").map { $0.rounded() }, 26) +checkEqual("MOSFET-Temperatur aus Zehntel-Kelvin", wattValue("temp_mos").map(round2), 22.8) +checkEqual("Platinentemperatur", wattValue("temp_pcb").map(round2), 21.8) +checkEqual("nur die Zellfühler landen in den Temperaturen", wattSnapshot.temperatures.count, 2) +checkEqual("Zustand aus negativem Strom", wattSnapshot.state, "Entlädt") +checkEqual("Modell aus den Produktdaten", wattSnapshot.info.first?.value, "WC-12100BT v1.4") +checkEqual("Seriennummer", wattSnapshot.info.last?.value, "WTaEaAA25342229") + +// Ladestrom ohne Nachkommastelle: Bit 7 und Bit 6 aus. +checkEqual("positiver Strom ohne Zehntel", WattCycleProtocol.current(high: 0x00, low: 0x2A), 42) +checkEqual("positiver Strom mit Zehnteln", WattCycleProtocol.current(high: 0x40, low: 0x2A), 4.2) + +// Kaputte Prüfsumme, falsches Endbyte, angefangener Rahmen +var brokenWatt = wattResponse(datapoint: 0x008C, payload: analog) +brokenWatt[brokenWatt.count - 2] ^= 0xFF +checkEqual("falsche Prüfsumme wird verworfen", + WattCycleProtocol.extractFrames(from: brokenWatt).frames.count, 0) +var badTail = wattResponse(datapoint: 0x008C, payload: analog) +badTail[badTail.count - 1] = 0x00 +checkEqual("falsches Endbyte wird verworfen", + WattCycleProtocol.extractFrames(from: badTail).frames.count, 0) +let halfWatt = Array(wattStream.prefix(wattStream.count - 5)) +checkEqual("nur der vollständige Rahmen wird ausgewertet", + WattCycleProtocol.extractFrames(from: halfWatt).frames.count, 1) + +// Fehlerantwort des Geräts darf keine Werte setzen. +var errorFrame = wattResponse(datapoint: 0x008C, payload: []) +errorFrame[3] = 0x86 +let recrc = DalyProtocol.crc16Modbus(Array(errorFrame[0..<(errorFrame.count - 3)])) +errorFrame[errorFrame.count - 3] = UInt8(recrc >> 8) +errorFrame[errorFrame.count - 2] = UInt8(recrc & 0xFF) +var errorState = WattCycleState() +for frame in WattCycleProtocol.extractFrames(from: errorFrame).frames { errorState.apply(frame) } +checkEqual("Fehlerantwort liefert keine Werte", errorState.hasUsableData, false) + print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.") exit(failures == 0 ? 0 : 1) diff --git a/run-tests.sh b/run-tests.sh index 4102e6b..d3bc5ac 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -11,6 +11,7 @@ swiftc -O -o "$OUT/tests" \ CamperMonitor/Bluetooth/DalyProtocol.swift \ CamperMonitor/Bluetooth/DalyState.swift \ CamperMonitor/Bluetooth/JBDProtocol.swift \ + CamperMonitor/Bluetooth/WattCycleProtocol.swift \ CamperMonitor/Models/DeviceSnapshot.swift \ CamperMonitor/Models/VictronCodes.swift \ CamperMonitor/Store/KeychainStore.swift \