diff --git a/CamperMonitor/Bluetooth/BMSSession.swift b/CamperMonitor/Bluetooth/BMSSession.swift index f7fe346..dc7afa7 100644 --- a/CamperMonitor/Bluetooth/BMSSession.swift +++ b/CamperMonitor/Bluetooth/BMSSession.swift @@ -65,6 +65,10 @@ final class BMSSession: NSObject { } let deviceID: UUID + /// Die Queue, auf der CoreBluetooth arbeitet. Alle Zeitgeber und + /// verzögerten Aufrufe laufen darauf, damit der Zustand dieser Klasse nur + /// von einem Thread aus angefasst wird. + private let queue: DispatchQueue private let peripheral: CBPeripheral private let onUpdate: (DeviceSnapshot) -> Void private let onStateChange: (DeviceLinkState) -> Void @@ -88,7 +92,7 @@ final class BMSSession: NSObject { /// Ob die Kühlbox in dieser Sitzung schon angemeldet wurde. private var didBind = false private var buffer: [UInt8] = [] - private var pollTimer: Timer? + private var pollTimer: DispatchSourceTimer? private var lastResponse: Data? private var lastCommand: Data? private var lastCommandAt: Date? @@ -116,10 +120,12 @@ final class BMSSession: NSObject { init(deviceID: UUID, peripheral: CBPeripheral, + queue: DispatchQueue, onUpdate: @escaping (DeviceSnapshot) -> Void, onStateChange: @escaping (DeviceLinkState) -> Void, onDiagnostics: @escaping (BMSDiagnostics) -> Void) { self.deviceID = deviceID + self.queue = queue self.peripheral = peripheral self.onUpdate = onUpdate self.onStateChange = onStateChange @@ -136,7 +142,7 @@ final class BMSSession: NSObject { } func stop() { - pollTimer?.invalidate() + pollTimer?.cancel() pollTimer = nil if let notify = currentEndpoint?.notify, peripheral.state == .connected { peripheral.setNotifyValue(false, for: notify) @@ -148,7 +154,7 @@ final class BMSSession: NSObject { } func handleDisconnect() { - pollTimer?.invalidate() + pollTimer?.cancel() pollTimer = nil endpoints.removeAll() endpointIndex = 0 @@ -231,7 +237,7 @@ final class BMSSession: NSObject { // Manche Module bestätigen das Abonnieren nie. Ohne Zeitlimit bliebe // die Suche hier für immer stehen, ohne je etwas zu senden. - DispatchQueue.main.asyncAfter(deadline: .now() + 4) { [weak self] in + queue.asyncAfter(deadline: .now() + 4) { [weak self] in guard let self, self.activationToken == token, !self.isNotifyActive else { return } if self.endpoints.count > 1 { self.advanceEndpoint() @@ -267,7 +273,7 @@ final class BMSSession: NSObject { return } let token = activationToken - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + queue.asyncAfter(deadline: .now() + 0.2) { [weak self] in guard let self, self.activationToken == token, self.peripheral.state == .connected else { return } let type: CBCharacteristicWriteType = @@ -276,7 +282,7 @@ final class BMSSession: NSObject { self.didUnlock = true self.publishDiagnostics() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + queue.asyncAfter(deadline: .now() + 0.3) { [weak self] in guard let self, self.activationToken == token else { return } self.beginPolling() } @@ -284,12 +290,14 @@ final class BMSSession: NSObject { } private func beginPolling() { - pollTimer?.invalidate() + pollTimer?.cancel() poll() let interval = dialect == .unknown ? searchInterval : pollInterval - pollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in - self?.poll() - } + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + interval, repeating: interval) + timer.setEventHandler { [weak self] in self?.poll() } + timer.resume() + pollTimer = timer } private func poll() { @@ -337,7 +345,7 @@ final class BMSSession: NSObject { /// die zu dicht aufeinander folgen. private func sendSequence(_ frames: [Data], spacing: TimeInterval, thenGiveUpAfter grace: TimeInterval) { for (index, frame) in frames.enumerated() { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * spacing) { [weak self] in + queue.asyncAfter(deadline: .now() + Double(index) * spacing) { [weak self] in self?.send(frame) } } @@ -346,7 +354,7 @@ final class BMSSession: NSObject { /// Kommt nichts Brauchbares zurück, wird der nächste Kandidat versucht. private func checkForSilence(after delay: TimeInterval) { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + queue.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self, self.peripheral.state == .connected else { return } guard !self.hasUsableData else { self.silentRounds = 0 @@ -390,7 +398,7 @@ final class BMSSession: NSObject { peripheral.writeValue(piece, for: endpoint.write, type: endpoint.writeType) continue } - DispatchQueue.main.asyncAfter( + queue.asyncAfter( deadline: .now() + Double(index) * AlpicoolProtocol.chunkDelay ) { [weak self] in guard let self, self.peripheral.state == .connected, @@ -413,7 +421,7 @@ final class BMSSession: NSObject { lastCommandAt = Date() send(packet) // Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist. - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in guard let self, self.dialect == .alpicool else { return } self.send(AlpicoolProtocol.packet(.query)) } diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index 073934e..bf85cd7 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -105,16 +105,34 @@ struct HistorySample: Identifiable, Hashable { /// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Victron-Werbedaten und /// hält parallel die Verbindung zum Daly-BMS. +/// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Victron-Werbedaten und +/// hält parallel die Verbindungen zu BMS und Kühlbox. +/// +/// **Threading.** CoreBluetooth arbeitet auf einer eigenen seriellen Queue, +/// nicht auf dem Hauptthread. Advertisements treffen einzeln ein – bei +/// aktivem Duplikat-Scan über alle Geräte in Reichweite sind das leicht +/// hunderte je Sekunde, die sonst mit dem Zeichnen der Oberfläche um +/// dieselbe Zeit konkurrieren. +/// +/// Daraus folgt eine strikte Trennung: +/// +/// * Alles unter „nur auf `queue`“ wird ausschließlich dort angefasst – +/// Verbindungen, Puffer, Zeitgeber. +/// * Die veröffentlichten Werte werden ausschließlich auf dem Hauptthread +/// geschrieben, über `publish`. +/// * Die Geräteliste wird nicht aus der Funk-Queue heraus im Speicher +/// nachgeschlagen, sondern als Momentaufnahme hinübergereicht. @Observable final class BluetoothManager: NSObject { + // MARK: - Veröffentlicht (nur auf dem Hauptthread schreiben) + 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…" @@ -122,45 +140,53 @@ final class BluetoothManager: NSObject { /// Solange true, werden alle gefundenen Peripherals gesammelt. var isDiscovering = false { didSet { - restartScan() - isDiscovering ? startDiscoveryFlush() : stopDiscoveryFlush() + let wanted = isDiscovering + queue.async { self.setDiscovering(wanted) } } } - private let store: DeviceStore + // MARK: - Nur auf `queue` + + private let queue = DispatchQueue(label: "de.fritob.CamperMonitor.bluetooth") private var central: CBCentralManager? + + /// Momentaufnahme der eingerichteten Geräte, damit die Funk-Queue nicht in + /// den Speicher greifen muss. + private struct ManagedDevice { + let id: UUID + let peripheralID: UUID + let transport: DeviceTransport + let role: DeviceRole + let fridgeZoneMode: FridgeZoneMode + let victronKey: [UInt8]? + } + private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID + private var bmsSessions: [UUID: BMSSession] = [:] private var connectedPeripherals: [UUID: CBPeripheral] = [:] - private var reconnectTimer: Timer? + private var reconnectTimer: DispatchSourceTimer? + private var discoveryFlushTimer: DispatchSourceTimer? + private var discovering = false - /// 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. + /// Advertisements treffen mehrmals je Sekunde und Gerät ein. Öfter als + /// hier festgelegt wird nichts ausgewertet – für die Anzeige ändert ein + /// Advertisement mehr nichts, es kostet aber Entschlüsselung und eine + /// Neuzeichnung. private var pendingDiscoveries: [UUID: Discovery] = [:] - private var discoveryFlushTimer: Timer? - - /// Wann ein Peripheral zuletzt ausgewertet wurde. - /// - /// CoreBluetooth meldet jedes Advertisement einzeln und auf dem - /// Hauptthread. Victron sendet mehrmals je Sekunde, dazu kommt alles - /// andere in Funkreichweite – auf einem Stellplatz schnell hunderte - /// Ereignisse pro Sekunde, die der Oberfläche die Zeit zum Zeichnen - /// nehmen. Öfter als hier festgelegt wird deshalb nichts verarbeitet. private var lastHandledAdvertisement: [UUID: Date] = [:] - private let minimumAdvertisementInterval: TimeInterval = 0.9 - /// Für die Geräteliste reicht ein gröberer Takt. - private let minimumDiscoveryInterval: TimeInterval = 2.0 - /// Die Rohdatenanzeige der Diagnose muss nicht live mitlaufen. - private var lastDiagnosticsUpdate: [UUID: Date] = [:] private var lastDiscoveryUpdate: [UUID: Date] = [:] + private var lastDiagnosticsUpdate: [UUID: Date] = [:] + private let minimumAdvertisementInterval: TimeInterval = 0.9 + private let minimumDiscoveryInterval: TimeInterval = 2.0 private let minimumDiagnosticsInterval: TimeInterval = 3.0 + // MARK: - Sonstiges + + private let store: DeviceStore + private let isDemo = DemoData.isEnabled /// 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() @@ -168,7 +194,13 @@ final class BluetoothManager: NSObject { loadDemoData() return } - central = CBCentralManager(delegate: self, queue: .main) + central = CBCentralManager(delegate: self, queue: queue) + refreshConfiguration() + } + + /// Führt Änderungen an den veröffentlichten Werten auf dem Hauptthread aus. + private func publish(_ work: @escaping () -> Void) { + if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) } } private func loadDemoData() { @@ -184,32 +216,105 @@ final class BluetoothManager: NSObject { } } - // MARK: - Steuerung + // MARK: - Steuerung (vom Hauptthread aufgerufen) func start() { - guard !isDemo, central?.state == .poweredOn else { return } - restartScan() - connectManagedPeripherals() - scheduleReconnects() + guard !isDemo else { return } + queue.async { self.startScanning() } } 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() + queue.async { self.stopEverything() } } - /// Nach Änderungen an der Geräteliste aufrufen. + /// Nach Änderungen an der Geräteliste oder den Schlüsseln 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) { + + // Momentaufnahme auf dem Hauptthread ziehen, wo der Speicher lebt. + let devices = store.activeDevices.map { device in + ManagedDevice(id: device.id, + peripheralID: device.peripheralID, + transport: device.role.transport, + role: device.role, + fridgeZoneMode: device.fridgeZoneMode, + victronKey: store.victronKeyBytes(for: device.id)) + } + let known = Set(devices.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) } + + queue.async { self.applyConfiguration(devices) } + } + + func clearDiscoveries() { + discoveries.removeAll() + queue.async { + self.pendingDiscoveries.removeAll() + self.lastDiscoveryUpdate.removeAll() + } + } + + // MARK: - Kühlbox steuern (vom Hauptthread aufgerufen) + + func updateFridgeZoneMode(for device: ConfiguredDevice) { + let deviceID = device.id, mode = device.fridgeZoneMode + queue.async { + guard let session = self.session(for: deviceID) else { return } + session.fridgeZoneMode = mode + let state = session.alpicoolState + self.publish { self.fridgeStates[deviceID] = state } + } + } + + func setFridgeTarget(_ celsius: Int, zone: AlpicoolState.Zone, for deviceID: UUID) { + queue.async { + self.session(for: deviceID)? + .sendControl(AlpicoolState.setTarget(zone: zone, to: celsius)) + } + } + + func setFridgePower(_ on: Bool, for deviceID: UUID) { + sendFridgeSettings(for: deviceID) { $0.settingsCommand(poweredOn: on) } + } + + func setFridgeEco(_ eco: Bool, for deviceID: UUID) { + sendFridgeSettings(for: deviceID) { $0.settingsCommand(eco: eco) } + } + + func setFridgeLock(_ locked: Bool, for deviceID: UUID) { + sendFridgeSettings(for: deviceID) { $0.settingsCommand(locked: locked) } + } + + /// Der Einstellungsblock wird aus dem zuletzt empfangenen Zustand gebaut – + /// und zwar auf der Funk-Queue, wo dieser Zustand lebt. + private func sendFridgeSettings(for deviceID: UUID, + build: @escaping (AlpicoolState) -> Data?) { + queue.async { + guard let session = self.session(for: deviceID), + let packet = build(session.alpicoolState) else { return } + session.sendControl(packet) + } + } + + private func session(for deviceID: UUID) -> BMSSession? { + guard let entry = managed.values.first(where: { $0.id == deviceID }) else { return nil } + return bmsSessions[entry.peripheralID] + } + + // MARK: - Ablauf auf der Funk-Queue + + private func applyConfiguration(_ devices: [ManagedDevice]) { + managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) }) + + // Verbindungen zu Geräten lösen, die nicht mehr dazugehören. + let wanted = Set(devices.filter { $0.transport == .connect }.map(\.peripheralID)) + for (peripheralID, session) in bmsSessions where !wanted.contains(peripheralID) { session.stop() bmsSessions[peripheralID] = nil if let peripheral = connectedPeripherals[peripheralID] { @@ -217,46 +322,47 @@ final class BluetoothManager: NSObject { 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) } + // Zoneneinstellung an bestehende Sitzungen weiterreichen. + for device in devices { + bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode + } lastHandledAdvertisement.removeAll() lastDiagnosticsUpdate.removeAll() - connectManagedPeripherals() + + startScanning() + } + + private func setDiscovering(_ wanted: Bool) { + guard discovering != wanted else { return } + discovering = wanted + if wanted { + startDiscoveryFlush() + } else { + discoveryFlushTimer?.cancel() + discoveryFlushTimer = nil + } restartScan() } - func clearDiscoveries() { - discoveries.removeAll() - pendingDiscoveries.removeAll() - lastDiscoveryUpdate.removeAll() + private func startScanning() { + guard central?.state == .poweredOn else { return } + restartScan() + connectManagedPeripherals() + scheduleReconnects() } - 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 stopEverything() { + central?.stopScan() + reconnectTimer?.cancel(); reconnectTimer = nil + discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil + for (_, session) in bmsSessions { session.stop() } + for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) } + bmsSessions.removeAll() + connectedPeripherals.removeAll() } - private func stopDiscoveryFlush() { - discoveryFlushTimer?.invalidate() - discoveryFlushTimer = nil - } - - // MARK: - Scannen - private func restartScan() { - guard !isDemo, central?.state == .poweredOn else { return } + guard central?.state == .poweredOn else { return } central?.stopScan() // Victron sendet seine Werte im Advertisement, also müssen auch // Wiederholungen durchgereicht werden. @@ -267,115 +373,98 @@ final class BluetoothManager: NSObject { } private func connectManagedPeripherals() { - guard !isDemo, central?.state == .poweredOn else { return } - for device in store.activeDevices where device.role.transport == .connect { + guard central?.state == .poweredOn else { return } + for device in managed.values where device.transport == .connect { connectIfNeeded(device) } } - private func connectIfNeeded(_ device: ConfiguredDevice) { - let peripheralID = device.peripheralID - if let existing = connectedPeripherals[peripheralID], + private func connectIfNeeded(_ device: ManagedDevice) { + if let existing = connectedPeripherals[device.peripheralID], existing.state == .connected || existing.state == .connecting { return } - guard let peripheral = central?.retrievePeripherals(withIdentifiers: [peripheralID]).first else { - linkStates[device.id] = .searching + guard let peripheral = central? + .retrievePeripherals(withIdentifiers: [device.peripheralID]).first else { + publish { self.linkStates[device.id] = .searching } return } - connectedPeripherals[peripheralID] = peripheral - linkStates[device.id] = .connecting + connectedPeripherals[device.peripheralID] = peripheral + publish { self.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() + reconnectTimer?.cancel() + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 15, repeating: 15) + timer.setEventHandler { [weak self] in self?.connectManagedPeripherals() } + timer.resume() + reconnectTimer = timer + } + + private func startDiscoveryFlush() { + discoveryFlushTimer?.cancel() + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 1, repeating: 1) + timer.setEventHandler { [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 } + let current = self.pendingDiscoveries + self.publish { self.discoveries = current } + } + timer.resume() + discoveryFlushTimer = timer + } + + // MARK: - Auswertung (auf der Funk-Queue) + + private func record(_ snapshot: DeviceSnapshot) { + publish { + self.snapshots[snapshot.deviceID] = snapshot + guard let primary = snapshot.primaryMetric, let value = primary.value else { return } + var samples = self.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 > self.historyLimit { + samples.removeFirst(samples.count - self.historyLimit) + } + self.history[snapshot.deviceID] = samples } } - /// Nach einer Änderung der Zoneneinstellung aufrufen. - func updateFridgeZoneMode(for device: ConfiguredDevice) { - guard let session = bmsSessions[device.peripheralID] else { return } - session.fridgeZoneMode = device.fridgeZoneMode - fridgeStates[device.id] = session.alpicoolState - } - - // 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, + device: ManagedDevice, rssi: Int) { - // Zuerst den unverschlüsselten Rahmen festhalten – gerade wenn der - // Schlüssel nicht passt, ist das die einzige verwertbare Information. - // Der Hex-String kostet mehr als die Auswertung selbst, deshalb nur + // Den unverschlüsselten Rahmen festhalten – gerade wenn der Schlüssel + // nicht passt, ist das die einzige verwertbare Information. Der + // Hex-String kostet mehr als die Auswertung selbst, deshalb nur // gelegentlich, und immer wenn noch gar nichts angezeigt werden kann. - let needsDiagnostics = diagnostics[device.id] == nil - || Date().timeIntervalSince(lastDiagnosticsUpdate[device.id] ?? .distantPast) - >= minimumDiagnosticsInterval - if needsDiagnostics, let envelope = VictronAdvertisement.envelope(from: manufacturerData) { - lastDiagnosticsUpdate[device.id] = Date() - diagnostics[device.id] = VictronDiagnostics( + let now = Date() + if now.timeIntervalSince(lastDiagnosticsUpdate[device.id] ?? .distantPast) + >= minimumDiagnosticsInterval, + let envelope = VictronAdvertisement.envelope(from: manufacturerData) { + lastDiagnosticsUpdate[device.id] = now + let info = 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() + updated: now ) + publish { self.diagnostics[device.id] = info } } - guard let key = store.victronKeyBytes(for: device.id) else { - linkStates[device.id] = .needsKey + guard let key = device.victronKey else { + publish { self.linkStates[device.id] = .needsKey } return } do { @@ -383,17 +472,19 @@ final class BluetoothManager: NSObject { key: key, deviceID: device.id, rssi: rssi) - linkStates[device.id] = .live + publish { self.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) + let message = error.localizedDescription + publish { self.linkStates[device.id] = .failed(message) } } } catch { - linkStates[device.id] = .failed(error.localizedDescription) + let message = error.localizedDescription + publish { self.linkStates[device.id] = .failed(message) } } } @@ -440,29 +531,24 @@ final class BluetoothManager: NSObject { } } -// MARK: - CBCentralManagerDelegate +// MARK: - CBCentralManagerDelegate (alles auf der Funk-Queue) extension BluetoothManager: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { + let ready: Bool, text: String 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…" + case .poweredOn: ready = true; text = "Bereit" + case .poweredOff: ready = false; text = "Bluetooth ist ausgeschaltet" + case .unauthorized: ready = false; text = "Bluetooth-Zugriff wurde nicht erlaubt" + case .unsupported: ready = false; text = "Dieses Gerät unterstützt kein Bluetooth LE" + default: ready = false; text = "Bluetooth wird gestartet…" } + publish { + self.isBluetoothReady = ready + self.bluetoothStatusText = text + } + if central.state == .poweredOn { startScanning() } } func centralManager(_ central: CBCentralManager, @@ -473,47 +559,51 @@ extension BluetoothManager: CBCentralManagerDelegate { let now = Date() let identifier = peripheral.identifier - if isDiscovering, + if discovering, now.timeIntervalSince(lastDiscoveryUpdate[identifier] ?? .distantPast) >= minimumDiscoveryInterval { lastDiscoveryUpdate[identifier] = now updateDiscovery(peripheral: peripheral, advertisementData: advertisementData, rssi: rssi) } - guard let device = store.device(withPeripheralID: identifier) else { return } - - // Ein Advertisement mehr ändert die Anzeige nicht, kostet aber - // Entschlüsselung und eine Neuzeichnung. + guard let device = managed[identifier] else { return } guard now.timeIntervalSince(lastHandledAdvertisement[identifier] ?? .distantPast) >= minimumAdvertisementInterval else { return } lastHandledAdvertisement[identifier] = now - switch device.role.transport { + switch device.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 { + // Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt aufbauen. + if connectedPeripherals[identifier] == nil { connectIfNeeded(device) } } } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { - guard let device = store.device(withPeripheralID: peripheral.identifier) else { return } + guard let device = managed[peripheral.identifier] else { return } connectedPeripherals[peripheral.identifier] = peripheral let session = BMSSession( deviceID: device.id, peripheral: peripheral, + queue: queue, 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 } + onStateChange: { [weak self] state in + self?.publish { self?.linkStates[device.id] = state } + }, + onDiagnostics: { [weak self] info in + self?.publish { self?.bmsDiagnostics[device.id] = info } + } ) - session.onFridgeState = { [weak self] state in self?.fridgeStates[device.id] = state } + session.onFridgeState = { [weak self] state in + self?.publish { self?.fridgeStates[device.id] = state } + } session.fridgeZoneMode = device.fridgeZoneMode bmsSessions[peripheral.identifier] = session session.start() @@ -522,8 +612,9 @@ extension BluetoothManager: CBCentralManagerDelegate { 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") + if let device = managed[peripheral.identifier] { + let message = error?.localizedDescription ?? "Verbindung fehlgeschlagen" + publish { self.linkStates[device.id] = .failed(message) } } connectedPeripherals[peripheral.identifier] = nil } @@ -535,8 +626,8 @@ extension BluetoothManager: CBCentralManagerDelegate { bmsSessions[peripheral.identifier] = nil connectedPeripherals[peripheral.identifier] = nil - if let device = store.device(withPeripheralID: peripheral.identifier) { - linkStates[device.id] = .searching + if let device = managed[peripheral.identifier] { + publish { self.linkStates[device.id] = .searching } // Direkt wieder anfragen; iOS stellt die Verbindung her, sobald das // Gerät wieder in Reichweite ist. connectIfNeeded(device)