init
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user