From fc73f730a7ac8df877f79aa3cbe031176e17ff84 Mon Sep 17 00:00:00 2001 From: BiasF Date: Sun, 30 Aug 2026 12:38:31 +0200 Subject: [PATCH] =?UTF-8?q?BMS:=20Warten=20auf=20die=20Notify-Best=C3=A4ti?= =?UTF-8?q?gung=20nicht=20mehr=20endlos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Suche nach dem richtigen Verbindungsweg blieb auf dem ersten Kandidaten stehen: gesendet wurde erst, nachdem iOS das Abonnieren der Notify-Charakteristik bestätigt hat. Bestätigt ein Modul das nie – bei der WattCycle-Batterie auf FFF1 der Fall –, wurde weder je eine Anfrage geschickt noch zum nächsten Kandidaten gewechselt. Die Diagnose zeigte dauerhaft "1 von 16" und "0 Anfragen / 0 Byte". Die Aktivierung eines Kandidaten hat jetzt ein Zeitlimit von vier Sekunden. Läuft es ab, geht es zum nächsten; ist es der einzige Weg, wird trotzdem gesendet – manche Module antworten auch ohne bestätigtes Abonnement. Ein Token verhindert, dass verspätete Rückmeldungen eines bereits verworfenen Kandidaten den aktuellen durcheinanderbringen. Damit sich Fortschritt überhaupt beobachten lässt, wird die Diagnose jetzt bei jeder gesendeten Anfrage aktualisiert statt nur am Ende einer Runde, und zeigt zusätzlich Verbindungszustand, ob der Empfang abonniert ist, und wann zuletzt gesendet wurde. Die Suche ist enger getaktet, damit alle Wege in gut zwei Minuten durch sind. Co-Authored-By: Claude Opus 5 --- CamperMonitor/Bluetooth/BMSSession.swift | 35 +++++++++++++++++-- .../Bluetooth/BluetoothManager.swift | 4 +++ CamperMonitor/Views/DeviceDetailView.swift | 22 ++++++++++-- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/CamperMonitor/Bluetooth/BMSSession.swift b/CamperMonitor/Bluetooth/BMSSession.swift index 04395aa..24842c1 100644 --- a/CamperMonitor/Bluetooth/BMSSession.swift +++ b/CamperMonitor/Bluetooth/BMSSession.swift @@ -70,11 +70,16 @@ final class BMSSession: NSObject { private var gattSummary: [String] = [] /// Runden ohne verwertbare Antwort auf dem aktuellen Kandidaten. private var silentRounds = 0 + /// Zählt hoch, sobald ein Kandidat aktiviert wird. Späte Rückmeldungen + /// eines bereits verworfenen Kandidaten lassen sich so ignorieren. + private var activationToken = 0 + private var isNotifyActive = false + private var lastSendAt: Date? /// Abstand zwischen zwei Abfragerunden im Normalbetrieb. var pollInterval: TimeInterval = 5 /// Kürzer, solange noch gesucht wird – sonst dauert das Durchprobieren lang. - private var searchInterval: TimeInterval = 8 + private var searchInterval: TimeInterval = 6 private var currentEndpoint: Endpoint? { endpoints.indices.contains(endpointIndex) ? endpoints[endpointIndex] : nil @@ -175,9 +180,26 @@ final class BMSSession: NSObject { private func activateCurrentEndpoint() { guard let endpoint = currentEndpoint else { return } silentRounds = 0 + isNotifyActive = false buffer.removeAll() + activationToken += 1 + let token = activationToken + peripheral.setNotifyValue(true, for: endpoint.notify) publishDiagnostics() + + // 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 + guard let self, self.activationToken == token, !self.isNotifyActive else { return } + if self.endpoints.count > 1 { + self.advanceEndpoint() + } else { + // Einziger Weg – trotzdem versuchen zu senden, vielleicht + // antwortet das Gerät auch ohne bestätigtes Abonnement. + self.beginPolling() + } + } } /// Wechselt auf den nächsten Kandidaten. Sind alle durch, wird von vorn @@ -214,7 +236,7 @@ final class BMSSession: NSObject { DalyProtocol.requestFrame(.soc), JBDProtocol.requestFrame(.basicInfo), DalyProtocol.modbusReadFrame(), - ], spacing: 1.2, thenGiveUpAfter: 5) + ], spacing: 0.6, thenGiveUpAfter: 3) case .dalyClassic: sendSequence(DalyProtocol.Command.allCases.map { DalyProtocol.requestFrame($0) }, spacing: 0.25, thenGiveUpAfter: 2) @@ -269,7 +291,11 @@ final class BMSSession: NSObject { private func send(_ data: Data) { guard let endpoint = currentEndpoint, peripheral.state == .connected else { return } sentFrameCount += 1 + lastSendAt = Date() peripheral.writeValue(data, for: endpoint.write, type: endpoint.writeType) + // Sofort melden, sonst sieht die Diagnose sekundenlang nach Stillstand + // aus, obwohl gerade gesucht wird. + publishDiagnostics() } // MARK: - Auswertung @@ -335,9 +361,12 @@ final class BMSSession: NSObject { endpointLabel: currentEndpoint?.label, endpointPosition: endpoints.isEmpty ? nil : .init(endpointIndex + 1, endpoints.count), serviceUUID: currentEndpoint?.write.service?.uuid.uuidString, + isConnected: peripheral.state == .connected, + isNotifyActive: isNotifyActive, gattSummary: gattSummary, sentFrames: sentFrameCount, receivedBytes: receivedByteCount, + lastSendAt: lastSendAt, lastResponseHex: lastResponse.map { $0.map { String(format: "%02X", $0) }.joined(separator: " ") }, updated: Date() )) @@ -402,6 +431,8 @@ extension BMSSession: CBPeripheralDelegate { return } if characteristic.isNotifying, characteristic == currentEndpoint?.notify { + isNotifyActive = true + publishDiagnostics() beginPolling() } } diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index f46f00f..63ee361 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -73,10 +73,14 @@ struct BMSDiagnostics: Hashable { /// Der wievielte von wie vielen Kandidaten das ist. var endpointPosition: Pair? var serviceUUID: String? + var isConnected: Bool + /// Ob das Gerät das Abonnieren der Notify-Charakteristik bestätigt hat. + var isNotifyActive: Bool /// Vollständiger Dienst-/Merkmalsbaum des Geräts. var gattSummary: [String] var sentFrames: Int var receivedBytes: Int + var lastSendAt: Date? var lastResponseHex: String? var updated: Date diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift index 6b52a21..54ceb64 100644 --- a/CamperMonitor/Views/DeviceDetailView.swift +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -252,8 +252,24 @@ struct DeviceDetailView: View { .foregroundStyle(.secondary) } } + LabeledContent("Verbunden") { + Label(info.isConnected ? "ja" : "nein", + systemImage: info.isConnected ? "checkmark.circle" : "xmark.circle") + .foregroundStyle(info.isConnected ? .green : .red) + } + LabeledContent("Empfang abonniert") { + Label(info.isNotifyActive ? "ja" : "nein", + systemImage: info.isNotifyActive ? "checkmark.circle" : "xmark.circle") + .foregroundStyle(info.isNotifyActive ? .green : .orange) + } LabeledContent("Gesendet / empfangen", value: "\(info.sentFrames) Anfragen / \(info.receivedBytes) Byte") + if let lastSendAt = info.lastSendAt { + LabeledContent("Zuletzt gesendet vor") { + Text(lastSendAt, style: .relative) + } + .foregroundStyle(.secondary) + } if let hex = info.lastResponseHex { VStack(alignment: .leading, spacing: 4) { Text("Letzte Antwort") @@ -268,8 +284,10 @@ struct DeviceDetailView: View { } footer: { Text("Die App probiert alle Schreib-/Empfangs-Kombinationen des Geräts " + "durch und fragt auf jeder Daly (klassisch und Modbus) sowie " - + "JBD/Xiaoxiang an. Bleibt „empfangen“ bei 0 Byte, nimmt das BMS " - + "die Kommandos nicht an.") + + "JBD/Xiaoxiang an. Der „Verbindungsweg“ zählt dabei hoch. " + + "Bleibt „empfangen“ am Ende bei 0 Byte, nimmt das BMS auf keinem " + + "Weg Kommandos an; kommen Bytes an, ohne dass ein Protokoll " + + "erkannt wird, spricht es ein noch unbekanntes.") } if !info.gattSummary.isEmpty {