Files
2026-08-30 10:36:52 +02:00

201 lines
7.8 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <Kommando> <Länge=00> <Prüfsumme 2 Byte> 77
/// Antwort: DD <Kommando> <Status> <Länge> <Daten…> <Prüfsumme 2 Byte> 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..<sensorCount {
let offset = 23 + sensor * 2
guard offset + 1 < d.count else { break }
readings.append((Double(u16(offset)) - 2731) / 10)
}
temperatures = readings
case 0x04:
var millivolts: [Int] = []
var offset = 0
while offset + 1 < d.count {
millivolts.append(u16(offset))
offset += 2
}
cellMillivolts = millivolts
default:
break
}
}
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", 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
}
}