forked from fritob/Camper-Monitor
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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user