WattCycle-Akkus unterstützen

WattCycle spricht weder Daly noch JBD, sondern ein eigenes Modbus-artiges
Protokoll – und verlangt vor der ersten Abfrage eine Freischaltung: der
Text "HiLink" muss auf die Charakteristik FFFA geschrieben werden, sonst
bleibt der Akku auf alles stumm. Genau daran scheiterte die Erkennung;
die Charakteristik war im GATT-Baum sichtbar, wurde aber nur als weiterer
Schreibkandidat behandelt.

Neu ist WattCycleProtocol mit Rahmenbau (1E … 0D für Anfragen, 7E … 0D für
Antworten), Prüfsummen und der Auswertung des Messwert-Datensatzes
0x008C. Der ist selbstbeschreibend: Zellenanzahl, Zellspannungen,
Fühleranzahl, MOSFET- und Platinentemperatur, Zellfühler, dann Strom,
Spannung, Kapazitäten, Zyklen und Ladezustand. Der Strom hat ein eigenes
Format, bei dem Bit 15 das Vorzeichen und Bit 14 die Nachkommastelle
angibt. Aus Datenpunkt 0x0092 kommen Modell, Hersteller und Seriennummer,
die einmalig gelesen und in der Detailansicht gezeigt werden.

BMSSession kennt die Freischaltung jetzt als Teil eines Kandidaten: liegt
im selben Dienst eine FFFA-Charakteristik, wird nach dem bestätigten Abo
kurz gewartet, freigeschaltet, nochmal gewartet und dann erst abgefragt.
Für die anderen Protokolle ist das unschädlich.

Protokoll und Feldbelegung stammen aus frabnet/esphome-wattcycle-ble. Die
dortige Tabellen-Prüfsumme ist gegen den klassischen Modbus-CRC
nachgerechnet (identisch über 3063 Testfälle), sodass die vorhandene
CRC-Funktion genügt und die 512 Byte Tabellen entfallen. Die
Anfragerahmen sind byteweise abgesichert, die Auswertung an einem
vollständigen Datensatz – 107 Prüfungen laufen durch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-30 13:00:16 +02:00
co-authored by Claude Opus 5
parent fc73f730a7
commit 091243a1d8
7 changed files with 482 additions and 14 deletions
+71 -7
View File
@@ -32,20 +32,29 @@ final class BMSSession: NSObject {
case dalyClassic = "Daly (klassisch)"
case dalyModbus = "Daly (Modbus)"
case jbd = "JBD / Xiaoxiang"
case wattCycle = "WattCycle"
}
/// Freischalt-Charakteristik der WattCycle-Akkus. Liegt im selben Dienst
/// wie Schreiben und Empfangen und muss vor der ersten Abfrage beschrieben
/// werden, sonst bleibt der Akku stumm.
private static let wattCycleAuthUUID = CBUUID(string: "FFFA")
/// Ein Kandidat: worüber geschrieben, worüber gelauscht und wie geschrieben
/// wird. Der Schreibmodus gehört dazu, weil manche Module nur die eine oder
/// nur die andere Variante annehmen.
private struct Endpoint {
let write: CBCharacteristic
let notify: CBCharacteristic
/// Falls vorhanden, wird hierauf vor der ersten Abfrage freigeschaltet.
let auth: CBCharacteristic?
let writeType: CBCharacteristicWriteType
let isKnownPair: Bool
var label: String {
let mode = writeType == .withoutResponse ? "ohne Bestätigung" : "mit Bestätigung"
return "\(write.uuid.uuidString)\(notify.uuid.uuidString), \(mode)"
let unlock = auth == nil ? "" : ", Freischaltung über \(auth!.uuid.uuidString)"
return "\(write.uuid.uuidString)\(notify.uuid.uuidString), \(mode)\(unlock)"
}
}
@@ -62,6 +71,7 @@ final class BMSSession: NSObject {
private(set) var dialect: Dialect = .unknown
private var dalyState = DalyState()
private var jbdState = JBDState()
private var wattCycleState = WattCycleState()
private var buffer: [UInt8] = []
private var pollTimer: Timer?
private var lastResponse: Data?
@@ -74,6 +84,8 @@ final class BMSSession: NSObject {
/// eines bereits verworfenen Kandidaten lassen sich so ignorieren.
private var activationToken = 0
private var isNotifyActive = false
/// Ob auf diesem Kandidaten schon freigeschaltet wurde.
private var didUnlock = false
private var lastSendAt: Date?
/// Abstand zwischen zwei Abfragerunden im Normalbetrieb.
@@ -143,7 +155,15 @@ final class BMSSession: NSObject {
}
guard !writable.isEmpty, !notifying.isEmpty else { continue }
for write in writable {
// Eine Freischalt-Charakteristik im selben Dienst gehört zum
// Kandidaten dazu; sie zu beschreiben schadet den anderen
// Protokollen nicht, für WattCycle ist sie zwingend.
let auth = characteristics.first {
$0.uuid == Self.wattCycleAuthUUID
&& ($0.properties.contains(.write) || $0.properties.contains(.writeWithoutResponse))
}
for write in writable where write.uuid != Self.wattCycleAuthUUID {
for notify in notifying {
let known = Self.knownPairs.contains {
CBUUID(string: $0.service) == service.uuid
@@ -152,11 +172,11 @@ final class BMSSession: NSObject {
}
// Beide Schreibarten anbieten, sofern das Gerät sie kann.
if write.properties.contains(.writeWithoutResponse) {
candidates.append(Endpoint(write: write, notify: notify,
candidates.append(Endpoint(write: write, notify: notify, auth: auth,
writeType: .withoutResponse, isKnownPair: known))
}
if write.properties.contains(.write) {
candidates.append(Endpoint(write: write, notify: notify,
candidates.append(Endpoint(write: write, notify: notify, auth: auth,
writeType: .withResponse, isKnownPair: known))
}
}
@@ -181,6 +201,7 @@ final class BMSSession: NSObject {
guard let endpoint = currentEndpoint else { return }
silentRounds = 0
isNotifyActive = false
didUnlock = false
buffer.removeAll()
activationToken += 1
let token = activationToken
@@ -217,6 +238,31 @@ final class BMSSession: NSObject {
// MARK: - Abfrage
/// WattCycle verlangt vor der ersten Abfrage ein HiLink auf der
/// Freischalt-Charakteristik, und danach eine kurze Pause. Die Referenz
/// wartet ~200 ms nach dem Abo und ~300 ms nach der Freischaltung.
private func unlockThenPoll() {
guard let endpoint = currentEndpoint, let auth = endpoint.auth else {
beginPolling()
return
}
let token = activationToken
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
guard let self, self.activationToken == token,
self.peripheral.state == .connected else { return }
let type: CBCharacteristicWriteType =
auth.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse
self.peripheral.writeValue(WattCycleProtocol.authPayload, for: auth, type: type)
self.didUnlock = true
self.publishDiagnostics()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
guard let self, self.activationToken == token else { return }
self.beginPolling()
}
}
}
private func beginPolling() {
pollTimer?.invalidate()
poll()
@@ -233,6 +279,7 @@ final class BMSSession: NSObject {
case .unknown:
// Alle drei Protokolle anfragen; was antwortet, gewinnt.
sendSequence([
WattCycleProtocol.requestFrame(.analog),
DalyProtocol.requestFrame(.soc),
JBDProtocol.requestFrame(.basicInfo),
DalyProtocol.modbusReadFrame(),
@@ -245,6 +292,13 @@ final class BMSSession: NSObject {
case .jbd:
sendSequence(JBDProtocol.Command.allCases.map { JBDProtocol.requestFrame($0) },
spacing: 0.25, thenGiveUpAfter: 2)
case .wattCycle:
// Modell und Seriennummer ändern sich nie nur einmal abfragen.
var frames = [WattCycleProtocol.requestFrame(.analog)]
if !wattCycleState.hasProductInfo {
frames.append(WattCycleProtocol.requestFrame(.product))
}
sendSequence(frames, spacing: 0.3, thenGiveUpAfter: 2)
}
}
@@ -285,7 +339,7 @@ final class BMSSession: NSObject {
}
private var hasUsableData: Bool {
dalyState.hasUsableData || jbdState.hasUsableData
dalyState.hasUsableData || jbdState.hasUsableData || wattCycleState.hasUsableData
}
private func send(_ data: Data) {
@@ -306,7 +360,17 @@ final class BMSSession: NSObject {
buffer.append(contentsOf: [UInt8](data))
if buffer.count > 512 { buffer.removeFirst(buffer.count - 512) }
// JBD zuerst: Start-, Endbyte und Prüfsumme machen den Rahmen eindeutig.
let (wattFrames, wattRemainder) = WattCycleProtocol.extractFrames(from: buffer)
if !wattFrames.isEmpty {
buffer = wattRemainder
adopt(.wattCycle)
for frame in wattFrames { wattCycleState.apply(frame) }
publish(wattCycleState.snapshot(deviceID: deviceID, rssi: nil),
usable: wattCycleState.hasUsableData)
return
}
// JBD als Nächstes: Start-, Endbyte und Prüfsumme machen den Rahmen eindeutig.
let (jbdFrames, jbdRemainder) = JBDProtocol.extractFrames(from: buffer)
if !jbdFrames.isEmpty {
buffer = jbdRemainder
@@ -433,7 +497,7 @@ extension BMSSession: CBPeripheralDelegate {
if characteristic.isNotifying, characteristic == currentEndpoint?.notify {
isNotifyActive = true
publishDiagnostics()
beginPolling()
unlockThenPoll()
}
}