forked from fritob/Camper-Monitor
.solarCharger und .solar sahen im Code zu ähnlich aus, um auf einen Blick zu erkennen, welches der Victron-Solarladeregler (passives BLE-Advertisement) und welches der Votronic-Solarladeregler (aktive GATT-Verbindung über die ESP32-Bridge) ist. Jetzt .victronSolarCharger und .votronicSolar, samt allen abgeleiteten Bezeichnern in DemoData, WatchDemo und Tests. Die rawValues bleiben "solarCharger"/"solar", damit bereits gespeicherte Geräte beim Decodieren nicht auf einen unbekannten Rollen-Wert treffen. VictronAdvertisement.RecordType.solarCharger (das rohe Protokollfeld) bleibt unverändert – schon durch den Typnamen eindeutig, und passend zur offiziellen Victron-Bezeichnung des Datensatztyps. run-tests.sh fehlten VehicleGraphicStyle.swift und VehicleTilt.swift, seit ConfiguredDevice sie referenziert – ergänzt, Testlauf grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1293 lines
63 KiB
Swift
1293 lines
63 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 victronSolar = 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", victronSolar != nil)
|
||
checkEqual("Zustand als Klartext", victronSolar?.state, "Konstantstrom (Bulk)")
|
||
checkEqual("kein Fehler gemeldet", victronSolar?.fault, nil)
|
||
checkEqual("Batteriespannung", value(victronSolar, "battery_voltage").map(round2), 13.45)
|
||
checkEqual("Ladestrom", value(victronSolar, "battery_current").map { ($0 * 10).rounded() / 10 }, 15.2)
|
||
checkEqual("Tagesertrag", value(victronSolar, "yield_today"), 2.34)
|
||
checkEqual("PV-Leistung", value(victronSolar, "pv_power"), 210)
|
||
checkEqual("Laststrom bleibt leer (NA)", value(victronSolar, "load_current"), nil)
|
||
checkEqual("PV-Leistung ist der Hauptwert", victronSolar?.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 Einzonen-Box, die den langen Datensatz nur auffüllt, bekommt den
|
||
// kurzen Befehl – den langen nimmt sie nicht an.
|
||
checkEqual("aufgefüllter Datensatz heisst trotzdem kurzer Stellbefehl",
|
||
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)
|
||
|
||
// Ein echtes Paket der Maentum/Plug-in Festival IceCube Dual, mitgeschnitten
|
||
// beim Ausschalten (Gruni22/alpicool_ha_ble#20). Es muss byteweise
|
||
// herauskommen - und in zwei Schreibvorgänge zerfallen, weil die Box längere
|
||
// nicht annimmt. Genau daran scheiterte das Ein- und Ausschalten.
|
||
let iceCubePayload: [UInt8] = [
|
||
0x00, // Bedienfeld frei
|
||
0x01, // eingeschaltet
|
||
0x01, // Eco
|
||
0x02, // Batteriewächter hoch
|
||
0x14, // Soll links 20
|
||
0x14, 0xEC, // Grenzen 20 / -20
|
||
0x02, // Rückschaltdifferenz
|
||
0x00, // Anlaufverzögerung
|
||
0x00, // Celsius
|
||
0xFD, 0xFD, 0xFD, 0x00, // Kompressordrehzahlen
|
||
0x0A, // Ist links 10
|
||
0x57, // 87 %
|
||
0x0C, 0x06, // 12,6 V
|
||
0x14, // Soll rechts 20
|
||
0x00, 0x00,
|
||
0x02, // Rückschaltdifferenz rechts
|
||
0xFD, 0xFD, 0xFD, 0x00, // Kompressordrehzahlen rechts
|
||
0x0A, // Ist rechts 10
|
||
0x01, // Kompressor läuft
|
||
]
|
||
var iceCube = AlpicoolState()
|
||
for frame in AlpicoolProtocol.extractFrames(from:
|
||
alpicoolResponse(command: 0x01, payload: iceCubePayload)).frames {
|
||
iceCube.apply(frame)
|
||
}
|
||
let expectedOff = Data([
|
||
0xFE, 0xFE, 0x1C, 0x02,
|
||
0x00, 0x00, 0x01, 0x02, 0x14, 0x14, 0xEC, 0x02, 0x00, 0x00,
|
||
0xFD, 0xFD, 0xFD, 0x00,
|
||
0x14, 0x00, 0x00, 0x02, 0xFD, 0xFD, 0xFD, 0x00, 0x00, 0x00, 0x00,
|
||
0x09, 0x37,
|
||
])
|
||
checkEqual("Ausschaltbefehl der IceCube Dual stimmt byteweise",
|
||
iceCube.settingsCommand(poweredOn: false), expectedOff)
|
||
// Eine echte Box aus dem Feld: 30 Byte Nutzlast, aber nur ein Fühler. Sie
|
||
// füllt den zweiten Block mit Nullen auf und meldet für den fehlenden
|
||
// zweiten Fühler -128. Der Stellbefehl muss trotzdem der kurze sein - der
|
||
// lange wurde von ihr wortlos verworfen, und genau daran scheiterte das
|
||
// Ein- und Ausschalten.
|
||
let paddedIceCubePayload: [UInt8] = [
|
||
0x00, // Bedienfeld frei
|
||
0x01, // eingeschaltet
|
||
0x01, // Eco
|
||
0x02, // Batteriewächter hoch
|
||
0x09, // Soll 9
|
||
0x14, 0xEC, // Grenzen 20 / -20
|
||
0x02, 0x00, 0x00, // Rückschaltdifferenz, Verzögerung, Celsius
|
||
0x00, 0x00, 0xFD, 0x00, // Kompressordrehzahlen
|
||
0x17, // Ist 23
|
||
0x64, // 100 %
|
||
0x0E, 0x03, // 14,3 V
|
||
0x00, 0x00, 0x00, 0x00, // rechte Zone: nur Füllung
|
||
0x00, 0x00, 0x00, 0x00,
|
||
0x80, // kein zweiter Fühler
|
||
0x00, // Kompressor aus
|
||
0x01, 0x00, // was darüber hinaus kommt
|
||
]
|
||
var single = AlpicoolState()
|
||
for frame in AlpicoolProtocol.extractFrames(from:
|
||
alpicoolResponse(command: 0x01, payload: paddedIceCubePayload)).frames {
|
||
single.apply(frame)
|
||
}
|
||
checkEqual("30 Byte Nutzlast werden gelesen", single.leftCurrent, 23)
|
||
checkEqual("fehlender zweiter Fühler heisst eine Zone", single.isDualZone, false)
|
||
checkEqual("und damit der kurze Stellbefehl",
|
||
single.settingsCommand(poweredOn: false)?.count, 20)
|
||
checkEqual("der in einen Schreibvorgang passt",
|
||
AlpicoolProtocol.chunks(single.settingsCommand(poweredOn: false)!,
|
||
limit: AlpicoolProtocol.maxWriteSize).count, 1)
|
||
// Von Hand lässt sich die Erkennung übersteuern, falls sie danebenliegt.
|
||
single.zoneMode = .dual
|
||
checkEqual("von Hand erzwungen wird er wieder lang",
|
||
single.settingsCommand(poweredOn: false)?.count, 31)
|
||
single.zoneMode = .automatic
|
||
|
||
// Ob ein Stellbefehl gewirkt hat, wird an den Einstellungsbytes abgelesen -
|
||
// nicht an den Messwerten, die ohnehin schwanken.
|
||
let fingerprintBefore = iceCube.settingsFingerprint
|
||
var iceCubeCooler = iceCube
|
||
var driftingPayload = iceCubePayload
|
||
driftingPayload[14] = 0x08 // Ist links gefallen
|
||
driftingPayload[16] = 0x0B // Spannung gesunken
|
||
for frame in AlpicoolProtocol.extractFrames(from:
|
||
alpicoolResponse(command: 0x01, payload: driftingPayload)).frames {
|
||
iceCubeCooler.apply(frame)
|
||
}
|
||
checkEqual("schwankende Messwerte gelten nicht als Wirkung",
|
||
iceCubeCooler.settingsFingerprint, fingerprintBefore)
|
||
|
||
var iceCubeOff = iceCube
|
||
var switchedPayload = iceCubePayload
|
||
switchedPayload[1] = 0x00 // ausgeschaltet
|
||
for frame in AlpicoolProtocol.extractFrames(from:
|
||
alpicoolResponse(command: 0x01, payload: switchedPayload)).frames {
|
||
iceCubeOff.apply(frame)
|
||
}
|
||
check("ein geänderter Schalter gilt als Wirkung",
|
||
iceCubeOff.settingsFingerprint != fingerprintBefore)
|
||
|
||
checkEqual("er passt nicht in einen Schreibvorgang",
|
||
AlpicoolProtocol.chunks(expectedOff, limit: AlpicoolProtocol.maxWriteSize)
|
||
.map(\.count), [20, 11])
|
||
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)
|
||
|
||
// MARK: 9b – Kühlbox: der Stand, der die Verbindung überdauert
|
||
|
||
print("\nKühlbox – zuletzt gestellter Stand")
|
||
|
||
// Derselbe Datensatz wie oben, nur diesmal auf das eingedampft, was ohne
|
||
// Verbindung noch gilt.
|
||
var boxState = AlpicoolState()
|
||
boxState.apply(AlpicoolProtocol.Frame(command: 0x01, payload: [
|
||
0x00, 0x01, 0x01, 0x00, 0x04, 0x14, 0xE2, 0x01, 0x00, 0x00,
|
||
0x00, 0x00, 0x00, 0x00, 0x06, 0x57, 0x0C, 0x07,
|
||
]))
|
||
|
||
guard let boxSettings = boxState.settings else {
|
||
check("Stand lässt sich aus dem Datensatz ableiten", false)
|
||
exit(1)
|
||
}
|
||
checkEqual("eingeschaltet", boxSettings.isPoweredOn, true)
|
||
checkEqual("Eco", boxSettings.isEco, true)
|
||
checkEqual("Solltemperatur", boxSettings.leftTarget, 4)
|
||
checkEqual("Einheit", boxSettings.unitSymbol, "°C")
|
||
checkEqual("in Worten", boxSettings.stateText, "Eco")
|
||
|
||
// Die Übersicht zeigt den Sollwert – und ausdrücklich keine Innentemperatur.
|
||
let boxSnapshot = boxSettings.snapshot(deviceID: UUID())
|
||
checkEqual("Hauptwert ist der Sollwert", boxSnapshot.primaryMetric?.key, "target_left")
|
||
checkEqual("Sollwert steht drin", value(boxSnapshot, "target_left"), 4)
|
||
checkEqual("keine gemessene Temperatur", boxSnapshot.metrics.contains { $0.key == "temp_left" }, false)
|
||
checkEqual("keine Bordspannung", boxSnapshot.metrics.contains { $0.key == "supply_voltage" }, false)
|
||
|
||
// Ohne Antwort der Box gibt es auch nichts aufzuheben.
|
||
checkEqual("leerer Zustand ergibt keinen Stand", AlpicoolState().settings == nil, true)
|
||
|
||
// Auf der Uhr dasselbe Bild: Sollwerte ja, Messwerte nein.
|
||
let watchBox = WatchFridge(boxSettings)
|
||
checkEqual("auf der Uhr als nicht live gekennzeichnet", watchBox.isLive, false)
|
||
checkEqual("Sollwert kommt mit", watchBox.leftTarget, 4)
|
||
checkEqual("Messwert bleibt leer", watchBox.leftCurrent == nil, true)
|
||
|
||
// MARK: 10 – VanAlign Neigungsmesser
|
||
print("\nVanAlign-Neigungsmesser")
|
||
|
||
/// So legt der ESP32 einen Float ab: memcpy, also little-endian.
|
||
func floatBytes(_ value: Float, littleEndian: Bool = true) -> [UInt8] {
|
||
let raw = value.bitPattern
|
||
let bytes = (0..<4).map { UInt8((raw >> UInt32(8 * $0)) & 0xFF) }
|
||
return littleEndian ? bytes : bytes.reversed()
|
||
}
|
||
|
||
checkEqual("positiver Winkel",
|
||
VanAlignProtocol.angle(from: Data(floatBytes(2.5))).map(round2), 2.5)
|
||
checkEqual("negativer Winkel",
|
||
VanAlignProtocol.angle(from: Data(floatBytes(-3.75))).map(round2), -3.75)
|
||
checkEqual("Null",
|
||
VanAlignProtocol.angle(from: Data(floatBytes(0))).map(round2), 0)
|
||
checkEqual("zu kurze Daten liefern nichts",
|
||
VanAlignProtocol.angle(from: Data([0x01, 0x02])) == nil, true)
|
||
|
||
// Vertauschte Bytes ergeben etwas völlig anderes und sind nicht als Fehler
|
||
// erkennbar - ein vertauschtes 4,25 liest sich als etwa 0. Genau deshalb wird
|
||
// gar nicht erst geraten, sondern fest little-endian gelesen.
|
||
check("vertauschte Bytes ergeben nicht denselben Winkel",
|
||
VanAlignProtocol.angle(from: Data(floatBytes(4.25, littleEndian: false)))
|
||
.map { abs($0 - 4.25) > 0.1 } ?? true)
|
||
checkEqual("unplausibel grosser Winkel wird verworfen",
|
||
VanAlignProtocol.angle(from: Data(floatBytes(5000))) == nil, true)
|
||
|
||
// NaN meldet die Firmware, solange der Sensor keine Werte liefert.
|
||
checkEqual("NaN gilt nicht als Messwert",
|
||
VanAlignProtocol.angle(from: Data(floatBytes(Float.nan))) == nil, true)
|
||
|
||
let offsetData = Data(floatBytes(1.5) + floatBytes(-2.25))
|
||
let offsets = VanAlignProtocol.offsets(from: offsetData)
|
||
checkEqual("Offset längs", offsets?.pitch.rounded(), 2)
|
||
checkEqual("Offset quer", offsets.map { ($0.roll * 100).rounded() / 100 }, -2.25)
|
||
checkEqual("halbe Offset-Daten liefern nichts",
|
||
VanAlignProtocol.offsets(from: Data(floatBytes(1.5))) == nil, true)
|
||
|
||
checkEqual("Kalibrierbefehl", [UInt8](VanAlignProtocol.calibrateCommand), [0x01])
|
||
checkEqual("Rücksetzbefehl", [UInt8](VanAlignProtocol.resetCommand), [0x00])
|
||
|
||
// Zustand und Handlungsanweisung
|
||
var level = LevelState()
|
||
level.pitch = 0.2; level.roll = -0.3
|
||
checkEqual("kleine Abweichung gilt als eben", level.isLevel, true)
|
||
checkEqual("Ansage bei ebenem Stand", level.instruction, "Steht eben")
|
||
|
||
level.pitch = 2.4; level.roll = 0.1
|
||
checkEqual("Längsneigung bricht die Waage", level.isLevel, false)
|
||
checkEqual("Ansage nennt die hohe Seite", level.instruction, "Heck steht höher")
|
||
checkEqual("grösste Abweichung", level.largestDeviation.map(round2), 2.4)
|
||
|
||
level.pitch = -1.0; level.roll = -2.0
|
||
checkEqual("beide Achsen in der Ansage", level.instruction,
|
||
"Front steht höher, links steht höher")
|
||
checkEqual("grösste Abweichung über beide Achsen", level.largestDeviation.map(round2), 2.0)
|
||
|
||
// Fehlende Offsets heissen "unbekannt", nicht "nicht kalibriert" - sonst
|
||
// stünde bei Firmware, die sie nicht meldet, dauerhaft eine Warnung, die sich
|
||
// nicht abstellen lässt.
|
||
checkEqual("ohne Offsets ist der Zustand unbekannt", level.calibrationState == nil, true)
|
||
checkEqual("unbekannt gilt nicht als kalibriert", level.isCalibrated, false)
|
||
checkEqual("unbekannt gilt aber auch nicht als unkalibriert", level.isKnownUncalibrated, false)
|
||
level.pitchOffset = 0; level.rollOffset = 0
|
||
checkEqual("gemeldete Nulloffsets sind belegt unkalibriert", level.isKnownUncalibrated, true)
|
||
level.pitchOffset = 1.2
|
||
checkEqual("ein gesetzter Offset genügt", level.isCalibrated, true)
|
||
|
||
let levelSnapshot = level.snapshot(deviceID: UUID(), rssi: nil)
|
||
checkEqual("Längsneigung ist der Hauptwert", levelSnapshot.primaryMetric?.key, "pitch")
|
||
checkEqual("Messwerte beider Achsen", levelSnapshot.metrics.count, 2)
|
||
|
||
var uncalibrated = LevelState()
|
||
uncalibrated.pitch = 0; uncalibrated.roll = 0
|
||
checkEqual("ohne Offset-Meldung keine Warnung",
|
||
uncalibrated.snapshot(deviceID: UUID(), rssi: nil).offReasons.isEmpty, true)
|
||
uncalibrated.pitchOffset = 0; uncalibrated.rollOffset = 0
|
||
checkEqual("belegt fehlende Kalibrierung wird gemeldet",
|
||
uncalibrated.snapshot(deviceID: UUID(), rssi: nil).offReasons.first,
|
||
"Nicht kalibriert")
|
||
|
||
// MARK: 11 – Ausrichtungs-Assistent
|
||
print("\nAusrichtungs-Assistent")
|
||
|
||
let start = Date(timeIntervalSince1970: 1_700_000_000)
|
||
var assistant = AlignmentAssistant()
|
||
checkEqual("ohne Messwerte kein Rat", assistant.trend, .unknown)
|
||
|
||
// Das Fahrzeug wird langsam flacher.
|
||
for (index, deviation) in [3.0, 2.6, 2.2, 1.8, 1.4, 1.0].enumerated() {
|
||
assistant.add(pitch: deviation, roll: 0, at: start.addingTimeInterval(Double(index) * 0.5))
|
||
}
|
||
checkEqual("fallende Abweichung wird als Verbesserung erkannt", assistant.trend, .improving)
|
||
checkEqual("kein Rückwärtshinweis, solange es besser wird",
|
||
assistant.improvementAtBest == nil, true)
|
||
checkEqual("Rat beim Verbessern", assistant.advice, "Wird besser – weiter so")
|
||
|
||
// Jetzt am flachsten Punkt vorbei.
|
||
for (index, deviation) in [1.4, 1.9, 2.5].enumerated() {
|
||
assistant.add(pitch: deviation, roll: 0, at: start.addingTimeInterval(3.0 + Double(index) * 0.5))
|
||
}
|
||
checkEqual("steigende Abweichung wird als Verschlechterung erkannt", assistant.trend, .worsening)
|
||
checkEqual("bester Punkt wird gefunden", assistant.best?.deviation.rounded(), 1)
|
||
check("Rückwärtshinweis erscheint", assistant.improvementAtBest != nil)
|
||
checkEqual("Rat verweist auf den besseren Punkt",
|
||
assistant.advice.hasPrefix("Vor ") && assistant.advice.hasSuffix("zurück"), true)
|
||
checkEqual("Zeit seit dem besten Punkt",
|
||
assistant.timeSinceBest.map { ($0 * 10).rounded() / 10 }, 1.5)
|
||
|
||
// Erreicht das Fahrzeug die Waage, zählt nur noch das.
|
||
assistant.add(pitch: 0.2, roll: 0.1, at: start.addingTimeInterval(5))
|
||
checkEqual("Ziel erreicht", assistant.hasReachedTarget, true)
|
||
checkEqual("Rat beim Ziel", assistant.advice, "Steht eben – anhalten")
|
||
|
||
// Gesamtabweichung über beide Achsen.
|
||
var both = AlignmentAssistant()
|
||
both.add(pitch: 3, roll: 4, at: start)
|
||
checkEqual("Gesamtabweichung ist der Betrag beider Achsen",
|
||
both.current?.deviation.rounded(), 5)
|
||
|
||
// Der Speicher endet nach der festgelegten Zeit.
|
||
var ageing = AlignmentAssistant()
|
||
ageing.add(pitch: 5, roll: 0, at: start)
|
||
ageing.add(pitch: 1, roll: 0, at: start.addingTimeInterval(AlignmentAssistant.memory + 10))
|
||
checkEqual("alte Messwerte fallen heraus", ageing.samples.count, 1)
|
||
|
||
// MARK: Auffahrkeile
|
||
|
||
print("\nAuffahrkeile – Höhe je Rad")
|
||
|
||
// Reine Querneigung: tan(2°) x 2,00 m = 6,99 cm unter beide Räder der
|
||
// tieferstehenden Seite. Rechts steht höher, also muss links hoch.
|
||
let acrossOnly = LevelingLift.compute(pitch: 0, roll: 2.0, trackWidth: 2.0, wheelbase: 3.5)
|
||
checkEqual("quer: vorne links",
|
||
acrossOnly.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 7.0)
|
||
checkEqual("quer: hinten links",
|
||
acrossOnly.map { ($0.centimetres(.rearLeft) * 10).rounded() / 10 }, 7.0)
|
||
checkEqual("quer: rechts bleibt liegen",
|
||
acrossOnly.map { $0.centimetres(.frontRight) + $0.centimetres(.rearRight) }, 0)
|
||
|
||
// Reine Längsneigung: tan(1,5°) x 3,50 m = 9,16 cm unter beide Vorderräder,
|
||
// wenn das Heck höher steht.
|
||
let alongOnly = LevelingLift.compute(pitch: 1.5, roll: 0, trackWidth: 2.0, wheelbase: 3.5)
|
||
checkEqual("längs: vorne links",
|
||
alongOnly.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 9.2)
|
||
checkEqual("längs: vorne rechts",
|
||
alongOnly.map { ($0.centimetres(.frontRight) * 10).rounded() / 10 }, 9.2)
|
||
checkEqual("längs: hinten bleibt liegen",
|
||
alongOnly.map { $0.centimetres(.rearLeft) + $0.centimetres(.rearRight) }, 0)
|
||
|
||
// Beides zusammen: Das tiefste Rad braucht die Summe, das höchste nichts, und
|
||
// die beiden dazwischen je einen Anteil. Genau das lässt sich mit getrennten
|
||
// Angaben für "rechts" und "vorne" nicht ausdrücken.
|
||
let liftBoth = LevelingLift.compute(pitch: 1.5, roll: 2.0, trackWidth: 2.0, wheelbase: 3.5)
|
||
checkEqual("kombiniert: tiefstes Rad ist vorne links", liftBoth?.deepest, .frontLeft)
|
||
checkEqual("kombiniert: vorne links trägt beide Anteile",
|
||
liftBoth.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 16.1)
|
||
checkEqual("kombiniert: vorne rechts nur den Längsanteil",
|
||
liftBoth.map { ($0.centimetres(.frontRight) * 10).rounded() / 10 }, 9.2)
|
||
checkEqual("kombiniert: hinten links nur den Queranteil",
|
||
liftBoth.map { ($0.centimetres(.rearLeft) * 10).rounded() / 10 }, 7.0)
|
||
checkEqual("kombiniert: hinten rechts steht am höchsten und bleibt liegen",
|
||
liftBoth.map { $0.centimetres(.rearRight) }, 0)
|
||
|
||
// Vorzeichen: andersherum geneigt, andere Ecke.
|
||
let mirrored = LevelingLift.compute(pitch: -1.5, roll: -2.0, trackWidth: 2.0, wheelbase: 3.5)
|
||
checkEqual("umgekehrt geneigt: tiefstes Rad ist hinten rechts",
|
||
mirrored?.deepest, .rearRight)
|
||
|
||
checkEqual("fast eben: kein Keil nötig",
|
||
LevelingLift.compute(pitch: 0.1, roll: 0.1,
|
||
trackWidth: 2.0, wheelbase: 3.5)?.isNegligible, true)
|
||
checkEqual("ohne Fahrzeugmasse keine Rechnung",
|
||
LevelingLift.compute(pitch: 2, roll: 2, trackWidth: 0, wheelbase: 3.5) == nil, true)
|
||
|
||
// MARK: 12 – Einbaulage des Neigungsmessers
|
||
print("\nEinbaulage")
|
||
|
||
typealias Reading = OrientationDetection.Reading
|
||
func detect(nose: Reading, side: Reading) -> Result<SensorOrientation, OrientationDetection.Failure> {
|
||
OrientationDetection.orientation(nose: nose, side: side)
|
||
}
|
||
|
||
// Gerade eingebaut: Kippen nach vorne bewegt die erste Achse ins Positive,
|
||
// Kippen nach links die zweite.
|
||
if case .success(let straight) = detect(nose: Reading(pitch: 12, roll: 0),
|
||
side: Reading(pitch: 0, roll: 9)) {
|
||
checkEqual("gerade: Längsneigung von der ersten Achse",
|
||
straight.longitudinalSource, .pitch)
|
||
checkEqual("gerade: längs nicht umgekehrt", straight.invertLongitudinal, false)
|
||
checkEqual("gerade: quer nicht umgekehrt", straight.invertLateral, false)
|
||
checkEqual("gerade ist die unveränderte Lage", straight.isIdentity, true)
|
||
checkEqual("Beschreibung", straight.summary, "Achsen unverändert")
|
||
} else {
|
||
check("gerade eingebaut wird erkannt", false)
|
||
}
|
||
|
||
// Um 180 Grad gedreht: beide Achsen kehren sich um.
|
||
if case .success(let turned) = detect(nose: Reading(pitch: -12, roll: 0),
|
||
side: Reading(pitch: 0, roll: -9)) {
|
||
checkEqual("gedreht: Achse bleibt", turned.longitudinalSource, .pitch)
|
||
checkEqual("gedreht: längs umgekehrt", turned.invertLongitudinal, true)
|
||
checkEqual("gedreht: quer umgekehrt", turned.invertLateral, true)
|
||
} else {
|
||
check("um 180 Grad gedreht wird erkannt", false)
|
||
}
|
||
|
||
// Quer eingebaut: die Achsen sind vertauscht.
|
||
if case .success(let sideways) = detect(nose: Reading(pitch: 0, roll: 11),
|
||
side: Reading(pitch: -8, roll: 0)) {
|
||
checkEqual("quer: Längsneigung von der zweiten Achse",
|
||
sideways.longitudinalSource, .roll)
|
||
checkEqual("quer: längs nicht umgekehrt", sideways.invertLongitudinal, false)
|
||
checkEqual("quer: quer umgekehrt", sideways.invertLateral, true)
|
||
checkEqual("Beschreibung nennt den Tausch",
|
||
sideways.summary.contains("getauscht"), true)
|
||
} else {
|
||
check("quer eingebaut wird erkannt", false)
|
||
}
|
||
|
||
// Zu wenig gekippt.
|
||
if case .failure(let failure) = detect(nose: Reading(pitch: 2, roll: 0),
|
||
side: Reading(pitch: 0, roll: 9)) {
|
||
checkEqual("zu wenig Bewegung wird gemeldet", failure, .tooLittleMovement)
|
||
} else {
|
||
check("zu wenig Bewegung wird abgefangen", false)
|
||
}
|
||
|
||
// Von Hand gekippt geht die andere Achse ein Stück mit. Einzeln betrachtet
|
||
// wäre das nicht zuzuordnen, im Paar schon.
|
||
if case .success(let sloppy) = detect(nose: Reading(pitch: 14, roll: 6),
|
||
side: Reading(pitch: -7, roll: 16)) {
|
||
checkEqual("mitlaufende Achse stört die Zuordnung nicht",
|
||
sloppy.longitudinalSource, .pitch)
|
||
checkEqual("mitlaufend: längs nicht umgekehrt", sloppy.invertLongitudinal, false)
|
||
checkEqual("mitlaufend: quer nicht umgekehrt", sloppy.invertLateral, false)
|
||
} else {
|
||
check("mitlaufende Achse wird verkraftet", false)
|
||
}
|
||
|
||
// Dasselbe bei quer eingebautem Sensor.
|
||
if case .success(let sloppySideways) = detect(nose: Reading(pitch: 6, roll: 14),
|
||
side: Reading(pitch: -16, roll: 7)) {
|
||
checkEqual("mitlaufend und quer: Achsen getauscht",
|
||
sloppySideways.longitudinalSource, .roll)
|
||
checkEqual("mitlaufend und quer: quer umgekehrt",
|
||
sloppySideways.invertLateral, true)
|
||
} else {
|
||
check("mitlaufende Achse bei quer eingebautem Sensor", false)
|
||
}
|
||
|
||
// Zweimal um dieselbe Achse gekippt ergibt keine Zuordnung.
|
||
if case .failure(let failure) = detect(nose: Reading(pitch: 10, roll: 0),
|
||
side: Reading(pitch: 9, roll: 0)) {
|
||
checkEqual("zweimal dieselbe Achse wird gemeldet", failure, .ambiguous)
|
||
} else {
|
||
check("zweimal dieselbe Achse wird abgefangen", false)
|
||
}
|
||
|
||
// Die Umrechnung selbst
|
||
var swapped = SensorOrientation()
|
||
swapped.longitudinalSource = .roll
|
||
swapped.invertLateral = true
|
||
let converted = swapped.apply(pitch: 3, roll: -2)
|
||
checkEqual("umgerechnet: längs kommt aus der zweiten Achse", converted.pitch, -2)
|
||
checkEqual("umgerechnet: quer ist umgekehrt", converted.roll, -3)
|
||
|
||
let untouched = SensorOrientation.identity.apply(pitch: 1.5, roll: -0.5)
|
||
checkEqual("unveränderte Lage lässt längs stehen", untouched.pitch, 1.5)
|
||
checkEqual("unveränderte Lage lässt quer stehen", untouched.roll, -0.5)
|
||
|
||
// Die Einbaulage liegt im Gerät, damit iPhone, Uhr und Android dieselbe sehen.
|
||
// Acht Byte, hin und zurück.
|
||
print("\nEinbaulage im Gerät")
|
||
|
||
var stored = SensorOrientation()
|
||
stored.longitudinalSource = .roll
|
||
stored.invertLongitudinal = true
|
||
stored.invertLateral = false
|
||
stored.twist = -17.5
|
||
|
||
let storedBytes = VanAlignProtocol.encoded(stored)
|
||
checkEqual("acht Byte lang", storedBytes.count, 8)
|
||
checkEqual("Version steht vorne", storedBytes.first, 1)
|
||
checkEqual("gelesen kommt dasselbe zurück", VanAlignProtocol.orientation(from: storedBytes), stored)
|
||
|
||
// Ein Gerät, in dem noch nie etwas stand, meldet Version 0. Das ist kein
|
||
// Fehler, sondern heisst "hier gilt, was die App hat" – und die schreibt sie
|
||
// dann hinauf.
|
||
checkEqual("Version 0 gilt als leer",
|
||
VanAlignProtocol.orientation(from: Data(repeating: 0, count: 8)) == nil, true)
|
||
checkEqual("zu kurze Antwort ergibt nichts",
|
||
VanAlignProtocol.orientation(from: Data([1, 0, 0, 0])) == nil, true)
|
||
|
||
// Die Feldbelegung byteweise festgenagelt: daran hängen drei Apps und die
|
||
// Firmware, das darf nicht unbemerkt verrutschen.
|
||
var plain = SensorOrientation()
|
||
plain.twist = 1
|
||
checkEqual("Feldbelegung", [UInt8](VanAlignProtocol.encoded(plain)),
|
||
[1, 0, 0, 0, 0x00, 0x00, 0x80, 0x3F]) // 1.0f little endian
|
||
|
||
var mounted = SensorOrientation()
|
||
mounted.longitudinalSource = .roll
|
||
mounted.invertLateral = true
|
||
checkEqual("Achsentausch und Vorzeichen an ihrem Platz",
|
||
[UInt8](VanAlignProtocol.encoded(mounted)).prefix(4).map { $0 }, [1, 1, 0, 1])
|
||
|
||
// Gespeicherte Einrichtungen müssen ältere Fassungen überleben. Ein neues Feld
|
||
// darf das Lesen nicht scheitern lassen – sonst ist die Geräteliste leer, und
|
||
// das nächste Speichern schreibt diese Leere über den Bestand.
|
||
print("\nGespeicherte Geräte lesen")
|
||
|
||
func decodeDevice(_ json: String) -> ConfiguredDevice? {
|
||
try? JSONDecoder().decode(ConfiguredDevice.self, from: Data(json.utf8))
|
||
}
|
||
|
||
let deviceBeforeTwist = """
|
||
{"role":"leveling","profileID":"00000000-0000-0000-0000-00000000C001",
|
||
"name":"Nivellierung","id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B5",
|
||
"advertisedName":"vanalign","fridgeZoneMode":"automatic",
|
||
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DA",
|
||
"sensorOrientation":{"invertLongitudinal":false,"invertLateral":true,
|
||
"longitudinalSource":"roll"}}
|
||
"""
|
||
let restoredDevice = decodeDevice(deviceBeforeTwist)
|
||
checkEqual("Gerät ohne twist lässt sich weiterhin lesen", restoredDevice?.name, "Nivellierung")
|
||
checkEqual("die bekannten Felder der Einbaulage bleiben erhalten",
|
||
restoredDevice?.sensorOrientation.longitudinalSource, .roll)
|
||
checkEqual("das neue Feld fällt auf null zurück", restoredDevice?.sensorOrientation.twist, 0)
|
||
|
||
let deviceWithoutOrientation = """
|
||
{"role":"bms","profileID":"00000000-0000-0000-0000-00000000C001","name":"Bulltron",
|
||
"id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B6",
|
||
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DB"}
|
||
"""
|
||
checkEqual("Gerät ganz ohne Einbaulage ebenso",
|
||
decodeDevice(deviceWithoutOrientation)?.sensorOrientation, .identity)
|
||
|
||
checkEqual("eine unbrauchbare Einbaulage bleibt ein Fehler",
|
||
decodeDevice("""
|
||
{"role":"bms","profileID":"00000000-0000-0000-0000-00000000C001","name":"Kaputt",
|
||
"id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B7",
|
||
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DC",
|
||
"sensorOrientation":{"longitudinalSource":"quer"}}
|
||
""") == nil, true)
|
||
|
||
// Schräg eingebauter Sensor: um 20 Grad um die Hochachse verdreht. Eine reine
|
||
// Kippbewegung nach vorne verteilt sich dann auf beide Sensorachsen.
|
||
let twistAngle = 20.0
|
||
func twisted(_ longitudinal: Double, _ lateral: Double) -> OrientationDetection.Reading {
|
||
// Umgekehrter Weg zu SensorOrientation.apply: aus Fahrzeugwerten wird das,
|
||
// was ein verdrehter Sensor melden würde.
|
||
let a = twistAngle * .pi / 180
|
||
return OrientationDetection.Reading(pitch: longitudinal * cos(a) - lateral * sin(a),
|
||
roll: longitudinal * sin(a) + lateral * cos(a))
|
||
}
|
||
|
||
let noseTwisted = twisted(10, 0) // Front nach unten, sauber um die Querachse
|
||
let sideTwisted = twisted(0, 10) // linke Seite nach unten
|
||
|
||
if case .success(let skew) = detect(nose: noseTwisted, side: sideTwisted) {
|
||
checkEqual("Verdrehung wird erkannt", (skew.twist * 10).rounded() / 10, 20.0)
|
||
checkEqual("Achsen bleiben dabei zugeordnet", skew.longitudinalSource, .pitch)
|
||
|
||
// Die Probe: zurückgerechnet muss aus der Frontbewegung reine Längsneigung
|
||
// werden und aus der Seitenbewegung reine Querneigung.
|
||
let front = skew.apply(pitch: noseTwisted.pitch, roll: noseTwisted.roll)
|
||
checkEqual("verdreht gemessene Frontbewegung wird reine Längsneigung",
|
||
front.pitch.map { ($0 * 10).rounded() / 10 }, 10.0)
|
||
checkEqual("und hat keine Querneigung mehr",
|
||
front.roll.map { abs($0) < 0.001 }, true)
|
||
|
||
let side = skew.apply(pitch: sideTwisted.pitch, roll: sideTwisted.roll)
|
||
checkEqual("verdreht gemessene Seitenbewegung wird reine Querneigung",
|
||
side.roll.map { ($0 * 10).rounded() / 10 }, 10.0)
|
||
checkEqual("und hat keine Längsneigung mehr",
|
||
side.pitch.map { abs($0) < 0.001 }, true)
|
||
} else {
|
||
check("verdrehter Einbau wird ausgewertet", false)
|
||
}
|
||
|
||
// Genau das, was vorher fehlte: ohne Korrektur schlägt eine reine Querneigung
|
||
// auf die Längsanzeige durch – bei 20 Grad Verdrehung mit gut einem Drittel.
|
||
let uncorrected = SensorOrientation.identity.apply(pitch: sideTwisted.pitch,
|
||
roll: sideTwisted.roll)
|
||
checkEqual("ohne Korrektur kippt die Längsanzeige mit",
|
||
uncorrected.pitch.map { ($0 * 10).rounded() / 10 }, -3.4)
|
||
|
||
// Ein bisschen Wackeln ist keine Verdrehung.
|
||
if case .success(let steady) = detect(nose: Reading(pitch: 10, roll: 0.2),
|
||
side: Reading(pitch: 0.2, roll: 10)) {
|
||
checkEqual("kleiner Rest gilt als Rauschen, nicht als Verdrehung", steady.twist, 0)
|
||
} else {
|
||
check("saubere Kippbewegung wird ausgewertet", false)
|
||
}
|
||
|
||
let missing = SensorOrientation.identity.apply(pitch: nil, roll: 2)
|
||
checkEqual("fehlende Werte bleiben leer", missing.pitch == nil, true)
|
||
checkEqual("vorhandene Werte kommen durch", missing.roll, 2)
|
||
|
||
// MARK: 13 – Strecke zur Apple Watch
|
||
|
||
print("\nApple Watch – Datensatz und Befehle")
|
||
|
||
let watchProfile = Profile(name: "Kastenwagen", symbol: "box.truck",
|
||
trackWidth: 1.85, wheelbase: 3.50)
|
||
let watchLevelID = UUID()
|
||
let watchFridgeID = UUID()
|
||
let watchBoosterID = UUID()
|
||
|
||
var watchLevel = LevelState()
|
||
watchLevel.pitch = 1.8
|
||
watchLevel.roll = -0.9
|
||
watchLevel.pitchOffset = 0.4
|
||
watchLevel.rollOffset = -0.2
|
||
|
||
// Feste Zeitstempel, damit der Vergleich unten wirklich den Inhalt prüft: über
|
||
// die Strecke bleibt die Millisekunde stehen, ein `Date()` wäre feiner.
|
||
let watchStamp = Date(timeIntervalSince1970: 1_699_999_999)
|
||
|
||
var watchLevelSnapshot = watchLevel.snapshot(deviceID: watchLevelID, rssi: -70)
|
||
watchLevelSnapshot.timestamp = watchStamp
|
||
|
||
var boosterSnapshot = DeviceSnapshot(deviceID: watchBoosterID, timestamp: watchStamp, rssi: -71)
|
||
boosterSnapshot.state = "Aus"
|
||
boosterSnapshot.offReasons = ["Keine Eingangsspannung"]
|
||
boosterSnapshot.metrics = [
|
||
Metric("output_voltage", "Ausgang", 14.09, unit: "V", precision: 2, primary: true),
|
||
Metric("input_voltage", "Eingang", 12.42, unit: "V", precision: 2),
|
||
]
|
||
|
||
// Quer eingebauter Sensor: Die Uhr rechnet damit selbst, also muss die
|
||
// Zuordnung mit über die Strecke.
|
||
var watchOrientation = SensorOrientation()
|
||
watchOrientation.longitudinalSource = .roll
|
||
watchOrientation.invertLateral = true
|
||
|
||
let watchFridge = WatchFridge(isLive: true,
|
||
updated: Date(timeIntervalSince1970: 1_699_999_000),
|
||
isPoweredOn: true, isEco: true, isLocked: false,
|
||
isDualZone: false, unitSymbol: "°C",
|
||
leftTarget: 4, leftCurrent: 6,
|
||
rightTarget: nil, rightCurrent: nil,
|
||
minTarget: -20, maxTarget: 20, batteryVolts: 12.7)
|
||
|
||
let watchPayload = WatchPayload(
|
||
generatedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||
profile: watchProfile,
|
||
isRadioReady: true,
|
||
radioStatus: "Bereit",
|
||
devices: [
|
||
WatchDevice(id: watchLevelID, name: "Nivellierung", role: .leveling,
|
||
link: .live, snapshot: watchLevelSnapshot,
|
||
level: watchLevel, orientation: watchOrientation, fridge: nil),
|
||
WatchDevice(id: watchBoosterID, name: "Ladebooster", role: .chargeBooster,
|
||
link: .failed("Nicht gefunden"), snapshot: boosterSnapshot,
|
||
level: nil, orientation: nil, fridge: nil),
|
||
WatchDevice(id: watchFridgeID, name: "Kühlbox", role: .fridge,
|
||
link: .live, snapshot: nil, level: nil,
|
||
orientation: nil, fridge: watchFridge),
|
||
])
|
||
|
||
// Der ganze Weg: verpacken, als WatchConnectivity-Wörterbuch lesen, vergleichen.
|
||
if let message = try? watchPayload.message(),
|
||
let restored = WatchPayload.decode(from: message) {
|
||
checkEqual("Datensatz kommt unverändert an", restored, watchPayload)
|
||
checkEqual("Zeitstempel überlebt das Verpacken",
|
||
restored.generatedAt.timeIntervalSince1970, 1_700_000_000)
|
||
checkEqual("Fehlerklartext bleibt am Gerät",
|
||
restored.devices[1].link, .failed("Nicht gefunden"))
|
||
checkEqual("Neigung wird gefunden", restored.levelDevice?.id, watchLevelID)
|
||
checkEqual("Kühlbox wird gefunden", restored.fridgeDevice?.id, watchFridgeID)
|
||
checkEqual("Sollwert der Box überlebt", restored.fridgeDevice?.fridge?.leftTarget, 4)
|
||
checkEqual("Fahrzeugmasse kommen mit", restored.profile.trackWidth, 1.85)
|
||
checkEqual("Einbaulage kommt mit – die Uhr misst selbst",
|
||
restored.levelDevice?.orientation?.longitudinalSource, .roll)
|
||
checkEqual("Umkehrung der Querachse ebenfalls",
|
||
restored.levelDevice?.orientation?.invertLateral, true)
|
||
} else {
|
||
check("Datensatz lässt sich verpacken und wieder lesen", false)
|
||
}
|
||
|
||
// Eine Uhr mit älterer App darf einen neueren Datensatz nicht halb verstehen.
|
||
var futurePayload = watchPayload
|
||
futurePayload.version = WatchPayload.currentVersion + 1
|
||
if let message = try? futurePayload.message() {
|
||
checkEqual("fremde Formatversion wird abgelehnt",
|
||
WatchPayload.decode(from: message) == nil, true)
|
||
} else {
|
||
check("fremde Formatversion lässt sich verpacken", false)
|
||
}
|
||
|
||
// Zeitpunkte reisen als Millisekunden seit 1970 in einer Fliesskommazahl. Auf
|
||
// die Millisekunde genau kommen sie zurück, aufs letzte Bit nicht – deshalb
|
||
// vergleicht der Test oben feste Zeitstempel und nicht ein `Date()`.
|
||
var preciseSnapshot = boosterSnapshot
|
||
preciseSnapshot.timestamp = Date(timeIntervalSince1970: 1_699_999_999.123_456)
|
||
var precisePayload = watchPayload
|
||
precisePayload.devices[1].snapshot = preciseSnapshot
|
||
if let message = try? precisePayload.message(),
|
||
let restored = WatchPayload.decode(from: message)?.devices[1].snapshot?.timestamp {
|
||
checkEqual("feiner Zeitstempel kommt auf die Millisekunde genau zurück",
|
||
abs(restored.timeIntervalSince1970 - 1_699_999_999.123_456) < 0.001, true)
|
||
} else {
|
||
check("Datensatz mit feinem Zeitstempel lässt sich verpacken", false)
|
||
}
|
||
|
||
checkEqual("leeres Wörterbuch ergibt keinen Datensatz",
|
||
WatchPayload.decode(from: [:]) == nil, true)
|
||
checkEqual("Unsinn im Nutzdatenfeld ergibt keinen Datensatz",
|
||
WatchPayload.decode(from: [WatchLink.payloadKey: Data([0x00, 0x01])]) == nil, true)
|
||
|
||
// Der Vergleich entscheidet, ob überhaupt gefunkt wird: der Zeitstempel allein
|
||
// darf keinen Funkverkehr auslösen, ein geänderter Messwert schon.
|
||
var laterPayload = watchPayload
|
||
laterPayload.generatedAt = watchPayload.generatedAt.addingTimeInterval(30)
|
||
checkEqual("gleicher Inhalt, neuer Zeitstempel gilt als unverändert",
|
||
watchPayload.hasSameContent(as: laterPayload), true)
|
||
|
||
var changedPayload = laterPayload
|
||
changedPayload.devices[2].fridge?.leftTarget = 5
|
||
checkEqual("geänderter Sollwert gilt als Änderung",
|
||
watchPayload.hasSameContent(as: changedPayload), false)
|
||
|
||
// Befehle in die Gegenrichtung.
|
||
let commands: [WatchCommand] = [
|
||
.hello(live: true),
|
||
.hello(live: false),
|
||
.fridgePower(device: watchFridgeID, on: false),
|
||
.fridgeEco(device: watchFridgeID, eco: true),
|
||
.fridgeLock(device: watchFridgeID, locked: true),
|
||
.fridgeTarget(device: watchFridgeID, zone: .right, value: -3),
|
||
.fridgeSession(device: watchFridgeID, wanted: true),
|
||
.fridgeSession(device: watchFridgeID, wanted: false),
|
||
]
|
||
for command in commands {
|
||
guard let message = try? command.message(),
|
||
let restored = WatchCommand.decode(from: message) else {
|
||
check("Befehl \(command) lässt sich verpacken", false)
|
||
continue
|
||
}
|
||
checkEqual("Befehl kommt unverändert an", restored, command)
|
||
}
|
||
|
||
checkEqual("Anmeldung ohne Hinschauen verlängert den schnellen Takt nicht",
|
||
WatchCommand.hello(live: false).wantsLiveUpdates, false)
|
||
checkEqual("wer stellt, schaut auch hin",
|
||
WatchCommand.fridgePower(device: watchFridgeID, on: true).wantsLiveUpdates, true)
|
||
checkEqual("ein Datensatz ist kein Befehl",
|
||
WatchCommand.decode(from: (try? watchPayload.message()) ?? [:]) == nil, true)
|
||
|
||
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
|
||
exit(failures == 0 ? 0 : 1)
|