BMS: Warten auf die Notify-Bestätigung nicht mehr endlos

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 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-30 12:38:31 +02:00
co-authored by Claude Opus 5
parent b0e7f057b6
commit fc73f730a7
3 changed files with 57 additions and 4 deletions
+33 -2
View File
@@ -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()
}
}
@@ -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
+20 -2
View File
@@ -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 {