import Foundation /// Protokoll der JBD-/Xiaoxiang-BMS, wie sie unter anderem in WattCycle-Akkus /// verbaut sind. Bekannt auch als „Smart BMS“ oder Overkill-Solar-Protokoll. /// /// Rahmenaufbau: /// /// Anfrage: DD A5 77 /// Antwort: DD 77 /// /// Die Prüfsumme ist `0x10000 − (Status + Länge + Daten)`, big-endian; in der /// Anfrage entsprechend über Kommando und Länge. enum JBDProtocol { enum Command: UInt8, CaseIterable { case basicInfo = 0x03 case cellVoltages = 0x04 } static func requestFrame(_ command: Command) -> Data { let checksum = checksum(over: [command.rawValue, 0x00]) return Data([0xDD, 0xA5, command.rawValue, 0x00, UInt8(checksum >> 8), UInt8(checksum & 0xFF), 0x77]) } static func checksum(over bytes: [UInt8]) -> UInt16 { let sum = bytes.reduce(UInt32(0)) { $0 + UInt32($1) } return UInt16(truncatingIfNeeded: 0x1_0000 &- sum) } struct Frame { let command: UInt8 let payload: [UInt8] } /// Sucht vollständige, prüfsummenkorrekte Rahmen im Puffer. static func extractFrames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) { var frames: [Frame] = [] var index = 0 var consumed = 0 while index + 7 <= buffer.count { guard buffer[index] == 0xDD else { index += 1 continue } let length = Int(buffer[index + 3]) let total = 4 + length + 3 // Kopf + Daten + Prüfsumme + 0x77 guard index + total <= buffer.count else { break } // Rest abwarten let frame = Array(buffer[index..<(index + total)]) guard frame[total - 1] == 0x77 else { index += 1 continue } let expected = checksum(over: Array(frame[2..<(4 + length)])) let actual = UInt16(frame[4 + length]) << 8 | UInt16(frame[5 + length]) guard expected == actual else { index += 1 continue } // Status ≠ 0 meldet einen Fehler; der Rahmen ist dann leer. if frame[2] == 0x00 { frames.append(Frame(command: frame[1], payload: Array(frame[4..<(4 + length)]))) } index += total consumed = index } let keepFrom = max(consumed, max(0, buffer.count - 128)) return (frames, Array(buffer[keepFrom...])) } /// Klartext der Schutzabschaltungen aus der 16-Bit-Maske. static func protectionReasons(_ mask: UInt16) -> [String] { guard mask != 0 else { return [] } let table: [(UInt16, String)] = [ (1 << 0, "Zellüberspannung"), (1 << 1, "Zellunterspannung"), (1 << 2, "Batterie Überspannung"), (1 << 3, "Batterie Unterspannung"), (1 << 4, "Ladetemperatur zu hoch"), (1 << 5, "Ladetemperatur zu niedrig"), (1 << 6, "Entladetemperatur zu hoch"), (1 << 7, "Entladetemperatur zu niedrig"), (1 << 8, "Ladestrom zu hoch"), (1 << 9, "Entladestrom zu hoch"), (1 << 10, "Kurzschluss"), (1 << 11, "Fehler im Messkreis"), (1 << 12, "MOSFET gesperrt"), ] return table.filter { mask & $0.0 != 0 }.map(\.1) } } /// Sammelt die Antworten eines JBD-BMS. struct JBDState { var totalVoltage: Double? var current: Double? var remainingCapacityAh: Double? var nominalCapacityAh: Double? var cycles: Int? var soc: Double? var chargeMOSOn: Bool? var dischargeMOSOn: Bool? var protections: [String] = [] var cellMillivolts: [Int] = [] var temperatures: [Double] = [] var hasUsableData: Bool { totalVoltage != nil || soc != nil || !cellMillivolts.isEmpty } mutating func apply(_ frame: JBDProtocol.Frame) { let d = frame.payload func u16(_ i: Int) -> Int { Int(d[i]) << 8 | Int(d[i + 1]) } func i16(_ i: Int) -> Int { Int(Int16(bitPattern: UInt16(u16(i)))) } switch frame.command { case 0x03: guard d.count >= 23 else { return } totalVoltage = Double(u16(0)) * 0.01 // 10 mV je Schritt current = Double(i16(2)) * 0.01 // 10 mA, negativ = Entladung remainingCapacityAh = Double(u16(4)) * 0.01 nominalCapacityAh = Double(u16(6)) * 0.01 cycles = u16(8) protections = JBDProtocol.protectionReasons(UInt16(u16(16))) soc = Double(d[19]) chargeMOSOn = d[20] & 0x01 != 0 dischargeMOSOn = d[20] & 0x02 != 0 // Ab Byte 23 folgen die NTC-Fühler, je zwei Byte in Zehntel-Kelvin. let sensorCount = Int(d[22]) var readings: [Double] = [] for sensor in 0.. 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", totalVoltage, unit: "V", precision: 2), Metric("current", "Strom", current, unit: "A", precision: 1), ] if let v = totalVoltage, let a = current { metrics.append(Metric("power", "Leistung", v * a, unit: "W", precision: 0)) } if let remainingCapacityAh { metrics.append(Metric("capacity", "Restkapazität", remainingCapacityAh, unit: "Ah", precision: 1)) } if let nominalCapacityAh { metrics.append(Metric("capacity_nominal", "Nennkapazität", nominalCapacityAh, unit: "Ah", precision: 1)) } if let maxV = cellMillivolts.max(), let minV = cellMillivolts.min() { metrics.append(Metric("cell_delta", "Zell-Differenz", Double(maxV - minV), unit: "mV", precision: 0)) metrics.append(Metric("cell_max", "Höchste Zelle", Double(maxV) / 1000, unit: "V", precision: 3)) metrics.append(Metric("cell_min", "Niedrigste Zelle", Double(minV) / 1000, unit: "V", precision: 3)) } if let warmest = temperatures.max() { metrics.append(Metric("temp_max", "Temperatur", warmest, unit: "°C", precision: 0)) } if let cycles { metrics.append(Metric("cycles", "Ladezyklen", Double(cycles), unit: "", precision: 0)) } snapshot.metrics = metrics snapshot.cellVoltages = cellMillivolts.map { Double($0) / 1000 } snapshot.temperatures = temperatures snapshot.fault = protections.isEmpty ? nil : protections.joined(separator: ", ") var notes: [String] = [] if chargeMOSOn == false { notes.append("Laden gesperrt") } if dischargeMOSOn == false { notes.append("Entladen gesperrt") } snapshot.offReasons = notes if let current { if current > 0.3 { snapshot.state = "Lädt" } else if current < -0.3 { snapshot.state = "Entlädt" } else { snapshot.state = "Ruhend" } } return snapshot } }