215 lines
7.9 KiB
Swift
215 lines
7.9 KiB
Swift
import Foundation
|
||
|
||
/// Sammelt die Antworten eines Daly-BMS. Das klassische Protokoll verteilt die
|
||
/// Werte auf mehrere Rahmen, deshalb wird hier über Abfragerunden hinweg
|
||
/// akkumuliert und erst am Ende ein Snapshot gebaut.
|
||
struct DalyState {
|
||
var totalVoltage: Double?
|
||
var current: Double?
|
||
var soc: Double?
|
||
|
||
var maxCellMillivolts: Int?
|
||
var maxCellNumber: Int?
|
||
var minCellMillivolts: Int?
|
||
var minCellNumber: Int?
|
||
|
||
var maxTemperature: Double?
|
||
var minTemperature: Double?
|
||
|
||
var chargeMOSOn: Bool?
|
||
var dischargeMOSOn: Bool?
|
||
var chargeDischargeStatus: UInt8?
|
||
var remainingCapacityAh: Double?
|
||
|
||
var cellCount: Int?
|
||
var temperatureSensorCount: Int?
|
||
var cycles: Int?
|
||
|
||
/// Zellnummer (1-basiert) → Spannung in Millivolt.
|
||
var cellMillivolts: [Int: Int] = [:]
|
||
/// Sensornummer (1-basiert) → Temperatur in °C.
|
||
var sensorTemperatures: [Int: Double] = [:]
|
||
|
||
/// Letzte Rohantwort, für die Diagnoseansicht.
|
||
var lastRawResponse: Data?
|
||
var usesModbus = false
|
||
|
||
// MARK: - Klassisches Protokoll
|
||
|
||
mutating func apply(_ frame: DalyProtocol.Frame) {
|
||
let d = frame.payload
|
||
func u16(_ i: Int) -> Int { Int(d[i]) << 8 | Int(d[i + 1]) }
|
||
|
||
switch frame.command {
|
||
case 0x90:
|
||
totalVoltage = Double(u16(0)) * 0.1
|
||
// Strom mit Offset 30000, damit Entladung negativ dargestellt wird.
|
||
current = Double(u16(4) - 30000) * 0.1
|
||
soc = Double(u16(6)) * 0.1
|
||
|
||
case 0x91:
|
||
maxCellMillivolts = u16(0)
|
||
maxCellNumber = Int(d[2])
|
||
minCellMillivolts = u16(3)
|
||
minCellNumber = Int(d[5])
|
||
|
||
case 0x92:
|
||
maxTemperature = Double(Int(d[0]) - 40)
|
||
minTemperature = Double(Int(d[2]) - 40)
|
||
|
||
case 0x93:
|
||
chargeDischargeStatus = d[0]
|
||
chargeMOSOn = d[1] == 1
|
||
dischargeMOSOn = d[2] == 1
|
||
let capacityMilliAh = (UInt32(d[4]) << 24) | (UInt32(d[5]) << 16)
|
||
| (UInt32(d[6]) << 8) | UInt32(d[7])
|
||
remainingCapacityAh = Double(capacityMilliAh) / 1000
|
||
|
||
case 0x94:
|
||
cellCount = Int(d[0])
|
||
temperatureSensorCount = Int(d[1])
|
||
cycles = u16(6)
|
||
|
||
case 0x95:
|
||
// d[0] = Rahmennummer (1-basiert), danach drei Zellen à 2 Byte.
|
||
let frameNumber = Int(d[0])
|
||
guard frameNumber > 0 else { break }
|
||
for slot in 0..<3 {
|
||
let cell = (frameNumber - 1) * 3 + slot + 1
|
||
let millivolts = u16(1 + slot * 2)
|
||
if millivolts > 0 && millivolts < 6000 {
|
||
cellMillivolts[cell] = millivolts
|
||
}
|
||
}
|
||
|
||
case 0x96:
|
||
let frameNumber = Int(d[0])
|
||
guard frameNumber > 0 else { break }
|
||
for slot in 0..<7 {
|
||
let sensor = (frameNumber - 1) * 7 + slot + 1
|
||
let raw = Int(d[1 + slot])
|
||
if raw != 0 {
|
||
sensorTemperatures[sensor] = Double(raw - 40)
|
||
}
|
||
}
|
||
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
// MARK: - Modbus-Protokoll
|
||
|
||
/// Registerbelegung der neueren Daly-BMS.
|
||
///
|
||
/// Achtung: Dieses Mapping variiert zwischen Firmwareständen. Die
|
||
/// Detailansicht zeigt deshalb die Rohantwort an, damit sich die Belegung
|
||
/// am realen Gerät nachprüfen lässt.
|
||
mutating func apply(registers: [UInt16]) {
|
||
usesModbus = true
|
||
func reg(_ i: Int) -> UInt16? { i < registers.count ? registers[i] : nil }
|
||
|
||
// Register 0–47: Zellspannungen in mV, unbenutzte Plätze sind 0.
|
||
cellMillivolts.removeAll(keepingCapacity: true)
|
||
for i in 0..<min(48, registers.count) {
|
||
let millivolts = Int(registers[i])
|
||
if millivolts > 500 && millivolts < 5000 {
|
||
cellMillivolts[i + 1] = millivolts
|
||
}
|
||
}
|
||
|
||
// Register 48–55: Temperaturfühler mit Offset 40.
|
||
sensorTemperatures.removeAll(keepingCapacity: true)
|
||
for i in 48..<min(56, registers.count) {
|
||
let raw = Int(registers[i])
|
||
if raw > 0 && raw < 200 {
|
||
sensorTemperatures[i - 47] = Double(raw - 40)
|
||
}
|
||
}
|
||
|
||
if let v = reg(56), v > 0 { totalVoltage = Double(v) * 0.1 }
|
||
if let c = reg(57) { current = (Double(c) - 30000) * 0.1 }
|
||
if let s = reg(58), s <= 1000 { soc = Double(s) * 0.1 }
|
||
|
||
maxCellMillivolts = cellMillivolts.values.max()
|
||
minCellMillivolts = cellMillivolts.values.min()
|
||
maxCellNumber = cellMillivolts.max(by: { $0.value < $1.value })?.key
|
||
minCellNumber = cellMillivolts.min(by: { $0.value < $1.value })?.key
|
||
maxTemperature = sensorTemperatures.values.max()
|
||
minTemperature = sensorTemperatures.values.min()
|
||
cellCount = cellMillivolts.isEmpty ? nil : cellMillivolts.count
|
||
temperatureSensorCount = sensorTemperatures.isEmpty ? nil : sensorTemperatures.count
|
||
}
|
||
|
||
// MARK: - Ausgabe
|
||
|
||
var hasUsableData: Bool {
|
||
totalVoltage != nil || soc != nil || !cellMillivolts.isEmpty
|
||
}
|
||
|
||
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: 1, 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 capacity = remainingCapacityAh {
|
||
metrics.append(Metric("capacity", "Restkapazität", capacity, unit: "Ah", precision: 1))
|
||
}
|
||
if let maxV = maxCellMillivolts, let minV = minCellMillivolts {
|
||
metrics.append(Metric("cell_delta", "Zell-Differenz", Double(maxV - minV), unit: "mV", precision: 0))
|
||
metrics.append(Metric("cell_max", "Höchste Zelle" + numberSuffix(maxCellNumber),
|
||
Double(maxV) / 1000, unit: "V", precision: 3))
|
||
metrics.append(Metric("cell_min", "Niedrigste Zelle" + numberSuffix(minCellNumber),
|
||
Double(minV) / 1000, unit: "V", precision: 3))
|
||
}
|
||
if let maxTemperature {
|
||
metrics.append(Metric("temp_max", "Temperatur", maxTemperature, unit: "°C", precision: 0))
|
||
}
|
||
if let minTemperature, minTemperature != maxTemperature {
|
||
metrics.append(Metric("temp_min", "Temperatur min.", minTemperature, unit: "°C", precision: 0))
|
||
}
|
||
if let cycles {
|
||
metrics.append(Metric("cycles", "Ladezyklen", Double(cycles), unit: "", precision: 0))
|
||
}
|
||
snapshot.metrics = metrics
|
||
|
||
snapshot.state = stateText
|
||
snapshot.cellVoltages = cellMillivolts
|
||
.sorted { $0.key < $1.key }
|
||
.map { Double($0.value) / 1000 }
|
||
snapshot.temperatures = sensorTemperatures.sorted { $0.key < $1.key }.map(\.value)
|
||
|
||
var warnings: [String] = []
|
||
if chargeMOSOn == false { warnings.append("Lade-MOSFET aus") }
|
||
if dischargeMOSOn == false { warnings.append("Entlade-MOSFET aus") }
|
||
snapshot.offReasons = warnings
|
||
|
||
return snapshot
|
||
}
|
||
|
||
private func numberSuffix(_ number: Int?) -> String {
|
||
number.map { " (Zelle \($0))" } ?? ""
|
||
}
|
||
|
||
private var stateText: String? {
|
||
if let status = chargeDischargeStatus {
|
||
switch status {
|
||
case 0: return "Ruhend"
|
||
case 1: return "Lädt"
|
||
case 2: return "Entlädt"
|
||
default: break
|
||
}
|
||
}
|
||
guard let current else { return nil }
|
||
if current > 0.3 { return "Lädt" }
|
||
if current < -0.3 { return "Entlädt" }
|
||
return "Ruhend"
|
||
}
|
||
}
|