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:
@@ -312,5 +312,106 @@ let halfJBD = Array(jbdStream.prefix(jbdStream.count - 4))
|
||||
checkEqual("nur der vollständige Rahmen wird ausgewertet",
|
||||
JBDProtocol.extractFrames(from: halfJBD).frames.count, 1)
|
||||
|
||||
// MARK: 8 – WattCycle
|
||||
print("\nWattCycle-Protokoll")
|
||||
|
||||
// Die Anfragerahmen sind gegen die Referenzimplementierung nachgerechnet
|
||||
// (frabnet/esphome-wattcycle-ble): CRC16 über die ersten acht Byte.
|
||||
checkEqual("Anfragerahmen Messwerte (DP 0x008C)",
|
||||
[UInt8](WattCycleProtocol.requestFrame(.analog)),
|
||||
[0x1E, 0x00, 0x01, 0x03, 0x00, 0x8C, 0x00, 0x00, 0xB1, 0x44, 0x0D])
|
||||
checkEqual("Anfragerahmen Produktdaten (DP 0x0092)",
|
||||
[UInt8](WattCycleProtocol.requestFrame(.product)),
|
||||
[0x1E, 0x00, 0x01, 0x03, 0x00, 0x92, 0x00, 0x00, 0xB7, 0x24, 0x0D])
|
||||
checkEqual("Freischalttext ist \"HiLink\"",
|
||||
[UInt8](WattCycleProtocol.authPayload),
|
||||
[0x48, 0x69, 0x4C, 0x69, 0x6E, 0x6B])
|
||||
|
||||
/// Baut eine Antwort, wie der Akku sie schickt.
|
||||
func wattResponse(datapoint: UInt16, payload: [UInt8]) -> [UInt8] {
|
||||
var frame: [UInt8] = [0x7E, 0x00, 0x01, 0x03,
|
||||
UInt8(datapoint >> 8), UInt8(datapoint & 0xFF),
|
||||
UInt8(payload.count >> 8), UInt8(payload.count & 0xFF)]
|
||||
frame += payload
|
||||
let crc = DalyProtocol.crc16Modbus(frame)
|
||||
frame += [UInt8(crc >> 8), UInt8(crc & 0xFF), 0x0D]
|
||||
return frame
|
||||
}
|
||||
|
||||
// 4 Zellen, 4 Fühler (MOSFET, Platine, 2 Zellfühler), 13,28 V, -25,4 A, 76 %
|
||||
var analog: [UInt8] = [0x04]
|
||||
analog += [0x0C, 0xFE, 0x0D, 0x12, 0x0D, 0x00, 0x0C, 0xF8] // 3326/3346/3328/3320 mV
|
||||
analog += [0x04] // vier Fühler
|
||||
analog += [0x0B, 0x8E] // MOSFET 2958 -> 22,8 °C
|
||||
analog += [0x0B, 0x84] // Platine 2948 -> 21,8 °C
|
||||
analog += [0x0B, 0x7A, 0x0B, 0x70] // Zellen 20,8 / 19,8 °C
|
||||
analog += [0xC0, 0xFE] // Strom: negativ, Zehntel, 254 -> -25,4 A
|
||||
analog += [0x05, 0x30] // 1328 -> 13,28 V
|
||||
analog += [0x05, 0xF0] // Rest 1520 -> 152,0 Ah
|
||||
analog += [0x07, 0xD0] // Geladen 2000 -> 200,0 Ah
|
||||
analog += [0x00, 0x2F] // 47 Zyklen
|
||||
analog += [0x07, 0xD0] // Nennkapazität 200,0 Ah
|
||||
analog += [0x00, 0x4C] // 76 %
|
||||
|
||||
var product = [UInt8](repeating: 0, count: 60)
|
||||
for (index, byte) in Array("WC-12100BT v1.4".utf8).enumerated() { product[index] = byte }
|
||||
for (index, byte) in Array("WattCycle".utf8).enumerated() { product[20 + index] = byte }
|
||||
for (index, byte) in Array("WTaEaAA25342229".utf8).enumerated() { product[40 + index] = byte }
|
||||
|
||||
var wattStream = wattResponse(datapoint: 0x008C, payload: analog)
|
||||
wattStream += wattResponse(datapoint: 0x0092, payload: product)
|
||||
|
||||
let (wattFrames, wattRest) = WattCycleProtocol.extractFrames(from: wattStream)
|
||||
checkEqual("beide Rahmen erkannt", wattFrames.count, 2)
|
||||
checkEqual("nichts bleibt übrig", wattRest.count, 0)
|
||||
|
||||
var watt = WattCycleState()
|
||||
for frame in wattFrames { watt.apply(frame) }
|
||||
let wattSnapshot = watt.snapshot(deviceID: UUID(), rssi: nil)
|
||||
func wattValue(_ key: String) -> Double? { wattSnapshot.metrics.first { $0.key == key }?.value }
|
||||
checkEqual("Spannung", wattValue("voltage").map(round2), 13.28)
|
||||
checkEqual("Entladestrom aus dem Sonderformat", wattValue("current").map(round2), -25.4)
|
||||
checkEqual("Ladezustand", wattValue("soc"), 76)
|
||||
checkEqual("Restkapazität", wattValue("capacity").map(round2), 152.0)
|
||||
checkEqual("geladene Kapazität", wattValue("capacity_total").map(round2), 200.0)
|
||||
checkEqual("Nennkapazität", wattValue("capacity_design").map(round2), 200.0)
|
||||
checkEqual("Ladezyklen", wattValue("cycles"), 47)
|
||||
checkEqual("vier Zellspannungen", wattSnapshot.cellVoltages.count, 4)
|
||||
checkEqual("höchste Zelle", wattValue("cell_max"), 3.346)
|
||||
checkEqual("Zell-Differenz", wattValue("cell_delta").map { $0.rounded() }, 26)
|
||||
checkEqual("MOSFET-Temperatur aus Zehntel-Kelvin", wattValue("temp_mos").map(round2), 22.8)
|
||||
checkEqual("Platinentemperatur", wattValue("temp_pcb").map(round2), 21.8)
|
||||
checkEqual("nur die Zellfühler landen in den Temperaturen", wattSnapshot.temperatures.count, 2)
|
||||
checkEqual("Zustand aus negativem Strom", wattSnapshot.state, "Entlädt")
|
||||
checkEqual("Modell aus den Produktdaten", wattSnapshot.info.first?.value, "WC-12100BT v1.4")
|
||||
checkEqual("Seriennummer", wattSnapshot.info.last?.value, "WTaEaAA25342229")
|
||||
|
||||
// Ladestrom ohne Nachkommastelle: Bit 7 und Bit 6 aus.
|
||||
checkEqual("positiver Strom ohne Zehntel", WattCycleProtocol.current(high: 0x00, low: 0x2A), 42)
|
||||
checkEqual("positiver Strom mit Zehnteln", WattCycleProtocol.current(high: 0x40, low: 0x2A), 4.2)
|
||||
|
||||
// Kaputte Prüfsumme, falsches Endbyte, angefangener Rahmen
|
||||
var brokenWatt = wattResponse(datapoint: 0x008C, payload: analog)
|
||||
brokenWatt[brokenWatt.count - 2] ^= 0xFF
|
||||
checkEqual("falsche Prüfsumme wird verworfen",
|
||||
WattCycleProtocol.extractFrames(from: brokenWatt).frames.count, 0)
|
||||
var badTail = wattResponse(datapoint: 0x008C, payload: analog)
|
||||
badTail[badTail.count - 1] = 0x00
|
||||
checkEqual("falsches Endbyte wird verworfen",
|
||||
WattCycleProtocol.extractFrames(from: badTail).frames.count, 0)
|
||||
let halfWatt = Array(wattStream.prefix(wattStream.count - 5))
|
||||
checkEqual("nur der vollständige Rahmen wird ausgewertet",
|
||||
WattCycleProtocol.extractFrames(from: halfWatt).frames.count, 1)
|
||||
|
||||
// Fehlerantwort des Geräts darf keine Werte setzen.
|
||||
var errorFrame = wattResponse(datapoint: 0x008C, payload: [])
|
||||
errorFrame[3] = 0x86
|
||||
let recrc = DalyProtocol.crc16Modbus(Array(errorFrame[0..<(errorFrame.count - 3)]))
|
||||
errorFrame[errorFrame.count - 3] = UInt8(recrc >> 8)
|
||||
errorFrame[errorFrame.count - 2] = UInt8(recrc & 0xFF)
|
||||
var errorState = WattCycleState()
|
||||
for frame in WattCycleProtocol.extractFrames(from: errorFrame).frames { errorState.apply(frame) }
|
||||
checkEqual("Fehlerantwort liefert keine Werte", errorState.hasUsableData, false)
|
||||
|
||||
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
|
||||
exit(failures == 0 ? 0 : 1)
|
||||
|
||||
Reference in New Issue
Block a user