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