forked from fritob/Camper-Monitor
201 lines
7.8 KiB
Swift
201 lines
7.8 KiB
Swift
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
|
||
}
|
||
}
|