forked from fritob/Camper-Monitor
Ausschalten blieb wirkungslos. Zwei Ursachen, beide behoben. Die Länge des Einstellungsblocks hing an der Länge des empfangenen Datensatzes statt an der Zonenzahl. Einzonen-Boxen, die den langen Datensatz trotzdem senden, bekamen so einen 25-Byte-Block für zwei Zonen und nahmen ihn offenbar nicht an. Jetzt entscheidet die erkannte Zonenzahl: eine Zone ergibt 20 Byte und passt damit ohne Aufteilen in einen Schreibvorgang. Der Bestätigungsdialog beim Ausschalten hing an einer Section, und dort zeigt SwiftUI ihn nicht zuverlässig an - der Schalter tat dann gar nichts. Ein und Aus laufen jetzt beide direkt über denselben Weg, ohne Rückfrage. Die Diagnose zeigt zusätzlich den zuletzt abgeschickten Stellbefehl mit Zeitstempel, damit sich unterscheiden lässt, ob ein Befehl das Gerät nicht erreicht oder von ihm verworfen wird. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
595 lines
30 KiB
Swift
595 lines
30 KiB
Swift
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)
|
||
|
||
// MARK: 9 – Alpicool-Kühlbox (Plug-in Festivals IceCube u.a.)
|
||
print("\nAlpicool-Protokoll")
|
||
|
||
// Die beiden festen Pakete aus der Referenzimplementierung; sie belegen
|
||
// zugleich, dass die allgemeine Prüfsummenregel für sie gilt.
|
||
checkEqual("Anmeldepaket", [UInt8](AlpicoolProtocol.packet(.bind)),
|
||
[0xFE, 0xFE, 0x03, 0x00, 0x01, 0xFF])
|
||
checkEqual("Abfragepaket", [UInt8](AlpicoolProtocol.packet(.query)),
|
||
[0xFE, 0xFE, 0x03, 0x01, 0x02, 0x00])
|
||
|
||
func alpicoolResponse(command: UInt8, payload: [UInt8]) -> [UInt8] {
|
||
var packet: [UInt8] = [0xFE, 0xFE, UInt8(payload.count + 3), command] + payload
|
||
let sum = AlpicoolProtocol.checksum(packet)
|
||
packet += [UInt8(sum >> 8), UInt8(sum & 0xFF)]
|
||
return packet
|
||
}
|
||
|
||
// Einzonen-Box: aus? nein · an · Eco · Soll 4 °C · ist 6 °C · 87 % · 12,7 V
|
||
var fridgePayload: [UInt8] = [
|
||
0x00, // Bedienfeld frei
|
||
0x01, // eingeschaltet
|
||
0x01, // Eco
|
||
0x00, // Batteriewächter niedrig
|
||
0x04, // Soll 4 °C
|
||
0x14, // Grenze oben 20 °C
|
||
0xE2, // Grenze unten -30 °C
|
||
0x01, // Rückschaltdifferenz
|
||
0x00, // Anlaufverzögerung
|
||
0x00, // Celsius
|
||
0x00, 0x00, 0x00, 0x00,
|
||
0x06, // Ist 6 °C
|
||
0x57, // 87 %
|
||
0x0C, 0x07, // 12,7 V
|
||
]
|
||
let fridgeStream = alpicoolResponse(command: 0x01, payload: fridgePayload)
|
||
let (fridgeFrames, fridgeRest) = AlpicoolProtocol.extractFrames(from: fridgeStream)
|
||
checkEqual("Statusrahmen erkannt", fridgeFrames.count, 1)
|
||
checkEqual("nichts bleibt übrig", fridgeRest.count, 0)
|
||
|
||
var fridge = AlpicoolState()
|
||
for frame in fridgeFrames { fridge.apply(frame) }
|
||
checkEqual("eingeschaltet", fridge.isPoweredOn, true)
|
||
checkEqual("Eco-Modus", fridge.isEco, true)
|
||
checkEqual("Isttemperatur", fridge.leftCurrent, 6)
|
||
checkEqual("Solltemperatur", fridge.leftTarget, 4)
|
||
checkEqual("negative Untergrenze als Zweierkomplement", fridge.temperatureMin, -30)
|
||
checkEqual("Bordspannung aus Vor- und Nachkommabyte", fridge.batteryVolts.map(round2), 12.7)
|
||
checkEqual("Batterieanzeige", fridge.batteryPercent, 87)
|
||
checkEqual("Einzonen-Box", fridge.isDualZone, false)
|
||
checkEqual("Sollbereich aus den gemeldeten Grenzen",
|
||
fridge.targetRange, -30...20)
|
||
|
||
let fridgeSnapshot = fridge.snapshot(deviceID: UUID(), rssi: nil)
|
||
checkEqual("Isttemperatur ist der Hauptwert", fridgeSnapshot.primaryMetric?.key, "temp_left")
|
||
checkEqual("Zustand", fridgeSnapshot.state, "Eco")
|
||
|
||
// Stellbefehl: Solltemperatur, auch negativ
|
||
checkEqual("Solltemperatur setzen",
|
||
[UInt8](AlpicoolState.setTarget(zone: .left, to: 4)),
|
||
[0xFE, 0xFE, 0x04, 0x05, 0x04, 0x02, 0x09])
|
||
let belowZero = [UInt8](AlpicoolState.setTarget(zone: .left, to: -18))
|
||
checkEqual("negative Solltemperatur als Zweierkomplement", belowZero[4], 0xEE)
|
||
checkEqual("rechte Zone nutzt ein eigenes Kommando",
|
||
[UInt8](AlpicoolState.setTarget(zone: .right, to: 4))[3], 0x06)
|
||
|
||
// Ein/Aus verändert genau ein Byte im Einstellungsblock, der Rest bleibt.
|
||
guard let offPacket = fridge.settingsCommand(poweredOn: false) else {
|
||
check("Ausschaltbefehl wird gebaut", false); exit(1)
|
||
}
|
||
let offBytes = [UInt8](offPacket)
|
||
checkEqual("Ausschalten nutzt das Einstellungskommando", offBytes[3], 0x02)
|
||
checkEqual("Ein-/Aus-Byte wird gesetzt", offBytes[5], 0x00)
|
||
checkEqual("Betriebsart bleibt unangetastet", offBytes[6], 0x01)
|
||
checkEqual("Solltemperatur bleibt unangetastet", offBytes[8], 0x04)
|
||
let offSum = AlpicoolProtocol.checksum(Array(offBytes[0..<(offBytes.count - 2)]))
|
||
checkEqual("Prüfsumme des Stellbefehls",
|
||
UInt16(offBytes[offBytes.count - 2]) << 8 | UInt16(offBytes[offBytes.count - 1]),
|
||
offSum)
|
||
|
||
// Ohne bekannten Zustand darf kein Einstellungsblock entstehen – sonst
|
||
// überschriebe er die Einstellungen der Box mit Nullen.
|
||
checkEqual("kein Stellbefehl ohne Status",
|
||
AlpicoolState().settingsCommand(poweredOn: true) == nil, true)
|
||
|
||
// Zweizonen-Box
|
||
var dualPayload = fridgePayload
|
||
dualPayload += [0xFE, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xFB, 0x01]
|
||
var dual = AlpicoolState()
|
||
for frame in AlpicoolProtocol.extractFrames(from:
|
||
alpicoolResponse(command: 0x01, payload: dualPayload)).frames {
|
||
dual.apply(frame)
|
||
}
|
||
checkEqual("Zweizonen-Box erkannt", dual.isDualZone, true)
|
||
dual.zoneMode = .single
|
||
checkEqual("auch eine echte Zweizonen-Box laesst sich auf eine reduzieren",
|
||
dual.isDualZone, false)
|
||
dual.zoneMode = .automatic
|
||
checkEqual("Soll rechts negativ", dual.rightTarget, -2)
|
||
checkEqual("Ist rechts negativ", dual.rightCurrent, -5)
|
||
checkEqual("Zustand meldet laufenden Kompressor",
|
||
dual.snapshot(deviceID: UUID(), rssi: nil).state, "Kühlt (Eco)")
|
||
|
||
// Einzonen-Box, die den langen Datensatz trotzdem sendet: der zweite Block
|
||
// ist dann leer und darf nicht als zweite Zone durchgehen.
|
||
var paddedPayload = fridgePayload
|
||
paddedPayload += [UInt8](repeating: 0, count: 10)
|
||
var padded = AlpicoolState()
|
||
for frame in AlpicoolProtocol.extractFrames(from:
|
||
alpicoolResponse(command: 0x01, payload: paddedPayload)).frames {
|
||
padded.apply(frame)
|
||
}
|
||
checkEqual("leerer zweiter Block gilt nicht als zweite Zone", padded.isDualZone, false)
|
||
|
||
// Schreibgrenze: ein Stellbefehl aus dem kurzen Datensatz passt mit genau
|
||
// 20 Byte noch in einen Schreibvorgang, aus dem langen nicht mehr. Ohne
|
||
// Aufteilen verwirft die Box ihn wortlos – genau das war der Fehler.
|
||
checkEqual("Stellbefehl aus dem kurzen Datensatz", offBytes.count, 20)
|
||
// Eine als einzonig erkannte Box bekommt auch nur den kurzen Befehl,
|
||
// obwohl sie den langen Datensatz sendet.
|
||
checkEqual("kurzer Stellbefehl bei einzoniger Box",
|
||
padded.settingsCommand(poweredOn: false)?.count, 20)
|
||
|
||
padded.zoneMode = .dual
|
||
guard let longPacket = padded.settingsCommand(poweredOn: true) else {
|
||
check("Stellbefehl aus dem langen Datensatz wird gebaut", false); exit(1)
|
||
}
|
||
check("aus dem langen Datensatz wird er zu lang für einen Schreibvorgang",
|
||
longPacket.count > 20, "-> \(longPacket.count) Byte")
|
||
let pieces = AlpicoolProtocol.chunks(longPacket, limit: 20)
|
||
checkEqual("wird in zwei Stücke geteilt", pieces.count, 2)
|
||
checkEqual("kein Stück überschreitet die Grenze", pieces.allSatisfy { $0.count <= 20 }, true)
|
||
checkEqual("zusammengesetzt ergibt sich das Original",
|
||
[UInt8](pieces.reduce(Data(), +)), [UInt8](longPacket))
|
||
checkEqual("kurze Pakete bleiben ungeteilt",
|
||
AlpicoolProtocol.chunks(AlpicoolProtocol.packet(.query), limit: 20).count, 1)
|
||
checkEqual("Einschaltbyte im langen Befehl", [UInt8](longPacket)[5], 0x01)
|
||
padded.zoneMode = .automatic
|
||
checkEqual("Hauptwert heisst dann schlicht Temperatur",
|
||
padded.snapshot(deviceID: UUID(), rssi: nil)
|
||
.metrics.first { $0.key == "temp_left" }?.label, "Temperatur")
|
||
checkEqual("keine Werte der rechten Zone in den Messwerten",
|
||
padded.snapshot(deviceID: UUID(), rssi: nil)
|
||
.metrics.contains { $0.key == "temp_right" }, false)
|
||
|
||
// Dasselbe mit 0xFF gefülltem zweiten Block.
|
||
var ffPayload = fridgePayload
|
||
ffPayload += [UInt8](repeating: 0xFF, count: 10)
|
||
var ffState = AlpicoolState()
|
||
for frame in AlpicoolProtocol.extractFrames(from:
|
||
alpicoolResponse(command: 0x01, payload: ffPayload)).frames {
|
||
ffState.apply(frame)
|
||
}
|
||
checkEqual("mit 0xFF gefuellter Block gilt nicht als zweite Zone", ffState.isDualZone, false)
|
||
|
||
// Handeinstellung schlaegt die Erkennung in beide Richtungen.
|
||
padded.zoneMode = .dual
|
||
checkEqual("Handeinstellung erzwingt zwei Zonen", padded.isDualZone, true)
|
||
padded.zoneMode = .single
|
||
checkEqual("Handeinstellung erzwingt eine Zone", padded.isDualZone, false)
|
||
|
||
// Echo und Status in einer Benachrichtigung: die Box antwortet auf
|
||
// Stellbefehle mit zwei aneinandergehängten Paketen.
|
||
let echoThenStatus = alpicoolResponse(command: 0x02, payload: [0x01]) + fridgeStream
|
||
let (bothFrames, _) = AlpicoolProtocol.extractFrames(from: echoThenStatus)
|
||
checkEqual("beide Pakete werden gefunden", bothFrames.count, 2)
|
||
var afterEcho = AlpicoolState()
|
||
for frame in bothFrames { afterEcho.apply(frame) }
|
||
checkEqual("das Echo verändert den Zustand nicht", afterEcho.leftCurrent, 6)
|
||
|
||
// Kaputte Prüfsumme und angefangene Pakete
|
||
var brokenFridge = fridgeStream
|
||
brokenFridge[brokenFridge.count - 1] ^= 0xFF
|
||
checkEqual("falsche Prüfsumme wird verworfen",
|
||
AlpicoolProtocol.extractFrames(from: brokenFridge).frames.count, 0)
|
||
checkEqual("angefangenes Paket wird aufgehoben",
|
||
AlpicoolProtocol.extractFrames(from: Array(fridgeStream.prefix(10))).frames.count, 0)
|
||
|
||
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
|
||
exit(failures == 0 ? 0 : 1)
|