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,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)
}
}
}