This commit is contained in:
BiasF
2026-08-30 10:36:52 +02:00
commit fdcc644828
30 changed files with 4152 additions and 0 deletions
@@ -0,0 +1,279 @@
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
}
}