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 /// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar. var isLevelSensor = false /// Der Solarladeregler bewirbt seinen Dienst ebenso. var isVotronicSolarESPSensor = false 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 isLevelSensor { return "VanAlign Neigungsmesser" } if isVotronicSolarESPSensor { return "VotronicSolarESP" } 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 /// Nur bei Kühlboxen: ob die Box die Anmeldung beantwortet hat. var isBound: Bool? /// Wieviele Schreibvorgänge das Gerät bestätigt hat. Zählt nur mit, wenn /// mit Bestätigung geschrieben wird. var confirmedWrites: Int /// Fehler des letzten bestätigten Schreibvorgangs. var lastWriteError: String? /// Nur bei Kühlboxen: die vollständige Nutzlast der letzten Statusantwort. /// Daran hängt die Länge des Stellbefehls, deshalb gehört sie in die /// Diagnose. var fridgePayloadHex: String? /// Vollständiger Dienst-/Merkmalsbaum des Geräts. var gattSummary: [String] var sentFrames: Int var receivedBytes: Int var lastSendAt: Date? var lastResponseHex: String? /// Zuletzt abgeschickter Stellbefehl, damit sich prüfen lässt, ob er das /// Gerät überhaupt erreicht hat. var lastCommandHex: String? var lastCommandAt: Date? 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. /// 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] = [:] private(set) var fridgeStates: [UUID: AlpicoolState] = [:] private(set) var levelStates: [UUID: LevelState] = [:] private(set) var votronicSolarESPStates: [UUID: VotronicSolarESPState] = [:] private(set) var isBluetoothReady = false private(set) var bluetoothStatusText = "Bluetooth wird gestartet…" /// Verbindet die Live Activity des Neigungsmessers mit dem Funkgeschehen – /// gesetzt von `VanControlApp`, damit diese Schicht nichts über /// ActivityKit wissen muss, wenn niemand zuhört. var activityManager: LevelActivityManager? /// Solange true, werden alle gefundenen Peripherals gesammelt. var isDiscovering = false { didSet { let wanted = isDiscovering queue.async { self.setDiscovering(wanted) } } } // MARK: - Nur auf `queue` private let queue = DispatchQueue(label: "de.fritob.VanControl.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 sensorOrientation: SensorOrientation let victronKey: [UInt8]? } private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID /// Geräte, die nur verbunden werden, solange ihre Ansicht offen ist. /// Schlüssel ist die Gerätekennung, gezählt wird nicht – eine Ansicht ist /// zu einer Zeit offen. private var requestedSessions: Set = [] private var bmsSessions: [UUID: BMSSession] = [:] private var levelSessions: [UUID: LevelSession] = [:] private var votronicSolarESPSessions: [UUID: VotronicSolarESPSession] = [:] private var connectedPeripherals: [UUID: CBPeripheral] = [:] private var reconnectTimer: DispatchSourceTimer? /// Frühester Zeitpunkt für den nächsten Verbindungsversuch je Gerät. /// /// Ohne diese Sperre entsteht ein Verbindungssturm: schlägt ein Versuch /// fehl, ist der Eintrag wieder frei, und das nächste Advertisement löst /// sofort den nächsten aus - bei aktivem Scan bis zu einmal je Sekunde. /// Geräte quittieren jeden Versuch, Kühlboxen etwa mit einem Piepton. private var nextConnectAttempt: [UUID: Date] = [:] private var connectFailures: [UUID: Int] = [:] private let firstRetryDelay: TimeInterval = 5 private let longestRetryDelay: TimeInterval = 60 /// Nach dem Abbruch einer Verbindung, die stand, wird zügig nachgefasst - /// das ist der Normalfall im Fahrzeug und kein Fehler. private let quickRetryDelay: TimeInterval = 2 /// Ab dieser Dauer gilt eine Verbindung als zustandegekommen. Was sofort /// wieder abbricht, zählt als Fehlschlag. private let stableConnection: TimeInterval = 3 private var connectedSince: [UUID: Date] = [:] /// Stellbefehle, die kamen, während die Verbindung weg war. Ohne das /// verschwindet ein Tippen lautlos: die Sitzung ist dann nicht da, und der /// Befehl fällt ins Leere. private var pendingControls: [UUID: [(packet: Data, queuedAt: Date)]] = [:] /// So lange darf ein aufgehobener Befehl warten. Was älter ist, wird /// verworfen - ein verspätetes Ausschalten würde nur überraschen. private let controlLifetime: TimeInterval = 30 /// Letzter bekannter Kühlbox-Zustand, auch über eine Trennung hinweg. Der /// Einstellungsblock wird daraus gebaut. private var fridgeStateCache: [UUID: AlpicoolState] = [:] private var discoveryFlushTimer: DispatchSourceTimer? private var discovering = false /// 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 lastHandledAdvertisement: [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 init(store: DeviceStore) { self.store = store super.init() if isDemo { loadDemoData() return } 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() { isBluetoothReady = true bluetoothStatusText = "Demo-Modus" fridgeStates[DemoData.fridge.id] = DemoData.fridgeState levelStates[DemoData.level.id] = DemoData.levelState for snapshot in DemoData.snapshots() { snapshots[snapshot.deviceID] = snapshot linkStates[snapshot.deviceID] = .live // Die Kühlbox wird nur beim Öffnen verbunden – im Vorführbetrieb // steht sie deshalb wie im Alltag auf „zuletzt gestellt“. if snapshot.deviceID == DemoData.fridge.id, let settings = DemoData.fridgeState.settings { linkStates[snapshot.deviceID] = .idle snapshots[snapshot.deviceID] = settings.snapshot(deviceID: snapshot.deviceID) } if let value = snapshot.primaryMetric?.value { history[snapshot.deviceID] = DemoData.history(for: snapshot.deviceID, around: value) } } } // MARK: - Steuerung (vom Hauptthread aufgerufen) func start() { guard !isDemo else { return } queue.async { self.startScanning() } } func stop() { guard !isDemo else { return } queue.async { self.stopEverything() } } /// Nach Änderungen an der Geräteliste oder den Schlüsseln aufrufen. func refreshConfiguration() { guard !isDemo else { return } // 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, sensorOrientation: device.sensorOrientation, 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) } levelStates = levelStates.filter { known.contains($0.key) } votronicSolarESPStates = votronicSolarESPStates.filter { known.contains($0.key) } // Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin // ihren zuletzt gestellten Stand. for device in store.activeDevices where device.role.connectsOnDemand { guard linkStates[device.id] != .live else { continue } linkStates[device.id] = .idle showLastKnownFridgeSettings(for: device.id) } queue.async { self.applyConfiguration(devices) } } func clearDiscoveries() { discoveries.removeAll() queue.async { self.pendingDiscoveries.removeAll() self.lastDiscoveryUpdate.removeAll() } } // MARK: - Verbindung auf Anforderung (vom Hauptthread aufgerufen) /// Verbindet ein Gerät, das sonst unverbunden bleibt – solange seine /// Ansicht offen ist. /// /// Für die Kühlbox: Jede Verbindung meldet sich an ihrem Display an, manche /// Modelle verlangen dabei sogar einen Tastendruck. Dauerhaft verbunden zu /// sein ist dort also nicht unsichtbar, sondern lästig. func beginSession(for device: ConfiguredDevice) { guard device.role.connectsOnDemand else { return } let deviceID = device.id queue.async { self.requestedSessions.insert(deviceID) guard let entry = self.managed.values.first(where: { $0.id == deviceID }) else { return } // Der Wunsch des Benutzers hebt die Wartesperre auf. self.clearBackOff(entry.peripheralID) self.connectIfNeeded(entry) } } /// Gibt das Gerät wieder frei. Der zuletzt gemeldete Stand bleibt in der /// Übersicht stehen, damit dort nicht plötzlich eine Lücke klafft. func endSession(for device: ConfiguredDevice) { guard device.role.connectsOnDemand else { return } let deviceID = device.id queue.async { self.requestedSessions.remove(deviceID) guard let entry = self.managed.values.first(where: { $0.id == deviceID }) else { return } self.bmsSessions[entry.peripheralID]?.stop() self.bmsSessions[entry.peripheralID] = nil self.disconnect(entry.peripheralID) self.publish { self.linkStates[deviceID] = .idle self.showLastKnownFridgeSettings(for: deviceID) } } } /// Setzt an die Stelle der Live-Werte den zuletzt gestellten Stand. /// /// Nur auf dem Hauptthread aufrufen: greift auf den Speicher zu. private func showLastKnownFridgeSettings(for deviceID: UUID) { guard let settings = store.lastFridgeSettings(for: deviceID) else { snapshots[deviceID] = nil return } snapshots[deviceID] = settings.snapshot(deviceID: deviceID) // Der Zustand bleibt stehen, damit sich die Box beim nächsten Öffnen // sofort bedienen lässt – die Schalter zeigen dann den letzten Stand. } // 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) { sendFridgeSettings(for: deviceID) { _ in 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. /// /// Fehlt die Verbindung gerade, wird der Befehl aufgehoben und ein Versuch /// angestossen. Ein Tippen darf nicht daran scheitern, dass die Box sich /// zwei Sekunden vorher abgemeldet hat. private func sendFridgeSettings(for deviceID: UUID, build: @escaping (AlpicoolState) -> Data?) { queue.async { guard let device = self.managed.values.first(where: { $0.id == deviceID }) else { return } if let session = self.bmsSessions[device.peripheralID] { if let packet = build(session.alpicoolState) { session.sendControl(packet) } return } guard let known = self.fridgeStateCache[device.peripheralID], let packet = build(known) else { return } var waiting = self.pendingControls[device.peripheralID] ?? [] waiting.append((packet, Date())) // Mehr als eine Handvoll ergibt keinen Sinn – wer mehrfach tippt, // meint den letzten Stand. if waiting.count > 4 { waiting.removeFirst(waiting.count - 4) } self.pendingControls[device.peripheralID] = waiting // Der Wunsch des Benutzers hebt die Wartesperre auf. self.clearBackOff(device.peripheralID) self.connectIfNeeded(device) } } /// Gibt weiter, was während der Trennung aufgelaufen ist. private func flushPendingControls(_ peripheralID: UUID, to session: BMSSession) { guard let waiting = pendingControls.removeValue(forKey: peripheralID) else { return } let fresh = waiting.filter { Date().timeIntervalSince($0.queuedAt) < controlLifetime } for (index, entry) in fresh.enumerated() { queue.asyncAfter(deadline: .now() + Double(index) * 0.4) { [weak session] in session?.sendControl(entry.packet) } } } /// Nach einer Änderung der Einbaulage aufrufen. /// /// Die Lage wandert zusätzlich ins Gerät. Dort gehört sie hin: Sie /// beschreibt den Einbau, und Uhr wie Android-App finden sie dann vor, /// ohne dass jemand sie ein zweites Mal bestimmen muss. func updateSensorOrientation(for device: ConfiguredDevice) { let deviceID = device.id, orientation = device.sensorOrientation queue.async { guard let session = self.levelSession(for: deviceID) else { return } session.orientation = orientation session.storeOrientation(orientation) } } /// Übernimmt die Einbaulage, die das Gerät meldet. /// /// Steht dort noch nichts, meldet das Gerät nichts – dann bleibt es bei der /// örtlich gespeicherten Fassung, und die wird beim nächsten Bestimmen /// hinaufgeschrieben. private func adoptOrientation(_ orientation: SensorOrientation, for deviceID: UUID) { guard var device = store.devices.first(where: { $0.id == deviceID }), device.sensorOrientation != orientation else { return } device.sensorOrientation = orientation store.update(device) } /// Setzt die aktuelle Lage des Fahrzeugs als neue Null. func calibrateLevel(for deviceID: UUID) { queue.async { self.levelSession(for: deviceID)?.calibrate() } } /// Verwirft die Kalibrierung. func resetLevelCalibration(for deviceID: UUID) { queue.async { self.levelSession(for: deviceID)?.resetCalibration() } } private func levelSession(for deviceID: UUID) -> LevelSession? { guard let entry = managed.values.first(where: { $0.id == deviceID }) else { return nil } return levelSessions[entry.peripheralID] } 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 – und zu // denen, die nur auf Anforderung verbunden werden und gerade niemand // anschaut. let wanted = Set(devices.filter { shouldStayConnected($0) }.map(\.peripheralID)) for (peripheralID, session) in bmsSessions where !wanted.contains(peripheralID) { session.stop() bmsSessions[peripheralID] = nil disconnect(peripheralID) } for (peripheralID, session) in levelSessions where !wanted.contains(peripheralID) { session.stop() levelSessions[peripheralID] = nil disconnect(peripheralID) } for (peripheralID, session) in votronicSolarESPSessions where !wanted.contains(peripheralID) { session.stop() votronicSolarESPSessions[peripheralID] = nil disconnect(peripheralID) } // Einstellungen an bestehende Sitzungen weiterreichen. for device in devices { bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode levelSessions[device.peripheralID]?.orientation = device.sensorOrientation } lastHandledAdvertisement.removeAll() lastDiagnosticsUpdate.removeAll() nextConnectAttempt.removeAll() connectFailures.removeAll() connectedSince.removeAll() startScanning() } private func disconnect(_ peripheralID: UUID) { guard let peripheral = connectedPeripherals[peripheralID] else { return } central?.cancelPeripheralConnection(peripheral) connectedPeripherals[peripheralID] = nil } private func setDiscovering(_ wanted: Bool) { guard discovering != wanted else { return } discovering = wanted if wanted { startDiscoveryFlush() } else { discoveryFlushTimer?.cancel() discoveryFlushTimer = nil } restartScan() } private func startScanning() { guard central?.state == .poweredOn else { return } restartScan() connectManagedPeripherals() scheduleReconnects() } private func stopEverything() { central?.stopScan() reconnectTimer?.cancel(); reconnectTimer = nil discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil for (_, session) in bmsSessions { session.stop() } for (_, session) in levelSessions { session.stop() } for (_, session) in votronicSolarESPSessions { session.stop() } for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) } bmsSessions.removeAll() levelSessions.removeAll() votronicSolarESPSessions.removeAll() connectedPeripherals.removeAll() connectedSince.removeAll() pendingControls.removeAll() } private func restartScan() { guard 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 central?.state == .poweredOn else { return } for device in managed.values where shouldStayConnected(device) { connectIfNeeded(device) } } /// Ob dieses Gerät von sich aus verbunden gehalten wird. /// /// Geräte auf Anforderung – die Kühlbox – nur dann, wenn ihre Ansicht /// gerade offen ist oder ein Stellbefehl darauf wartet. private func shouldStayConnected(_ device: ManagedDevice) -> Bool { guard device.transport == .connect else { return false } guard device.role.connectsOnDemand else { return true } return requestedSessions.contains(device.id) || pendingControls[device.peripheralID]?.isEmpty == false } private func connectIfNeeded(_ device: ManagedDevice) { if let existing = connectedPeripherals[device.peripheralID], existing.state == .connected || existing.state == .connecting { return } // Nach einem Fehlschlag eine Weile Ruhe geben, sonst wird das Gerät // im Sekundentakt angeklopft. if let earliest = nextConnectAttempt[device.peripheralID], Date() < earliest { return } guard let peripheral = central? .retrievePeripherals(withIdentifiers: [device.peripheralID]).first else { publish { self.linkStates[device.id] = .searching } return } connectedPeripherals[device.peripheralID] = peripheral publish { self.linkStates[device.id] = .connecting } central?.connect(peripheral, options: nil) } /// Sperrt weitere Versuche für eine Weile. Der Abstand wächst mit jedem /// Fehlschlag, damit ein dauerhaft unerreichbares Gerät nicht endlos /// angeklopft wird. private func backOff(_ peripheralID: UUID) { let failures = (connectFailures[peripheralID] ?? 0) + 1 connectFailures[peripheralID] = failures let delay = min(firstRetryDelay * pow(2, Double(failures - 1)), longestRetryDelay) nextConnectAttempt[peripheralID] = Date().addingTimeInterval(delay) } private func clearBackOff(_ peripheralID: UUID) { connectFailures[peripheralID] = nil nextConnectAttempt[peripheralID] = nil } /// Verbindungen fallen im Fahrzeug regelmässig weg – deshalb regelmässig /// nachfassen statt nur auf das Disconnect-Ereignis zu reagieren. private func scheduleReconnects() { 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 } } private func handleVictronAdvertisement(_ manufacturerData: Data, device: ManagedDevice, rssi: Int) { // 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 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: now ) publish { self.diagnostics[device.id] = info } } guard let key = device.victronKey else { publish { self.linkStates[device.id] = .needsKey } return } do { let snapshot = try VictronAdvertisement.decode(manufacturerData: manufacturerData, key: key, deviceID: device.id, rssi: rssi) 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: let message = error.localizedDescription publish { self.linkStates[device.id] = .failed(message) } } } catch { let message = error.localizedDescription publish { self.linkStates[device.id] = .failed(message) } } } 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 } let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [] entry.isLevelSensor = services.contains(LevelSession.serviceUUID) entry.isVotronicSolarESPSensor = services.contains(VotronicSolarESPSession.serviceUUID) entry.looksLikeSupported = entry.isLevelSensor || entry.isVotronicSolarESPSensor || 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 (alles auf der Funk-Queue) extension BluetoothManager: CBCentralManagerDelegate { func centralManagerDidUpdateState(_ central: CBCentralManager) { let ready: Bool, text: String switch central.state { 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, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { let rssi = RSSI.intValue let now = Date() let identifier = peripheral.identifier if discovering, now.timeIntervalSince(lastDiscoveryUpdate[identifier] ?? .distantPast) >= minimumDiscoveryInterval { lastDiscoveryUpdate[identifier] = now updateDiscovery(peripheral: peripheral, advertisementData: advertisementData, rssi: rssi) } guard let device = managed[identifier] else { return } guard now.timeIntervalSince(lastHandledAdvertisement[identifier] ?? .distantPast) >= minimumAdvertisementInterval else { return } lastHandledAdvertisement[identifier] = now switch device.transport { case .advertisement: guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data else { return } handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi) case .connect: // Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt // aufbauen. Ausser bei Geräten auf Anforderung: Die Kühlbox piept // bei jeder Verbindung, und dass sie in Reichweite ist, ist kein // Grund, sie anzufassen. if connectedPeripherals[identifier] == nil, shouldStayConnected(device) { connectIfNeeded(device) } } } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { guard let device = managed[peripheral.identifier] else { return } connectedPeripherals[peripheral.identifier] = peripheral connectedSince[peripheral.identifier] = Date() if device.role == .leveling { let session = LevelSession( deviceID: device.id, peripheral: peripheral, queue: queue, onUpdate: { [weak self] snapshot in self?.record(snapshot) }, onStateChange: { [weak self] state in self?.publish { self?.linkStates[device.id] = state if state == .live { // Verbindung wieder da: ein anstehendes Ende // verwerfen oder eine zwischenzeitlich doch schon // beendete Live Activity wieder aufnehmen. self?.activityManager?.handleReconnect( deviceID: device.id, state: self?.levelStates[device.id] ?? LevelState()) } } }, onLevelState: { [weak self] state in self?.publish { self?.levelStates[device.id] = state self?.activityManager?.update(deviceID: device.id, state: state) } }, onDeviceOrientation: { [weak self] orientation in // Im Gerät steht, wie der Sensor eingebaut ist – für alle // Clients dasselbe. Also übernehmen statt überschreiben. self?.publish { self?.adoptOrientation(orientation, for: device.id) } } ) session.orientation = device.sensorOrientation levelSessions[peripheral.identifier] = session session.start() return } if device.role == .votronicSolar { // Bewusst kein Draht zu `activityManager`: Der Solarertrag soll // nicht in der Live Activity/CarPlay auftauchen, die ist dem // Neigungsmesser vorbehalten. let session = VotronicSolarESPSession( deviceID: device.id, peripheral: peripheral, queue: queue, onUpdate: { [weak self] snapshot in self?.record(snapshot) }, onStateChange: { [weak self] state in self?.publish { self?.linkStates[device.id] = state } }, onVotronicSolarESPState: { [weak self] state in self?.publish { self?.votronicSolarESPStates[device.id] = state } } ) votronicSolarESPSessions[peripheral.identifier] = session session.start() return } let session = BMSSession( deviceID: device.id, peripheral: peripheral, queue: queue, onUpdate: { [weak self] snapshot in self?.record(snapshot) }, 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?.fridgeStateCache[peripheral.identifier] = state self?.publish { self?.fridgeStates[device.id] = state // Für die Übersicht aufheben: Sie zeigt den Stand auch dann, // wenn längst niemand mehr verbunden ist. if let settings = state.settings { self?.store.setLastFridgeSettings(settings, for: device.id) } } } session.fridgeZoneMode = device.fridgeZoneMode bmsSessions[peripheral.identifier] = session session.start() flushPendingControls(peripheral.identifier, to: session) } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { if let device = managed[peripheral.identifier] { let message = error?.localizedDescription ?? "Verbindung fehlgeschlagen" publish { self.linkStates[device.id] = .failed(message) } } connectedPeripherals[peripheral.identifier] = nil backOff(peripheral.identifier) } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { bmsSessions[peripheral.identifier]?.handleDisconnect() bmsSessions[peripheral.identifier] = nil levelSessions[peripheral.identifier]?.handleDisconnect() levelSessions[peripheral.identifier] = nil votronicSolarESPSessions[peripheral.identifier]?.handleDisconnect() votronicSolarESPSessions[peripheral.identifier] = nil connectedPeripherals[peripheral.identifier] = nil let lifetime = connectedSince.removeValue(forKey: peripheral.identifier) .map { Date().timeIntervalSince($0) } ?? 0 if let device = managed[peripheral.identifier], !shouldStayConnected(device) { // Auf Anforderung verbunden und niemand schaut mehr hin: nicht // wieder anklopfen, sondern den letzten Stand stehenlassen. let deviceID = device.id publish { self.linkStates[deviceID] = .idle self.showLastKnownFridgeSettings(for: deviceID) } } else if let device = managed[peripheral.identifier] { let deviceName = store.devices.first { $0.id == device.id }?.name ?? "" publish { self.linkStates[device.id] = .searching // Erst nach einer Gnadenfrist wirklich beenden – sonst // flackerte die Live Activity bei jedem kurzen Funkloch. self.activityManager?.handleDisconnect(deviceID: device.id, deviceName: deviceName) } if lifetime >= stableConnection { // Die Verbindung stand und ist weggefallen - im Fahrzeug der // Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das // Gerät minutenlang nicht bedienbar. clearBackOff(peripheral.identifier) nextConnectAttempt[peripheral.identifier] = Date().addingTimeInterval(quickRetryDelay) } else { // Sofort wieder abgebrochen: das zählt wie ein Fehlschlag. So // entsteht kein Wechselspiel aus Verbinden und Trennen, wenn // etwa eine andere App das Gerät belegt. backOff(peripheral.identifier) } } } }