import Foundation /// Dekodiert die "Instant Readout"-Werbedaten von Victron-Geräten. /// /// Aufbau der Herstellerdaten (inkl. der zwei Bytes Company-ID, die /// CoreBluetooth mitliefert): /// /// [0..1] E1 02 Company-ID 0x02E1 (Victron Energy) /// [2..3] 10 00 Record-Typ "Product Advertisement", 16 Bit /// [4..5] ll hh Produkt-ID, little-endian /// [6] rr Art des Datensatzes (Solarlader, DC/DC, …) /// [7..8] ll hh Nonce / Zähler, little-endian /// [9] kk Erstes Byte des Geräteschlüssels (Prüfbyte) /// [10..] Mit AES-128-CTR verschlüsselte Nutzdaten /// /// Die Aufteilung ist an einem Orion XS belegt: Datensatztyp 0x0F passt zum /// Gerät, das Prüfbyte zum hinterlegten Schlüssel, und die verbleibenden /// 14 Byte entsprechen genau der Länge eines Orion-XS-Datensatzes. /// /// Der Schlüssel stammt aus VictronConnect /// (Gerät → Zahnrad → ⋮ → Produkt-Info → Verschlüsselungsdaten). enum VictronAdvertisement { static let companyIdentifier: UInt16 = 0x02E1 enum RecordType: UInt8 { case solarCharger = 0x01 case batteryMonitor = 0x02 case inverter = 0x03 case dcdcConverter = 0x04 case smartLithium = 0x05 case inverterRS = 0x06 case gxDevice = 0x07 case acCharger = 0x08 case smartBatteryProtect = 0x09 case lynxSmartBMS = 0x0A case multiRS = 0x0B case veBus = 0x0C case dcEnergyMeter = 0x0D case orionXS = 0x0F } enum DecodeError: Error, LocalizedError { case notVictron case malformed case keyMismatch(expected: UInt8, got: UInt8) case decryptionFailed case unsupportedRecord(UInt8) var errorDescription: String? { switch self { case .notVictron: return "Kein Victron-Advertisement" case .malformed: return "Advertisement zu kurz" case .keyMismatch(let expected, let got): return String(format: "Schlüssel passt nicht: Das Gerät sendet 0x%02X als erstes Byte, " + "der eingetragene Schlüssel beginnt mit 0x%02X.", expected, got) case .decryptionFailed: return "Entschlüsselung fehlgeschlagen" case .unsupportedRecord(let r): return String(format: "Datensatz-Typ 0x%02X wird nicht unterstützt", r) } } } /// Der unverschlüsselte Rahmen – lässt sich auch ohne Schlüssel lesen und /// wird beim Einrichten benutzt, um Victron-Geräte zu erkennen. struct Envelope { let productID: UInt16 let recordType: UInt8 let nonce: UInt16 let keyCheckByte: UInt8 let ciphertext: [UInt8] var knownRecord: RecordType? { RecordType(rawValue: recordType) } var productIDText: String { String(format: "0x%04X", productID) } } static func envelope(from manufacturerData: Data) -> Envelope? { let bytes = [UInt8](manufacturerData) guard bytes.count >= 11 else { return nil } let company = UInt16(bytes[0]) | (UInt16(bytes[1]) << 8) guard company == companyIdentifier, bytes[2] == 0x10 else { return nil } return Envelope( productID: UInt16(bytes[4]) | (UInt16(bytes[5]) << 8), recordType: bytes[6], nonce: UInt16(bytes[7]) | (UInt16(bytes[8]) << 8), keyCheckByte: bytes[9], ciphertext: Array(bytes[10...]) ) } /// Entschlüsselt und interpretiert ein Advertisement. static func decode(manufacturerData: Data, key: [UInt8], deviceID: UUID, rssi: Int?) throws -> DeviceSnapshot { guard let envelope = envelope(from: manufacturerData) else { throw DecodeError.notVictron } guard key.count == 16 else { throw DecodeError.malformed } guard key[0] == envelope.keyCheckByte else { throw DecodeError.keyMismatch(expected: envelope.keyCheckByte, got: key[0]) } // Zählerblock: Nonce little-endian in den ersten zwei Bytes, Rest null. var counter = [UInt8](repeating: 0, count: 16) counter[0] = UInt8(envelope.nonce & 0xFF) counter[1] = UInt8(envelope.nonce >> 8) guard let plain = AESCounterMode.crypt(envelope.ciphertext, key: key, nonce: counter) else { throw DecodeError.decryptionFailed } var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi) switch envelope.knownRecord { case .solarCharger: fill(solarCharger: plain, into: &snapshot) case .dcdcConverter: fill(dcdcConverter: plain, into: &snapshot) case .orionXS: fill(orionXS: plain, into: &snapshot) case .batteryMonitor: fill(batteryMonitor: plain, into: &snapshot) case .acCharger: fill(acCharger: plain, into: &snapshot) default: throw DecodeError.unsupportedRecord(envelope.recordType) } return snapshot } // MARK: - Datensätze /// 0x01 – Solarladeregler (SmartSolar / BlueSolar MPPT). private static func fill(solarCharger bytes: [UInt8], into snapshot: inout DeviceSnapshot) { var r = BitReader(bytes) let state = r.readOptional(8) let error = r.readOptional(8) let batteryVoltage = r.scaledSigned(16, 0.01) let batteryCurrent = r.scaledSigned(16, 0.1) let yieldToday = r.scaled(16, 0.01) let pvPower = r.scaled(16, 1) let loadCurrent = r.scaled(9, 0.1) snapshot.state = VictronCodes.deviceState(state) snapshot.fault = VictronCodes.chargerError(error) snapshot.metrics = [ Metric("pv_power", "PV-Leistung", pvPower, unit: "W", precision: 0, primary: true), Metric("battery_voltage", "Batteriespannung", batteryVoltage, unit: "V", precision: 2), Metric("battery_current", "Ladestrom", batteryCurrent, unit: "A", precision: 1), Metric("yield_today", "Ertrag heute", yieldToday, unit: "kWh", precision: 2), Metric("load_current", "Laststrom", loadCurrent, unit: "A", precision: 1), ] if let v = batteryVoltage, let a = batteryCurrent { snapshot.metrics.insert( Metric("battery_power", "Ladeleistung", v * a, unit: "W", precision: 0), at: 1) } } /// 0x04 – DC/DC-Wandler (Orion-TR Smart Ladebooster). private static func fill(dcdcConverter bytes: [UInt8], into snapshot: inout DeviceSnapshot) { var r = BitReader(bytes) let state = r.readOptional(8) let error = r.readOptional(8) let inputVoltage = r.scaled(16, 0.01) let outputVoltage = r.scaledSigned(16, 0.01) let offReason = r.readOptional(32) snapshot.state = VictronCodes.deviceState(state) snapshot.fault = VictronCodes.chargerError(error) snapshot.offReasons = VictronCodes.offReasons(offReason) snapshot.metrics = [ Metric("output_voltage", "Ausgang (Aufbaubatterie)", outputVoltage, unit: "V", precision: 2, primary: true), Metric("input_voltage", "Eingang (Starterbatterie)", inputVoltage, unit: "V", precision: 2), ] } /// 0x0F – Orion XS. Sendet im Gegensatz zum Orion-TR auch Ströme. private static func fill(orionXS bytes: [UInt8], into snapshot: inout DeviceSnapshot) { var r = BitReader(bytes) let state = r.readOptional(8) let error = r.readOptional(8) let outputVoltage = r.scaled(16, 0.01) let outputCurrent = r.scaledSigned(16, 0.1) let inputVoltage = r.scaled(16, 0.01) let inputCurrent = r.scaledSigned(16, 0.1) let offReason = r.readOptional(32) snapshot.state = VictronCodes.deviceState(state) snapshot.fault = VictronCodes.chargerError(error) snapshot.offReasons = VictronCodes.offReasons(offReason) var metrics: [Metric] = [] if let v = outputVoltage, let a = outputCurrent { metrics.append(Metric("output_power", "Ladeleistung", v * a, unit: "W", precision: 0, primary: true)) } metrics += [ Metric("output_voltage", "Ausgang (Aufbaubatterie)", outputVoltage, unit: "V", precision: 2, primary: outputCurrent == nil), Metric("output_current", "Ladestrom", outputCurrent, unit: "A", precision: 1), Metric("input_voltage", "Eingang (Starterbatterie)", inputVoltage, unit: "V", precision: 2), Metric("input_current", "Eingangsstrom", inputCurrent, unit: "A", precision: 1), ] snapshot.metrics = metrics } /// 0x08 – AC-Ladegerät (Blue Smart IP65/IP22), falls im Camper verbaut. private static func fill(acCharger bytes: [UInt8], into snapshot: inout DeviceSnapshot) { var r = BitReader(bytes) let state = r.readOptional(8) let error = r.readOptional(8) let voltage1 = r.scaled(13, 0.01) let current1 = r.scaled(11, 0.1) let voltage2 = r.scaled(13, 0.01) let current2 = r.scaled(11, 0.1) let voltage3 = r.scaled(13, 0.01) let current3 = r.scaled(11, 0.1) let temperature = r.scaled(7, 1) let acCurrent = r.scaled(9, 0.1) snapshot.state = VictronCodes.deviceState(state) snapshot.fault = VictronCodes.chargerError(error) snapshot.metrics = [ Metric("out1_voltage", "Ausgang 1 Spannung", voltage1, unit: "V", precision: 2, primary: true), Metric("out1_current", "Ausgang 1 Strom", current1, unit: "A", precision: 1), Metric("out2_voltage", "Ausgang 2 Spannung", voltage2, unit: "V", precision: 2), Metric("out2_current", "Ausgang 2 Strom", current2, unit: "A", precision: 1), Metric("out3_voltage", "Ausgang 3 Spannung", voltage3, unit: "V", precision: 2), Metric("out3_current", "Ausgang 3 Strom", current3, unit: "A", precision: 1), Metric("ac_current", "AC-Eingangsstrom", acCurrent, unit: "A", precision: 1), ] if let temperature { snapshot.temperatures = [temperature - 40] } } /// 0x02 – Batteriewächter (SmartShunt, BMV). private static func fill(batteryMonitor bytes: [UInt8], into snapshot: inout DeviceSnapshot) { var r = BitReader(bytes) let timeToGo = r.scaled(16, 1) // Minuten let voltage = r.scaledSigned(16, 0.01) let alarm = r.readOptional(16) let auxRaw = r.read(16) let auxType = r.read(2) let current = r.scaledSigned(22, 0.001) let consumedAh = r.scaled(20, 0.1) let soc = r.scaled(10, 0.1) snapshot.state = nil let alarms = VictronCodes.alarmReasons(alarm) snapshot.fault = alarms.isEmpty ? nil : alarms.joined(separator: ", ") var metrics: [Metric] = [ Metric("soc", "Ladezustand", soc, unit: "%", precision: 1, primary: true), Metric("voltage", "Spannung", voltage, unit: "V", precision: 2), Metric("current", "Strom", current, unit: "A", precision: 2), ] if let v = voltage, let a = current { metrics.append(Metric("power", "Leistung", v * a, unit: "W", precision: 0)) } // Entnommene Kapazität wird positiv gesendet, ist aber eine Entnahme. metrics.append(Metric("consumed", "Entnommen", consumedAh.map { -$0 }, unit: "Ah", precision: 1)) if let timeToGo, timeToGo < 65535 { metrics.append(Metric("ttg", "Restlaufzeit", timeToGo / 60, unit: "h", precision: 1)) } // Der Hilfseingang ist je nach Konfiguration Starterbatterie, // Mittenspannung oder Temperatur. if let auxRaw, let auxType, auxRaw != 0xFFFF { switch auxType { case 0: let starter = Double(Int16(bitPattern: UInt16(auxRaw))) * 0.01 metrics.append(Metric("aux_starter", "Starterbatterie", starter, unit: "V", precision: 2)) case 1: metrics.append(Metric("aux_mid", "Mittenspannung", Double(auxRaw) * 0.01, unit: "V", precision: 2)) case 2: snapshot.temperatures = [Double(auxRaw) * 0.01 - 273.15] default: break } } snapshot.metrics = metrics } }