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
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user