forked from fritob/Camper-Monitor
Die Kalibrier-Nullpunkte lagen schon immer im ESP und wurden dort auch
abgezogen – nur verriet die Firmware sie niemandem. Die Einbaulage lag
dagegen in jeder App einzeln, und damit stand sie bei drei Endgeräten
dreimal da, im Zweifel dreimal verschieden. Sie beschreibt aber den
Einbau und nicht das Telefon.
Zwei neue Charakteristiken:
...3426 Nullpunkte, zwei Floats, nur lesen
...3428 Einbaulage, acht Byte, lesen und schreiben
0 Version, 1 = gültig gesetzt, 0 = nie geschrieben
1 Längsachse: 0 = Pitch des Sensors, 1 = Roll
2 längs umgekehrt (0/1)
3 quer umgekehrt (0/1)
4..7 Verdrehung um die Hochachse, float32, Grad
Das Versionsbyte löst die Umstellung: Steht dort 0, hat nie jemand
geschrieben – dann gilt weiter, was die App örtlich gespeichert hat, und
beim nächsten Bestimmen wandert sie hinauf. Mit älterer Firmware ohne die
Charakteristik passiert schlicht nichts.
Angewandt wird die Lage weiterhin in den Apps. Der Sensor verwahrt sie
nur: Meldete er die Winkel bereits umgerechnet, rechnete jede ältere App
die Korrektur ein zweites Mal ein.
Beim Verbinden liest jede App die Lage und übernimmt sie; nach dem
Einbaulage-Assistenten schreibt sie sie hinauf. Die Uhr braucht das
iPhone dafür nicht mehr – sie holt sie sich selbst. Die Android-App
bekommt dabei auch die Verdrehung, die ihrer Fassung bisher ganz fehlte:
Modell, Erkennung und Persistenz nachgezogen.
Geprüft: Firmware übersetzt, im erzeugten Code stehen beide
Charakteristiken mit den richtigen Rechten und der Schreib-Handler;
iPhone- und Watch-Ziel bauen; sieben neue Prüfungen in run-tests.sh
nageln das Byte-Format fest, samt Rundlauf und der Bedeutung von
Version 0. Nicht geprüft ist die Android-Fassung: Auf dieser Maschine
liegt nur Java 8, Gradle verlangt mindestens 11.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1251 lines
61 KiB
Swift
1251 lines
61 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 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: 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(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),
|
||
]
|
||
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)
|