This commit is contained in:
BiasF
2026-08-30 10:36:52 +02:00
commit fdcc644828
30 changed files with 4152 additions and 0 deletions
+316
View File
@@ -0,0 +1,316 @@
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)
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
exit(failures == 0 ? 0 : 1)