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

215 lines
7.9 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
/// 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 047: 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 4855: 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"
}
}