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 looksLikeSupported: 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 looksLikeSupported { return "Sieht nach BMS oder Kühlbox 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 /// Welche Schreib-/Notify-Kombination gerade versucht wird. var endpointLabel: String? /// Der wievielte von wie vielen Kandidaten das ist. var endpointPosition: Pair? var serviceUUID: String? var isConnected: Bool /// Ob das Gerät das Abonnieren der Notify-Charakteristik bestätigt hat. var isNotifyActive: Bool /// Vollständiger Dienst-/Merkmalsbaum des Geräts. var gattSummary: [String] var sentFrames: Int var receivedBytes: Int var lastSendAt: Date? var lastResponseHex: String? var updated: Date struct Pair: Hashable { var index: Int var total: Int init(_ index: Int, _ total: Int) { self.index = index; self.total = total } } } /// 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] = [:] /// Bedienzustand der Kühlboxen, damit die Schalter dem Gerät folgen. private(set) var fridgeStates: [UUID: AlpicoolState] = [:] 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" fridgeStates[DemoData.fridge.id] = DemoData.fridgeState 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) } fridgeStates = fridgeStates.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: - Kühlbox steuern private func fridgeSession(for deviceID: UUID) -> BMSSession? { guard let device = store.devices.first(where: { $0.id == deviceID }) else { return nil } return bmsSessions[device.peripheralID] } /// Solltemperatur einer Zone setzen. func setFridgeTarget(_ celsius: Int, zone: AlpicoolState.Zone, for deviceID: UUID) { fridgeSession(for: deviceID)? .sendControl(AlpicoolState.setTarget(zone: zone, to: celsius)) } /// Kühlbox ein- oder ausschalten. func setFridgePower(_ on: Bool, for deviceID: UUID) { guard let session = fridgeSession(for: deviceID), let packet = session.alpicoolState.settingsCommand(poweredOn: on) else { return } session.sendControl(packet) } /// Zwischen sparsamem und schnellem Kühlen umschalten. func setFridgeEco(_ eco: Bool, for deviceID: UUID) { guard let session = fridgeSession(for: deviceID), let packet = session.alpicoolState.settingsCommand(eco: eco) else { return } session.sendControl(packet) } /// Bedienfeld der Box sperren oder freigeben. func setFridgeLock(_ locked: Bool, for deviceID: UUID) { guard let session = fridgeSession(for: deviceID), let packet = session.alpicoolState.settingsCommand(locked: locked) else { return } session.sendControl(packet) } // 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, looksLikeSupported: 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.looksLikeSupported = Self.looksLikeSupported(name: entry.name) pendingDiscoveries[peripheral.identifier] = entry } /// Grobe Namensprüfung für die Vorauswahl in der Geräteliste. Nur ein /// Hinweis – auswählbar ist über "Alle" weiterhin jedes Gerät. private static func looksLikeSupported(name: String?) -> Bool { guard let name = name?.lowercased() else { return false } let needles = [ "daly", "bms", "bulltron", "wattcycle", // Batterien "alpicool", "icecube", "ice cube", "fridge", "cool", // Kühlboxen ] return name.hasPrefix("dl-") || name.hasPrefix("wt") || needles.contains { name.contains($0) } } } // 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 } ) session.onFridgeState = { [weak self] state in self?.fridgeStates[device.id] = state } 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) } } }