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
/// 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))
}