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 {