Files
Camper-Monitor/VanControl/Bluetooth/DalyState.swift
T
fototeddyandClaude Sonnet 5 303a9735d0 App-Namen im iOS-Projekt auf VanControl vereinheitlichen
CamperMonitor (Haupt-Repo) und VanAligneiOS (aus dem gemergten
solar-integration-Branch) liefen unter zwei verschiedenen internen
Namen, obwohl die App nach aussen längst einheitlich "VanControl Pro"
heisst. Jetzt durchgängig VanControl:

- Ordner: CamperMonitor/, CamperMonitorWatch/, CamperMonitorComplication/,
  VanAligneiOSWidget/ → VanControl/, VanControlWatch/,
  VanControlComplication/, VanControlWidget/
- Xcode-Projekt: CamperMonitor.xcodeproj → VanControl.xcodeproj, alle
  Targets/Schemes/Produktnamen entsprechend umbenannt
- Bundle-Identifier auf Wunsch mitgeändert: de.s0.fototeddy.VanControl*
  (App noch nicht veröffentlicht); dabei auch die
  WKCompanionAppBundleIdentifier-Werte korrigiert, die noch das alte
  de.fritob-Präfix statt des tatsächlichen de.s0.fototeddy-Präfixes
  trugen
- Swift-Dateien/Typen: CamperMonitorApp → VanControlApp,
  VanAligneiOSWidget* → VanControlWidget*
- Config/*-Info.plist umbenannt, README.md/Tools/README.md/run-tests.sh
  auf die neuen Pfade angepasst

Bewusst unverändert: firmware/vanalign und alle Bezüge auf "VanAlign"
als Namen der Neigungsmesser-Hardware (eigenständiges Produkt, kein
App-Name) sowie der komplette Android/-Ordner.

Build (App, Watch, Debug) und Protokoll-Testlauf grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:25:28 +02:00

215 lines
7.9 KiB
Swift
Raw 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"
}
}