init
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import CommonCrypto
|
||||
import Foundation
|
||||
|
||||
/// AES-128 im Counter-Modus. CryptoKit bietet CTR nicht an, deshalb CommonCrypto.
|
||||
enum AESCounterMode {
|
||||
/// - Parameters:
|
||||
/// - data: Der verschlüsselte Nutzteil des Advertisements.
|
||||
/// - key: 16 Byte Geräteschlüssel aus VictronConnect.
|
||||
/// - nonce: Der 16-Byte-Zählerblock (Victron: Nonce little-endian in den
|
||||
/// ersten beiden Bytes, Rest 0).
|
||||
static func crypt(_ data: [UInt8], key: [UInt8], nonce: [UInt8]) -> [UInt8]? {
|
||||
guard key.count == kCCKeySizeAES128, nonce.count == kCCBlockSizeAES128 else { return nil }
|
||||
|
||||
var cryptor: CCCryptorRef?
|
||||
let createStatus = key.withUnsafeBytes { keyBuffer in
|
||||
nonce.withUnsafeBytes { ivBuffer in
|
||||
CCCryptorCreateWithMode(
|
||||
CCOperation(kCCEncrypt), // CTR ist symmetrisch
|
||||
CCMode(kCCModeCTR),
|
||||
CCAlgorithm(kCCAlgorithmAES),
|
||||
CCPadding(ccNoPadding),
|
||||
ivBuffer.baseAddress,
|
||||
keyBuffer.baseAddress, key.count,
|
||||
nil, 0, 0,
|
||||
CCModeOptions(kCCModeOptionCTR_BE),
|
||||
&cryptor
|
||||
)
|
||||
}
|
||||
}
|
||||
guard createStatus == kCCSuccess, let cryptor else { return nil }
|
||||
defer { CCCryptorRelease(cryptor) }
|
||||
|
||||
var output = [UInt8](repeating: 0, count: data.count)
|
||||
var moved = 0
|
||||
let updateStatus = data.withUnsafeBytes { input in
|
||||
output.withUnsafeMutableBytes { out in
|
||||
CCCryptorUpdate(cryptor,
|
||||
input.baseAddress, data.count,
|
||||
out.baseAddress, data.count,
|
||||
&moved)
|
||||
}
|
||||
}
|
||||
guard updateStatus == kCCSuccess else { return nil }
|
||||
return Array(output.prefix(moved))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
/// Hält die GATT-Verbindung zu einem BMS, pollt die Werte und meldet fertige
|
||||
/// Snapshots zurück.
|
||||
///
|
||||
/// Unterstützt drei Dialekte und erkennt selbst, welchen das Gerät spricht:
|
||||
///
|
||||
/// * **Daly klassisch** – 13-Byte-Rahmen, beginnend mit `A5`
|
||||
/// * **Daly Modbus** – `D2 03 …`, neuere Daly-Firmware
|
||||
/// * **JBD / Xiaoxiang** – `DD A5 …`, u.a. in WattCycle-Akkus
|
||||
///
|
||||
/// Auch die BLE-Charakteristiken werden gesucht statt vorausgesetzt: die Module
|
||||
/// unterscheiden sich zwischen Herstellern und Fertigungschargen.
|
||||
final class BMSSession: NSObject {
|
||||
|
||||
/// Bekannte Dienste, in Reihenfolge der Wahrscheinlichkeit.
|
||||
private static let preferredServices: [CBUUID] = [
|
||||
CBUUID(string: "FFF0"), // Daly
|
||||
CBUUID(string: "FF00"), // JBD
|
||||
CBUUID(string: "FFE0"),
|
||||
CBUUID(string: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"), // Nordic UART
|
||||
]
|
||||
|
||||
enum Dialect: String {
|
||||
case unknown = "wird ermittelt"
|
||||
case dalyClassic = "Daly (klassisch)"
|
||||
case dalyModbus = "Daly (Modbus)"
|
||||
case jbd = "JBD / Xiaoxiang"
|
||||
}
|
||||
|
||||
let deviceID: UUID
|
||||
private let peripheral: CBPeripheral
|
||||
private let onUpdate: (DeviceSnapshot) -> Void
|
||||
private let onStateChange: (DeviceLinkState) -> Void
|
||||
private let onDiagnostics: (BMSDiagnostics) -> Void
|
||||
|
||||
private var writeCharacteristic: CBCharacteristic?
|
||||
private var notifyCharacteristic: CBCharacteristic?
|
||||
|
||||
private(set) var dialect: Dialect = .unknown
|
||||
private var dalyState = DalyState()
|
||||
private var jbdState = JBDState()
|
||||
private var buffer: [UInt8] = []
|
||||
private var pollTimer: Timer?
|
||||
private var silentRounds = 0
|
||||
private var lastResponse: Data?
|
||||
|
||||
/// Abstand zwischen zwei Abfragerunden.
|
||||
var pollInterval: TimeInterval = 5
|
||||
|
||||
init(deviceID: UUID,
|
||||
peripheral: CBPeripheral,
|
||||
onUpdate: @escaping (DeviceSnapshot) -> Void,
|
||||
onStateChange: @escaping (DeviceLinkState) -> Void,
|
||||
onDiagnostics: @escaping (BMSDiagnostics) -> Void) {
|
||||
self.deviceID = deviceID
|
||||
self.peripheral = peripheral
|
||||
self.onUpdate = onUpdate
|
||||
self.onStateChange = onStateChange
|
||||
self.onDiagnostics = onDiagnostics
|
||||
super.init()
|
||||
peripheral.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - Lebenszyklus
|
||||
|
||||
func start() {
|
||||
onStateChange(.connecting)
|
||||
peripheral.discoverServices(nil)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = nil
|
||||
if let notifyCharacteristic, peripheral.state == .connected {
|
||||
peripheral.setNotifyValue(false, for: notifyCharacteristic)
|
||||
}
|
||||
writeCharacteristic = nil
|
||||
notifyCharacteristic = nil
|
||||
dialect = .unknown
|
||||
buffer.removeAll()
|
||||
}
|
||||
|
||||
func handleDisconnect() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = nil
|
||||
writeCharacteristic = nil
|
||||
notifyCharacteristic = nil
|
||||
buffer.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Abfrage
|
||||
|
||||
private func beginPolling() {
|
||||
pollTimer?.invalidate()
|
||||
onStateChange(.live)
|
||||
poll()
|
||||
pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
|
||||
self?.poll()
|
||||
}
|
||||
}
|
||||
|
||||
private func poll() {
|
||||
guard peripheral.state == .connected, writeCharacteristic != nil else { return }
|
||||
|
||||
switch dialect {
|
||||
case .unknown:
|
||||
probeDialects()
|
||||
case .dalyClassic:
|
||||
sendSequence(DalyProtocol.Command.allCases.map { DalyProtocol.requestFrame($0) })
|
||||
case .dalyModbus:
|
||||
sendSequence([DalyProtocol.modbusReadFrame()])
|
||||
case .jbd:
|
||||
sendSequence(JBDProtocol.Command.allCases.map { JBDProtocol.requestFrame($0) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Nacheinander alle bekannten Anfragen schicken. Der erste gültige Rahmen
|
||||
/// in der Antwort legt den Dialekt fest.
|
||||
private func probeDialects() {
|
||||
let probes: [Data] = [
|
||||
DalyProtocol.requestFrame(.soc),
|
||||
JBDProtocol.requestFrame(.basicInfo),
|
||||
DalyProtocol.modbusReadFrame(),
|
||||
]
|
||||
for (index, probe) in probes.enumerated() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 1.5) { [weak self] in
|
||||
guard let self, self.dialect == .unknown else { return }
|
||||
self.send(probe)
|
||||
}
|
||||
}
|
||||
checkForSilence(after: Double(probes.count) * 1.5 + 1.5)
|
||||
}
|
||||
|
||||
/// Kommandos leicht versetzt senden – manche Module verschlucken Anfragen,
|
||||
/// die zu dicht aufeinander folgen.
|
||||
private func sendSequence(_ frames: [Data]) {
|
||||
for (index, frame) in frames.enumerated() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.25) { [weak self] in
|
||||
self?.send(frame)
|
||||
}
|
||||
}
|
||||
checkForSilence(after: Double(frames.count) * 0.25 + 2)
|
||||
}
|
||||
|
||||
/// Kommt mehrere Runden nichts Brauchbares zurück, wird der erkannte
|
||||
/// Dialekt verworfen und neu gesucht.
|
||||
private func checkForSilence(after delay: TimeInterval) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self else { return }
|
||||
guard !self.hasUsableData else {
|
||||
self.silentRounds = 0
|
||||
return
|
||||
}
|
||||
self.silentRounds += 1
|
||||
if self.silentRounds >= 3 {
|
||||
self.silentRounds = 0
|
||||
self.dialect = .unknown
|
||||
self.onStateChange(.failed("Keine verwertbare Antwort vom BMS"))
|
||||
self.publishDiagnostics()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hasUsableData: Bool {
|
||||
dalyState.hasUsableData || jbdState.hasUsableData
|
||||
}
|
||||
|
||||
private func send(_ data: Data) {
|
||||
guard let characteristic = writeCharacteristic else { return }
|
||||
let type: CBCharacteristicWriteType =
|
||||
characteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse
|
||||
peripheral.writeValue(data, for: characteristic, type: type)
|
||||
}
|
||||
|
||||
// MARK: - Auswertung
|
||||
|
||||
private func consume(_ data: Data) {
|
||||
lastResponse = data
|
||||
buffer.append(contentsOf: [UInt8](data))
|
||||
if buffer.count > 512 { buffer.removeFirst(buffer.count - 512) }
|
||||
|
||||
// JBD zuerst: der Rahmen ist durch Start-, Endbyte und Prüfsumme
|
||||
// eindeutig und kann nicht mit den Daly-Rahmen verwechselt werden.
|
||||
let (jbdFrames, jbdRemainder) = JBDProtocol.extractFrames(from: buffer)
|
||||
if !jbdFrames.isEmpty {
|
||||
buffer = jbdRemainder
|
||||
dialect = .jbd
|
||||
silentRounds = 0
|
||||
for frame in jbdFrames { jbdState.apply(frame) }
|
||||
publish(jbdState.snapshot(deviceID: deviceID, rssi: nil), usable: jbdState.hasUsableData)
|
||||
return
|
||||
}
|
||||
|
||||
if let start = buffer.firstIndex(where: { $0 == 0xD2 }),
|
||||
let registers = DalyProtocol.parseModbusResponse(Array(buffer[start...])) {
|
||||
buffer.removeAll()
|
||||
dialect = .dalyModbus
|
||||
silentRounds = 0
|
||||
dalyState.apply(registers: registers)
|
||||
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
|
||||
return
|
||||
}
|
||||
|
||||
let (dalyFrames, dalyRemainder) = DalyProtocol.extractA5Frames(from: buffer)
|
||||
if !dalyFrames.isEmpty {
|
||||
buffer = dalyRemainder
|
||||
dialect = .dalyClassic
|
||||
silentRounds = 0
|
||||
for frame in dalyFrames { dalyState.apply(frame) }
|
||||
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
|
||||
return
|
||||
}
|
||||
|
||||
// Nichts erkannt – trotzdem melden, damit die Diagnose etwas zeigt.
|
||||
publishDiagnostics()
|
||||
}
|
||||
|
||||
private func publish(_ snapshot: DeviceSnapshot, usable: Bool) {
|
||||
publishDiagnostics()
|
||||
guard usable else { return }
|
||||
onStateChange(.live)
|
||||
onUpdate(snapshot)
|
||||
}
|
||||
|
||||
private func publishDiagnostics() {
|
||||
onDiagnostics(BMSDiagnostics(
|
||||
dialect: dialect.rawValue,
|
||||
serviceUUID: writeCharacteristic?.service?.uuid.uuidString,
|
||||
writeUUID: writeCharacteristic?.uuid.uuidString,
|
||||
notifyUUID: notifyCharacteristic?.uuid.uuidString,
|
||||
lastResponseHex: lastResponse.map { $0.map { String(format: "%02X", $0) }.joined(separator: " ") },
|
||||
updated: Date()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBPeripheralDelegate
|
||||
|
||||
extension BMSSession: CBPeripheralDelegate {
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
if let error {
|
||||
onStateChange(.failed(error.localizedDescription))
|
||||
return
|
||||
}
|
||||
for service in peripheral.services ?? [] {
|
||||
peripheral.discoverCharacteristics(nil, for: service)
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral,
|
||||
didDiscoverCharacteristicsFor service: CBService,
|
||||
error: Error?) {
|
||||
guard error == nil, let characteristics = service.characteristics else { return }
|
||||
|
||||
let writable = characteristics.first {
|
||||
$0.properties.contains(.write) || $0.properties.contains(.writeWithoutResponse)
|
||||
}
|
||||
let notifying = characteristics.first {
|
||||
$0.properties.contains(.notify) || $0.properties.contains(.indicate)
|
||||
}
|
||||
guard let writable, let notifying else { return }
|
||||
|
||||
// Einen bekannten Dienst immer bevorzugen, sonst den erstbesten nehmen.
|
||||
let isPreferred = Self.preferredServices.contains(service.uuid)
|
||||
let alreadyPreferred = writeCharacteristic
|
||||
.flatMap { $0.service?.uuid }
|
||||
.map { Self.preferredServices.contains($0) } ?? false
|
||||
guard writeCharacteristic == nil || (isPreferred && !alreadyPreferred) else { return }
|
||||
|
||||
writeCharacteristic = writable
|
||||
notifyCharacteristic = notifying
|
||||
peripheral.setNotifyValue(true, for: notifying)
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral,
|
||||
didUpdateNotificationStateFor characteristic: CBCharacteristic,
|
||||
error: Error?) {
|
||||
if let error {
|
||||
onStateChange(.failed(error.localizedDescription))
|
||||
return
|
||||
}
|
||||
if characteristic.isNotifying, characteristic == notifyCharacteristic {
|
||||
publishDiagnostics()
|
||||
beginPolling()
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral,
|
||||
didUpdateValueFor characteristic: CBCharacteristic,
|
||||
error: Error?) {
|
||||
guard error == nil, let value = characteristic.value else { return }
|
||||
consume(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Liest Felder beliebiger Bitbreite aus einem Byte-Array.
|
||||
///
|
||||
/// Victron packt die Felder seiner Werbedaten little-endian und bitweise ohne
|
||||
/// Byte-Ausrichtung: das erste Feld beginnt am niederwertigsten Bit von Byte 0,
|
||||
/// jedes weitere schliesst direkt an.
|
||||
struct BitReader {
|
||||
private let bytes: [UInt8]
|
||||
private var bitOffset = 0
|
||||
|
||||
init(_ bytes: [UInt8]) { self.bytes = bytes }
|
||||
|
||||
var bitsRemaining: Int { bytes.count * 8 - bitOffset }
|
||||
|
||||
/// Liest `width` Bits als vorzeichenlose Zahl. Gibt nil zurück, wenn die
|
||||
/// Daten zu kurz sind.
|
||||
mutating func read(_ width: Int) -> UInt32? {
|
||||
guard width > 0, width <= 32, bitsRemaining >= width else { return nil }
|
||||
var result: UInt32 = 0
|
||||
for i in 0..<width {
|
||||
let absolute = bitOffset + i
|
||||
let byte = bytes[absolute / 8]
|
||||
let bit = (byte >> UInt8(absolute % 8)) & 1
|
||||
result |= UInt32(bit) << UInt32(i)
|
||||
}
|
||||
bitOffset += width
|
||||
return result
|
||||
}
|
||||
|
||||
/// Wie `read`, liefert aber nil wenn alle Bits gesetzt sind – so markiert
|
||||
/// Victron "Wert nicht verfügbar".
|
||||
mutating func readOptional(_ width: Int) -> UInt32? {
|
||||
guard let raw = read(width) else { return nil }
|
||||
let notAvailable: UInt32 = width >= 32 ? .max : (1 << UInt32(width)) - 1
|
||||
return raw == notAvailable ? nil : raw
|
||||
}
|
||||
|
||||
/// Zweierkomplement-Feld. Der NA-Wert 0x7F..F wird zu nil.
|
||||
mutating func readOptionalSigned(_ width: Int) -> Int32? {
|
||||
guard width > 1, let raw = read(width) else { return nil }
|
||||
let notAvailable: UInt32 = (1 << UInt32(width - 1)) - 1
|
||||
if raw == notAvailable { return nil }
|
||||
let signBit: UInt32 = 1 << UInt32(width - 1)
|
||||
if raw & signBit != 0 {
|
||||
let mask: UInt32 = width >= 32 ? 0 : ~((1 << UInt32(width)) - 1)
|
||||
return Int32(bitPattern: raw | mask)
|
||||
}
|
||||
return Int32(bitPattern: raw)
|
||||
}
|
||||
|
||||
/// Skaliertes, optionales Feld ohne Vorzeichen.
|
||||
mutating func scaled(_ width: Int, _ factor: Double) -> Double? {
|
||||
readOptional(width).map { Double($0) * factor }
|
||||
}
|
||||
|
||||
/// Skaliertes, optionales Feld mit Vorzeichen.
|
||||
mutating func scaledSigned(_ width: Int, _ factor: Double) -> Double? {
|
||||
readOptionalSigned(width).map { Double($0) * factor }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Ein während des Einrichtens gefundenes Gerät.
|
||||
struct Discovery: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
var name: String?
|
||||
/// Geglättet, damit die Signalanzeige nicht flackert.
|
||||
var rssi: Int
|
||||
/// Sortierschlüssel: die Reihenfolge des Auftauchens ist stabil,
|
||||
/// die Signalstärke wäre es nicht.
|
||||
var firstSeen: Date
|
||||
var lastSeen: Date
|
||||
var victronRecordType: UInt8?
|
||||
var victronProductID: UInt16?
|
||||
var looksLikeDaly: Bool
|
||||
|
||||
var isVictron: Bool { victronRecordType != nil }
|
||||
|
||||
var displayName: String {
|
||||
if let name, !name.isEmpty { return name }
|
||||
return isVictron ? "Victron-Gerät" : "Unbenanntes Gerät"
|
||||
}
|
||||
|
||||
var subtitle: String {
|
||||
if let type = victronRecordType {
|
||||
return "Victron · " + Self.victronRecordName(type)
|
||||
}
|
||||
if looksLikeDaly { return "Sieht nach Daly BMS aus" }
|
||||
return "Bluetooth-Gerät"
|
||||
}
|
||||
|
||||
static func victronRecordName(_ type: UInt8) -> String {
|
||||
switch VictronAdvertisement.RecordType(rawValue: type) {
|
||||
case .solarCharger: return "Solarladeregler"
|
||||
case .batteryMonitor: return "Batteriewächter"
|
||||
case .inverter: return "Wechselrichter"
|
||||
case .dcdcConverter: return "DC/DC-Lader (Orion-TR)"
|
||||
case .orionXS: return "Orion XS"
|
||||
case .acCharger: return "AC-Ladegerät"
|
||||
case .smartLithium: return "Smart Lithium"
|
||||
case .smartBatteryProtect: return "Battery Protect"
|
||||
case .lynxSmartBMS: return "Lynx Smart BMS"
|
||||
case .dcEnergyMeter: return "DC-Energiezähler"
|
||||
default: return String(format: "Typ 0x%02X", type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Was ein Victron-Gerät unverschlüsselt mitsendet. Wird unabhängig vom
|
||||
/// Schlüssel gefüllt und hilft, eine falsche Eingabe einzugrenzen.
|
||||
struct VictronDiagnostics: Hashable {
|
||||
var productID: UInt16
|
||||
var recordType: UInt8
|
||||
var expectedKeyFirstByte: UInt8
|
||||
var nonce: UInt16
|
||||
var payloadLength: Int
|
||||
var rawHex: String
|
||||
var updated: Date
|
||||
|
||||
var recordName: String { Discovery.victronRecordName(recordType) }
|
||||
var productIDText: String { String(format: "0x%04X", productID) }
|
||||
var expectedKeyText: String { String(format: "0x%02X", expectedKeyFirstByte) }
|
||||
}
|
||||
|
||||
/// Was bei der BMS-Verbindung erkannt wurde. Zeigt, ob und welches Protokoll
|
||||
/// greift, und gibt die letzte Rohantwort zum Nachsehen aus.
|
||||
struct BMSDiagnostics: Hashable {
|
||||
var dialect: String
|
||||
var serviceUUID: String?
|
||||
var writeUUID: String?
|
||||
var notifyUUID: String?
|
||||
var lastResponseHex: String?
|
||||
var updated: Date
|
||||
}
|
||||
|
||||
/// Ein Messpunkt für die Verlaufsgrafik.
|
||||
struct HistorySample: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let time: Date
|
||||
let value: Double
|
||||
}
|
||||
|
||||
/// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Victron-Werbedaten und
|
||||
/// hält parallel die Verbindung zum Daly-BMS.
|
||||
@Observable
|
||||
final class BluetoothManager: NSObject {
|
||||
|
||||
private(set) var snapshots: [UUID: DeviceSnapshot] = [:]
|
||||
private(set) var linkStates: [UUID: DeviceLinkState] = [:]
|
||||
private(set) var discoveries: [UUID: Discovery] = [:]
|
||||
private(set) var history: [UUID: [HistorySample]] = [:]
|
||||
private(set) var diagnostics: [UUID: VictronDiagnostics] = [:]
|
||||
private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
|
||||
private(set) var isBluetoothReady = false
|
||||
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
|
||||
|
||||
/// Solange true, werden alle gefundenen Peripherals gesammelt.
|
||||
var isDiscovering = false {
|
||||
didSet {
|
||||
restartScan()
|
||||
isDiscovering ? startDiscoveryFlush() : stopDiscoveryFlush()
|
||||
}
|
||||
}
|
||||
|
||||
private let store: DeviceStore
|
||||
private var central: CBCentralManager?
|
||||
private var bmsSessions: [UUID: BMSSession] = [:]
|
||||
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
|
||||
private var reconnectTimer: Timer?
|
||||
|
||||
/// Advertisements treffen mehrmals pro Sekunde und Gerät ein. Sie werden
|
||||
/// hier gesammelt und nur im Sekundentakt an die Ansicht durchgereicht –
|
||||
/// sonst baut sich die Liste unter dem Finger ständig neu auf.
|
||||
private var pendingDiscoveries: [UUID: Discovery] = [:]
|
||||
private var discoveryFlushTimer: Timer?
|
||||
|
||||
/// Wieviele Messpunkte je Gerät im Verlauf behalten werden.
|
||||
private let historyLimit = 720
|
||||
|
||||
/// Im Demo-Modus wird nichts gefunkt, die Werte kommen aus `DemoData`.
|
||||
private let isDemo = DemoData.isEnabled
|
||||
|
||||
init(store: DeviceStore) {
|
||||
self.store = store
|
||||
super.init()
|
||||
if isDemo {
|
||||
loadDemoData()
|
||||
return
|
||||
}
|
||||
central = CBCentralManager(delegate: self, queue: .main)
|
||||
}
|
||||
|
||||
private func loadDemoData() {
|
||||
isBluetoothReady = true
|
||||
bluetoothStatusText = "Demo-Modus"
|
||||
for snapshot in DemoData.snapshots() {
|
||||
snapshots[snapshot.deviceID] = snapshot
|
||||
linkStates[snapshot.deviceID] = .live
|
||||
if let value = snapshot.primaryMetric?.value {
|
||||
history[snapshot.deviceID] = DemoData.history(for: snapshot.deviceID, around: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Steuerung
|
||||
|
||||
func start() {
|
||||
guard !isDemo, central?.state == .poweredOn else { return }
|
||||
restartScan()
|
||||
connectManagedPeripherals()
|
||||
scheduleReconnects()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard !isDemo else { return }
|
||||
central?.stopScan()
|
||||
reconnectTimer?.invalidate()
|
||||
reconnectTimer = nil
|
||||
stopDiscoveryFlush()
|
||||
for (_, session) in bmsSessions { session.stop() }
|
||||
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
|
||||
bmsSessions.removeAll()
|
||||
connectedPeripherals.removeAll()
|
||||
}
|
||||
|
||||
/// Nach Änderungen an der Geräteliste aufrufen.
|
||||
func refreshConfiguration() {
|
||||
guard !isDemo else { return }
|
||||
let managed = Set(store.activeDevices.filter { $0.role.transport == .connect }.map(\.peripheralID))
|
||||
for (peripheralID, session) in bmsSessions where !managed.contains(peripheralID) {
|
||||
session.stop()
|
||||
bmsSessions[peripheralID] = nil
|
||||
if let peripheral = connectedPeripherals[peripheralID] {
|
||||
central?.cancelPeripheralConnection(peripheral)
|
||||
connectedPeripherals[peripheralID] = nil
|
||||
}
|
||||
}
|
||||
let known = Set(store.activeDevices.map(\.id))
|
||||
snapshots = snapshots.filter { known.contains($0.key) }
|
||||
linkStates = linkStates.filter { known.contains($0.key) }
|
||||
history = history.filter { known.contains($0.key) }
|
||||
diagnostics = diagnostics.filter { known.contains($0.key) }
|
||||
bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
|
||||
connectManagedPeripherals()
|
||||
restartScan()
|
||||
}
|
||||
|
||||
func clearDiscoveries() {
|
||||
discoveries.removeAll()
|
||||
pendingDiscoveries.removeAll()
|
||||
}
|
||||
|
||||
private func startDiscoveryFlush() {
|
||||
discoveryFlushTimer?.invalidate()
|
||||
discoveryFlushTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
// Verschwundene Geräte erst nach einer Weile fallen lassen, damit
|
||||
// ein Eintrag nicht wegen eines verpassten Pakets verschwindet.
|
||||
let cutoff = Date().addingTimeInterval(-20)
|
||||
self.pendingDiscoveries = self.pendingDiscoveries.filter { $0.value.lastSeen > cutoff }
|
||||
self.discoveries = self.pendingDiscoveries
|
||||
}
|
||||
}
|
||||
|
||||
private func stopDiscoveryFlush() {
|
||||
discoveryFlushTimer?.invalidate()
|
||||
discoveryFlushTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - Scannen
|
||||
|
||||
private func restartScan() {
|
||||
guard !isDemo, central?.state == .poweredOn else { return }
|
||||
central?.stopScan()
|
||||
// Victron sendet seine Werte im Advertisement, also müssen auch
|
||||
// Wiederholungen durchgereicht werden.
|
||||
central?.scanForPeripherals(
|
||||
withServices: nil,
|
||||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
|
||||
)
|
||||
}
|
||||
|
||||
private func connectManagedPeripherals() {
|
||||
guard !isDemo, central?.state == .poweredOn else { return }
|
||||
for device in store.activeDevices where device.role.transport == .connect {
|
||||
connectIfNeeded(device)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectIfNeeded(_ device: ConfiguredDevice) {
|
||||
let peripheralID = device.peripheralID
|
||||
if let existing = connectedPeripherals[peripheralID],
|
||||
existing.state == .connected || existing.state == .connecting {
|
||||
return
|
||||
}
|
||||
guard let peripheral = central?.retrievePeripherals(withIdentifiers: [peripheralID]).first else {
|
||||
linkStates[device.id] = .searching
|
||||
return
|
||||
}
|
||||
connectedPeripherals[peripheralID] = peripheral
|
||||
linkStates[device.id] = .connecting
|
||||
central?.connect(peripheral, options: nil)
|
||||
}
|
||||
|
||||
/// Verbindungen fallen im Fahrzeug regelmässig weg – deshalb regelmässig
|
||||
/// nachfassen statt nur auf das Disconnect-Ereignis zu reagieren.
|
||||
private func scheduleReconnects() {
|
||||
reconnectTimer?.invalidate()
|
||||
reconnectTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in
|
||||
self?.connectManagedPeripherals()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auswertung
|
||||
|
||||
private func record(_ snapshot: DeviceSnapshot) {
|
||||
snapshots[snapshot.deviceID] = snapshot
|
||||
guard let primary = snapshot.primaryMetric, let value = primary.value else { return }
|
||||
var samples = history[snapshot.deviceID] ?? []
|
||||
// Höchstens alle fünf Sekunden einen Punkt aufnehmen.
|
||||
if let last = samples.last, snapshot.timestamp.timeIntervalSince(last.time) < 5 { return }
|
||||
samples.append(HistorySample(time: snapshot.timestamp, value: value))
|
||||
if samples.count > historyLimit { samples.removeFirst(samples.count - historyLimit) }
|
||||
history[snapshot.deviceID] = samples
|
||||
}
|
||||
|
||||
private func handleVictronAdvertisement(_ manufacturerData: Data,
|
||||
device: ConfiguredDevice,
|
||||
rssi: Int) {
|
||||
// Zuerst den unverschlüsselten Rahmen festhalten – gerade wenn der
|
||||
// Schlüssel nicht passt, ist das die einzige verwertbare Information.
|
||||
if let envelope = VictronAdvertisement.envelope(from: manufacturerData) {
|
||||
diagnostics[device.id] = VictronDiagnostics(
|
||||
productID: envelope.productID,
|
||||
recordType: envelope.recordType,
|
||||
expectedKeyFirstByte: envelope.keyCheckByte,
|
||||
nonce: envelope.nonce,
|
||||
payloadLength: envelope.ciphertext.count,
|
||||
rawHex: manufacturerData.map { String(format: "%02X", $0) }.joined(separator: " "),
|
||||
updated: Date()
|
||||
)
|
||||
}
|
||||
|
||||
guard let key = store.victronKeyBytes(for: device.id) else {
|
||||
linkStates[device.id] = .needsKey
|
||||
return
|
||||
}
|
||||
do {
|
||||
let snapshot = try VictronAdvertisement.decode(manufacturerData: manufacturerData,
|
||||
key: key,
|
||||
deviceID: device.id,
|
||||
rssi: rssi)
|
||||
linkStates[device.id] = .live
|
||||
record(snapshot)
|
||||
} catch let error as VictronAdvertisement.DecodeError {
|
||||
switch error {
|
||||
case .notVictron:
|
||||
break // Fremdes Advertisement desselben Peripherals, ignorieren.
|
||||
default:
|
||||
linkStates[device.id] = .failed(error.localizedDescription)
|
||||
}
|
||||
} catch {
|
||||
linkStates[device.id] = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDiscovery(peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any],
|
||||
rssi: Int) {
|
||||
let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
|
||||
let envelope = manufacturerData.flatMap { VictronAdvertisement.envelope(from: $0) }
|
||||
let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name
|
||||
|
||||
let existing = pendingDiscoveries[peripheral.identifier]
|
||||
var entry = existing ?? Discovery(
|
||||
id: peripheral.identifier,
|
||||
name: name,
|
||||
rssi: rssi,
|
||||
firstSeen: Date(),
|
||||
lastSeen: Date(),
|
||||
victronRecordType: nil,
|
||||
victronProductID: nil,
|
||||
looksLikeDaly: false
|
||||
)
|
||||
// Gleitender Mittelwert über die letzten Messungen.
|
||||
entry.rssi = existing.map { ($0.rssi * 3 + rssi) / 4 } ?? rssi
|
||||
entry.lastSeen = Date()
|
||||
if let name, !name.isEmpty { entry.name = name }
|
||||
if let envelope {
|
||||
entry.victronRecordType = envelope.recordType
|
||||
entry.victronProductID = envelope.productID
|
||||
}
|
||||
entry.looksLikeDaly = Self.looksLikeDaly(name: entry.name)
|
||||
pendingDiscoveries[peripheral.identifier] = entry
|
||||
}
|
||||
|
||||
/// Die BLE-Module der Daly-BMS melden sich meist als "DL-…" oder tragen
|
||||
/// "BMS" im Namen. Nur ein Hinweis für die Liste – auswählbar ist alles.
|
||||
private static func looksLikeDaly(name: String?) -> Bool {
|
||||
guard let name = name?.lowercased() else { return false }
|
||||
return name.hasPrefix("dl-") || name.contains("daly") || name.contains("bms")
|
||||
|| name.contains("bulltron") || name.hasPrefix("smart bms")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBCentralManagerDelegate
|
||||
|
||||
extension BluetoothManager: CBCentralManagerDelegate {
|
||||
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
switch central.state {
|
||||
case .poweredOn:
|
||||
isBluetoothReady = true
|
||||
bluetoothStatusText = "Bereit"
|
||||
start()
|
||||
case .poweredOff:
|
||||
isBluetoothReady = false
|
||||
bluetoothStatusText = "Bluetooth ist ausgeschaltet"
|
||||
case .unauthorized:
|
||||
isBluetoothReady = false
|
||||
bluetoothStatusText = "Bluetooth-Zugriff wurde nicht erlaubt"
|
||||
case .unsupported:
|
||||
isBluetoothReady = false
|
||||
bluetoothStatusText = "Dieses Gerät unterstützt kein Bluetooth LE"
|
||||
default:
|
||||
isBluetoothReady = false
|
||||
bluetoothStatusText = "Bluetooth wird gestartet…"
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager,
|
||||
didDiscover peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any],
|
||||
rssi RSSI: NSNumber) {
|
||||
let rssi = RSSI.intValue
|
||||
|
||||
if isDiscovering {
|
||||
updateDiscovery(peripheral: peripheral, advertisementData: advertisementData, rssi: rssi)
|
||||
}
|
||||
|
||||
guard let device = store.device(withPeripheralID: peripheral.identifier) else { return }
|
||||
|
||||
switch device.role.transport {
|
||||
case .advertisement:
|
||||
guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
|
||||
else { return }
|
||||
handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi)
|
||||
|
||||
case .connect:
|
||||
// Das BMS wurde gesehen – falls die Verbindung fehlt, jetzt aufbauen.
|
||||
if connectedPeripherals[peripheral.identifier] == nil {
|
||||
connectIfNeeded(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
guard let device = store.device(withPeripheralID: peripheral.identifier) else { return }
|
||||
connectedPeripherals[peripheral.identifier] = peripheral
|
||||
|
||||
let session = BMSSession(
|
||||
deviceID: device.id,
|
||||
peripheral: peripheral,
|
||||
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
|
||||
onStateChange: { [weak self] state in self?.linkStates[device.id] = state },
|
||||
onDiagnostics: { [weak self] info in self?.bmsDiagnostics[device.id] = info }
|
||||
)
|
||||
bmsSessions[peripheral.identifier] = session
|
||||
session.start()
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager,
|
||||
didFailToConnect peripheral: CBPeripheral,
|
||||
error: Error?) {
|
||||
if let device = store.device(withPeripheralID: peripheral.identifier) {
|
||||
linkStates[device.id] = .failed(error?.localizedDescription ?? "Verbindung fehlgeschlagen")
|
||||
}
|
||||
connectedPeripherals[peripheral.identifier] = nil
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager,
|
||||
didDisconnectPeripheral peripheral: CBPeripheral,
|
||||
error: Error?) {
|
||||
bmsSessions[peripheral.identifier]?.handleDisconnect()
|
||||
bmsSessions[peripheral.identifier] = nil
|
||||
connectedPeripherals[peripheral.identifier] = nil
|
||||
|
||||
if let device = store.device(withPeripheralID: peripheral.identifier) {
|
||||
linkStates[device.id] = .searching
|
||||
// Direkt wieder anfragen; iOS stellt die Verbindung her, sobald das
|
||||
// Gerät wieder in Reichweite ist.
|
||||
connectIfNeeded(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
|
||||
/// Reine Protokoll-Logik für Daly-BMS – ohne CoreBluetooth, damit sie sich
|
||||
/// isoliert prüfen lässt.
|
||||
///
|
||||
/// Daly hat zwei Generationen im Umlauf:
|
||||
///
|
||||
/// * **Klassisch (`A5`)** – 13-Byte-Rahmen `A5 <adr> <cmd> 08 <8 Datenbytes> <Prüfsumme>`.
|
||||
/// Verbreitet bei den Smart-BMS mit dem blauen BLE-Stick, wie sie in vielen
|
||||
/// Bulltron-Akkus stecken.
|
||||
/// * **Neu (`D2`)** – Modbus-RTU über BLE, `D2 03 <Startregister> <Anzahl> <CRC16>`.
|
||||
///
|
||||
/// Welche Generation verbaut ist, erkennt `DalySession` anhand der Antwort.
|
||||
enum DalyProtocol {
|
||||
|
||||
// MARK: - Klassisches A5-Protokoll
|
||||
|
||||
enum Command: UInt8, CaseIterable {
|
||||
case soc = 0x90 // Spannung, Strom, Ladezustand
|
||||
case cellVoltageMinMax = 0x91
|
||||
case temperatureMinMax = 0x92
|
||||
case mosfetStatus = 0x93
|
||||
case statusInfo = 0x94
|
||||
case cellVoltages = 0x95
|
||||
case cellTemperatures = 0x96
|
||||
}
|
||||
|
||||
/// Adresse des Anfragenden. 0x80 = Bluetooth-Modul.
|
||||
static let hostAddress: UInt8 = 0x80
|
||||
|
||||
static func requestFrame(_ command: Command) -> Data {
|
||||
var frame: [UInt8] = [0xA5, hostAddress, command.rawValue, 0x08]
|
||||
frame.append(contentsOf: [UInt8](repeating: 0, count: 8))
|
||||
frame.append(frame.reduce(0) { UInt8(($0 &+ $1) & 0xFF) })
|
||||
return Data(frame)
|
||||
}
|
||||
|
||||
struct Frame {
|
||||
let address: UInt8
|
||||
let command: UInt8
|
||||
let payload: [UInt8] // immer 8 Bytes
|
||||
}
|
||||
|
||||
/// Sucht vollständige, prüfsummenkorrekte A5-Rahmen im Puffer und gibt sie
|
||||
/// zusammen mit dem unverbrauchten Rest zurück.
|
||||
static func extractA5Frames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) {
|
||||
var frames: [Frame] = []
|
||||
var index = 0
|
||||
var lastConsumed = 0
|
||||
|
||||
while index + 13 <= buffer.count {
|
||||
guard buffer[index] == 0xA5, buffer[index + 3] == 0x08 else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
let slice = Array(buffer[index..<(index + 13)])
|
||||
let checksum = slice[0..<12].reduce(UInt8(0)) { UInt8(($0 &+ $1) & 0xFF) }
|
||||
guard checksum == slice[12] else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
frames.append(Frame(address: slice[1],
|
||||
command: slice[2],
|
||||
payload: Array(slice[4..<12])))
|
||||
index += 13
|
||||
lastConsumed = index
|
||||
}
|
||||
// Angefangene Rahmen aufheben – BLE liefert Antworten oft gestückelt.
|
||||
let keepFrom = max(lastConsumed, max(0, buffer.count - 64))
|
||||
return (frames, Array(buffer[keepFrom...]))
|
||||
}
|
||||
|
||||
// MARK: - Modbus (D2)
|
||||
|
||||
/// Ein Lesekommando über alle interessanten Register.
|
||||
static func modbusReadFrame(start: UInt16 = 0, count: UInt16 = 62) -> Data {
|
||||
var frame: [UInt8] = [0xD2, 0x03,
|
||||
UInt8(start >> 8), UInt8(start & 0xFF),
|
||||
UInt8(count >> 8), UInt8(count & 0xFF)]
|
||||
let crc = crc16Modbus(frame)
|
||||
frame.append(UInt8(crc & 0xFF))
|
||||
frame.append(UInt8(crc >> 8))
|
||||
return Data(frame)
|
||||
}
|
||||
|
||||
static func crc16Modbus(_ bytes: [UInt8]) -> UInt16 {
|
||||
var crc: UInt16 = 0xFFFF
|
||||
for byte in bytes {
|
||||
crc ^= UInt16(byte)
|
||||
for _ in 0..<8 {
|
||||
if crc & 1 != 0 {
|
||||
crc = (crc >> 1) ^ 0xA001
|
||||
} else {
|
||||
crc >>= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc
|
||||
}
|
||||
|
||||
/// Prüft einen vollständigen Modbus-Antwortrahmen und liefert die
|
||||
/// Registerwerte. Gibt nil zurück, solange der Rahmen unvollständig ist.
|
||||
static func parseModbusResponse(_ buffer: [UInt8]) -> [UInt16]? {
|
||||
guard buffer.count >= 5, buffer[0] == 0xD2, buffer[1] == 0x03 else { return nil }
|
||||
let byteCount = Int(buffer[2])
|
||||
let total = 3 + byteCount + 2
|
||||
guard buffer.count >= total else { return nil }
|
||||
|
||||
let body = Array(buffer[0..<(3 + byteCount)])
|
||||
let expected = crc16Modbus(body)
|
||||
let actual = UInt16(buffer[3 + byteCount]) | (UInt16(buffer[4 + byteCount]) << 8)
|
||||
guard expected == actual else { return nil }
|
||||
|
||||
var registers: [UInt16] = []
|
||||
registers.reserveCapacity(byteCount / 2)
|
||||
var i = 3
|
||||
while i + 1 < 3 + byteCount {
|
||||
registers.append(UInt16(body[i]) << 8 | UInt16(body[i + 1]))
|
||||
i += 2
|
||||
}
|
||||
return registers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import Foundation
|
||||
|
||||
/// Sammelt die Antworten eines Daly-BMS. Das klassische Protokoll verteilt die
|
||||
/// Werte auf mehrere Rahmen, deshalb wird hier über Abfragerunden hinweg
|
||||
/// akkumuliert und erst am Ende ein Snapshot gebaut.
|
||||
struct DalyState {
|
||||
var totalVoltage: Double?
|
||||
var current: Double?
|
||||
var soc: Double?
|
||||
|
||||
var maxCellMillivolts: Int?
|
||||
var maxCellNumber: Int?
|
||||
var minCellMillivolts: Int?
|
||||
var minCellNumber: Int?
|
||||
|
||||
var maxTemperature: Double?
|
||||
var minTemperature: Double?
|
||||
|
||||
var chargeMOSOn: Bool?
|
||||
var dischargeMOSOn: Bool?
|
||||
var chargeDischargeStatus: UInt8?
|
||||
var remainingCapacityAh: Double?
|
||||
|
||||
var cellCount: Int?
|
||||
var temperatureSensorCount: Int?
|
||||
var cycles: Int?
|
||||
|
||||
/// Zellnummer (1-basiert) → Spannung in Millivolt.
|
||||
var cellMillivolts: [Int: Int] = [:]
|
||||
/// Sensornummer (1-basiert) → Temperatur in °C.
|
||||
var sensorTemperatures: [Int: Double] = [:]
|
||||
|
||||
/// Letzte Rohantwort, für die Diagnoseansicht.
|
||||
var lastRawResponse: Data?
|
||||
var usesModbus = false
|
||||
|
||||
// MARK: - Klassisches Protokoll
|
||||
|
||||
mutating func apply(_ frame: DalyProtocol.Frame) {
|
||||
let d = frame.payload
|
||||
func u16(_ i: Int) -> Int { Int(d[i]) << 8 | Int(d[i + 1]) }
|
||||
|
||||
switch frame.command {
|
||||
case 0x90:
|
||||
totalVoltage = Double(u16(0)) * 0.1
|
||||
// Strom mit Offset 30000, damit Entladung negativ dargestellt wird.
|
||||
current = Double(u16(4) - 30000) * 0.1
|
||||
soc = Double(u16(6)) * 0.1
|
||||
|
||||
case 0x91:
|
||||
maxCellMillivolts = u16(0)
|
||||
maxCellNumber = Int(d[2])
|
||||
minCellMillivolts = u16(3)
|
||||
minCellNumber = Int(d[5])
|
||||
|
||||
case 0x92:
|
||||
maxTemperature = Double(Int(d[0]) - 40)
|
||||
minTemperature = Double(Int(d[2]) - 40)
|
||||
|
||||
case 0x93:
|
||||
chargeDischargeStatus = d[0]
|
||||
chargeMOSOn = d[1] == 1
|
||||
dischargeMOSOn = d[2] == 1
|
||||
let capacityMilliAh = (UInt32(d[4]) << 24) | (UInt32(d[5]) << 16)
|
||||
| (UInt32(d[6]) << 8) | UInt32(d[7])
|
||||
remainingCapacityAh = Double(capacityMilliAh) / 1000
|
||||
|
||||
case 0x94:
|
||||
cellCount = Int(d[0])
|
||||
temperatureSensorCount = Int(d[1])
|
||||
cycles = u16(6)
|
||||
|
||||
case 0x95:
|
||||
// d[0] = Rahmennummer (1-basiert), danach drei Zellen à 2 Byte.
|
||||
let frameNumber = Int(d[0])
|
||||
guard frameNumber > 0 else { break }
|
||||
for slot in 0..<3 {
|
||||
let cell = (frameNumber - 1) * 3 + slot + 1
|
||||
let millivolts = u16(1 + slot * 2)
|
||||
if millivolts > 0 && millivolts < 6000 {
|
||||
cellMillivolts[cell] = millivolts
|
||||
}
|
||||
}
|
||||
|
||||
case 0x96:
|
||||
let frameNumber = Int(d[0])
|
||||
guard frameNumber > 0 else { break }
|
||||
for slot in 0..<7 {
|
||||
let sensor = (frameNumber - 1) * 7 + slot + 1
|
||||
let raw = Int(d[1 + slot])
|
||||
if raw != 0 {
|
||||
sensorTemperatures[sensor] = Double(raw - 40)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Modbus-Protokoll
|
||||
|
||||
/// Registerbelegung der neueren Daly-BMS.
|
||||
///
|
||||
/// Achtung: Dieses Mapping variiert zwischen Firmwareständen. Die
|
||||
/// Detailansicht zeigt deshalb die Rohantwort an, damit sich die Belegung
|
||||
/// am realen Gerät nachprüfen lässt.
|
||||
mutating func apply(registers: [UInt16]) {
|
||||
usesModbus = true
|
||||
func reg(_ i: Int) -> UInt16? { i < registers.count ? registers[i] : nil }
|
||||
|
||||
// Register 0–47: Zellspannungen in mV, unbenutzte Plätze sind 0.
|
||||
cellMillivolts.removeAll(keepingCapacity: true)
|
||||
for i in 0..<min(48, registers.count) {
|
||||
let millivolts = Int(registers[i])
|
||||
if millivolts > 500 && millivolts < 5000 {
|
||||
cellMillivolts[i + 1] = millivolts
|
||||
}
|
||||
}
|
||||
|
||||
// Register 48–55: Temperaturfühler mit Offset 40.
|
||||
sensorTemperatures.removeAll(keepingCapacity: true)
|
||||
for i in 48..<min(56, registers.count) {
|
||||
let raw = Int(registers[i])
|
||||
if raw > 0 && raw < 200 {
|
||||
sensorTemperatures[i - 47] = Double(raw - 40)
|
||||
}
|
||||
}
|
||||
|
||||
if let v = reg(56), v > 0 { totalVoltage = Double(v) * 0.1 }
|
||||
if let c = reg(57) { current = (Double(c) - 30000) * 0.1 }
|
||||
if let s = reg(58), s <= 1000 { soc = Double(s) * 0.1 }
|
||||
|
||||
maxCellMillivolts = cellMillivolts.values.max()
|
||||
minCellMillivolts = cellMillivolts.values.min()
|
||||
maxCellNumber = cellMillivolts.max(by: { $0.value < $1.value })?.key
|
||||
minCellNumber = cellMillivolts.min(by: { $0.value < $1.value })?.key
|
||||
maxTemperature = sensorTemperatures.values.max()
|
||||
minTemperature = sensorTemperatures.values.min()
|
||||
cellCount = cellMillivolts.isEmpty ? nil : cellMillivolts.count
|
||||
temperatureSensorCount = sensorTemperatures.isEmpty ? nil : sensorTemperatures.count
|
||||
}
|
||||
|
||||
// MARK: - Ausgabe
|
||||
|
||||
var hasUsableData: Bool {
|
||||
totalVoltage != nil || soc != nil || !cellMillivolts.isEmpty
|
||||
}
|
||||
|
||||
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
|
||||
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
|
||||
|
||||
var metrics: [Metric] = [
|
||||
Metric("soc", "Ladezustand", soc, unit: "%", precision: 1, primary: true),
|
||||
Metric("voltage", "Spannung", totalVoltage, unit: "V", precision: 2),
|
||||
Metric("current", "Strom", current, unit: "A", precision: 1),
|
||||
]
|
||||
if let v = totalVoltage, let a = current {
|
||||
metrics.append(Metric("power", "Leistung", v * a, unit: "W", precision: 0))
|
||||
}
|
||||
if let capacity = remainingCapacityAh {
|
||||
metrics.append(Metric("capacity", "Restkapazität", capacity, unit: "Ah", precision: 1))
|
||||
}
|
||||
if let maxV = maxCellMillivolts, let minV = minCellMillivolts {
|
||||
metrics.append(Metric("cell_delta", "Zell-Differenz", Double(maxV - minV), unit: "mV", precision: 0))
|
||||
metrics.append(Metric("cell_max", "Höchste Zelle" + numberSuffix(maxCellNumber),
|
||||
Double(maxV) / 1000, unit: "V", precision: 3))
|
||||
metrics.append(Metric("cell_min", "Niedrigste Zelle" + numberSuffix(minCellNumber),
|
||||
Double(minV) / 1000, unit: "V", precision: 3))
|
||||
}
|
||||
if let maxTemperature {
|
||||
metrics.append(Metric("temp_max", "Temperatur", maxTemperature, unit: "°C", precision: 0))
|
||||
}
|
||||
if let minTemperature, minTemperature != maxTemperature {
|
||||
metrics.append(Metric("temp_min", "Temperatur min.", minTemperature, unit: "°C", precision: 0))
|
||||
}
|
||||
if let cycles {
|
||||
metrics.append(Metric("cycles", "Ladezyklen", Double(cycles), unit: "", precision: 0))
|
||||
}
|
||||
snapshot.metrics = metrics
|
||||
|
||||
snapshot.state = stateText
|
||||
snapshot.cellVoltages = cellMillivolts
|
||||
.sorted { $0.key < $1.key }
|
||||
.map { Double($0.value) / 1000 }
|
||||
snapshot.temperatures = sensorTemperatures.sorted { $0.key < $1.key }.map(\.value)
|
||||
|
||||
var warnings: [String] = []
|
||||
if chargeMOSOn == false { warnings.append("Lade-MOSFET aus") }
|
||||
if dischargeMOSOn == false { warnings.append("Entlade-MOSFET aus") }
|
||||
snapshot.offReasons = warnings
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private func numberSuffix(_ number: Int?) -> String {
|
||||
number.map { " (Zelle \($0))" } ?? ""
|
||||
}
|
||||
|
||||
private var stateText: String? {
|
||||
if let status = chargeDischargeStatus {
|
||||
switch status {
|
||||
case 0: return "Ruhend"
|
||||
case 1: return "Lädt"
|
||||
case 2: return "Entlädt"
|
||||
default: break
|
||||
}
|
||||
}
|
||||
guard let current else { return nil }
|
||||
if current > 0.3 { return "Lädt" }
|
||||
if current < -0.3 { return "Entlädt" }
|
||||
return "Ruhend"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
|
||||
/// Protokoll der JBD-/Xiaoxiang-BMS, wie sie unter anderem in WattCycle-Akkus
|
||||
/// verbaut sind. Bekannt auch als „Smart BMS“ oder Overkill-Solar-Protokoll.
|
||||
///
|
||||
/// Rahmenaufbau:
|
||||
///
|
||||
/// Anfrage: DD A5 <Kommando> <Länge=00> <Prüfsumme 2 Byte> 77
|
||||
/// Antwort: DD <Kommando> <Status> <Länge> <Daten…> <Prüfsumme 2 Byte> 77
|
||||
///
|
||||
/// Die Prüfsumme ist `0x10000 − (Status + Länge + Daten)`, big-endian; in der
|
||||
/// Anfrage entsprechend über Kommando und Länge.
|
||||
enum JBDProtocol {
|
||||
|
||||
enum Command: UInt8, CaseIterable {
|
||||
case basicInfo = 0x03
|
||||
case cellVoltages = 0x04
|
||||
}
|
||||
|
||||
static func requestFrame(_ command: Command) -> Data {
|
||||
let checksum = checksum(over: [command.rawValue, 0x00])
|
||||
return Data([0xDD, 0xA5, command.rawValue, 0x00,
|
||||
UInt8(checksum >> 8), UInt8(checksum & 0xFF), 0x77])
|
||||
}
|
||||
|
||||
static func checksum(over bytes: [UInt8]) -> UInt16 {
|
||||
let sum = bytes.reduce(UInt32(0)) { $0 + UInt32($1) }
|
||||
return UInt16(truncatingIfNeeded: 0x1_0000 &- sum)
|
||||
}
|
||||
|
||||
struct Frame {
|
||||
let command: UInt8
|
||||
let payload: [UInt8]
|
||||
}
|
||||
|
||||
/// Sucht vollständige, prüfsummenkorrekte Rahmen im Puffer.
|
||||
static func extractFrames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) {
|
||||
var frames: [Frame] = []
|
||||
var index = 0
|
||||
var consumed = 0
|
||||
|
||||
while index + 7 <= buffer.count {
|
||||
guard buffer[index] == 0xDD else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
let length = Int(buffer[index + 3])
|
||||
let total = 4 + length + 3 // Kopf + Daten + Prüfsumme + 0x77
|
||||
guard index + total <= buffer.count else { break } // Rest abwarten
|
||||
|
||||
let frame = Array(buffer[index..<(index + total)])
|
||||
guard frame[total - 1] == 0x77 else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
let expected = checksum(over: Array(frame[2..<(4 + length)]))
|
||||
let actual = UInt16(frame[4 + length]) << 8 | UInt16(frame[5 + length])
|
||||
guard expected == actual else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
// Status ≠ 0 meldet einen Fehler; der Rahmen ist dann leer.
|
||||
if frame[2] == 0x00 {
|
||||
frames.append(Frame(command: frame[1],
|
||||
payload: Array(frame[4..<(4 + length)])))
|
||||
}
|
||||
index += total
|
||||
consumed = index
|
||||
}
|
||||
let keepFrom = max(consumed, max(0, buffer.count - 128))
|
||||
return (frames, Array(buffer[keepFrom...]))
|
||||
}
|
||||
|
||||
/// Klartext der Schutzabschaltungen aus der 16-Bit-Maske.
|
||||
static func protectionReasons(_ mask: UInt16) -> [String] {
|
||||
guard mask != 0 else { return [] }
|
||||
let table: [(UInt16, String)] = [
|
||||
(1 << 0, "Zellüberspannung"),
|
||||
(1 << 1, "Zellunterspannung"),
|
||||
(1 << 2, "Batterie Überspannung"),
|
||||
(1 << 3, "Batterie Unterspannung"),
|
||||
(1 << 4, "Ladetemperatur zu hoch"),
|
||||
(1 << 5, "Ladetemperatur zu niedrig"),
|
||||
(1 << 6, "Entladetemperatur zu hoch"),
|
||||
(1 << 7, "Entladetemperatur zu niedrig"),
|
||||
(1 << 8, "Ladestrom zu hoch"),
|
||||
(1 << 9, "Entladestrom zu hoch"),
|
||||
(1 << 10, "Kurzschluss"),
|
||||
(1 << 11, "Fehler im Messkreis"),
|
||||
(1 << 12, "MOSFET gesperrt"),
|
||||
]
|
||||
return table.filter { mask & $0.0 != 0 }.map(\.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sammelt die Antworten eines JBD-BMS.
|
||||
struct JBDState {
|
||||
var totalVoltage: Double?
|
||||
var current: Double?
|
||||
var remainingCapacityAh: Double?
|
||||
var nominalCapacityAh: Double?
|
||||
var cycles: Int?
|
||||
var soc: Double?
|
||||
var chargeMOSOn: Bool?
|
||||
var dischargeMOSOn: Bool?
|
||||
var protections: [String] = []
|
||||
var cellMillivolts: [Int] = []
|
||||
var temperatures: [Double] = []
|
||||
|
||||
var hasUsableData: Bool { totalVoltage != nil || soc != nil || !cellMillivolts.isEmpty }
|
||||
|
||||
mutating func apply(_ frame: JBDProtocol.Frame) {
|
||||
let d = frame.payload
|
||||
func u16(_ i: Int) -> Int { Int(d[i]) << 8 | Int(d[i + 1]) }
|
||||
func i16(_ i: Int) -> Int { Int(Int16(bitPattern: UInt16(u16(i)))) }
|
||||
|
||||
switch frame.command {
|
||||
case 0x03:
|
||||
guard d.count >= 23 else { return }
|
||||
totalVoltage = Double(u16(0)) * 0.01 // 10 mV je Schritt
|
||||
current = Double(i16(2)) * 0.01 // 10 mA, negativ = Entladung
|
||||
remainingCapacityAh = Double(u16(4)) * 0.01
|
||||
nominalCapacityAh = Double(u16(6)) * 0.01
|
||||
cycles = u16(8)
|
||||
protections = JBDProtocol.protectionReasons(UInt16(u16(16)))
|
||||
soc = Double(d[19])
|
||||
chargeMOSOn = d[20] & 0x01 != 0
|
||||
dischargeMOSOn = d[20] & 0x02 != 0
|
||||
|
||||
// Ab Byte 23 folgen die NTC-Fühler, je zwei Byte in Zehntel-Kelvin.
|
||||
let sensorCount = Int(d[22])
|
||||
var readings: [Double] = []
|
||||
for sensor in 0..<sensorCount {
|
||||
let offset = 23 + sensor * 2
|
||||
guard offset + 1 < d.count else { break }
|
||||
readings.append((Double(u16(offset)) - 2731) / 10)
|
||||
}
|
||||
temperatures = readings
|
||||
|
||||
case 0x04:
|
||||
var millivolts: [Int] = []
|
||||
var offset = 0
|
||||
while offset + 1 < d.count {
|
||||
millivolts.append(u16(offset))
|
||||
offset += 2
|
||||
}
|
||||
cellMillivolts = millivolts
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
|
||||
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
|
||||
|
||||
var metrics: [Metric] = [
|
||||
Metric("soc", "Ladezustand", soc, unit: "%", precision: 0, primary: true),
|
||||
Metric("voltage", "Spannung", totalVoltage, unit: "V", precision: 2),
|
||||
Metric("current", "Strom", current, unit: "A", precision: 1),
|
||||
]
|
||||
if let v = totalVoltage, let a = current {
|
||||
metrics.append(Metric("power", "Leistung", v * a, unit: "W", precision: 0))
|
||||
}
|
||||
if let remainingCapacityAh {
|
||||
metrics.append(Metric("capacity", "Restkapazität", remainingCapacityAh, unit: "Ah", precision: 1))
|
||||
}
|
||||
if let nominalCapacityAh {
|
||||
metrics.append(Metric("capacity_nominal", "Nennkapazität", nominalCapacityAh, unit: "Ah", precision: 1))
|
||||
}
|
||||
if let maxV = cellMillivolts.max(), let minV = cellMillivolts.min() {
|
||||
metrics.append(Metric("cell_delta", "Zell-Differenz", Double(maxV - minV), unit: "mV", precision: 0))
|
||||
metrics.append(Metric("cell_max", "Höchste Zelle", Double(maxV) / 1000, unit: "V", precision: 3))
|
||||
metrics.append(Metric("cell_min", "Niedrigste Zelle", Double(minV) / 1000, unit: "V", precision: 3))
|
||||
}
|
||||
if let warmest = temperatures.max() {
|
||||
metrics.append(Metric("temp_max", "Temperatur", warmest, unit: "°C", precision: 0))
|
||||
}
|
||||
if let cycles {
|
||||
metrics.append(Metric("cycles", "Ladezyklen", Double(cycles), unit: "", precision: 0))
|
||||
}
|
||||
snapshot.metrics = metrics
|
||||
|
||||
snapshot.cellVoltages = cellMillivolts.map { Double($0) / 1000 }
|
||||
snapshot.temperatures = temperatures
|
||||
snapshot.fault = protections.isEmpty ? nil : protections.joined(separator: ", ")
|
||||
|
||||
var notes: [String] = []
|
||||
if chargeMOSOn == false { notes.append("Laden gesperrt") }
|
||||
if dischargeMOSOn == false { notes.append("Entladen gesperrt") }
|
||||
snapshot.offReasons = notes
|
||||
|
||||
if let current {
|
||||
if current > 0.3 { snapshot.state = "Lädt" }
|
||||
else if current < -0.3 { snapshot.state = "Entlädt" }
|
||||
else { snapshot.state = "Ruhend" }
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user