Files
Camper-Monitor/Tests/main.swift
T
BiasFandClaude Opus 5 58de005fa7 Kühlbox: Einstellungsblock in 20-Byte-Stücken schreiben
Die Ursache, belegt statt vermutet. Im Referenzprojekt gibt es einen
Bericht von genau dieser Box - einer Maentum/Plug-in Festival IceCube Dual
(Gruni22/alpicool_ha_ble#20). Dort galt: Solltemperatur ändern geht,
Ein- und Ausschalten nicht. Genau unser Bild.

Der Grund ist nicht das Paket, sondern der Schreibvorgang. Die Box nimmt
nur die 20 Nutzbytes der Standard-MTU je Schreibvorgang an. Der kurze
Temperaturbefehl hat sieben Byte und geht durch, der Einstellungsblock hat
31 und wird abgelehnt. Wir haben bisher nach der ausgehandelten MTU
aufgeteilt - und die ist unter iOS weit grösser als 20, also wurde nie
aufgeteilt. Jetzt gilt für die Kühlbox die Grenze der Box, nicht die der
Verbindung: 31 Byte gehen als 20 und 11 raus, mit der üblichen Pause
dazwischen.

Das mitgeschnittene Paket aus dem Bericht steht als Prüfung im Testlauf.
Unsere Erzeugung trifft es byteweise - Längenbyte, Datenblock, rechte
Zone, Prüfsumme -, es scheiterte also wirklich nur am Schreiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:58:47 +02:00

890 lines
44 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
// Der Befehl folgt der Länge, in der die Box selbst antwortet nicht dem,
// was die Anzeige aus dem leeren zweiten Block macht. Eine Box, die den
// langen Datensatz sendet, nimmt nur den langen Befehl an.
checkEqual("langer Datensatz heisst langer Stellbefehl",
padded.settingsCommand(poweredOn: false)?.count, 31)
checkEqual("die Anzeige bleibt trotzdem einzonig", padded.isDualZone, false)
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)
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")
// tan(2°) x 2,00 m = 6,99 cm
let acrossWedge = LevelingWedge.across(roll: 2.0, trackWidth: 2.0)
checkEqual("Keilhöhe quer",
acrossWedge.map { ($0.heightInCentimetres * 10).rounded() / 10 }, 7.0)
checkEqual("rechts höher heisst Keil nach links", acrossWedge?.side, .left)
checkEqual("links höher heisst Keil nach rechts",
LevelingWedge.across(roll: -2.0, trackWidth: 2.0)?.side, .right)
// tan(1,5°) x 3,50 m = 9,16 cm
let alongWedge = LevelingWedge.along(pitch: 1.5, wheelbase: 3.5)
checkEqual("Keilhöhe längs",
alongWedge.map { ($0.heightInCentimetres * 10).rounded() / 10 }, 9.2)
checkEqual("Heck höher heisst Keil nach vorne", alongWedge?.side, .front)
checkEqual("Front höher heisst Keil nach hinten",
LevelingWedge.along(pitch: -1.5, wheelbase: 3.5)?.side, .rear)
checkEqual("innerhalb der Toleranz kein Keil",
LevelingWedge.across(roll: 0.3, trackWidth: 2.0) == nil, true)
checkEqual("ohne Maß kein Keil",
LevelingWedge.across(roll: 3.0, trackWidth: 0) == 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)
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)
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
exit(failures == 0 ? 0 : 1)