WattCycle spricht weder Daly noch JBD, sondern ein eigenes Modbus-artiges Protokoll – und verlangt vor der ersten Abfrage eine Freischaltung: der Text "HiLink" muss auf die Charakteristik FFFA geschrieben werden, sonst bleibt der Akku auf alles stumm. Genau daran scheiterte die Erkennung; die Charakteristik war im GATT-Baum sichtbar, wurde aber nur als weiterer Schreibkandidat behandelt. Neu ist WattCycleProtocol mit Rahmenbau (1E … 0D für Anfragen, 7E … 0D für Antworten), Prüfsummen und der Auswertung des Messwert-Datensatzes 0x008C. Der ist selbstbeschreibend: Zellenanzahl, Zellspannungen, Fühleranzahl, MOSFET- und Platinentemperatur, Zellfühler, dann Strom, Spannung, Kapazitäten, Zyklen und Ladezustand. Der Strom hat ein eigenes Format, bei dem Bit 15 das Vorzeichen und Bit 14 die Nachkommastelle angibt. Aus Datenpunkt 0x0092 kommen Modell, Hersteller und Seriennummer, die einmalig gelesen und in der Detailansicht gezeigt werden. BMSSession kennt die Freischaltung jetzt als Teil eines Kandidaten: liegt im selben Dienst eine FFFA-Charakteristik, wird nach dem bestätigten Abo kurz gewartet, freigeschaltet, nochmal gewartet und dann erst abgefragt. Für die anderen Protokolle ist das unschädlich. Protokoll und Feldbelegung stammen aus frabnet/esphome-wattcycle-ble. Die dortige Tabellen-Prüfsumme ist gegen den klassischen Modbus-CRC nachgerechnet (identisch über 3063 Testfälle), sodass die vorhandene CRC-Funktion genügt und die 512 Byte Tabellen entfallen. Die Anfragerahmen sind byteweise abgesichert, die Auswertung an einem vollständigen Datensatz – 107 Prüfungen laufen durch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
98 lines
2.7 KiB
Swift
98 lines
2.7 KiB
Swift
import Foundation
|
||
|
||
/// Eine einzelne Messgrösse in einer bereits formatierten Form.
|
||
struct Metric: Identifiable, Hashable, Sendable {
|
||
let key: String
|
||
let label: String
|
||
let value: Double?
|
||
let unit: String
|
||
/// Nachkommastellen für die Anzeige.
|
||
let precision: Int
|
||
/// Wird auf der Kachel gross dargestellt.
|
||
let isPrimary: Bool
|
||
|
||
var id: String { key }
|
||
|
||
init(_ key: String,
|
||
_ label: String,
|
||
_ value: Double?,
|
||
unit: String,
|
||
precision: Int = 2,
|
||
primary: Bool = false) {
|
||
self.key = key
|
||
self.label = label
|
||
self.value = value
|
||
self.unit = unit
|
||
self.precision = precision
|
||
self.isPrimary = primary
|
||
}
|
||
|
||
var formatted: String {
|
||
guard let value else { return "–" }
|
||
return String(format: "%.\(precision)f", value)
|
||
}
|
||
|
||
var formattedWithUnit: String {
|
||
guard value != nil else { return "–" }
|
||
return unit.isEmpty ? formatted : "\(formatted) \(unit)"
|
||
}
|
||
}
|
||
|
||
/// Der komplette, zuletzt empfangene Zustand eines Geräts.
|
||
struct DeviceSnapshot: Identifiable, Sendable {
|
||
var id: UUID { deviceID }
|
||
var deviceID: UUID
|
||
var timestamp: Date
|
||
var metrics: [Metric] = []
|
||
/// z.B. "Bulk", "Float", "Aus"
|
||
var state: String?
|
||
/// Klartext einer aktiven Störung, sonst nil.
|
||
var fault: String?
|
||
/// Grund, warum das Gerät gerade nicht lädt (Victron Off-Reason).
|
||
var offReasons: [String] = []
|
||
var rssi: Int?
|
||
/// Einzelzellspannungen in Volt (nur BMS).
|
||
var cellVoltages: [Double] = []
|
||
/// Temperaturfühler in °C (nur BMS).
|
||
var temperatures: [Double] = []
|
||
/// Feste Angaben des Geräts, etwa Modell oder Seriennummer.
|
||
var info: [InfoItem] = []
|
||
|
||
struct InfoItem: Identifiable, Hashable, Sendable {
|
||
let label: String
|
||
let value: String
|
||
var id: String { label }
|
||
}
|
||
|
||
var primaryMetric: Metric? {
|
||
metrics.first(where: \.isPrimary) ?? metrics.first
|
||
}
|
||
|
||
var age: TimeInterval { Date().timeIntervalSince(timestamp) }
|
||
|
||
/// Werte älter als eine Minute gelten als veraltet – Victron sendet etwa
|
||
/// jede Sekunde, das Daly wird alle paar Sekunden gepollt.
|
||
var isStale: Bool { age > 60 }
|
||
}
|
||
|
||
/// Verbindungszustand für die UI.
|
||
enum DeviceLinkState: Equatable, Sendable {
|
||
case idle
|
||
case searching
|
||
case connecting
|
||
case live
|
||
case needsKey
|
||
case failed(String)
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .idle: return "Inaktiv"
|
||
case .searching: return "Suche…"
|
||
case .connecting: return "Verbinde…"
|
||
case .live: return "Live"
|
||
case .needsKey: return "Schlüssel fehlt"
|
||
case .failed(let m): return m
|
||
}
|
||
}
|
||
}
|