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