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:
@@ -32,20 +32,29 @@ final class BMSSession: NSObject {
|
|||||||
case dalyClassic = "Daly (klassisch)"
|
case dalyClassic = "Daly (klassisch)"
|
||||||
case dalyModbus = "Daly (Modbus)"
|
case dalyModbus = "Daly (Modbus)"
|
||||||
case jbd = "JBD / Xiaoxiang"
|
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
|
/// Ein Kandidat: worüber geschrieben, worüber gelauscht und wie geschrieben
|
||||||
/// wird. Der Schreibmodus gehört dazu, weil manche Module nur die eine oder
|
/// wird. Der Schreibmodus gehört dazu, weil manche Module nur die eine oder
|
||||||
/// nur die andere Variante annehmen.
|
/// nur die andere Variante annehmen.
|
||||||
private struct Endpoint {
|
private struct Endpoint {
|
||||||
let write: CBCharacteristic
|
let write: CBCharacteristic
|
||||||
let notify: CBCharacteristic
|
let notify: CBCharacteristic
|
||||||
|
/// Falls vorhanden, wird hierauf vor der ersten Abfrage freigeschaltet.
|
||||||
|
let auth: CBCharacteristic?
|
||||||
let writeType: CBCharacteristicWriteType
|
let writeType: CBCharacteristicWriteType
|
||||||
let isKnownPair: Bool
|
let isKnownPair: Bool
|
||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
let mode = writeType == .withoutResponse ? "ohne Bestätigung" : "mit Bestätigung"
|
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(set) var dialect: Dialect = .unknown
|
||||||
private var dalyState = DalyState()
|
private var dalyState = DalyState()
|
||||||
private var jbdState = JBDState()
|
private var jbdState = JBDState()
|
||||||
|
private var wattCycleState = WattCycleState()
|
||||||
private var buffer: [UInt8] = []
|
private var buffer: [UInt8] = []
|
||||||
private var pollTimer: Timer?
|
private var pollTimer: Timer?
|
||||||
private var lastResponse: Data?
|
private var lastResponse: Data?
|
||||||
@@ -74,6 +84,8 @@ final class BMSSession: NSObject {
|
|||||||
/// eines bereits verworfenen Kandidaten lassen sich so ignorieren.
|
/// eines bereits verworfenen Kandidaten lassen sich so ignorieren.
|
||||||
private var activationToken = 0
|
private var activationToken = 0
|
||||||
private var isNotifyActive = false
|
private var isNotifyActive = false
|
||||||
|
/// Ob auf diesem Kandidaten schon freigeschaltet wurde.
|
||||||
|
private var didUnlock = false
|
||||||
private var lastSendAt: Date?
|
private var lastSendAt: Date?
|
||||||
|
|
||||||
/// Abstand zwischen zwei Abfragerunden im Normalbetrieb.
|
/// Abstand zwischen zwei Abfragerunden im Normalbetrieb.
|
||||||
@@ -143,7 +155,15 @@ final class BMSSession: NSObject {
|
|||||||
}
|
}
|
||||||
guard !writable.isEmpty, !notifying.isEmpty else { continue }
|
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 {
|
for notify in notifying {
|
||||||
let known = Self.knownPairs.contains {
|
let known = Self.knownPairs.contains {
|
||||||
CBUUID(string: $0.service) == service.uuid
|
CBUUID(string: $0.service) == service.uuid
|
||||||
@@ -152,11 +172,11 @@ final class BMSSession: NSObject {
|
|||||||
}
|
}
|
||||||
// Beide Schreibarten anbieten, sofern das Gerät sie kann.
|
// Beide Schreibarten anbieten, sofern das Gerät sie kann.
|
||||||
if write.properties.contains(.writeWithoutResponse) {
|
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))
|
writeType: .withoutResponse, isKnownPair: known))
|
||||||
}
|
}
|
||||||
if write.properties.contains(.write) {
|
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))
|
writeType: .withResponse, isKnownPair: known))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +201,7 @@ final class BMSSession: NSObject {
|
|||||||
guard let endpoint = currentEndpoint else { return }
|
guard let endpoint = currentEndpoint else { return }
|
||||||
silentRounds = 0
|
silentRounds = 0
|
||||||
isNotifyActive = false
|
isNotifyActive = false
|
||||||
|
didUnlock = false
|
||||||
buffer.removeAll()
|
buffer.removeAll()
|
||||||
activationToken += 1
|
activationToken += 1
|
||||||
let token = activationToken
|
let token = activationToken
|
||||||
@@ -217,6 +238,31 @@ final class BMSSession: NSObject {
|
|||||||
|
|
||||||
// MARK: - Abfrage
|
// 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() {
|
private func beginPolling() {
|
||||||
pollTimer?.invalidate()
|
pollTimer?.invalidate()
|
||||||
poll()
|
poll()
|
||||||
@@ -233,6 +279,7 @@ final class BMSSession: NSObject {
|
|||||||
case .unknown:
|
case .unknown:
|
||||||
// Alle drei Protokolle anfragen; was antwortet, gewinnt.
|
// Alle drei Protokolle anfragen; was antwortet, gewinnt.
|
||||||
sendSequence([
|
sendSequence([
|
||||||
|
WattCycleProtocol.requestFrame(.analog),
|
||||||
DalyProtocol.requestFrame(.soc),
|
DalyProtocol.requestFrame(.soc),
|
||||||
JBDProtocol.requestFrame(.basicInfo),
|
JBDProtocol.requestFrame(.basicInfo),
|
||||||
DalyProtocol.modbusReadFrame(),
|
DalyProtocol.modbusReadFrame(),
|
||||||
@@ -245,6 +292,13 @@ final class BMSSession: NSObject {
|
|||||||
case .jbd:
|
case .jbd:
|
||||||
sendSequence(JBDProtocol.Command.allCases.map { JBDProtocol.requestFrame($0) },
|
sendSequence(JBDProtocol.Command.allCases.map { JBDProtocol.requestFrame($0) },
|
||||||
spacing: 0.25, thenGiveUpAfter: 2)
|
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 {
|
private var hasUsableData: Bool {
|
||||||
dalyState.hasUsableData || jbdState.hasUsableData
|
dalyState.hasUsableData || jbdState.hasUsableData || wattCycleState.hasUsableData
|
||||||
}
|
}
|
||||||
|
|
||||||
private func send(_ data: Data) {
|
private func send(_ data: Data) {
|
||||||
@@ -306,7 +360,17 @@ final class BMSSession: NSObject {
|
|||||||
buffer.append(contentsOf: [UInt8](data))
|
buffer.append(contentsOf: [UInt8](data))
|
||||||
if buffer.count > 512 { buffer.removeFirst(buffer.count - 512) }
|
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)
|
let (jbdFrames, jbdRemainder) = JBDProtocol.extractFrames(from: buffer)
|
||||||
if !jbdFrames.isEmpty {
|
if !jbdFrames.isEmpty {
|
||||||
buffer = jbdRemainder
|
buffer = jbdRemainder
|
||||||
@@ -433,7 +497,7 @@ extension BMSSession: CBPeripheralDelegate {
|
|||||||
if characteristic.isNotifying, characteristic == currentEndpoint?.notify {
|
if characteristic.isNotifying, characteristic == currentEndpoint?.notify {
|
||||||
isNotifyActive = true
|
isNotifyActive = true
|
||||||
publishDiagnostics()
|
publishDiagnostics()
|
||||||
beginPolling()
|
unlockThenPoll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Protokoll der WattCycle-BLE-Akkus.
|
||||||
|
///
|
||||||
|
/// Weder Daly noch JBD, sondern ein eigenes Modbus-artiges Format. Zwei
|
||||||
|
/// Besonderheiten:
|
||||||
|
///
|
||||||
|
/// * Vor der ersten Abfrage muss der ASCII-Text `HiLink` auf eine eigene
|
||||||
|
/// Freischalt-Charakteristik (`FFFA`) geschrieben werden. Ohne das bleibt
|
||||||
|
/// der Akku auf jede Anfrage stumm.
|
||||||
|
/// * Anfragen gehen auf `FFF2`, Antworten kommen über `FFF1`.
|
||||||
|
///
|
||||||
|
/// Rahmenaufbau:
|
||||||
|
///
|
||||||
|
/// Anfrage (11 Byte):
|
||||||
|
/// 1E 00 01 03 <Datenpunkt 2 Byte> 00 00 <CRC16 2 Byte> 0D
|
||||||
|
/// Antwort:
|
||||||
|
/// 7E <Ver> <Adr> <Funktion> <Datenpunkt 2 Byte> <Länge 2 Byte>
|
||||||
|
/// <Daten…> <CRC16 2 Byte> 0D
|
||||||
|
///
|
||||||
|
/// Die Prüfsumme ist der übliche Modbus-CRC16 über alles vor der Prüfsumme,
|
||||||
|
/// höherwertiges Byte zuerst. Nachgerechnet gegen die Tabellenvariante der
|
||||||
|
/// Referenzimplementierung (frabnet/esphome-wattcycle-ble).
|
||||||
|
enum WattCycleProtocol {
|
||||||
|
|
||||||
|
static let frameHeadRequest: UInt8 = 0x1E
|
||||||
|
static let frameHeadResponse: UInt8 = 0x7E
|
||||||
|
static let frameTail: UInt8 = 0x0D
|
||||||
|
static let functionRead: UInt8 = 0x03
|
||||||
|
static let functionError: UInt8 = 0x86
|
||||||
|
|
||||||
|
/// Der Freischalt-Text, der vor der ersten Abfrage geschrieben wird.
|
||||||
|
static let authPayload = Data("HiLink".utf8)
|
||||||
|
|
||||||
|
enum Datapoint: UInt16 {
|
||||||
|
case analog = 0x008C // Messwerte
|
||||||
|
case product = 0x0092 // Modell, Hersteller, Seriennummer
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestFrame(_ datapoint: Datapoint) -> Data {
|
||||||
|
var frame: [UInt8] = [
|
||||||
|
frameHeadRequest,
|
||||||
|
0x00, // Version
|
||||||
|
0x01, // Adresse
|
||||||
|
functionRead,
|
||||||
|
UInt8(datapoint.rawValue >> 8),
|
||||||
|
UInt8(datapoint.rawValue & 0xFF),
|
||||||
|
0x00, 0x00, // Anzahl: 0 liefert den ganzen Datensatz
|
||||||
|
]
|
||||||
|
let crc = DalyProtocol.crc16Modbus(frame)
|
||||||
|
frame.append(UInt8(crc >> 8))
|
||||||
|
frame.append(UInt8(crc & 0xFF))
|
||||||
|
frame.append(frameTail)
|
||||||
|
return Data(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Frame {
|
||||||
|
let function: UInt8
|
||||||
|
let datapoint: UInt16
|
||||||
|
let payload: [UInt8]
|
||||||
|
|
||||||
|
var isError: Bool { function == functionError }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sucht vollständige, prüfsummenkorrekte Antwortrahmen im Puffer.
|
||||||
|
static func extractFrames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) {
|
||||||
|
var frames: [Frame] = []
|
||||||
|
var index = 0
|
||||||
|
var consumed = 0
|
||||||
|
|
||||||
|
while index + 11 <= buffer.count {
|
||||||
|
guard buffer[index] == frameHeadResponse else {
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let dataLength = Int(buffer[index + 6]) << 8 | Int(buffer[index + 7])
|
||||||
|
let total = dataLength + 11
|
||||||
|
guard total <= 512 else { index += 1; continue }
|
||||||
|
guard index + total <= buffer.count else { break } // Rest abwarten
|
||||||
|
|
||||||
|
let frame = Array(buffer[index..<(index + total)])
|
||||||
|
guard frame[total - 1] == frameTail else {
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let expected = DalyProtocol.crc16Modbus(Array(frame[0..<(total - 3)]))
|
||||||
|
let actual = UInt16(frame[total - 3]) << 8 | UInt16(frame[total - 2])
|
||||||
|
guard expected == actual else {
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frames.append(Frame(
|
||||||
|
function: frame[3],
|
||||||
|
datapoint: UInt16(frame[4]) << 8 | UInt16(frame[5]),
|
||||||
|
payload: Array(frame[8..<(8 + dataLength)])
|
||||||
|
))
|
||||||
|
index += total
|
||||||
|
consumed = index
|
||||||
|
}
|
||||||
|
let keepFrom = max(consumed, max(0, buffer.count - 256))
|
||||||
|
return (frames, Array(buffer[keepFrom...]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Temperaturen kommen in Zehntel-Kelvin.
|
||||||
|
static func temperature(_ raw: UInt16) -> Double {
|
||||||
|
(Double(raw) - 2730) / 10
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Strom hat ein eigenes Format: Bit 15 ist das Vorzeichen, Bit 14 gibt
|
||||||
|
/// an, ob der Rest in Zehntel-Ampere zu lesen ist, der Rest ist der Betrag.
|
||||||
|
static func current(high: UInt8, low: UInt8) -> Double {
|
||||||
|
let isNegative = high & 0x80 != 0
|
||||||
|
let hasDecimal = high & 0x40 != 0
|
||||||
|
let magnitude = Double(Int(low) | (Int(high & 0x3F) << 8))
|
||||||
|
let value = hasDecimal ? magnitude / 10 : magnitude
|
||||||
|
return isNegative ? -value : value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sammelt die Antworten eines WattCycle-Akkus.
|
||||||
|
struct WattCycleState {
|
||||||
|
var cellVolts: [Double] = []
|
||||||
|
var mosTemperature: Double?
|
||||||
|
var pcbTemperature: Double?
|
||||||
|
var cellTemperatures: [Double] = []
|
||||||
|
var current: Double?
|
||||||
|
var voltage: Double?
|
||||||
|
var remainingAh: Double?
|
||||||
|
var totalAh: Double?
|
||||||
|
var designAh: Double?
|
||||||
|
var cycles: Int?
|
||||||
|
var soc: Double?
|
||||||
|
|
||||||
|
var model: String?
|
||||||
|
var manufacturer: String?
|
||||||
|
var serial: String?
|
||||||
|
|
||||||
|
var hasUsableData: Bool { voltage != nil || soc != nil || !cellVolts.isEmpty }
|
||||||
|
var hasProductInfo: Bool { model != nil || manufacturer != nil || serial != nil }
|
||||||
|
|
||||||
|
mutating func apply(_ frame: WattCycleProtocol.Frame) {
|
||||||
|
guard !frame.isError else { return }
|
||||||
|
switch WattCycleProtocol.Datapoint(rawValue: frame.datapoint) {
|
||||||
|
case .analog: applyAnalog(frame.payload)
|
||||||
|
case .product: applyProduct(frame.payload)
|
||||||
|
case nil: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Messwert-Datensatz ist selbstbeschreibend: erst die Zellenanzahl,
|
||||||
|
/// dann die Zellspannungen, dann die Fühleranzahl und so weiter. Die
|
||||||
|
/// Feldlängen stehen also nicht fest und werden mitgelesen.
|
||||||
|
private mutating func applyAnalog(_ data: [UInt8]) {
|
||||||
|
var offset = 0
|
||||||
|
|
||||||
|
func readUInt16() -> UInt16? {
|
||||||
|
guard offset + 1 < data.count else { return nil }
|
||||||
|
defer { offset += 2 }
|
||||||
|
return UInt16(data[offset]) << 8 | UInt16(data[offset + 1])
|
||||||
|
}
|
||||||
|
func readUInt8() -> UInt8? {
|
||||||
|
guard offset < data.count else { return nil }
|
||||||
|
defer { offset += 1 }
|
||||||
|
return data[offset]
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let cellCount = readUInt8() else { return }
|
||||||
|
var cells: [Double] = []
|
||||||
|
for _ in 0..<Int(cellCount) {
|
||||||
|
guard let millivolts = readUInt16() else { return }
|
||||||
|
cells.append(Double(millivolts) / 1000)
|
||||||
|
}
|
||||||
|
cellVolts = cells
|
||||||
|
|
||||||
|
// Die ersten beiden Fühler sind MOSFET und Platine, danach die Zellen.
|
||||||
|
guard let temperatureCount = readUInt8(), temperatureCount >= 2 else { return }
|
||||||
|
guard let mos = readUInt16(), let pcb = readUInt16() else { return }
|
||||||
|
mosTemperature = WattCycleProtocol.temperature(mos)
|
||||||
|
pcbTemperature = WattCycleProtocol.temperature(pcb)
|
||||||
|
|
||||||
|
var probes: [Double] = []
|
||||||
|
for _ in 0..<(Int(temperatureCount) - 2) {
|
||||||
|
guard let raw = readUInt16() else { return }
|
||||||
|
probes.append(WattCycleProtocol.temperature(raw))
|
||||||
|
}
|
||||||
|
cellTemperatures = probes
|
||||||
|
|
||||||
|
guard offset + 1 < data.count else { return }
|
||||||
|
current = WattCycleProtocol.current(high: data[offset], low: data[offset + 1])
|
||||||
|
offset += 2
|
||||||
|
|
||||||
|
guard let voltageRaw = readUInt16() else { return }
|
||||||
|
voltage = Double(voltageRaw) / 100
|
||||||
|
|
||||||
|
guard let remaining = readUInt16(),
|
||||||
|
let total = readUInt16(),
|
||||||
|
let cycleCount = readUInt16(),
|
||||||
|
let design = readUInt16(),
|
||||||
|
let charge = readUInt16() else { return }
|
||||||
|
remainingAh = Double(remaining) / 10
|
||||||
|
totalAh = Double(total) / 10
|
||||||
|
cycles = Int(cycleCount)
|
||||||
|
designAh = Double(design) / 10
|
||||||
|
soc = Double(charge)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drei ASCII-Felder à 20 Byte.
|
||||||
|
private mutating func applyProduct(_ data: [UInt8]) {
|
||||||
|
guard data.count >= 60 else { return }
|
||||||
|
func text(_ range: Range<Int>) -> String? {
|
||||||
|
let value = String(decoding: data[range], as: UTF8.self)
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "\0 "))
|
||||||
|
return value.isEmpty ? nil : value
|
||||||
|
}
|
||||||
|
model = text(0..<20)
|
||||||
|
manufacturer = text(20..<40)
|
||||||
|
serial = text(40..<60)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
|
||||||
|
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
|
||||||
|
|
||||||
|
var metrics: [Metric] = [
|
||||||
|
Metric("soc", "Ladezustand", soc, unit: "%", precision: 0, primary: true),
|
||||||
|
Metric("voltage", "Spannung", voltage, unit: "V", precision: 2),
|
||||||
|
Metric("current", "Strom", current, unit: "A", precision: 1),
|
||||||
|
]
|
||||||
|
if let voltage, let current {
|
||||||
|
metrics.append(Metric("power", "Leistung", voltage * current, unit: "W", precision: 0))
|
||||||
|
}
|
||||||
|
metrics.append(Metric("capacity", "Restkapazität", remainingAh, unit: "Ah", precision: 1))
|
||||||
|
if let totalAh {
|
||||||
|
metrics.append(Metric("capacity_total", "Kapazität geladen", totalAh, unit: "Ah", precision: 1))
|
||||||
|
}
|
||||||
|
if let designAh {
|
||||||
|
metrics.append(Metric("capacity_design", "Nennkapazität", designAh, unit: "Ah", precision: 1))
|
||||||
|
}
|
||||||
|
if let maxV = cellVolts.max(), let minV = cellVolts.min() {
|
||||||
|
metrics.append(Metric("cell_delta", "Zell-Differenz",
|
||||||
|
(maxV - minV) * 1000, unit: "mV", precision: 0))
|
||||||
|
metrics.append(Metric("cell_max", "Höchste Zelle", maxV, unit: "V", precision: 3))
|
||||||
|
metrics.append(Metric("cell_min", "Niedrigste Zelle", minV, unit: "V", precision: 3))
|
||||||
|
}
|
||||||
|
if let mosTemperature {
|
||||||
|
metrics.append(Metric("temp_mos", "Temperatur MOSFET", mosTemperature, unit: "°C", precision: 1))
|
||||||
|
}
|
||||||
|
if let pcbTemperature {
|
||||||
|
metrics.append(Metric("temp_pcb", "Temperatur Platine", pcbTemperature, unit: "°C", precision: 1))
|
||||||
|
}
|
||||||
|
if let cycles {
|
||||||
|
metrics.append(Metric("cycles", "Ladezyklen", Double(cycles), unit: "", precision: 0))
|
||||||
|
}
|
||||||
|
snapshot.metrics = metrics
|
||||||
|
|
||||||
|
snapshot.cellVoltages = cellVolts
|
||||||
|
snapshot.temperatures = cellTemperatures
|
||||||
|
|
||||||
|
if let current {
|
||||||
|
if current > 0.3 { snapshot.state = "Lädt" }
|
||||||
|
else if current < -0.3 { snapshot.state = "Entlädt" }
|
||||||
|
else { snapshot.state = "Ruhend" }
|
||||||
|
}
|
||||||
|
|
||||||
|
var info: [DeviceSnapshot.InfoItem] = []
|
||||||
|
if let model { info.append(.init(label: "Modell / Firmware", value: model)) }
|
||||||
|
if let manufacturer { info.append(.init(label: "Hersteller", value: manufacturer)) }
|
||||||
|
if let serial { info.append(.init(label: "Seriennummer", value: serial)) }
|
||||||
|
snapshot.info = info
|
||||||
|
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,14 @@ struct DeviceSnapshot: Identifiable, Sendable {
|
|||||||
var cellVoltages: [Double] = []
|
var cellVoltages: [Double] = []
|
||||||
/// Temperaturfühler in °C (nur BMS).
|
/// Temperaturfühler in °C (nur BMS).
|
||||||
var temperatures: [Double] = []
|
var temperatures: [Double] = []
|
||||||
|
/// Feste Angaben des Geräts, etwa Modell oder Seriennummer.
|
||||||
|
var info: [InfoItem] = []
|
||||||
|
|
||||||
|
struct InfoItem: Identifiable, Hashable, Sendable {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
var id: String { label }
|
||||||
|
}
|
||||||
|
|
||||||
var primaryMetric: Metric? {
|
var primaryMetric: Metric? {
|
||||||
metrics.first(where: \.isPrimary) ?? metrics.first
|
metrics.first(where: \.isPrimary) ?? metrics.first
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ struct DeviceDetailView: View {
|
|||||||
cellSection(snapshot.cellVoltages)
|
cellSection(snapshot.cellVoltages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let snapshot, !snapshot.info.isEmpty {
|
||||||
|
Section("Gerät") {
|
||||||
|
ForEach(snapshot.info) { item in
|
||||||
|
LabeledContent(item.label, value: item.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let snapshot, snapshot.temperatures.count > 1 {
|
if let snapshot, snapshot.temperatures.count > 1 {
|
||||||
Section("Temperaturen") {
|
Section("Temperaturen") {
|
||||||
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Victron Ladebooster (Orion-TR Smart / Orion XS) | Instant Readout im Advertisement | Ein-/Ausgangsspannung, beim XS auch Ströme und Ladeleistung, Zustand, Abschaltgrund |
|
| Victron Ladebooster (Orion-TR Smart / Orion XS) | Instant Readout im Advertisement | Ein-/Ausgangsspannung, beim XS auch Ströme und Ladeleistung, Zustand, Abschaltgrund |
|
||||||
| Victron Solarladeregler (SmartSolar MPPT) | Instant Readout im Advertisement | PV-Leistung, Batteriespannung, Ladestrom, Tagesertrag, Laststrom, Ladezustand (Bulk/Absorption/Float) |
|
| Victron Solarladeregler (SmartSolar MPPT) | Instant Readout im Advertisement | PV-Leistung, Batteriespannung, Ladestrom, Tagesertrag, Laststrom, Ladezustand (Bulk/Absorption/Float) |
|
||||||
| Batterie-BMS (Bulltron/Daly, WattCycle/JBD) | GATT-Verbindung, alle 5 s abgefragt | SoC, Spannung, Strom, Restkapazität, alle Einzelzellspannungen, Zelldifferenz, Temperaturen, Zyklen, MOSFET-Status |
|
| Batterie-BMS (Bulltron/Daly, WattCycle) | GATT-Verbindung, alle 5 s abgefragt | SoC, Spannung, Strom, Restkapazität, alle Einzelzellspannungen, Zelldifferenz, Temperaturen, Zyklen, MOSFET-Status |
|
||||||
|
|
||||||
Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt.
|
Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt.
|
||||||
|
|
||||||
@@ -75,7 +75,20 @@ Die App probiert drei Protokolle durch und übernimmt, was antwortet:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Daly klassisch (`A5`) | Bulltron und viele Daly-BMS |
|
| Daly klassisch (`A5`) | Bulltron und viele Daly-BMS |
|
||||||
| Daly Modbus (`D2`) | neuere Daly-Firmware |
|
| Daly Modbus (`D2`) | neuere Daly-Firmware |
|
||||||
| JBD / Xiaoxiang (`DD A5`) | WattCycle und viele andere LiFePO4-Akkus |
|
| JBD / Xiaoxiang (`DD A5`) | viele LiFePO4-Akkus mit eigener App |
|
||||||
|
| WattCycle (`1E`/`7E`) | WattCycle-Bluetooth-Serie |
|
||||||
|
|
||||||
|
WattCycle-Akkus verlangen eine Besonderheit: vor der ersten Abfrage muss der
|
||||||
|
Text `HiLink` auf eine eigene Freischalt-Charakteristik (`FFFA`) geschrieben
|
||||||
|
werden, sonst bleiben sie auf jede Anfrage stumm. Die App macht das
|
||||||
|
automatisch, sobald ein Gerät diese Charakteristik anbietet. Modell,
|
||||||
|
Hersteller und Seriennummer liest sie einmalig mit aus und zeigt sie in der
|
||||||
|
Detailansicht unter *Gerät*.
|
||||||
|
|
||||||
|
Protokoll und Feldbelegung stammen aus
|
||||||
|
[frabnet/esphome-wattcycle-ble](https://github.com/frabnet/esphome-wattcycle-ble);
|
||||||
|
die Prüfsummen-Variante von dort ist gegen den klassischen Modbus-CRC
|
||||||
|
nachgerechnet, die Anfragerahmen sind byteweise in `run-tests.sh` abgesichert.
|
||||||
|
|
||||||
Auch die GATT-Charakteristiken werden gesucht statt vorausgesetzt, weil sich
|
Auch die GATT-Charakteristiken werden gesucht statt vorausgesetzt, weil sich
|
||||||
die BLE-Module zwischen Herstellern und Chargen unterscheiden. Welches
|
die BLE-Module zwischen Herstellern und Chargen unterscheiden. Welches
|
||||||
@@ -126,7 +139,8 @@ CamperMonitor/
|
|||||||
│ ├── BitReader.swift Bitweises Lesen der gepackten Felder
|
│ ├── BitReader.swift Bitweises Lesen der gepackten Felder
|
||||||
│ ├── DalyProtocol.swift Daly-Rahmen und Prüfsummen
|
│ ├── DalyProtocol.swift Daly-Rahmen und Prüfsummen
|
||||||
│ ├── DalyState.swift Sammelt Daly-Antworten zu einem Gesamtbild
|
│ ├── DalyState.swift Sammelt Daly-Antworten zu einem Gesamtbild
|
||||||
│ ├── JBDProtocol.swift JBD/Xiaoxiang (WattCycle), Rahmen und Auswertung
|
│ ├── JBDProtocol.swift JBD/Xiaoxiang, Rahmen und Auswertung
|
||||||
|
│ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung
|
||||||
│ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage
|
│ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage
|
||||||
├── Store/
|
├── Store/
|
||||||
│ ├── DeviceStore.swift Geräteliste, Persistenz
|
│ ├── DeviceStore.swift Geräteliste, Persistenz
|
||||||
@@ -149,10 +163,10 @@ CamperMonitor/
|
|||||||
gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel
|
gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel
|
||||||
aussehen, muss das Mapping in `DalyState.apply(registers:)` am realen Gerät
|
aussehen, muss das Mapping in `DalyState.apply(registers:)` am realen Gerät
|
||||||
nachgezogen werden.
|
nachgezogen werden.
|
||||||
* **Welches BMS in einem WattCycle-Akku steckt, ist nicht garantiert.** Die
|
* **Die Feldbelegung der BMS-Datensätze ist nicht an jedem Modell geprüft.**
|
||||||
Unterstützung ist auf JBD/Xiaoxiang ausgelegt, das dort üblich ist. Meldet
|
Meldet die Diagnose dauerhaft „wird ermittelt“, spricht der Akku ein
|
||||||
die Diagnose dauerhaft „wird ermittelt“, spricht der Akku etwas anderes –
|
Protokoll, das die App nicht kennt – die dort angezeigte Rohantwort ist dann
|
||||||
die dort angezeigte Rohantwort ist dann der Ansatzpunkt.
|
der Ansatzpunkt.
|
||||||
* Die Feldbelegungen der Victron-Datensätze stammen aus Victrons
|
* Die Feldbelegungen der Victron-Datensätze stammen aus Victrons
|
||||||
„Extra Manufacturer Data“-Beschreibung. Solarladeregler und DC/DC-Wandler
|
„Extra Manufacturer Data“-Beschreibung. Solarladeregler und DC/DC-Wandler
|
||||||
sind die am besten belegten Typen.
|
sind die am besten belegten Typen.
|
||||||
|
|||||||
@@ -312,5 +312,106 @@ let halfJBD = Array(jbdStream.prefix(jbdStream.count - 4))
|
|||||||
checkEqual("nur der vollständige Rahmen wird ausgewertet",
|
checkEqual("nur der vollständige Rahmen wird ausgewertet",
|
||||||
JBDProtocol.extractFrames(from: halfJBD).frames.count, 1)
|
JBDProtocol.extractFrames(from: halfJBD).frames.count, 1)
|
||||||
|
|
||||||
|
// MARK: 8 – WattCycle
|
||||||
|
print("\nWattCycle-Protokoll")
|
||||||
|
|
||||||
|
// Die Anfragerahmen sind gegen die Referenzimplementierung nachgerechnet
|
||||||
|
// (frabnet/esphome-wattcycle-ble): CRC16 über die ersten acht Byte.
|
||||||
|
checkEqual("Anfragerahmen Messwerte (DP 0x008C)",
|
||||||
|
[UInt8](WattCycleProtocol.requestFrame(.analog)),
|
||||||
|
[0x1E, 0x00, 0x01, 0x03, 0x00, 0x8C, 0x00, 0x00, 0xB1, 0x44, 0x0D])
|
||||||
|
checkEqual("Anfragerahmen Produktdaten (DP 0x0092)",
|
||||||
|
[UInt8](WattCycleProtocol.requestFrame(.product)),
|
||||||
|
[0x1E, 0x00, 0x01, 0x03, 0x00, 0x92, 0x00, 0x00, 0xB7, 0x24, 0x0D])
|
||||||
|
checkEqual("Freischalttext ist \"HiLink\"",
|
||||||
|
[UInt8](WattCycleProtocol.authPayload),
|
||||||
|
[0x48, 0x69, 0x4C, 0x69, 0x6E, 0x6B])
|
||||||
|
|
||||||
|
/// Baut eine Antwort, wie der Akku sie schickt.
|
||||||
|
func wattResponse(datapoint: UInt16, payload: [UInt8]) -> [UInt8] {
|
||||||
|
var frame: [UInt8] = [0x7E, 0x00, 0x01, 0x03,
|
||||||
|
UInt8(datapoint >> 8), UInt8(datapoint & 0xFF),
|
||||||
|
UInt8(payload.count >> 8), UInt8(payload.count & 0xFF)]
|
||||||
|
frame += payload
|
||||||
|
let crc = DalyProtocol.crc16Modbus(frame)
|
||||||
|
frame += [UInt8(crc >> 8), UInt8(crc & 0xFF), 0x0D]
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4 Zellen, 4 Fühler (MOSFET, Platine, 2 Zellfühler), 13,28 V, -25,4 A, 76 %
|
||||||
|
var analog: [UInt8] = [0x04]
|
||||||
|
analog += [0x0C, 0xFE, 0x0D, 0x12, 0x0D, 0x00, 0x0C, 0xF8] // 3326/3346/3328/3320 mV
|
||||||
|
analog += [0x04] // vier Fühler
|
||||||
|
analog += [0x0B, 0x8E] // MOSFET 2958 -> 22,8 °C
|
||||||
|
analog += [0x0B, 0x84] // Platine 2948 -> 21,8 °C
|
||||||
|
analog += [0x0B, 0x7A, 0x0B, 0x70] // Zellen 20,8 / 19,8 °C
|
||||||
|
analog += [0xC0, 0xFE] // Strom: negativ, Zehntel, 254 -> -25,4 A
|
||||||
|
analog += [0x05, 0x30] // 1328 -> 13,28 V
|
||||||
|
analog += [0x05, 0xF0] // Rest 1520 -> 152,0 Ah
|
||||||
|
analog += [0x07, 0xD0] // Geladen 2000 -> 200,0 Ah
|
||||||
|
analog += [0x00, 0x2F] // 47 Zyklen
|
||||||
|
analog += [0x07, 0xD0] // Nennkapazität 200,0 Ah
|
||||||
|
analog += [0x00, 0x4C] // 76 %
|
||||||
|
|
||||||
|
var product = [UInt8](repeating: 0, count: 60)
|
||||||
|
for (index, byte) in Array("WC-12100BT v1.4".utf8).enumerated() { product[index] = byte }
|
||||||
|
for (index, byte) in Array("WattCycle".utf8).enumerated() { product[20 + index] = byte }
|
||||||
|
for (index, byte) in Array("WTaEaAA25342229".utf8).enumerated() { product[40 + index] = byte }
|
||||||
|
|
||||||
|
var wattStream = wattResponse(datapoint: 0x008C, payload: analog)
|
||||||
|
wattStream += wattResponse(datapoint: 0x0092, payload: product)
|
||||||
|
|
||||||
|
let (wattFrames, wattRest) = WattCycleProtocol.extractFrames(from: wattStream)
|
||||||
|
checkEqual("beide Rahmen erkannt", wattFrames.count, 2)
|
||||||
|
checkEqual("nichts bleibt übrig", wattRest.count, 0)
|
||||||
|
|
||||||
|
var watt = WattCycleState()
|
||||||
|
for frame in wattFrames { watt.apply(frame) }
|
||||||
|
let wattSnapshot = watt.snapshot(deviceID: UUID(), rssi: nil)
|
||||||
|
func wattValue(_ key: String) -> Double? { wattSnapshot.metrics.first { $0.key == key }?.value }
|
||||||
|
checkEqual("Spannung", wattValue("voltage").map(round2), 13.28)
|
||||||
|
checkEqual("Entladestrom aus dem Sonderformat", wattValue("current").map(round2), -25.4)
|
||||||
|
checkEqual("Ladezustand", wattValue("soc"), 76)
|
||||||
|
checkEqual("Restkapazität", wattValue("capacity").map(round2), 152.0)
|
||||||
|
checkEqual("geladene Kapazität", wattValue("capacity_total").map(round2), 200.0)
|
||||||
|
checkEqual("Nennkapazität", wattValue("capacity_design").map(round2), 200.0)
|
||||||
|
checkEqual("Ladezyklen", wattValue("cycles"), 47)
|
||||||
|
checkEqual("vier Zellspannungen", wattSnapshot.cellVoltages.count, 4)
|
||||||
|
checkEqual("höchste Zelle", wattValue("cell_max"), 3.346)
|
||||||
|
checkEqual("Zell-Differenz", wattValue("cell_delta").map { $0.rounded() }, 26)
|
||||||
|
checkEqual("MOSFET-Temperatur aus Zehntel-Kelvin", wattValue("temp_mos").map(round2), 22.8)
|
||||||
|
checkEqual("Platinentemperatur", wattValue("temp_pcb").map(round2), 21.8)
|
||||||
|
checkEqual("nur die Zellfühler landen in den Temperaturen", wattSnapshot.temperatures.count, 2)
|
||||||
|
checkEqual("Zustand aus negativem Strom", wattSnapshot.state, "Entlädt")
|
||||||
|
checkEqual("Modell aus den Produktdaten", wattSnapshot.info.first?.value, "WC-12100BT v1.4")
|
||||||
|
checkEqual("Seriennummer", wattSnapshot.info.last?.value, "WTaEaAA25342229")
|
||||||
|
|
||||||
|
// Ladestrom ohne Nachkommastelle: Bit 7 und Bit 6 aus.
|
||||||
|
checkEqual("positiver Strom ohne Zehntel", WattCycleProtocol.current(high: 0x00, low: 0x2A), 42)
|
||||||
|
checkEqual("positiver Strom mit Zehnteln", WattCycleProtocol.current(high: 0x40, low: 0x2A), 4.2)
|
||||||
|
|
||||||
|
// Kaputte Prüfsumme, falsches Endbyte, angefangener Rahmen
|
||||||
|
var brokenWatt = wattResponse(datapoint: 0x008C, payload: analog)
|
||||||
|
brokenWatt[brokenWatt.count - 2] ^= 0xFF
|
||||||
|
checkEqual("falsche Prüfsumme wird verworfen",
|
||||||
|
WattCycleProtocol.extractFrames(from: brokenWatt).frames.count, 0)
|
||||||
|
var badTail = wattResponse(datapoint: 0x008C, payload: analog)
|
||||||
|
badTail[badTail.count - 1] = 0x00
|
||||||
|
checkEqual("falsches Endbyte wird verworfen",
|
||||||
|
WattCycleProtocol.extractFrames(from: badTail).frames.count, 0)
|
||||||
|
let halfWatt = Array(wattStream.prefix(wattStream.count - 5))
|
||||||
|
checkEqual("nur der vollständige Rahmen wird ausgewertet",
|
||||||
|
WattCycleProtocol.extractFrames(from: halfWatt).frames.count, 1)
|
||||||
|
|
||||||
|
// Fehlerantwort des Geräts darf keine Werte setzen.
|
||||||
|
var errorFrame = wattResponse(datapoint: 0x008C, payload: [])
|
||||||
|
errorFrame[3] = 0x86
|
||||||
|
let recrc = DalyProtocol.crc16Modbus(Array(errorFrame[0..<(errorFrame.count - 3)]))
|
||||||
|
errorFrame[errorFrame.count - 3] = UInt8(recrc >> 8)
|
||||||
|
errorFrame[errorFrame.count - 2] = UInt8(recrc & 0xFF)
|
||||||
|
var errorState = WattCycleState()
|
||||||
|
for frame in WattCycleProtocol.extractFrames(from: errorFrame).frames { errorState.apply(frame) }
|
||||||
|
checkEqual("Fehlerantwort liefert keine Werte", errorState.hasUsableData, false)
|
||||||
|
|
||||||
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
|
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
|
||||||
exit(failures == 0 ? 0 : 1)
|
exit(failures == 0 ? 0 : 1)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ swiftc -O -o "$OUT/tests" \
|
|||||||
CamperMonitor/Bluetooth/DalyProtocol.swift \
|
CamperMonitor/Bluetooth/DalyProtocol.swift \
|
||||||
CamperMonitor/Bluetooth/DalyState.swift \
|
CamperMonitor/Bluetooth/DalyState.swift \
|
||||||
CamperMonitor/Bluetooth/JBDProtocol.swift \
|
CamperMonitor/Bluetooth/JBDProtocol.swift \
|
||||||
|
CamperMonitor/Bluetooth/WattCycleProtocol.swift \
|
||||||
CamperMonitor/Models/DeviceSnapshot.swift \
|
CamperMonitor/Models/DeviceSnapshot.swift \
|
||||||
CamperMonitor/Models/VictronCodes.swift \
|
CamperMonitor/Models/VictronCodes.swift \
|
||||||
CamperMonitor/Store/KeychainStore.swift \
|
CamperMonitor/Store/KeychainStore.swift \
|
||||||
|
|||||||
Reference in New Issue
Block a user