Bluetooth auf eine eigene Queue legen

CoreBluetooth lief auf dem Hauptthread, also demselben, der die
Oberfläche zeichnet. Gescannt wird mit Duplikaten über alle Geräte in
Reichweite, weil Victron seine Werte im Advertisement mitschickt - jedes
einzelne davon kam als Rückruf dort an, samt Entschlüsselung und
Neuzeichnung. Das war die Ursache der trägen Bedienung.

CoreBluetooth arbeitet jetzt auf einer eigenen seriellen Queue. Daraus
folgt eine strikte Trennung:

* Verbindungen, Puffer und Zeitgeber werden ausschliesslich auf dieser
  Queue angefasst. Die Zeitgeber sind deshalb DispatchSourceTimer statt
  Timer, denn auf einer DispatchQueue gibt es keine RunLoop.
* Die veröffentlichten Werte werden ausschliesslich auf dem Hauptthread
  geschrieben, gebündelt über publish().
* Die Geräteliste wird nicht mehr aus der Funk-Queue heraus im Speicher
  nachgeschlagen. Stattdessen zieht refreshConfiguration auf dem
  Hauptthread eine Momentaufnahme - Kennung, Transportart, Zonenwahl und
  den bereits ausgelesenen Victron-Schlüssel - und reicht sie hinüber.
  Damit greift kein Hintergrundthread mehr auf den beobachteten Speicher
  zu.

Die Stellbefehle der Kühlbox bauen ihren Einstellungsblock ebenfalls auf
der Funk-Queue, weil der zugrundeliegende Zustand dort lebt.

Geprüft im Simulator: der Statuswechsel von CoreBluetooth kommt über die
Funk-Queue und erreicht die Anzeige korrekt, beide Startpfade laufen ohne
Absturz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-30 14:39:43 +02:00
co-authored by Claude Opus 5
parent d7cad6945d
commit 1ad476a963
2 changed files with 300 additions and 201 deletions
+22 -14
View File
@@ -65,6 +65,10 @@ final class BMSSession: NSObject {
} }
let deviceID: UUID 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 peripheral: CBPeripheral
private let onUpdate: (DeviceSnapshot) -> Void private let onUpdate: (DeviceSnapshot) -> Void
private let onStateChange: (DeviceLinkState) -> Void private let onStateChange: (DeviceLinkState) -> Void
@@ -88,7 +92,7 @@ final class BMSSession: NSObject {
/// Ob die Kühlbox in dieser Sitzung schon angemeldet wurde. /// Ob die Kühlbox in dieser Sitzung schon angemeldet wurde.
private var didBind = false private var didBind = false
private var buffer: [UInt8] = [] private var buffer: [UInt8] = []
private var pollTimer: Timer? private var pollTimer: DispatchSourceTimer?
private var lastResponse: Data? private var lastResponse: Data?
private var lastCommand: Data? private var lastCommand: Data?
private var lastCommandAt: Date? private var lastCommandAt: Date?
@@ -116,10 +120,12 @@ final class BMSSession: NSObject {
init(deviceID: UUID, init(deviceID: UUID,
peripheral: CBPeripheral, peripheral: CBPeripheral,
queue: DispatchQueue,
onUpdate: @escaping (DeviceSnapshot) -> Void, onUpdate: @escaping (DeviceSnapshot) -> Void,
onStateChange: @escaping (DeviceLinkState) -> Void, onStateChange: @escaping (DeviceLinkState) -> Void,
onDiagnostics: @escaping (BMSDiagnostics) -> Void) { onDiagnostics: @escaping (BMSDiagnostics) -> Void) {
self.deviceID = deviceID self.deviceID = deviceID
self.queue = queue
self.peripheral = peripheral self.peripheral = peripheral
self.onUpdate = onUpdate self.onUpdate = onUpdate
self.onStateChange = onStateChange self.onStateChange = onStateChange
@@ -136,7 +142,7 @@ final class BMSSession: NSObject {
} }
func stop() { func stop() {
pollTimer?.invalidate() pollTimer?.cancel()
pollTimer = nil pollTimer = nil
if let notify = currentEndpoint?.notify, peripheral.state == .connected { if let notify = currentEndpoint?.notify, peripheral.state == .connected {
peripheral.setNotifyValue(false, for: notify) peripheral.setNotifyValue(false, for: notify)
@@ -148,7 +154,7 @@ final class BMSSession: NSObject {
} }
func handleDisconnect() { func handleDisconnect() {
pollTimer?.invalidate() pollTimer?.cancel()
pollTimer = nil pollTimer = nil
endpoints.removeAll() endpoints.removeAll()
endpointIndex = 0 endpointIndex = 0
@@ -231,7 +237,7 @@ final class BMSSession: NSObject {
// Manche Module bestätigen das Abonnieren nie. Ohne Zeitlimit bliebe // Manche Module bestätigen das Abonnieren nie. Ohne Zeitlimit bliebe
// die Suche hier für immer stehen, ohne je etwas zu senden. // 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 } guard let self, self.activationToken == token, !self.isNotifyActive else { return }
if self.endpoints.count > 1 { if self.endpoints.count > 1 {
self.advanceEndpoint() self.advanceEndpoint()
@@ -267,7 +273,7 @@ final class BMSSession: NSObject {
return return
} }
let token = activationToken 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, guard let self, self.activationToken == token,
self.peripheral.state == .connected else { return } self.peripheral.state == .connected else { return }
let type: CBCharacteristicWriteType = let type: CBCharacteristicWriteType =
@@ -276,7 +282,7 @@ final class BMSSession: NSObject {
self.didUnlock = true self.didUnlock = true
self.publishDiagnostics() 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 } guard let self, self.activationToken == token else { return }
self.beginPolling() self.beginPolling()
} }
@@ -284,12 +290,14 @@ final class BMSSession: NSObject {
} }
private func beginPolling() { private func beginPolling() {
pollTimer?.invalidate() pollTimer?.cancel()
poll() poll()
let interval = dialect == .unknown ? searchInterval : pollInterval let interval = dialect == .unknown ? searchInterval : pollInterval
pollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in let timer = DispatchSource.makeTimerSource(queue: queue)
self?.poll() timer.schedule(deadline: .now() + interval, repeating: interval)
} timer.setEventHandler { [weak self] in self?.poll() }
timer.resume()
pollTimer = timer
} }
private func poll() { private func poll() {
@@ -337,7 +345,7 @@ final class BMSSession: NSObject {
/// die zu dicht aufeinander folgen. /// die zu dicht aufeinander folgen.
private func sendSequence(_ frames: [Data], spacing: TimeInterval, thenGiveUpAfter grace: TimeInterval) { private func sendSequence(_ frames: [Data], spacing: TimeInterval, thenGiveUpAfter grace: TimeInterval) {
for (index, frame) in frames.enumerated() { 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) self?.send(frame)
} }
} }
@@ -346,7 +354,7 @@ final class BMSSession: NSObject {
/// Kommt nichts Brauchbares zurück, wird der nächste Kandidat versucht. /// Kommt nichts Brauchbares zurück, wird der nächste Kandidat versucht.
private func checkForSilence(after delay: TimeInterval) { 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 let self, self.peripheral.state == .connected else { return }
guard !self.hasUsableData else { guard !self.hasUsableData else {
self.silentRounds = 0 self.silentRounds = 0
@@ -390,7 +398,7 @@ final class BMSSession: NSObject {
peripheral.writeValue(piece, for: endpoint.write, type: endpoint.writeType) peripheral.writeValue(piece, for: endpoint.write, type: endpoint.writeType)
continue continue
} }
DispatchQueue.main.asyncAfter( queue.asyncAfter(
deadline: .now() + Double(index) * AlpicoolProtocol.chunkDelay deadline: .now() + Double(index) * AlpicoolProtocol.chunkDelay
) { [weak self] in ) { [weak self] in
guard let self, self.peripheral.state == .connected, guard let self, self.peripheral.state == .connected,
@@ -413,7 +421,7 @@ final class BMSSession: NSObject {
lastCommandAt = Date() lastCommandAt = Date()
send(packet) send(packet)
// Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist. // 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 } guard let self, self.dialect == .alpicool else { return }
self.send(AlpicoolProtocol.packet(.query)) self.send(AlpicoolProtocol.packet(.query))
} }
+269 -178
View File
@@ -105,16 +105,34 @@ struct HistorySample: Identifiable, Hashable {
/// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Victron-Werbedaten und /// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Victron-Werbedaten und
/// hält parallel die Verbindung zum Daly-BMS. /// 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 @Observable
final class BluetoothManager: NSObject { final class BluetoothManager: NSObject {
// MARK: - Veröffentlicht (nur auf dem Hauptthread schreiben)
private(set) var snapshots: [UUID: DeviceSnapshot] = [:] private(set) var snapshots: [UUID: DeviceSnapshot] = [:]
private(set) var linkStates: [UUID: DeviceLinkState] = [:] private(set) var linkStates: [UUID: DeviceLinkState] = [:]
private(set) var discoveries: [UUID: Discovery] = [:] private(set) var discoveries: [UUID: Discovery] = [:]
private(set) var history: [UUID: [HistorySample]] = [:] private(set) var history: [UUID: [HistorySample]] = [:]
private(set) var diagnostics: [UUID: VictronDiagnostics] = [:] private(set) var diagnostics: [UUID: VictronDiagnostics] = [:]
private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:] 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 fridgeStates: [UUID: AlpicoolState] = [:]
private(set) var isBluetoothReady = false private(set) var isBluetoothReady = false
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…" private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
@@ -122,45 +140,53 @@ final class BluetoothManager: NSObject {
/// Solange true, werden alle gefundenen Peripherals gesammelt. /// Solange true, werden alle gefundenen Peripherals gesammelt.
var isDiscovering = false { var isDiscovering = false {
didSet { didSet {
restartScan() let wanted = isDiscovering
isDiscovering ? startDiscoveryFlush() : stopDiscoveryFlush() 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? 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 bmsSessions: [UUID: BMSSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:] 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 /// Advertisements treffen mehrmals je Sekunde und Gerät ein. Öfter als
/// hier gesammelt und nur im Sekundentakt an die Ansicht durchgereicht /// hier festgelegt wird nichts ausgewertet für die Anzeige ändert ein
/// sonst baut sich die Liste unter dem Finger ständig neu auf. /// Advertisement mehr nichts, es kostet aber Entschlüsselung und eine
/// Neuzeichnung.
private var pendingDiscoveries: [UUID: Discovery] = [:] 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 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 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 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. /// Wieviele Messpunkte je Gerät im Verlauf behalten werden.
private let historyLimit = 720 private let historyLimit = 720
/// Im Demo-Modus wird nichts gefunkt, die Werte kommen aus `DemoData`.
private let isDemo = DemoData.isEnabled
init(store: DeviceStore) { init(store: DeviceStore) {
self.store = store self.store = store
super.init() super.init()
@@ -168,7 +194,13 @@ final class BluetoothManager: NSObject {
loadDemoData() loadDemoData()
return 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() { private func loadDemoData() {
@@ -184,32 +216,105 @@ final class BluetoothManager: NSObject {
} }
} }
// MARK: - Steuerung // MARK: - Steuerung (vom Hauptthread aufgerufen)
func start() { func start() {
guard !isDemo, central?.state == .poweredOn else { return } guard !isDemo else { return }
restartScan() queue.async { self.startScanning() }
connectManagedPeripherals()
scheduleReconnects()
} }
func stop() { func stop() {
guard !isDemo else { return } guard !isDemo else { return }
central?.stopScan() queue.async { self.stopEverything() }
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. /// Nach Änderungen an der Geräteliste oder den Schlüsseln aufrufen.
func refreshConfiguration() { func refreshConfiguration() {
guard !isDemo else { return } 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() session.stop()
bmsSessions[peripheralID] = nil bmsSessions[peripheralID] = nil
if let peripheral = connectedPeripherals[peripheralID] { if let peripheral = connectedPeripherals[peripheralID] {
@@ -217,46 +322,47 @@ final class BluetoothManager: NSObject {
connectedPeripherals[peripheralID] = nil connectedPeripherals[peripheralID] = nil
} }
} }
let known = Set(store.activeDevices.map(\.id)) // Zoneneinstellung an bestehende Sitzungen weiterreichen.
snapshots = snapshots.filter { known.contains($0.key) } for device in devices {
linkStates = linkStates.filter { known.contains($0.key) } bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode
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) }
lastHandledAdvertisement.removeAll() lastHandledAdvertisement.removeAll()
lastDiagnosticsUpdate.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() restartScan()
} }
func clearDiscoveries() { private func startScanning() {
discoveries.removeAll() guard central?.state == .poweredOn else { return }
pendingDiscoveries.removeAll() restartScan()
lastDiscoveryUpdate.removeAll() connectManagedPeripherals()
scheduleReconnects()
} }
private func startDiscoveryFlush() { private func stopEverything() {
discoveryFlushTimer?.invalidate() central?.stopScan()
discoveryFlushTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in reconnectTimer?.cancel(); reconnectTimer = nil
guard let self else { return } discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
// Verschwundene Geräte erst nach einer Weile fallen lassen, damit for (_, session) in bmsSessions { session.stop() }
// ein Eintrag nicht wegen eines verpassten Pakets verschwindet. for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
let cutoff = Date().addingTimeInterval(-20) bmsSessions.removeAll()
self.pendingDiscoveries = self.pendingDiscoveries.filter { $0.value.lastSeen > cutoff } connectedPeripherals.removeAll()
self.discoveries = self.pendingDiscoveries
} }
}
private func stopDiscoveryFlush() {
discoveryFlushTimer?.invalidate()
discoveryFlushTimer = nil
}
// MARK: - Scannen
private func restartScan() { private func restartScan() {
guard !isDemo, central?.state == .poweredOn else { return } guard central?.state == .poweredOn else { return }
central?.stopScan() central?.stopScan()
// Victron sendet seine Werte im Advertisement, also müssen auch // Victron sendet seine Werte im Advertisement, also müssen auch
// Wiederholungen durchgereicht werden. // Wiederholungen durchgereicht werden.
@@ -267,115 +373,98 @@ final class BluetoothManager: NSObject {
} }
private func connectManagedPeripherals() { private func connectManagedPeripherals() {
guard !isDemo, central?.state == .poweredOn else { return } guard central?.state == .poweredOn else { return }
for device in store.activeDevices where device.role.transport == .connect { for device in managed.values where device.transport == .connect {
connectIfNeeded(device) connectIfNeeded(device)
} }
} }
private func connectIfNeeded(_ device: ConfiguredDevice) { private func connectIfNeeded(_ device: ManagedDevice) {
let peripheralID = device.peripheralID if let existing = connectedPeripherals[device.peripheralID],
if let existing = connectedPeripherals[peripheralID],
existing.state == .connected || existing.state == .connecting { existing.state == .connected || existing.state == .connecting {
return return
} }
guard let peripheral = central?.retrievePeripherals(withIdentifiers: [peripheralID]).first else { guard let peripheral = central?
linkStates[device.id] = .searching .retrievePeripherals(withIdentifiers: [device.peripheralID]).first else {
publish { self.linkStates[device.id] = .searching }
return return
} }
connectedPeripherals[peripheralID] = peripheral connectedPeripherals[device.peripheralID] = peripheral
linkStates[device.id] = .connecting publish { self.linkStates[device.id] = .connecting }
central?.connect(peripheral, options: nil) central?.connect(peripheral, options: nil)
} }
/// Verbindungen fallen im Fahrzeug regelmässig weg deshalb regelmässig /// Verbindungen fallen im Fahrzeug regelmässig weg deshalb regelmässig
/// nachfassen statt nur auf das Disconnect-Ereignis zu reagieren. /// nachfassen statt nur auf das Disconnect-Ereignis zu reagieren.
private func scheduleReconnects() { private func scheduleReconnects() {
reconnectTimer?.invalidate() reconnectTimer?.cancel()
reconnectTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in let timer = DispatchSource.makeTimerSource(queue: queue)
self?.connectManagedPeripherals() timer.schedule(deadline: .now() + 15, repeating: 15)
} timer.setEventHandler { [weak self] in self?.connectManagedPeripherals() }
timer.resume()
reconnectTimer = timer
} }
/// Nach einer Änderung der Zoneneinstellung aufrufen. private func startDiscoveryFlush() {
func updateFridgeZoneMode(for device: ConfiguredDevice) { discoveryFlushTimer?.cancel()
guard let session = bmsSessions[device.peripheralID] else { return } let timer = DispatchSource.makeTimerSource(queue: queue)
session.fridgeZoneMode = device.fridgeZoneMode timer.schedule(deadline: .now() + 1, repeating: 1)
fridgeStates[device.id] = session.alpicoolState 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: - Kühlbox steuern // MARK: - Auswertung (auf der Funk-Queue)
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) { private func record(_ snapshot: DeviceSnapshot) {
snapshots[snapshot.deviceID] = snapshot publish {
self.snapshots[snapshot.deviceID] = snapshot
guard let primary = snapshot.primaryMetric, let value = primary.value else { return } guard let primary = snapshot.primaryMetric, let value = primary.value else { return }
var samples = history[snapshot.deviceID] ?? [] var samples = self.history[snapshot.deviceID] ?? []
// Höchstens alle fünf Sekunden einen Punkt aufnehmen. // Höchstens alle fünf Sekunden einen Punkt aufnehmen.
if let last = samples.last, snapshot.timestamp.timeIntervalSince(last.time) < 5 { return } if let last = samples.last, snapshot.timestamp.timeIntervalSince(last.time) < 5 { return }
samples.append(HistorySample(time: snapshot.timestamp, value: value)) samples.append(HistorySample(time: snapshot.timestamp, value: value))
if samples.count > historyLimit { samples.removeFirst(samples.count - historyLimit) } if samples.count > self.historyLimit {
history[snapshot.deviceID] = samples samples.removeFirst(samples.count - self.historyLimit)
}
self.history[snapshot.deviceID] = samples
}
} }
private func handleVictronAdvertisement(_ manufacturerData: Data, private func handleVictronAdvertisement(_ manufacturerData: Data,
device: ConfiguredDevice, device: ManagedDevice,
rssi: Int) { rssi: Int) {
// Zuerst den unverschlüsselten Rahmen festhalten gerade wenn der // Den unverschlüsselten Rahmen festhalten gerade wenn der Schlüssel
// Schlüssel nicht passt, ist das die einzige verwertbare Information. // nicht passt, ist das die einzige verwertbare Information. Der
// Der Hex-String kostet mehr als die Auswertung selbst, deshalb nur // Hex-String kostet mehr als die Auswertung selbst, deshalb nur
// gelegentlich, und immer wenn noch gar nichts angezeigt werden kann. // gelegentlich, und immer wenn noch gar nichts angezeigt werden kann.
let needsDiagnostics = diagnostics[device.id] == nil let now = Date()
|| Date().timeIntervalSince(lastDiagnosticsUpdate[device.id] ?? .distantPast) if now.timeIntervalSince(lastDiagnosticsUpdate[device.id] ?? .distantPast)
>= minimumDiagnosticsInterval >= minimumDiagnosticsInterval,
if needsDiagnostics, let envelope = VictronAdvertisement.envelope(from: manufacturerData) { let envelope = VictronAdvertisement.envelope(from: manufacturerData) {
lastDiagnosticsUpdate[device.id] = Date() lastDiagnosticsUpdate[device.id] = now
diagnostics[device.id] = VictronDiagnostics( let info = VictronDiagnostics(
productID: envelope.productID, productID: envelope.productID,
recordType: envelope.recordType, recordType: envelope.recordType,
expectedKeyFirstByte: envelope.keyCheckByte, expectedKeyFirstByte: envelope.keyCheckByte,
nonce: envelope.nonce, nonce: envelope.nonce,
payloadLength: envelope.ciphertext.count, payloadLength: envelope.ciphertext.count,
rawHex: manufacturerData.map { String(format: "%02X", $0) }.joined(separator: " "), 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 { guard let key = device.victronKey else {
linkStates[device.id] = .needsKey publish { self.linkStates[device.id] = .needsKey }
return return
} }
do { do {
@@ -383,17 +472,19 @@ final class BluetoothManager: NSObject {
key: key, key: key,
deviceID: device.id, deviceID: device.id,
rssi: rssi) rssi: rssi)
linkStates[device.id] = .live publish { self.linkStates[device.id] = .live }
record(snapshot) record(snapshot)
} catch let error as VictronAdvertisement.DecodeError { } catch let error as VictronAdvertisement.DecodeError {
switch error { switch error {
case .notVictron: case .notVictron:
break // Fremdes Advertisement desselben Peripherals, ignorieren. break // Fremdes Advertisement desselben Peripherals, ignorieren.
default: default:
linkStates[device.id] = .failed(error.localizedDescription) let message = error.localizedDescription
publish { self.linkStates[device.id] = .failed(message) }
} }
} catch { } 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 { extension BluetoothManager: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) { func centralManagerDidUpdateState(_ central: CBCentralManager) {
let ready: Bool, text: String
switch central.state { switch central.state {
case .poweredOn: case .poweredOn: ready = true; text = "Bereit"
isBluetoothReady = true case .poweredOff: ready = false; text = "Bluetooth ist ausgeschaltet"
bluetoothStatusText = "Bereit" case .unauthorized: ready = false; text = "Bluetooth-Zugriff wurde nicht erlaubt"
start() case .unsupported: ready = false; text = "Dieses Gerät unterstützt kein Bluetooth LE"
case .poweredOff: default: ready = false; text = "Bluetooth wird gestartet…"
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…"
} }
publish {
self.isBluetoothReady = ready
self.bluetoothStatusText = text
}
if central.state == .poweredOn { startScanning() }
} }
func centralManager(_ central: CBCentralManager, func centralManager(_ central: CBCentralManager,
@@ -473,47 +559,51 @@ extension BluetoothManager: CBCentralManagerDelegate {
let now = Date() let now = Date()
let identifier = peripheral.identifier let identifier = peripheral.identifier
if isDiscovering, if discovering,
now.timeIntervalSince(lastDiscoveryUpdate[identifier] ?? .distantPast) now.timeIntervalSince(lastDiscoveryUpdate[identifier] ?? .distantPast)
>= minimumDiscoveryInterval { >= minimumDiscoveryInterval {
lastDiscoveryUpdate[identifier] = now lastDiscoveryUpdate[identifier] = now
updateDiscovery(peripheral: peripheral, advertisementData: advertisementData, rssi: rssi) updateDiscovery(peripheral: peripheral, advertisementData: advertisementData, rssi: rssi)
} }
guard let device = store.device(withPeripheralID: identifier) else { return } guard let device = managed[identifier] else { return }
// Ein Advertisement mehr ändert die Anzeige nicht, kostet aber
// Entschlüsselung und eine Neuzeichnung.
guard now.timeIntervalSince(lastHandledAdvertisement[identifier] ?? .distantPast) guard now.timeIntervalSince(lastHandledAdvertisement[identifier] ?? .distantPast)
>= minimumAdvertisementInterval else { return } >= minimumAdvertisementInterval else { return }
lastHandledAdvertisement[identifier] = now lastHandledAdvertisement[identifier] = now
switch device.role.transport { switch device.transport {
case .advertisement: case .advertisement:
guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
else { return } else { return }
handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi) handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi)
case .connect: case .connect:
// Das BMS wurde gesehen falls die Verbindung fehlt, jetzt aufbauen. // Das Gerät wurde gesehen falls die Verbindung fehlt, jetzt aufbauen.
if connectedPeripherals[peripheral.identifier] == nil { if connectedPeripherals[identifier] == nil {
connectIfNeeded(device) connectIfNeeded(device)
} }
} }
} }
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { 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 connectedPeripherals[peripheral.identifier] = peripheral
let session = BMSSession( let session = BMSSession(
deviceID: device.id, deviceID: device.id,
peripheral: peripheral, peripheral: peripheral,
queue: queue,
onUpdate: { [weak self] snapshot in self?.record(snapshot) }, onUpdate: { [weak self] snapshot in self?.record(snapshot) },
onStateChange: { [weak self] state in self?.linkStates[device.id] = state }, onStateChange: { [weak self] state in
onDiagnostics: { [weak self] info in self?.bmsDiagnostics[device.id] = info } 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 session.fridgeZoneMode = device.fridgeZoneMode
bmsSessions[peripheral.identifier] = session bmsSessions[peripheral.identifier] = session
session.start() session.start()
@@ -522,8 +612,9 @@ extension BluetoothManager: CBCentralManagerDelegate {
func centralManager(_ central: CBCentralManager, func centralManager(_ central: CBCentralManager,
didFailToConnect peripheral: CBPeripheral, didFailToConnect peripheral: CBPeripheral,
error: Error?) { error: Error?) {
if let device = store.device(withPeripheralID: peripheral.identifier) { if let device = managed[peripheral.identifier] {
linkStates[device.id] = .failed(error?.localizedDescription ?? "Verbindung fehlgeschlagen") let message = error?.localizedDescription ?? "Verbindung fehlgeschlagen"
publish { self.linkStates[device.id] = .failed(message) }
} }
connectedPeripherals[peripheral.identifier] = nil connectedPeripherals[peripheral.identifier] = nil
} }
@@ -535,8 +626,8 @@ extension BluetoothManager: CBCentralManagerDelegate {
bmsSessions[peripheral.identifier] = nil bmsSessions[peripheral.identifier] = nil
connectedPeripherals[peripheral.identifier] = nil connectedPeripherals[peripheral.identifier] = nil
if let device = store.device(withPeripheralID: peripheral.identifier) { if let device = managed[peripheral.identifier] {
linkStates[device.id] = .searching publish { self.linkStates[device.id] = .searching }
// Direkt wieder anfragen; iOS stellt die Verbindung her, sobald das // Direkt wieder anfragen; iOS stellt die Verbindung her, sobald das
// Gerät wieder in Reichweite ist. // Gerät wieder in Reichweite ist.
connectIfNeeded(device) connectIfNeeded(device)