Files
Camper-Monitor/CamperMonitor/Bluetooth/VictronAdvertisement.swift
T
2026-08-30 10:36:52 +02:00

280 lines
12 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
/// 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
}
}