init
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user