Files
Camper-Monitor/Tests/main.swift
T
BiasFandClaude Opus 5 091243a1d8 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>
2026-08-30 13:00:16 +02:00

418 lines
22 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
var failures = 0
func check(_ name: String, _ condition: Bool, _ detail: String = "") {
if condition { print(" ok \(name)") }
else { failures += 1; print(" FAIL \(name) \(detail)") }
}
func checkEqual<T: Equatable>(_ name: String, _ actual: T?, _ expected: T?) {
check(name, actual == expected, "-> got \(String(describing: actual)), want \(String(describing: expected))")
}
func hex(_ bytes: [UInt8]) -> String { bytes.map { String(format: "%02x", $0) }.joined() }
func round2(_ v: Double) -> Double { (v * 100).rounded() / 100 }
// MARK: 1 AES-CTR gegen NIST SP 800-38A F.5.1
print("AES-128-CTR (NIST SP 800-38A F.5.1)")
let nistKey = "2b7e151628aed2a6abf7158809cf4f3c".hexBytes!
let nistCounter = "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff".hexBytes!
let nistPlain = "6bc1bee22e409f96e93d7e117393172a".hexBytes!
let nistCipher = AESCounterMode.crypt(nistPlain, key: nistKey, nonce: nistCounter)
checkEqual("Block 1 stimmt mit dem Referenzvektor überein",
nistCipher.map(hex), "874d6191b620e3261bef6864990db6ce")
// MARK: 2 BitReader
print("\nBitReader")
var reader = BitReader([0xB5, 0x03])
checkEqual("read(3) liest die untersten Bits zuerst", reader.read(3), 5)
checkEqual("read(5) setzt bitgenau fort", reader.read(5), 22)
checkEqual("read(8) liest das zweite Byte", reader.read(8), 3)
checkEqual("read hinter dem Ende liefert nil", reader.read(1), nil)
var na = BitReader([0xFF, 0xFF])
checkEqual("readOptional erkennt den NA-Wert", na.readOptional(16), nil)
var signed = BitReader([0xFF, 0x7F, 0x9C, 0xFF, 0xFF, 0xFF])
checkEqual("readOptionalSigned erkennt 0x7FFF als NA", signed.readOptionalSigned(16), nil)
checkEqual("readOptionalSigned dekodiert -100", signed.readOptionalSigned(16), -100)
checkEqual("readOptionalSigned dekodiert -1", signed.readOptionalSigned(16), -1)
// MARK: 3 Victron Solarladeregler, kompletter Weg
print("\nVictron Advertisement Solarladeregler (0x01)")
/// Packt Felder so, wie Victron sie sendet: LSB zuerst, ohne Byte-Ausrichtung.
struct BitWriter {
private var bits: [UInt8] = []
mutating func write(_ value: UInt32, _ width: Int) {
for i in 0..<width { bits.append(UInt8((value >> UInt32(i)) & 1)) }
}
var bytes: [UInt8] {
var out = [UInt8](repeating: 0, count: (bits.count + 7) / 8)
for (index, bit) in bits.enumerated() where bit == 1 {
out[index / 8] |= 1 << UInt8(index % 8)
}
return out
}
}
let deviceKey = "aa112233445566778899aabbccddeeff".hexBytes!
var writer = BitWriter()
writer.write(3, 8) // Zustand: Bulk
writer.write(0, 8) // kein Fehler
writer.write(1345, 16) // 13,45 V
writer.write(152, 16) // 15,2 A
writer.write(234, 16) // 2,34 kWh
writer.write(210, 16) // 210 W
writer.write(0x1FF, 9) // Laststrom nicht verfügbar
let plaintext = writer.bytes
let nonce: UInt16 = 0x1234
var counterBlock = [UInt8](repeating: 0, count: 16)
counterBlock[0] = UInt8(nonce & 0xFF)
counterBlock[1] = UInt8(nonce >> 8)
let encrypted = AESCounterMode.crypt(plaintext, key: deviceKey, nonce: counterBlock)!
var advertisement: [UInt8] = [0xE1, 0x02, 0x10, 0x00, 0x4C, 0xA0, 0x01,
UInt8(nonce & 0xFF), UInt8(nonce >> 8), deviceKey[0]]
advertisement += encrypted
let solar = try? VictronAdvertisement.decode(manufacturerData: Data(advertisement),
key: deviceKey,
deviceID: UUID(),
rssi: -55)
func value(_ snapshot: DeviceSnapshot?, _ key: String) -> Double? {
snapshot?.metrics.first { $0.key == key }?.value
}
check("Advertisement wird dekodiert", solar != nil)
checkEqual("Zustand als Klartext", solar?.state, "Konstantstrom (Bulk)")
checkEqual("kein Fehler gemeldet", solar?.fault, nil)
checkEqual("Batteriespannung", value(solar, "battery_voltage").map(round2), 13.45)
checkEqual("Ladestrom", value(solar, "battery_current").map { ($0 * 10).rounded() / 10 }, 15.2)
checkEqual("Tagesertrag", value(solar, "yield_today"), 2.34)
checkEqual("PV-Leistung", value(solar, "pv_power"), 210)
checkEqual("Laststrom bleibt leer (NA)", value(solar, "load_current"), nil)
checkEqual("PV-Leistung ist der Hauptwert", solar?.primaryMetric?.key, "pv_power")
var wrongKey = deviceKey; wrongKey[0] = 0x00
do {
_ = try VictronAdvertisement.decode(manufacturerData: Data(advertisement),
key: wrongKey, deviceID: UUID(), rssi: nil)
check("falscher Schlüssel wird abgewiesen", false)
} catch {
check("falscher Schlüssel wird abgewiesen", true)
}
// MARK: 4 Victron Orion XS
print("\nVictron Advertisement Orion XS (0x0F)")
var xsWriter = BitWriter()
xsWriter.write(3, 8) // Bulk
xsWriter.write(0, 8)
xsWriter.write(1420, 16) // Ausgang 14,20 V
xsWriter.write(180, 16) // 18,0 A
xsWriter.write(1310, 16) // Eingang 13,10 V
xsWriter.write(210, 16) // 21,0 A
xsWriter.write(0x00000002, 32) // "Per Schalter ausgeschaltet"
let xsPlain = xsWriter.bytes
let xsEncrypted = AESCounterMode.crypt(xsPlain, key: deviceKey, nonce: counterBlock)!
// Rahmen wie vom echten Orion XS: Produkt-ID 0xA3F0, Datensatztyp 0x0F.
var xsAdvert: [UInt8] = [0xE1, 0x02, 0x10, 0x00, 0xF0, 0xA3, 0x0F,
UInt8(nonce & 0xFF), UInt8(nonce >> 8), deviceKey[0]]
xsAdvert += xsEncrypted
let xs = try? VictronAdvertisement.decode(manufacturerData: Data(xsAdvert),
key: deviceKey, deviceID: UUID(), rssi: nil)
let xsEnvelope = VictronAdvertisement.envelope(from: Data(xsAdvert))
checkEqual("Produkt-ID aus dem Rahmen", xsEnvelope?.productID, 0xA3F0)
checkEqual("Datensatztyp aus dem Rahmen", xsEnvelope?.recordType, 0x0F)
checkEqual("Nonce aus dem Rahmen", xsEnvelope?.nonce, nonce)
checkEqual("Prüfbyte aus dem Rahmen", xsEnvelope?.keyCheckByte, deviceKey[0])
checkEqual("Nutzdaten sind 14 Byte lang", xsEnvelope?.ciphertext.count, 14)
checkEqual("Ausgangsspannung", value(xs, "output_voltage").map(round2), 14.20)
checkEqual("Ladestrom", value(xs, "output_current").map { ($0 * 10).rounded() / 10 }, 18.0)
checkEqual("Eingangsspannung", value(xs, "input_voltage").map(round2), 13.10)
checkEqual("Ladeleistung wird gerechnet", value(xs, "output_power").map { ($0).rounded() }, 256)
checkEqual("Abschaltgrund im Klartext", xs?.offReasons.first, "Per Schalter ausgeschaltet")
// MARK: 4b Echtes Advertisement eines Orion XS
// Aus der Diagnoseansicht der App abgelesen. Ohne Schlüssel lässt sich der
// Inhalt nicht prüfen, wohl aber der Rahmen und genau dort war der Fehler.
print("\nVictron Advertisement echter Rahmen vom Gerät")
let realFrame = "E1 02 10 00 F0 A3 0F 17 28 3C 81 9C 8F 42 FC 94 5F 4D 59 C9 F8 73 DB 48".hexBytes!
let realEnvelope = VictronAdvertisement.envelope(from: Data(realFrame))
check("wird als Victron erkannt", realEnvelope != nil)
checkEqual("Datensatztyp ist Orion XS", realEnvelope?.recordType, 0x0F)
checkEqual("Produkt-ID", realEnvelope?.productID, 0xA3F0)
checkEqual("Nonce", realEnvelope?.nonce, 0x2817)
checkEqual("Prüfbyte des Schlüssels", realEnvelope?.keyCheckByte, 0x3C)
checkEqual("Nutzdaten passen genau auf einen Orion-XS-Datensatz",
realEnvelope?.ciphertext.count, 14)
// MARK: 5 Daly, klassisches Protokoll
print("\nDaly A5-Protokoll")
let request = [UInt8](DalyProtocol.requestFrame(.soc))
checkEqual("Anfragerahmen ist 13 Byte lang", request.count, 13)
checkEqual("Prüfsumme der Anfrage", request.last, 0xBD)
/// Baut eine Antwort so, wie das BMS sie schickt.
func dalyResponse(command: UInt8, payload: [UInt8]) -> [UInt8] {
var frame: [UInt8] = [0xA5, 0x01, command, 0x08] + payload
frame.append(frame.reduce(0) { UInt8(($0 &+ $1) & 0xFF) })
return frame
}
// 13,42 V · 25,0 A Ladung · 87,5 %
let socPayload: [UInt8] = [0x00, 0x86, 0x00, 0x86, 0x76, 0x2A, 0x03, 0x6B]
// 30000 + 250 = 30250 = 0x765A -> +25,0 A ; SOC 875 = 0x036B -> 87,5 %
var stream = dalyResponse(command: 0x90, payload: socPayload)
stream += dalyResponse(command: 0x91, payload: [0x0D, 0x12, 0x03, 0x0C, 0xFE, 0x07, 0x00, 0x00])
stream += dalyResponse(command: 0x92, payload: [0x40, 0x01, 0x3E, 0x02, 0x00, 0x00, 0x00, 0x00])
stream += dalyResponse(command: 0x95, payload: [0x01, 0x0C, 0xFE, 0x0D, 0x00, 0x0D, 0x12, 0x00])
let (frames, remainder) = DalyProtocol.extractA5Frames(from: stream)
checkEqual("alle vier Rahmen erkannt", frames.count, 4)
checkEqual("nichts bleibt übrig", remainder.count, 0)
var state = DalyState()
for frame in frames { state.apply(frame) }
let bms = state.snapshot(deviceID: UUID(), rssi: nil)
func bmsValue(_ key: String) -> Double? { bms.metrics.first { $0.key == key }?.value }
checkEqual("Gesamtspannung", bmsValue("voltage").map { ($0 * 100).rounded() / 100 }, 13.4)
checkEqual("Strom mit Offset 30000", bmsValue("current").map { ($0 * 10).rounded() / 10 }, 25.0)
checkEqual("Ladezustand", bmsValue("soc").map { ($0 * 10).rounded() / 10 }, 87.5)
checkEqual("Zell-Differenz in mV", bmsValue("cell_delta"), 20)
checkEqual("höchste Zelle", bmsValue("cell_max"), 3.346)
checkEqual("Temperatur", bmsValue("temp_max"), 24)
checkEqual("Zustand aus dem Strom abgeleitet", bms.state, "Lädt")
checkEqual("drei Zellspannungen aus Rahmen 0x95", bms.cellVoltages.count, 3)
// Halber Rahmen am Ende muss aufgehoben werden.
let partial = Array(stream.prefix(13 + 6))
let (someFrames, rest) = DalyProtocol.extractA5Frames(from: partial)
checkEqual("vollständiger Rahmen wird ausgewertet", someFrames.count, 1)
check("angefangener Rahmen bleibt im Puffer", rest.count >= 6)
// Kaputte Prüfsumme darf nicht durchrutschen.
var corrupted = dalyResponse(command: 0x90, payload: socPayload)
corrupted[12] ^= 0xFF
checkEqual("falsche Prüfsumme wird verworfen",
DalyProtocol.extractA5Frames(from: corrupted).frames.count, 0)
// MARK: 6 Daly Modbus
print("\nDaly Modbus-Protokoll")
let modbusRequest = [UInt8](DalyProtocol.modbusReadFrame())
checkEqual("Anfrage beginnt korrekt", Array(modbusRequest.prefix(6)), [0xD2, 0x03, 0x00, 0x00, 0x00, 0x3E])
print(" CRC16 der Anfrage: \(hex(Array(modbusRequest.suffix(2))))")
// Antwort mit vier Zellen und einem Temperaturfühler bauen.
var registers = [UInt16](repeating: 0, count: 62)
registers[0] = 3320; registers[1] = 3325; registers[2] = 3318; registers[3] = 3330
registers[48] = 64 // 24 °C
registers[56] = 133 // 13,3 V
registers[57] = 29750 // -25,0 A
registers[58] = 642 // 64,2 %
var body: [UInt8] = [0xD2, 0x03, UInt8(registers.count * 2)]
for register in registers { body.append(UInt8(register >> 8)); body.append(UInt8(register & 0xFF)) }
let responseCRC = DalyProtocol.crc16Modbus(body)
body.append(UInt8(responseCRC & 0xFF)); body.append(UInt8(responseCRC >> 8))
let parsed = DalyProtocol.parseModbusResponse(body)
checkEqual("62 Register gelesen", parsed?.count, 62)
var modbusState = DalyState()
modbusState.apply(registers: parsed ?? [])
let modbusSnapshot = modbusState.snapshot(deviceID: UUID(), rssi: nil)
func modbusValue(_ key: String) -> Double? { modbusSnapshot.metrics.first { $0.key == key }?.value }
checkEqual("nur belegte Zellen zählen", modbusSnapshot.cellVoltages.count, 4)
checkEqual("Spannung", modbusValue("voltage").map { ($0 * 10).rounded() / 10 }, 13.3)
checkEqual("Entladestrom ist negativ", modbusValue("current").map { ($0 * 10).rounded() / 10 }, -25.0)
checkEqual("Ladezustand", modbusValue("soc").map { ($0 * 10).rounded() / 10 }, 64.2)
checkEqual("Zell-Differenz", modbusValue("cell_delta"), 12)
checkEqual("Zustand aus negativem Strom", modbusSnapshot.state, "Entlädt")
var truncated = body; truncated.removeLast(30)
checkEqual("unvollständige Antwort wird nicht ausgewertet",
DalyProtocol.parseModbusResponse(truncated) == nil, true)
var badCRC = body; badCRC[body.count - 1] ^= 0xFF
checkEqual("falscher CRC wird verworfen",
DalyProtocol.parseModbusResponse(badCRC) == nil, true)
// MARK: 7 JBD / Xiaoxiang (WattCycle)
print("\nJBD-Protokoll")
let jbdRequest = [UInt8](JBDProtocol.requestFrame(.basicInfo))
checkEqual("Anfragerahmen Basisinfo", jbdRequest, [0xDD, 0xA5, 0x03, 0x00, 0xFF, 0xFD, 0x77])
checkEqual("Anfragerahmen Zellspannungen",
[UInt8](JBDProtocol.requestFrame(.cellVoltages)),
[0xDD, 0xA5, 0x04, 0x00, 0xFF, 0xFC, 0x77])
/// Baut eine Antwort, wie das BMS sie schickt.
func jbdResponse(command: UInt8, payload: [UInt8]) -> [UInt8] {
let body: [UInt8] = [0x00, UInt8(payload.count)] + payload
let sum = JBDProtocol.checksum(over: body)
return [0xDD, command] + body + [UInt8(sum >> 8), UInt8(sum & 0xFF), 0x77]
}
// 13,25 V · -12,80 A (Entladung) · 88 % · 4 Zellen · 1 Fühler bei 23,0 °C
var basic: [UInt8] = []
basic += [0x05, 0x2D] // 1325 -> 13,25 V
basic += [0xFB, 0x00] // -1280 -> -12,80 A
basic += [0x44, 0xC0] // 17600 -> 176,00 Ah Rest
basic += [0x4E, 0x20] // 20000 -> 200,00 Ah nominal
basic += [0x00, 0x2A] // 42 Zyklen
basic += [0x00, 0x00] // Produktionsdatum
basic += [0x00, 0x00, 0x00, 0x00] // Balancer
basic += [0x00, 0x00] // keine Schutzabschaltung
basic += [0x16] // Softwareversion
basic += [0x58] // 88 %
basic += [0x03] // beide MOSFET an
basic += [0x04] // 4 Zellen
basic += [0x01] // 1 Fühler
basic += [0x0B, 0x99] // 2969 -> (2969-2731)/10 = 23,8 °C
let cells: [UInt8] = [0x0C, 0xFE, 0x0D, 0x12, 0x0D, 0x00, 0x0C, 0xF8]
var jbdStream = jbdResponse(command: 0x03, payload: basic)
jbdStream += jbdResponse(command: 0x04, payload: cells)
let (jbdFrames, jbdRest) = JBDProtocol.extractFrames(from: jbdStream)
checkEqual("beide Rahmen erkannt", jbdFrames.count, 2)
checkEqual("nichts bleibt übrig", jbdRest.count, 0)
var jbd = JBDState()
for frame in jbdFrames { jbd.apply(frame) }
let jbdSnapshot = jbd.snapshot(deviceID: UUID(), rssi: nil)
func jbdValue(_ key: String) -> Double? { jbdSnapshot.metrics.first { $0.key == key }?.value }
checkEqual("Spannung", jbdValue("voltage").map(round2), 13.25)
checkEqual("Entladestrom ist negativ", jbdValue("current").map(round2), -12.80)
checkEqual("Ladezustand", jbdValue("soc"), 88)
checkEqual("Restkapazität", jbdValue("capacity").map(round2), 176.0)
checkEqual("Nennkapazität", jbdValue("capacity_nominal").map(round2), 200.0)
checkEqual("Ladezyklen", jbdValue("cycles"), 42)
checkEqual("Temperatur aus Zehntel-Kelvin", jbdSnapshot.temperatures.first.map(round2), 23.8)
checkEqual("vier Zellspannungen", jbdSnapshot.cellVoltages.count, 4)
checkEqual("höchste Zelle", jbdValue("cell_max"), 3.346)
checkEqual("Zell-Differenz", jbdValue("cell_delta"), 26)
checkEqual("Zustand aus negativem Strom", jbdSnapshot.state, "Entlädt")
checkEqual("keine Störung gemeldet", jbdSnapshot.fault, nil)
// Schutzabschaltung und gesperrte MOSFET
var tripped = basic
tripped[16] = 0x00; tripped[17] = 0x02 // Zellunterspannung
tripped[20] = 0x01 // nur Laden erlaubt
var trippedState = JBDState()
for frame in JBDProtocol.extractFrames(from: jbdResponse(command: 0x03, payload: tripped)).frames {
trippedState.apply(frame)
}
let trippedSnapshot = trippedState.snapshot(deviceID: UUID(), rssi: nil)
checkEqual("Schutzabschaltung im Klartext", trippedSnapshot.fault, "Zellunterspannung")
checkEqual("gesperrtes Entladen wird gemeldet", trippedSnapshot.offReasons.first, "Entladen gesperrt")
// Kaputte Prüfsumme und angefangene Rahmen
var brokenJBD = jbdResponse(command: 0x03, payload: basic)
brokenJBD[brokenJBD.count - 2] ^= 0xFF
checkEqual("falsche Prüfsumme wird verworfen",
JBDProtocol.extractFrames(from: brokenJBD).frames.count, 0)
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)