forked from fritob/Camper-Monitor
WattCycle-Akkus unterstützen
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>
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
import Foundation
|
||||
|
||||
/// Protokoll der WattCycle-BLE-Akkus.
|
||||
///
|
||||
/// Weder Daly noch JBD, sondern ein eigenes Modbus-artiges Format. Zwei
|
||||
/// Besonderheiten:
|
||||
///
|
||||
/// * Vor der ersten Abfrage muss der ASCII-Text `HiLink` auf eine eigene
|
||||
/// Freischalt-Charakteristik (`FFFA`) geschrieben werden. Ohne das bleibt
|
||||
/// der Akku auf jede Anfrage stumm.
|
||||
/// * Anfragen gehen auf `FFF2`, Antworten kommen über `FFF1`.
|
||||
///
|
||||
/// Rahmenaufbau:
|
||||
///
|
||||
/// Anfrage (11 Byte):
|
||||
/// 1E 00 01 03 <Datenpunkt 2 Byte> 00 00 <CRC16 2 Byte> 0D
|
||||
/// Antwort:
|
||||
/// 7E <Ver> <Adr> <Funktion> <Datenpunkt 2 Byte> <Länge 2 Byte>
|
||||
/// <Daten…> <CRC16 2 Byte> 0D
|
||||
///
|
||||
/// Die Prüfsumme ist der übliche Modbus-CRC16 über alles vor der Prüfsumme,
|
||||
/// höherwertiges Byte zuerst. Nachgerechnet gegen die Tabellenvariante der
|
||||
/// Referenzimplementierung (frabnet/esphome-wattcycle-ble).
|
||||
enum WattCycleProtocol {
|
||||
|
||||
static let frameHeadRequest: UInt8 = 0x1E
|
||||
static let frameHeadResponse: UInt8 = 0x7E
|
||||
static let frameTail: UInt8 = 0x0D
|
||||
static let functionRead: UInt8 = 0x03
|
||||
static let functionError: UInt8 = 0x86
|
||||
|
||||
/// Der Freischalt-Text, der vor der ersten Abfrage geschrieben wird.
|
||||
static let authPayload = Data("HiLink".utf8)
|
||||
|
||||
enum Datapoint: UInt16 {
|
||||
case analog = 0x008C // Messwerte
|
||||
case product = 0x0092 // Modell, Hersteller, Seriennummer
|
||||
}
|
||||
|
||||
static func requestFrame(_ datapoint: Datapoint) -> Data {
|
||||
var frame: [UInt8] = [
|
||||
frameHeadRequest,
|
||||
0x00, // Version
|
||||
0x01, // Adresse
|
||||
functionRead,
|
||||
UInt8(datapoint.rawValue >> 8),
|
||||
UInt8(datapoint.rawValue & 0xFF),
|
||||
0x00, 0x00, // Anzahl: 0 liefert den ganzen Datensatz
|
||||
]
|
||||
let crc = DalyProtocol.crc16Modbus(frame)
|
||||
frame.append(UInt8(crc >> 8))
|
||||
frame.append(UInt8(crc & 0xFF))
|
||||
frame.append(frameTail)
|
||||
return Data(frame)
|
||||
}
|
||||
|
||||
struct Frame {
|
||||
let function: UInt8
|
||||
let datapoint: UInt16
|
||||
let payload: [UInt8]
|
||||
|
||||
var isError: Bool { function == functionError }
|
||||
}
|
||||
|
||||
/// Sucht vollständige, prüfsummenkorrekte Antwortrahmen im Puffer.
|
||||
static func extractFrames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) {
|
||||
var frames: [Frame] = []
|
||||
var index = 0
|
||||
var consumed = 0
|
||||
|
||||
while index + 11 <= buffer.count {
|
||||
guard buffer[index] == frameHeadResponse else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
let dataLength = Int(buffer[index + 6]) << 8 | Int(buffer[index + 7])
|
||||
let total = dataLength + 11
|
||||
guard total <= 512 else { index += 1; continue }
|
||||
guard index + total <= buffer.count else { break } // Rest abwarten
|
||||
|
||||
let frame = Array(buffer[index..<(index + total)])
|
||||
guard frame[total - 1] == frameTail else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
let expected = DalyProtocol.crc16Modbus(Array(frame[0..<(total - 3)]))
|
||||
let actual = UInt16(frame[total - 3]) << 8 | UInt16(frame[total - 2])
|
||||
guard expected == actual else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
frames.append(Frame(
|
||||
function: frame[3],
|
||||
datapoint: UInt16(frame[4]) << 8 | UInt16(frame[5]),
|
||||
payload: Array(frame[8..<(8 + dataLength)])
|
||||
))
|
||||
index += total
|
||||
consumed = index
|
||||
}
|
||||
let keepFrom = max(consumed, max(0, buffer.count - 256))
|
||||
return (frames, Array(buffer[keepFrom...]))
|
||||
}
|
||||
|
||||
/// Temperaturen kommen in Zehntel-Kelvin.
|
||||
static func temperature(_ raw: UInt16) -> Double {
|
||||
(Double(raw) - 2730) / 10
|
||||
}
|
||||
|
||||
/// Der Strom hat ein eigenes Format: Bit 15 ist das Vorzeichen, Bit 14 gibt
|
||||
/// an, ob der Rest in Zehntel-Ampere zu lesen ist, der Rest ist der Betrag.
|
||||
static func current(high: UInt8, low: UInt8) -> Double {
|
||||
let isNegative = high & 0x80 != 0
|
||||
let hasDecimal = high & 0x40 != 0
|
||||
let magnitude = Double(Int(low) | (Int(high & 0x3F) << 8))
|
||||
let value = hasDecimal ? magnitude / 10 : magnitude
|
||||
return isNegative ? -value : value
|
||||
}
|
||||
}
|
||||
|
||||
/// Sammelt die Antworten eines WattCycle-Akkus.
|
||||
struct WattCycleState {
|
||||
var cellVolts: [Double] = []
|
||||
var mosTemperature: Double?
|
||||
var pcbTemperature: Double?
|
||||
var cellTemperatures: [Double] = []
|
||||
var current: Double?
|
||||
var voltage: Double?
|
||||
var remainingAh: Double?
|
||||
var totalAh: Double?
|
||||
var designAh: Double?
|
||||
var cycles: Int?
|
||||
var soc: Double?
|
||||
|
||||
var model: String?
|
||||
var manufacturer: String?
|
||||
var serial: String?
|
||||
|
||||
var hasUsableData: Bool { voltage != nil || soc != nil || !cellVolts.isEmpty }
|
||||
var hasProductInfo: Bool { model != nil || manufacturer != nil || serial != nil }
|
||||
|
||||
mutating func apply(_ frame: WattCycleProtocol.Frame) {
|
||||
guard !frame.isError else { return }
|
||||
switch WattCycleProtocol.Datapoint(rawValue: frame.datapoint) {
|
||||
case .analog: applyAnalog(frame.payload)
|
||||
case .product: applyProduct(frame.payload)
|
||||
case nil: break
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Messwert-Datensatz ist selbstbeschreibend: erst die Zellenanzahl,
|
||||
/// dann die Zellspannungen, dann die Fühleranzahl und so weiter. Die
|
||||
/// Feldlängen stehen also nicht fest und werden mitgelesen.
|
||||
private mutating func applyAnalog(_ data: [UInt8]) {
|
||||
var offset = 0
|
||||
|
||||
func readUInt16() -> UInt16? {
|
||||
guard offset + 1 < data.count else { return nil }
|
||||
defer { offset += 2 }
|
||||
return UInt16(data[offset]) << 8 | UInt16(data[offset + 1])
|
||||
}
|
||||
func readUInt8() -> UInt8? {
|
||||
guard offset < data.count else { return nil }
|
||||
defer { offset += 1 }
|
||||
return data[offset]
|
||||
}
|
||||
|
||||
guard let cellCount = readUInt8() else { return }
|
||||
var cells: [Double] = []
|
||||
for _ in 0..<Int(cellCount) {
|
||||
guard let millivolts = readUInt16() else { return }
|
||||
cells.append(Double(millivolts) / 1000)
|
||||
}
|
||||
cellVolts = cells
|
||||
|
||||
// Die ersten beiden Fühler sind MOSFET und Platine, danach die Zellen.
|
||||
guard let temperatureCount = readUInt8(), temperatureCount >= 2 else { return }
|
||||
guard let mos = readUInt16(), let pcb = readUInt16() else { return }
|
||||
mosTemperature = WattCycleProtocol.temperature(mos)
|
||||
pcbTemperature = WattCycleProtocol.temperature(pcb)
|
||||
|
||||
var probes: [Double] = []
|
||||
for _ in 0..<(Int(temperatureCount) - 2) {
|
||||
guard let raw = readUInt16() else { return }
|
||||
probes.append(WattCycleProtocol.temperature(raw))
|
||||
}
|
||||
cellTemperatures = probes
|
||||
|
||||
guard offset + 1 < data.count else { return }
|
||||
current = WattCycleProtocol.current(high: data[offset], low: data[offset + 1])
|
||||
offset += 2
|
||||
|
||||
guard let voltageRaw = readUInt16() else { return }
|
||||
voltage = Double(voltageRaw) / 100
|
||||
|
||||
guard let remaining = readUInt16(),
|
||||
let total = readUInt16(),
|
||||
let cycleCount = readUInt16(),
|
||||
let design = readUInt16(),
|
||||
let charge = readUInt16() else { return }
|
||||
remainingAh = Double(remaining) / 10
|
||||
totalAh = Double(total) / 10
|
||||
cycles = Int(cycleCount)
|
||||
designAh = Double(design) / 10
|
||||
soc = Double(charge)
|
||||
}
|
||||
|
||||
/// Drei ASCII-Felder à 20 Byte.
|
||||
private mutating func applyProduct(_ data: [UInt8]) {
|
||||
guard data.count >= 60 else { return }
|
||||
func text(_ range: Range<Int>) -> String? {
|
||||
let value = String(decoding: data[range], as: UTF8.self)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\0 "))
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
model = text(0..<20)
|
||||
manufacturer = text(20..<40)
|
||||
serial = text(40..<60)
|
||||
}
|
||||
|
||||
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", voltage, unit: "V", precision: 2),
|
||||
Metric("current", "Strom", current, unit: "A", precision: 1),
|
||||
]
|
||||
if let voltage, let current {
|
||||
metrics.append(Metric("power", "Leistung", voltage * current, unit: "W", precision: 0))
|
||||
}
|
||||
metrics.append(Metric("capacity", "Restkapazität", remainingAh, unit: "Ah", precision: 1))
|
||||
if let totalAh {
|
||||
metrics.append(Metric("capacity_total", "Kapazität geladen", totalAh, unit: "Ah", precision: 1))
|
||||
}
|
||||
if let designAh {
|
||||
metrics.append(Metric("capacity_design", "Nennkapazität", designAh, unit: "Ah", precision: 1))
|
||||
}
|
||||
if let maxV = cellVolts.max(), let minV = cellVolts.min() {
|
||||
metrics.append(Metric("cell_delta", "Zell-Differenz",
|
||||
(maxV - minV) * 1000, unit: "mV", precision: 0))
|
||||
metrics.append(Metric("cell_max", "Höchste Zelle", maxV, unit: "V", precision: 3))
|
||||
metrics.append(Metric("cell_min", "Niedrigste Zelle", minV, unit: "V", precision: 3))
|
||||
}
|
||||
if let mosTemperature {
|
||||
metrics.append(Metric("temp_mos", "Temperatur MOSFET", mosTemperature, unit: "°C", precision: 1))
|
||||
}
|
||||
if let pcbTemperature {
|
||||
metrics.append(Metric("temp_pcb", "Temperatur Platine", pcbTemperature, unit: "°C", precision: 1))
|
||||
}
|
||||
if let cycles {
|
||||
metrics.append(Metric("cycles", "Ladezyklen", Double(cycles), unit: "", precision: 0))
|
||||
}
|
||||
snapshot.metrics = metrics
|
||||
|
||||
snapshot.cellVoltages = cellVolts
|
||||
snapshot.temperatures = cellTemperatures
|
||||
|
||||
if let current {
|
||||
if current > 0.3 { snapshot.state = "Lädt" }
|
||||
else if current < -0.3 { snapshot.state = "Entlädt" }
|
||||
else { snapshot.state = "Ruhend" }
|
||||
}
|
||||
|
||||
var info: [DeviceSnapshot.InfoItem] = []
|
||||
if let model { info.append(.init(label: "Modell / Firmware", value: model)) }
|
||||
if let manufacturer { info.append(.init(label: "Hersteller", value: manufacturer)) }
|
||||
if let serial { info.append(.init(label: "Seriennummer", value: serial)) }
|
||||
snapshot.info = info
|
||||
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user