forked from fritob/Camper-Monitor
init
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user