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 } }