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,4 @@
{
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.400", "green" : "0.620", "red" : "0.110" } }, "idiom" : "universal" } ],
"info" : { "author" : "xcode", "version" : 1 }
}
@@ -0,0 +1,4 @@
{
"images" : [ { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ],
"info" : { "author" : "xcode", "version" : 1 }
}
@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }
@@ -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))
}
}
+297
View File
@@ -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)
}
}
+61
View File
@@ -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)
}
}
}
+123
View File
@@ -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
}
}
+214
View File
@@ -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 047: 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 4855: 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"
}
}
+200
View File
@@ -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
}
}
+31
View File
@@ -0,0 +1,31 @@
import SwiftUI
@main
struct CamperMonitorApp: App {
@State private var store: DeviceStore
@State private var bluetooth: BluetoothManager
@Environment(\.scenePhase) private var scenePhase
init() {
let store = DeviceStore()
_store = State(initialValue: store)
_bluetooth = State(initialValue: BluetoothManager(store: store))
}
var body: some Scene {
WindowGroup {
DashboardView()
.environment(store)
.environment(bluetooth)
}
.onChange(of: scenePhase) { _, phase in
// Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt
// werden, also Funk sparen und beim Zurückkommen neu starten.
switch phase {
case .active: bluetooth.start()
case .background: bluetooth.stop()
default: break
}
}
}
}
@@ -0,0 +1,85 @@
import Foundation
/// Welche Rolle ein Gerät im Camper spielt. Bestimmt Icon, Sortierung und
/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
case chargeBooster
case solarCharger
case batteryMonitor
case bms
var id: String { rawValue }
var title: String {
switch self {
case .chargeBooster: return "Ladebooster"
case .solarCharger: return "Solarladeregler"
case .batteryMonitor: return "Batteriemonitor"
case .bms: return "Batterie / BMS"
}
}
var symbol: String {
switch self {
case .chargeBooster: return "bolt.car"
case .solarCharger: return "sun.max"
case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
case .bms: return "battery.100percent.bolt"
}
}
/// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS
/// braucht eine echte GATT-Verbindung.
var transport: DeviceTransport {
self == .bms ? .connect : .advertisement
}
}
enum DeviceTransport: Sendable {
/// Passives Mitlesen der BLE-Werbedaten (Victron Instant Readout).
case advertisement
/// Verbindungsaufbau, Kommando schreiben, Antwort per Notify lesen (Daly).
case connect
}
/// Ein vom Nutzer eingerichtetes Gerät. Der Victron-Schlüssel liegt nicht hier,
/// sondern in der Keychain (siehe `KeychainStore`).
struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
var id: UUID
var name: String
var role: DeviceRole
/// Zu welchem Fahrzeug das Gerät gehört.
var profileID: UUID
/// CoreBluetooth-Identifier des Peripherals. Pro iPhone stabil, aber nicht
/// geräteübergreifend deshalb wird beim Einrichten neu gescannt.
var peripheralID: UUID
/// Zuletzt gesehener Advertised Name, nur zur Wiedererkennung in der UI.
var advertisedName: String?
init(id: UUID = UUID(),
name: String,
role: DeviceRole,
profileID: UUID,
peripheralID: UUID,
advertisedName: String? = nil) {
self.id = id
self.name = name
self.role = role
self.profileID = profileID
self.peripheralID = peripheralID
self.advertisedName = advertisedName
}
/// Geräte, die vor der Profilverwaltung angelegt wurden, haben noch keine
/// Zuordnung die landen im Standardprofil.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
role = try container.decode(DeviceRole.self, forKey: .role)
profileID = try container.decodeIfPresent(UUID.self, forKey: .profileID)
?? Profile.defaultID
peripheralID = try container.decode(UUID.self, forKey: .peripheralID)
advertisedName = try container.decodeIfPresent(String.self, forKey: .advertisedName)
}
}
+89
View File
@@ -0,0 +1,89 @@
import Foundation
/// Eine einzelne Messgrösse in einer bereits formatierten Form.
struct Metric: Identifiable, Hashable, Sendable {
let key: String
let label: String
let value: Double?
let unit: String
/// Nachkommastellen für die Anzeige.
let precision: Int
/// Wird auf der Kachel gross dargestellt.
let isPrimary: Bool
var id: String { key }
init(_ key: String,
_ label: String,
_ value: Double?,
unit: String,
precision: Int = 2,
primary: Bool = false) {
self.key = key
self.label = label
self.value = value
self.unit = unit
self.precision = precision
self.isPrimary = primary
}
var formatted: String {
guard let value else { return "" }
return String(format: "%.\(precision)f", value)
}
var formattedWithUnit: String {
guard value != nil else { return "" }
return unit.isEmpty ? formatted : "\(formatted) \(unit)"
}
}
/// Der komplette, zuletzt empfangene Zustand eines Geräts.
struct DeviceSnapshot: Identifiable, Sendable {
var id: UUID { deviceID }
var deviceID: UUID
var timestamp: Date
var metrics: [Metric] = []
/// z.B. "Bulk", "Float", "Aus"
var state: String?
/// Klartext einer aktiven Störung, sonst nil.
var fault: String?
/// Grund, warum das Gerät gerade nicht lädt (Victron Off-Reason).
var offReasons: [String] = []
var rssi: Int?
/// Einzelzellspannungen in Volt (nur BMS).
var cellVoltages: [Double] = []
/// Temperaturfühler in °C (nur BMS).
var temperatures: [Double] = []
var primaryMetric: Metric? {
metrics.first(where: \.isPrimary) ?? metrics.first
}
var age: TimeInterval { Date().timeIntervalSince(timestamp) }
/// Werte älter als eine Minute gelten als veraltet Victron sendet etwa
/// jede Sekunde, das Daly wird alle paar Sekunden gepollt.
var isStale: Bool { age > 60 }
}
/// Verbindungszustand für die UI.
enum DeviceLinkState: Equatable, Sendable {
case idle
case searching
case connecting
case live
case needsKey
case failed(String)
var label: String {
switch self {
case .idle: return "Inaktiv"
case .searching: return "Suche…"
case .connecting: return "Verbinde…"
case .live: return "Live"
case .needsKey: return "Schlüssel fehlt"
case .failed(let m): return m
}
}
}
+32
View File
@@ -0,0 +1,32 @@
import Foundation
/// Ein Fahrzeug. Jedes Profil hat seinen eigenen Satz Geräte; die App zeigt und
/// funkt immer nur für das gerade gewählte.
struct Profile: Identifiable, Codable, Hashable, Sendable {
var id: UUID
var name: String
/// SF-Symbol für die Auswahl im Dashboard.
var symbol: String
init(id: UUID = UUID(), name: String, symbol: String = "box.truck") {
self.id = id
self.name = name
self.symbol = symbol
}
/// Profil, dem Geräte aus der Zeit vor der Profilverwaltung zugeordnet
/// werden. Feste Kennung, damit die Zuordnung beim Update erhalten bleibt.
static let defaultID = UUID(uuidString: "00000000-0000-0000-0000-00000000C001")!
static var initial: Profile {
Profile(id: defaultID, name: "Mein Camper")
}
/// Auswahl für die Profilbearbeitung.
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft ein nicht
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
static let symbols = [
"box.truck", "truck.pickup.side", "bus", "bus.doubledecker",
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
]
}
+101
View File
@@ -0,0 +1,101 @@
import Foundation
/// Klartexte für die Zustands- und Fehlercodes aus den Victron-Werbedaten.
enum VictronCodes {
/// VE.Reg 0x0201 Betriebszustand des Laders.
static func deviceState(_ code: UInt32?) -> String? {
guard let code else { return nil }
switch code {
case 0: return "Aus"
case 1: return "Stromsparmodus"
case 2: return "Störung"
case 3: return "Konstantstrom (Bulk)"
case 4: return "Konstantspannung (Absorption)"
case 5: return "Erhaltung (Float)"
case 6: return "Lagerung"
case 7: return "Ausgleichsladung"
case 9: return "Wechselrichten"
case 11: return "Netzteilbetrieb"
case 245: return "Startet"
case 246: return "Wiederholte Absorption"
case 247: return "Auto-Ausgleich"
case 248: return "Battery Safe"
case 252: return "Externe Steuerung"
default: return "Zustand \(code)"
}
}
/// VE.Reg 0xEDDA Ladefehler. 0 bedeutet "kein Fehler".
static func chargerError(_ code: UInt32?) -> String? {
guard let code, code != 0 else { return nil }
switch code {
case 1: return "Batterietemperatur zu hoch"
case 2: return "Batteriespannung zu hoch"
case 3: return "Temperatursensor defekt"
case 4: return "Temperatursensor Kurzschluss"
case 5: return "Temperatursensor unplausibel"
case 6: return "Spannungsmessung defekt"
case 7: return "Spannungsmessung Kurzschluss"
case 8: return "Spannungsmessung unplausibel"
case 11: return "Zu hohe Restwelligkeit"
case 14: return "Batterietemperatur zu niedrig"
case 17: return "Lader überhitzt"
case 18: return "Lader Überstrom"
case 19: return "Stromrichtung verkehrt"
case 20: return "Bulk-Zeit überschritten"
case 21: return "Stromsensor defekt"
case 22: return "Interner Temperatursensor defekt"
case 26: return "Anschlussklemme überhitzt"
case 27: return "Kurzschluss im Lader"
case 28: return "Endstufenfehler"
case 29: return "Überladeschutz"
case 33: return "Eingangsspannung zu hoch (PV)"
case 34: return "Eingangsstrom zu hoch (PV)"
case 38: return "Eingang abgeschaltet (Batteriespannung)"
case 39: return "Eingang abgeschaltet (Stromfluss)"
case 65: return "Kommunikation verloren"
case 66: return "Konfiguration synchronisierter Lader fehlerhaft"
case 67: return "BMS-Verbindung verloren"
case 68: return "Netzwerk fehlkonfiguriert"
case 116: return "Kalibrierdaten verloren"
case 117: return "Inkompatible Firmware"
case 119: return "Einstellungen ungültig"
default: return "Fehler \(code)"
}
}
/// VE.Reg 0x0207 Bitmaske, warum das Gerät gerade nicht arbeitet.
static func offReasons(_ mask: UInt32?) -> [String] {
guard let mask, mask != 0 else { return [] }
let table: [(UInt32, String)] = [
(0x0000_0001, "Keine Eingangsspannung"),
(0x0000_0002, "Per Schalter ausgeschaltet"),
(0x0000_0004, "Per Einstellung ausgeschaltet"),
(0x0000_0008, "Remote-Eingang"),
(0x0000_0010, "Schutzfunktion aktiv"),
(0x0000_0020, "Paygo"),
(0x0000_0040, "BMS"),
(0x0000_0080, "Motor-Abschalterkennung"),
(0x0000_0100, "Eingangsspannung wird geprüft"),
]
return table.filter { mask & $0.0 != 0 }.map(\.1)
}
/// VE.Reg 0xEEB8 Alarmgründe des Batteriewächters.
static func alarmReasons(_ mask: UInt32?) -> [String] {
guard let mask, mask != 0 else { return [] }
let table: [(UInt32, String)] = [
(0x0001, "Unterspannung"),
(0x0002, "Überspannung"),
(0x0004, "Niedriger Ladezustand"),
(0x0008, "Starterbatterie Unterspannung"),
(0x0010, "Starterbatterie Überspannung"),
(0x0020, "Temperatur zu niedrig"),
(0x0040, "Temperatur zu hoch"),
(0x0080, "Mittenspannung"),
(0x0100, "Ladung überfällig"),
]
return table.filter { mask & $0.0 != 0 }.map(\.1)
}
}
+110
View File
@@ -0,0 +1,110 @@
import Foundation
/// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne
/// Fahrzeug und ohne Bluetooth prüfen lassen.
///
/// Nur in Debug-Builds und nur, wenn beim Start `CAMPER_DEMO=1` gesetzt ist:
///
/// xcrun simctl launch --terminate-running-process \
/// booted de.fritob.CamperMonitor
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
///
/// Im normalen Betrieb wird hiervon nichts ausgeführt.
enum DemoData {
static var isEnabled: Bool {
#if DEBUG
return ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
#else
return false
#endif
}
static let mainProfile = Profile(id: Profile.defaultID, name: "Kastenwagen", symbol: "box.truck")
static let secondProfile = Profile(
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C2")!,
name: "Wohnwagen", symbol: "caravan")
static var profiles: [Profile] { [mainProfile, secondProfile] }
static let booster = ConfiguredDevice(
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000B0")!,
name: "Ladebooster", role: .chargeBooster, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000B1")!)
static let solar = ConfiguredDevice(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000050")!,
name: "Solar Dach", role: .solarCharger, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-000000000051")!)
static let battery = ConfiguredDevice(
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000A0")!,
name: "Bulltron 200 Ah", role: .bms, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000A1")!)
static let caravanSolar = ConfiguredDevice(
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C3")!,
name: "Solar Wohnwagen", role: .solarCharger, profileID: secondProfile.id,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000C4")!)
static var devices: [ConfiguredDevice] { [solar, booster, battery, caravanSolar] }
static func snapshots() -> [DeviceSnapshot] {
var solarSnapshot = DeviceSnapshot(deviceID: solar.id, timestamp: Date(), rssi: -58)
solarSnapshot.state = "Konstantspannung (Absorption)"
solarSnapshot.metrics = [
Metric("pv_power", "PV-Leistung", 284, unit: "W", precision: 0, primary: true),
Metric("battery_power", "Ladeleistung", 262, unit: "W", precision: 0),
Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2),
Metric("battery_current", "Ladestrom", 18.6, unit: "A", precision: 1),
Metric("yield_today", "Ertrag heute", 1.34, unit: "kWh", precision: 2),
]
var boosterSnapshot = DeviceSnapshot(deviceID: booster.id, timestamp: Date(), rssi: -71)
boosterSnapshot.state = "Aus"
boosterSnapshot.offReasons = ["Keine Eingangsspannung"]
boosterSnapshot.metrics = [
Metric("output_voltage", "Ausgang (Aufbaubatterie)", 14.09, unit: "V", precision: 2, primary: true),
Metric("input_voltage", "Eingang (Starterbatterie)", 12.42, unit: "V", precision: 2),
]
let cells = [3.412, 3.418, 3.409, 3.421]
var batterySnapshot = DeviceSnapshot(deviceID: battery.id, timestamp: Date(), rssi: -64)
batterySnapshot.state = "Lädt"
batterySnapshot.cellVoltages = cells
batterySnapshot.temperatures = [21, 22]
batterySnapshot.metrics = [
Metric("soc", "Ladezustand", 78.4, unit: "%", precision: 1, primary: true),
Metric("voltage", "Spannung", 13.66, unit: "V", precision: 2),
Metric("current", "Strom", 18.6, unit: "A", precision: 1),
Metric("power", "Leistung", 254, unit: "W", precision: 0),
Metric("capacity", "Restkapazität", 156.8, unit: "Ah", precision: 1),
Metric("cell_delta", "Zell-Differenz", 12, unit: "mV", precision: 0),
Metric("cell_max", "Höchste Zelle (Zelle 4)", 3.421, unit: "V", precision: 3),
Metric("cell_min", "Niedrigste Zelle (Zelle 3)", 3.409, unit: "V", precision: 3),
Metric("temp_max", "Temperatur", 22, unit: "°C", precision: 0),
Metric("cycles", "Ladezyklen", 143, unit: "", precision: 0),
]
var caravanSnapshot = DeviceSnapshot(deviceID: caravanSolar.id, timestamp: Date(), rssi: -77)
caravanSnapshot.state = "Erhaltung (Float)"
caravanSnapshot.metrics = [
Metric("pv_power", "PV-Leistung", 62, unit: "W", precision: 0, primary: true),
Metric("battery_voltage", "Batteriespannung", 13.62, unit: "V", precision: 2),
Metric("battery_current", "Ladestrom", 4.4, unit: "A", precision: 1),
]
return [solarSnapshot, boosterSnapshot, batterySnapshot, caravanSnapshot]
}
/// Ein paar Stunden Verlauf, damit die Grafik etwas zeigt.
static func history(for deviceID: UUID, around value: Double) -> [HistorySample] {
let now = Date()
return (0..<80).reversed().map { step in
let t = Double(step)
let wave = sin(t / 9) * value * 0.18 + cos(t / 23) * value * 0.07
return HistorySample(time: now.addingTimeInterval(-t * 60),
value: max(0, value + wave))
}
}
}
+184
View File
@@ -0,0 +1,184 @@
import Foundation
import Observation
/// Verwaltet die Fahrzeugprofile, die darin eingerichteten Geräte und deren
/// Schlüssel.
@Observable
final class DeviceStore {
private static let devicesKey = "configuredDevices"
private static let profilesKey = "profiles"
private static let activeProfileKey = "activeProfileID"
/// Alle Geräte über alle Profile hinweg.
private(set) var devices: [ConfiguredDevice] = []
private(set) var profiles: [Profile] = []
private(set) var activeProfileID: UUID = Profile.defaultID
/// Zwischenspeicher, damit nicht bei jedem Advertisement die Keychain
/// befragt wird das passiert bis zu mehrmals pro Sekunde.
@ObservationIgnored private var keyCache: [UUID: [UInt8]] = [:]
init() {
if DemoData.isEnabled {
profiles = DemoData.profiles
devices = DemoData.devices
activeProfileID = DemoData.profiles[0].id
return
}
load()
}
// MARK: - Profile
var activeProfile: Profile? {
profiles.first { $0.id == activeProfileID }
}
/// Die Geräte des gewählten Fahrzeugs.
var activeDevices: [ConfiguredDevice] {
devices.filter { $0.profileID == activeProfileID }
}
var hasMultipleProfiles: Bool { profiles.count > 1 }
func selectProfile(_ profile: Profile) {
guard profile.id != activeProfileID else { return }
activeProfileID = profile.id
save()
}
@discardableResult
func addProfile(named name: String, symbol: String = "box.truck") -> Profile {
let profile = Profile(name: name, symbol: symbol)
profiles.append(profile)
save()
return profile
}
func update(_ profile: Profile) {
guard let index = profiles.firstIndex(where: { $0.id == profile.id }) else { return }
profiles[index] = profile
save()
}
/// Entfernt ein Profil samt seiner Geräte und Schlüssel. Das letzte Profil
/// bleibt bestehen ohne Profil hätte die App keinen Ort für Geräte.
func removeProfile(_ profile: Profile) {
guard profiles.count > 1 else { return }
for device in devices where device.profileID == profile.id {
KeychainStore.setKey(nil, for: device.id)
keyCache[device.id] = nil
}
devices.removeAll { $0.profileID == profile.id }
profiles.removeAll { $0.id == profile.id }
if activeProfileID == profile.id, let first = profiles.first {
activeProfileID = first.id
}
save()
}
func deviceCount(in profile: Profile) -> Int {
devices.count { $0.profileID == profile.id }
}
// MARK: - Geräte
/// Legt ein Gerät im gerade gewählten Profil an.
func add(_ device: ConfiguredDevice, victronKey: String? = nil) {
devices.append(device)
if let victronKey {
setVictronKey(victronKey, for: device.id)
}
save()
}
func update(_ device: ConfiguredDevice) {
guard let index = devices.firstIndex(where: { $0.id == device.id }) else { return }
devices[index] = device
save()
}
func remove(_ device: ConfiguredDevice) {
devices.removeAll { $0.id == device.id }
KeychainStore.setKey(nil, for: device.id)
keyCache[device.id] = nil
save()
}
/// Nur im aktiven Profil suchen: die Geräte des anderen Fahrzeugs sollen
/// weder gelesen noch verbunden werden.
func device(withPeripheralID id: UUID) -> ConfiguredDevice? {
activeDevices.first { $0.peripheralID == id }
}
// MARK: - Victron-Schlüssel
func victronKeyText(for deviceID: UUID) -> String? {
KeychainStore.key(for: deviceID)
}
func setVictronKey(_ hex: String?, for deviceID: UUID) {
let trimmed = hex?.trimmingCharacters(in: .whitespacesAndNewlines)
let value = (trimmed?.isEmpty ?? true) ? nil : trimmed
KeychainStore.setKey(value, for: deviceID)
keyCache[deviceID] = value?.hexBytes
}
/// Der Schlüssel als Bytes, oder nil wenn keiner hinterlegt bzw. der
/// hinterlegte keine 16 Byte lang ist.
func victronKeyBytes(for deviceID: UUID) -> [UInt8]? {
if let cached = keyCache[deviceID] { return cached }
guard let bytes = KeychainStore.key(for: deviceID)?.hexBytes, bytes.count == 16 else {
return nil
}
keyCache[deviceID] = bytes
return bytes
}
// MARK: - Persistenz
private func load() {
let defaults = UserDefaults.standard
if let data = defaults.data(forKey: Self.profilesKey),
let decoded = try? JSONDecoder().decode([Profile].self, from: data),
!decoded.isEmpty {
profiles = decoded
} else {
// Erster Start oder Update von einer Version ohne Profile.
profiles = [.initial]
}
if let data = defaults.data(forKey: Self.devicesKey),
let decoded = try? JSONDecoder().decode([ConfiguredDevice].self, from: data) {
devices = decoded
}
if let raw = defaults.string(forKey: Self.activeProfileKey),
let id = UUID(uuidString: raw),
profiles.contains(where: { $0.id == id }) {
activeProfileID = id
} else {
activeProfileID = profiles[0].id
}
// Geräte, deren Profil nicht mehr existiert, wären sonst unerreichbar.
let known = Set(profiles.map(\.id))
for index in devices.indices where !known.contains(devices[index].profileID) {
devices[index].profileID = profiles[0].id
}
}
private func save() {
let defaults = UserDefaults.standard
if let data = try? JSONEncoder().encode(devices) {
defaults.set(data, forKey: Self.devicesKey)
}
if let data = try? JSONEncoder().encode(profiles) {
defaults.set(data, forKey: Self.profilesKey)
}
defaults.set(activeProfileID.uuidString, forKey: Self.activeProfileKey)
}
}
+59
View File
@@ -0,0 +1,59 @@
import Foundation
import Security
/// Ablage für die Victron-Verschlüsselungsschlüssel. Die gehören nicht in die
/// UserDefaults, deshalb Keychain.
enum KeychainStore {
private static let service = "de.fritob.CamperMonitor.victronKeys"
static func setKey(_ hex: String?, for deviceID: UUID) {
let account = deviceID.uuidString
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
guard let hex, let data = hex.data(using: .utf8) else { return }
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(query as CFDictionary, nil)
}
static func key(for deviceID: UUID) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: deviceID.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
}
extension String {
/// Wandelt einen Hex-String in Bytes. Leerzeichen, Doppelpunkte und ein
/// führendes "0x" werden ignoriert, damit sich der Schlüssel aus
/// VictronConnect einfach einfügen lässt.
var hexBytes: [UInt8]? {
var cleaned = self.lowercased()
.replacingOccurrences(of: "0x", with: "")
.filter { $0.isHexDigit }
guard !cleaned.isEmpty, cleaned.count % 2 == 0 else { return nil }
var bytes: [UInt8] = []
bytes.reserveCapacity(cleaned.count / 2)
while !cleaned.isEmpty {
let pair = String(cleaned.prefix(2))
cleaned.removeFirst(2)
guard let byte = UInt8(pair, radix: 16) else { return nil }
bytes.append(byte)
}
return bytes
}
}
+239
View File
@@ -0,0 +1,239 @@
import SwiftUI
/// Einrichten eines neuen Geräts: scannen, auswählen, benennen.
struct AddDeviceView: View {
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@State private var selected: Discovery?
/// Auf einem Stellplatz sind dutzende fremde Geräte in Reichweite.
@State private var showsAllDevices = false
/// Nur Geräte anzeigen, die in den letzten Sekunden zu hören waren
/// sonst füllt sich die Liste in Wohnmobilparks endlos.
private var visibleDiscoveries: [Discovery] {
// Bewusst nicht nach Signalstärke sortieren: die schwankt im
// Sekundentakt und die Liste würde unter dem Finger springen.
bluetooth.discoveries.values
.filter { showsAllDevices || $0.isVictron || $0.looksLikeDaly }
.sorted { lhs, rhs in
if lhs.isVictron != rhs.isVictron { return lhs.isVictron }
if lhs.looksLikeDaly != rhs.looksLikeDaly { return lhs.looksLikeDaly }
return lhs.firstSeen < rhs.firstSeen
}
}
private var alreadyAdded: Set<UUID> {
Set(store.activeDevices.map(\.peripheralID))
}
var body: some View {
NavigationStack {
List {
Picker("Anzeige", selection: $showsAllDevices) {
Text("Passende Geräte").tag(false)
Text("Alle").tag(true)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
Section {
if visibleDiscoveries.isEmpty {
Label(showsAllDevices ? "Suche…" : "Noch nichts Passendes gefunden",
systemImage: "antenna.radiowaves.left.and.right")
.foregroundStyle(.secondary)
}
ForEach(visibleDiscoveries) { discovery in
Button {
selected = discovery
} label: {
row(for: discovery)
}
.disabled(alreadyAdded.contains(discovery.id))
}
} header: {
HStack {
Text("Gefundene Geräte")
Spacer()
ProgressView().controlSize(.small)
}
} footer: {
Text("Victron-Geräte werden automatisch erkannt. Damit sie hier "
+ "auftauchen, muss „Instant Readout“ in VictronConnect aktiv sein. "
+ "Das BMS meldet sich meist als „DL-…“ findest du es nicht, "
+ "auf „Alle“ umschalten.")
}
}
.navigationTitle("Gerät für \(store.activeProfile?.name ?? "Camper")")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Fertig") { dismiss() }
}
}
.sheet(item: $selected) { discovery in
ConfigureDeviceView(discovery: discovery) {
dismiss()
}
}
.onAppear {
bluetooth.clearDiscoveries()
bluetooth.isDiscovering = true
}
.onDisappear {
bluetooth.isDiscovering = false
}
}
}
private func row(for discovery: Discovery) -> some View {
HStack(spacing: 12) {
Image(systemName: discovery.isVictron ? "bolt.circle.fill"
: discovery.looksLikeDaly ? "battery.100percent" : "dot.radiowaves.left.and.right")
.font(.title3)
.foregroundStyle(discovery.isVictron || discovery.looksLikeDaly ? Color.accentColor : Color.secondary)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(discovery.displayName)
.foregroundStyle(.primary)
Text(discovery.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if alreadyAdded.contains(discovery.id) {
Text("Hinzugefügt")
.font(.caption)
.foregroundStyle(.secondary)
} else {
SignalBars(rssi: discovery.rssi)
}
}
}
}
/// Formular für ein neu gewähltes Gerät.
private struct ConfigureDeviceView: View {
let discovery: Discovery
let onSaved: () -> Void
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@State private var name: String = ""
@State private var role: DeviceRole = .solarCharger
@State private var key: String = ""
private var needsKey: Bool { role.transport == .advertisement }
private var keyIsValid: Bool { key.hexBytes?.count == 16 }
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty && (!needsKey || key.isEmpty || keyIsValid)
}
var body: some View {
NavigationStack {
Form {
Section("Gerät") {
LabeledContent("Gefunden als", value: discovery.displayName)
TextField("Name", text: $name)
Picker("Art", selection: $role) {
ForEach(DeviceRole.allCases) { role in
Text(role.title).tag(role)
}
}
}
if needsKey {
Section {
TextField("32 Hex-Zeichen", text: $key)
.font(.system(.body, design: .monospaced))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if !key.isEmpty && !keyIsValid {
Label("Der Schlüssel muss 16 Byte (32 Hex-Zeichen) haben.",
systemImage: "exclamationmark.circle")
.font(.caption)
.foregroundStyle(.orange)
}
} header: {
Text("Verschlüsselungsschlüssel")
} footer: {
Text("VictronConnect → Gerät → Zahnrad → ⋮ → Produkt-Info → "
+ "„Instant Readout“ aktivieren → Verschlüsselungsdaten anzeigen. "
+ "Kann auch später nachgetragen werden.")
}
}
}
.navigationTitle("Einrichten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Sichern", action: save).disabled(!canSave)
}
}
.onAppear(perform: prefill)
}
}
/// Aus dem Advertisement lässt sich Art und Name oft schon erraten.
private func prefill() {
if let recordType = discovery.victronRecordType {
switch VictronAdvertisement.RecordType(rawValue: recordType) {
case .solarCharger: role = .solarCharger
case .dcdcConverter, .orionXS: role = .chargeBooster
case .batteryMonitor: role = .batteryMonitor
default: role = .solarCharger
}
} else if discovery.looksLikeDaly {
role = .bms
}
name = discovery.name?.isEmpty == false ? discovery.name! : role.title
}
private func save() {
let device = ConfiguredDevice(
name: name.trimmingCharacters(in: .whitespaces),
role: role,
profileID: store.activeProfileID,
peripheralID: discovery.id,
advertisedName: discovery.name
)
store.add(device, victronKey: needsKey && keyIsValid ? key : nil)
bluetooth.refreshConfiguration()
dismiss()
onSaved()
}
}
/// Signalstärke als drei Balken.
struct SignalBars: View {
let rssi: Int
private var level: Int {
switch rssi {
case (-60)...: return 3
case (-75)..<(-60): return 2
case (-90)..<(-75): return 1
default: return 0
}
}
var body: some View {
HStack(alignment: .bottom, spacing: 2) {
ForEach(1...3, id: \.self) { bar in
RoundedRectangle(cornerRadius: 1)
.fill(bar <= level ? Color.accentColor : Color.secondary.opacity(0.25))
.frame(width: 3, height: CGFloat(bar) * 4 + 2)
}
}
.accessibilityLabel("Signalstärke \(level) von 3")
}
}
+109
View File
@@ -0,0 +1,109 @@
import SwiftUI
struct DashboardView: View {
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@State private var isAddingDevice = false
@State private var isManagingProfiles = false
private let columns = [GridItem(.adaptive(minimum: 300), spacing: 16)]
var body: some View {
NavigationStack {
ScrollView {
if !bluetooth.isBluetoothReady {
statusBanner
}
if store.activeDevices.isEmpty {
emptyState
} else {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(store.activeDevices) { device in
NavigationLink {
DeviceDetailView(device: device)
} label: {
DeviceCard(
device: device,
snapshot: bluetooth.snapshots[device.id],
linkState: bluetooth.linkStates[device.id] ?? .searching
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.top, 8)
}
}
.background(Color(.systemGroupedBackground))
.navigationTitle(store.activeProfile?.name ?? "Camper")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
profileMenu
}
ToolbarItem(placement: .primaryAction) {
Button("Gerät hinzufügen", systemImage: "plus") {
isAddingDevice = true
}
}
}
.sheet(isPresented: $isAddingDevice) {
AddDeviceView()
}
.sheet(isPresented: $isManagingProfiles) {
ProfilesView()
}
}
}
/// Umschalter zwischen den Fahrzeugen.
private var profileMenu: some View {
Menu {
Picker("Fahrzeug", selection: Binding(
get: { store.activeProfileID },
set: { id in
guard let profile = store.profiles.first(where: { $0.id == id }) else { return }
store.selectProfile(profile)
bluetooth.refreshConfiguration()
}
)) {
ForEach(store.profiles) { profile in
Label(profile.name, systemImage: profile.symbol).tag(profile.id)
}
}
Divider()
Button("Fahrzeuge verwalten…", systemImage: "gearshape") {
isManagingProfiles = true
}
} label: {
Label(store.activeProfile?.name ?? "Fahrzeug",
systemImage: store.activeProfile?.symbol ?? "box.truck")
.labelStyle(.iconOnly)
}
}
private var statusBanner: some View {
Label(bluetooth.bluetoothStatusText, systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.orange.opacity(0.15), in: .rect(cornerRadius: 12))
.padding(.horizontal)
.padding(.top, 8)
}
private var emptyState: some View {
ContentUnavailableView {
Label("Noch keine Geräte", systemImage: "antenna.radiowaves.left.and.right")
} description: {
Text("Füge \(store.activeProfile.map { "\($0.name)" } ?? "diesem Fahrzeug") "
+ "den Ladebooster, den Solarladeregler und das BMS hinzu.")
} actions: {
Button("Gerät suchen") { isAddingDevice = true }
.buttonStyle(.borderedProminent)
}
.padding(.top, 60)
}
}
+137
View File
@@ -0,0 +1,137 @@
import SwiftUI
/// Kachel auf dem Dashboard: Hauptwert gross, darunter die wichtigsten
/// Nebenwerte und der Verbindungszustand.
struct DeviceCard: View {
let device: ConfiguredDevice
let snapshot: DeviceSnapshot?
let linkState: DeviceLinkState
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
if let snapshot, let primary = snapshot.primaryMetric {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(primary.formatted)
.font(.system(size: 44, weight: .semibold, design: .rounded))
.contentTransition(.numericText())
Text(primary.unit)
.font(.title3.weight(.medium))
.foregroundStyle(.secondary)
}
.foregroundStyle(snapshot.isStale ? .secondary : .primary)
secondaryValues(for: snapshot)
} else {
Text(placeholderText)
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 18)
}
footer
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
}
private var header: some View {
HStack(spacing: 8) {
Image(systemName: device.role.symbol)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 1) {
Text(device.name)
.font(.headline)
Text(device.role.title)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true)
}
}
private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
let others = snapshot.metrics
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
.prefix(3)
return HStack(spacing: 16) {
ForEach(Array(others)) { metric in
VStack(alignment: .leading, spacing: 1) {
Text(metric.formattedWithUnit)
.font(.subheadline.weight(.medium))
.monospacedDigit()
Text(metric.label)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
}
@ViewBuilder
private var footer: some View {
if let fault = snapshot?.fault {
Label(fault, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.red)
.lineLimit(2)
} else if let state = snapshot?.state {
// Bei "Aus" ist erst der Grund die eigentliche Information.
let reason = snapshot?.offReasons.first
Text(reason.map { "\(state) · \($0)" } ?? state)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else if case .failed(let message) = linkState {
Text(message)
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(2)
}
}
private var placeholderText: String {
switch linkState {
case .needsKey: return "Verschlüsselungsschlüssel fehlt im Detail eintragen."
case .failed(let message): return message
default: return "Warte auf Daten…"
}
}
}
/// Kleiner Punkt, der Verbindungszustand und Aktualität zusammenfasst.
struct StatusDot: View {
let linkState: DeviceLinkState
let isStale: Bool
var body: some View {
HStack(spacing: 5) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private var color: Color {
switch linkState {
case .live: return isStale ? .orange : .green
case .needsKey: return .orange
case .failed: return .red
default: return .secondary
}
}
private var label: String {
if case .live = linkState, isStale { return "Veraltet" }
return linkState.label
}
}
+298
View File
@@ -0,0 +1,298 @@
import Charts
import SwiftUI
struct DeviceDetailView: View {
let device: ConfiguredDevice
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@State private var editedName = ""
@State private var keyInput = ""
@State private var showDeleteConfirmation = false
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
var body: some View {
List {
statusSection
if let snapshot, !snapshot.metrics.isEmpty {
Section("Messwerte") {
ForEach(snapshot.metrics) { metric in
LabeledContent(metric.label) {
Text(metric.formattedWithUnit)
.monospacedDigit()
.foregroundStyle(metric.value == nil ? .secondary : .primary)
}
}
}
}
if samples.count > 1, let primary = snapshot?.primaryMetric {
Section("Verlauf \(primary.label)") {
Chart(samples) { sample in
AreaMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value))
.foregroundStyle(.tint.opacity(0.15))
LineMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value))
.foregroundStyle(.tint)
.interpolationMethod(.monotone)
}
.chartYAxisLabel(primary.unit)
.frame(height: 180)
.padding(.vertical, 8)
}
}
if let snapshot, !snapshot.cellVoltages.isEmpty {
cellSection(snapshot.cellVoltages)
}
if let snapshot, snapshot.temperatures.count > 1 {
Section("Temperaturen") {
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
LabeledContent("Fühler \(index + 1)") {
Text(String(format: "%.0f °C", value)).monospacedDigit()
}
}
}
}
if device.role.transport == .advertisement {
keySection
diagnosticsSection
} else {
bmsDiagnosticsSection
}
settingsSection
}
.navigationTitle(device.name)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
editedName = device.name
keyInput = store.victronKeyText(for: device.id) ?? ""
}
.confirmationDialog("Gerät entfernen?",
isPresented: $showDeleteConfirmation,
titleVisibility: .visible) {
Button("Entfernen", role: .destructive) {
store.remove(device)
bluetooth.refreshConfiguration()
dismiss()
}
} message: {
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
}
}
// MARK: - Abschnitte
private var statusSection: some View {
Section {
LabeledContent("Verbindung") {
StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true)
}
if let state = snapshot?.state {
LabeledContent("Zustand", value: state)
}
if let fault = snapshot?.fault {
Label(fault, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
ForEach(snapshot?.offReasons ?? [], id: \.self) { reason in
Label(reason, systemImage: "pause.circle")
.foregroundStyle(.orange)
}
if let snapshot {
LabeledContent("Aktualisiert vor") {
Text(snapshot.timestamp, style: .relative)
}
.foregroundStyle(.secondary)
}
if let rssi = snapshot?.rssi {
LabeledContent("Signal", value: "\(rssi) dBm")
.foregroundStyle(.secondary)
}
}
}
private func cellSection(_ voltages: [Double]) -> some View {
let minimum = voltages.min() ?? 0
let maximum = voltages.max() ?? 0
return Section("Zellspannungen") {
Chart(Array(voltages.enumerated()), id: \.offset) { index, voltage in
// Kategoriale x-Achse: sonst stehen die Balken zwischen den
// Beschriftungen statt darüber.
BarMark(
x: .value("Zelle", "\(index + 1)"),
y: .value("Spannung", voltage)
)
.foregroundStyle(voltage == maximum ? Color.orange
: voltage == minimum ? Color.blue : Color.accentColor)
// Die Zellnummer als Beschriftung am Balken statt über die
// x-Achse die blendet Swift Charts in der Liste aus.
// Ab neun Zellen wird es zu eng, dann ordnet die Liste zu.
.annotation(position: .bottom, alignment: .center) {
if voltages.count <= 8 {
Text("\(index + 1)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
.chartXAxis(.hidden)
// Der interessante Bereich sind die letzten Millivolt, nicht die
// absolute Spannung deshalb eng um die Messwerte zoomen.
.chartYScale(domain: (minimum - 0.05)...(maximum + 0.05))
.chartYAxisLabel("V")
.frame(height: 160)
.padding(.vertical, 8)
ForEach(Array(voltages.enumerated()), id: \.offset) { index, voltage in
LabeledContent("Zelle \(index + 1)") {
Text(String(format: "%.3f V", voltage)).monospacedDigit()
}
}
}
}
private var keySection: some View {
Section {
TextField("32 Hex-Zeichen", text: $keyInput)
.font(.system(.body, design: .monospaced))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit(saveKey)
Button("Schlüssel speichern", action: saveKey)
.disabled(keyInput.hexBytes?.count != 16)
} header: {
Text("Verschlüsselungsschlüssel")
} footer: {
Text("In VictronConnect: Gerät öffnen → Zahnrad → ⋮ → Produkt-Info → "
+ "„Instant Readout“ einschalten → Verschlüsselungsdaten anzeigen. "
+ "Der Schlüssel ist 16 Byte lang (32 Hex-Zeichen).")
}
}
/// Zeigt, was das Gerät unverschlüsselt sendet. Wichtigster Wert ist das
/// erste Schlüsselbyte: stimmt es nicht mit der Eingabe überein, gehört der
/// Schlüssel zu einem anderen Victron-Gerät.
@ViewBuilder
private var diagnosticsSection: some View {
if let info = bluetooth.diagnostics[device.id] {
Section {
LabeledContent("Gerät sendet als erstes Schlüsselbyte") {
Text(info.expectedKeyText)
.font(.body.monospaced())
.foregroundStyle(keyBytesAgree == false ? .red : .primary)
}
LabeledContent("Eingetragener Schlüssel beginnt mit") {
Text(enteredKeyText)
.font(.body.monospaced())
.foregroundStyle(keyBytesAgree == false ? .red : .secondary)
}
LabeledContent("Datensatz", value: info.recordName)
LabeledContent("Produkt-ID") {
Text(info.productIDText).font(.body.monospaced())
}
VStack(alignment: .leading, spacing: 4) {
Text("Rohdaten")
Text(info.rawHex)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
} header: {
Text("Diagnose")
} footer: {
if keyBytesAgree == false {
Text("Die beiden Bytes müssen übereinstimmen. Tun sie das nicht, "
+ "stammt der Schlüssel von einem anderen Victron-Gerät in "
+ "VictronConnect prüfen, ob wirklich dieses Gerät geöffnet war.")
} else {
Text("Diese Werte sendet das Gerät unverschlüsselt mit.")
}
}
}
}
/// nil, solange kein vollständiger Schlüssel eingetragen ist.
private var keyBytesAgree: Bool? {
guard let expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte,
let entered = keyInput.hexBytes?.first else { return nil }
return expected == entered
}
private var enteredKeyText: String {
guard let byte = keyInput.hexBytes?.first else { return "" }
return String(format: "0x%02X", byte)
}
/// Welches Protokoll das BMS spricht und was zuletzt ankam.
@ViewBuilder
private var bmsDiagnosticsSection: some View {
if let info = bluetooth.bmsDiagnostics[device.id] {
Section {
LabeledContent("Erkanntes Protokoll", value: info.dialect)
if let service = info.serviceUUID {
LabeledContent("Dienst") {
Text(service).font(.caption.monospaced()).foregroundStyle(.secondary)
}
}
if let hex = info.lastResponseHex {
VStack(alignment: .leading, spacing: 4) {
Text("Letzte Antwort")
Text(hex)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
} header: {
Text("Diagnose")
} footer: {
Text("Die App probiert Daly (klassisch und Modbus) sowie JBD/Xiaoxiang "
+ "durch und übernimmt, was antwortet. Bleibt es bei „wird ermittelt“, "
+ "spricht das BMS ein anderes Protokoll dann hilft die Rohantwort weiter.")
}
}
}
private var settingsSection: some View {
Section("Einstellungen") {
TextField("Name", text: $editedName)
.onSubmit(saveName)
Button("Namen übernehmen", action: saveName)
.disabled(editedName.trimmingCharacters(in: .whitespaces).isEmpty
|| editedName == device.name)
LabeledContent("Typ", value: device.role.title)
LabeledContent("Bluetooth-ID") {
Text(device.peripheralID.uuidString.prefix(8) + "")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Button("Gerät entfernen", role: .destructive) {
showDeleteConfirmation = true
}
}
}
// MARK: - Aktionen
private func saveKey() {
store.setVictronKey(keyInput, for: device.id)
bluetooth.refreshConfiguration()
}
private func saveName() {
var updated = device
updated.name = editedName.trimmingCharacters(in: .whitespaces)
store.update(updated)
}
}
+168
View File
@@ -0,0 +1,168 @@
import SwiftUI
/// Fahrzeuge anlegen, umbenennen und entfernen.
struct ProfilesView: View {
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@State private var editing: Profile?
@State private var pendingDeletion: Profile?
var body: some View {
NavigationStack {
List {
Section {
ForEach(store.profiles) { profile in
row(for: profile)
}
} footer: {
Text("Jedes Fahrzeug hat seine eigenen Geräte. Die App liest immer "
+ "nur die des gewählten Fahrzeugs aus.")
}
Section {
Button("Fahrzeug hinzufügen", systemImage: "plus") {
let profile = store.addProfile(named: "Camper \(store.profiles.count + 1)")
editing = profile
}
}
}
.navigationTitle("Fahrzeuge")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Fertig") { dismiss() }
}
}
.sheet(item: $editing) { profile in
ProfileEditView(profile: profile)
}
.confirmationDialog("Fahrzeug entfernen?",
isPresented: .init(get: { pendingDeletion != nil },
set: { if !$0 { pendingDeletion = nil } }),
titleVisibility: .visible) {
Button("Entfernen", role: .destructive) {
if let pendingDeletion {
store.removeProfile(pendingDeletion)
bluetooth.refreshConfiguration()
}
pendingDeletion = nil
}
} message: {
if let pendingDeletion {
Text("\(pendingDeletion.name)“ und die \(store.deviceCount(in: pendingDeletion)) "
+ "darin eingerichteten Geräte werden gelöscht.")
}
}
}
}
private func row(for profile: Profile) -> some View {
HStack {
Label {
VStack(alignment: .leading, spacing: 2) {
Text(profile.name)
Text(deviceSummary(for: profile))
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: profile.symbol)
}
Spacer()
if profile.id == store.activeProfileID {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
.fontWeight(.semibold)
}
}
.contentShape(.rect)
.onTapGesture {
store.selectProfile(profile)
bluetooth.refreshConfiguration()
}
.swipeActions(edge: .trailing) {
// Das letzte Fahrzeug muss bleiben, sonst hätten Geräte keinen Ort.
if store.hasMultipleProfiles {
Button("Entfernen", systemImage: "trash", role: .destructive) {
pendingDeletion = profile
}
}
Button("Bearbeiten", systemImage: "pencil") {
editing = profile
}
.tint(.gray)
}
}
private func deviceSummary(for profile: Profile) -> String {
let count = store.deviceCount(in: profile)
return count == 1 ? "1 Gerät" : "\(count) Geräte"
}
}
/// Name und Symbol eines Fahrzeugs ändern.
private struct ProfileEditView: View {
let profile: Profile
@Environment(DeviceStore.self) private var store
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var symbol = "box.truck"
private let columns = [GridItem(.adaptive(minimum: 60), spacing: 12)]
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("Name", text: $name)
}
Section("Symbol") {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(Profile.symbols, id: \.self) { candidate in
Button {
symbol = candidate
} label: {
Image(systemName: candidate)
.font(.title2)
.frame(width: 52, height: 52)
.background(symbol == candidate ? Color.accentColor.opacity(0.18)
: Color.secondary.opacity(0.08),
in: .rect(cornerRadius: 12))
.foregroundStyle(symbol == candidate ? Color.accentColor : Color.primary)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
}
.navigationTitle("Fahrzeug")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Sichern") {
var updated = profile
updated.name = name.trimmingCharacters(in: .whitespaces)
updated.symbol = symbol
store.update(updated)
dismiss()
}
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.onAppear {
name = profile.name
symbol = profile.symbol
}
}
}
}