forked from fritob/Camper-Monitor
App-Namen im iOS-Projekt auf VanControl vereinheitlichen
CamperMonitor (Haupt-Repo) und VanAligneiOS (aus dem gemergten solar-integration-Branch) liefen unter zwei verschiedenen internen Namen, obwohl die App nach aussen längst einheitlich "VanControl Pro" heisst. Jetzt durchgängig VanControl: - Ordner: CamperMonitor/, CamperMonitorWatch/, CamperMonitorComplication/, VanAligneiOSWidget/ → VanControl/, VanControlWatch/, VanControlComplication/, VanControlWidget/ - Xcode-Projekt: CamperMonitor.xcodeproj → VanControl.xcodeproj, alle Targets/Schemes/Produktnamen entsprechend umbenannt - Bundle-Identifier auf Wunsch mitgeändert: de.s0.fototeddy.VanControl* (App noch nicht veröffentlicht); dabei auch die WKCompanionAppBundleIdentifier-Werte korrigiert, die noch das alte de.fritob-Präfix statt des tatsächlichen de.s0.fototeddy-Präfixes trugen - Swift-Dateien/Typen: CamperMonitorApp → VanControlApp, VanAligneiOSWidget* → VanControlWidget* - Config/*-Info.plist umbenannt, README.md/Tools/README.md/run-tests.sh auf die neuen Pfade angepasst Bewusst unverändert: firmware/vanalign und alle Bezüge auf "VanAlign" als Namen der Neigungsmesser-Hardware (eigenständiges Produkt, kein App-Name) sowie der komplette Android/-Ordner. Build (App, Watch, Debug) und Protokoll-Testlauf grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
83ea85f3b8
commit
303a9735d0
@@ -0,0 +1,46 @@
|
||||
import CommonCrypto
|
||||
import Foundation
|
||||
|
||||
/// AES-128 im Counter-Modus. CryptoKit bietet CTR nicht an, deshalb CommonCrypto.
|
||||
enum AESCounterMode {
|
||||
/// - Parameters:
|
||||
/// - data: Der verschlüsselte Nutzteil des Advertisements.
|
||||
/// - key: 16 Byte Geräteschlüssel aus VictronConnect.
|
||||
/// - nonce: Der 16-Byte-Zählerblock (Victron: Nonce little-endian in den
|
||||
/// ersten beiden Bytes, Rest 0).
|
||||
static func crypt(_ data: [UInt8], key: [UInt8], nonce: [UInt8]) -> [UInt8]? {
|
||||
guard key.count == kCCKeySizeAES128, nonce.count == kCCBlockSizeAES128 else { return nil }
|
||||
|
||||
var cryptor: CCCryptorRef?
|
||||
let createStatus = key.withUnsafeBytes { keyBuffer in
|
||||
nonce.withUnsafeBytes { ivBuffer in
|
||||
CCCryptorCreateWithMode(
|
||||
CCOperation(kCCEncrypt), // CTR ist symmetrisch
|
||||
CCMode(kCCModeCTR),
|
||||
CCAlgorithm(kCCAlgorithmAES),
|
||||
CCPadding(ccNoPadding),
|
||||
ivBuffer.baseAddress,
|
||||
keyBuffer.baseAddress, key.count,
|
||||
nil, 0, 0,
|
||||
CCModeOptions(kCCModeOptionCTR_BE),
|
||||
&cryptor
|
||||
)
|
||||
}
|
||||
}
|
||||
guard createStatus == kCCSuccess, let cryptor else { return nil }
|
||||
defer { CCCryptorRelease(cryptor) }
|
||||
|
||||
var output = [UInt8](repeating: 0, count: data.count)
|
||||
var moved = 0
|
||||
let updateStatus = data.withUnsafeBytes { input in
|
||||
output.withUnsafeMutableBytes { out in
|
||||
CCCryptorUpdate(cryptor,
|
||||
input.baseAddress, data.count,
|
||||
out.baseAddress, data.count,
|
||||
&moved)
|
||||
}
|
||||
}
|
||||
guard updateStatus == kCCSuccess else { return nil }
|
||||
return Array(output.prefix(moved))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import Foundation
|
||||
|
||||
/// Protokoll der Alpicool-Kompressorkühlboxen. Dieselbe Elektronik steckt
|
||||
/// unter anderem in den IceCube-Boxen von Plug-in Festivals sowie in Modellen
|
||||
/// von BrassMonkey und Ocean Comfort.
|
||||
///
|
||||
/// Gesprochen wird über zwei Charakteristiken: geschrieben auf `00001235-…`,
|
||||
/// Antworten kommen über `00001236-…`.
|
||||
///
|
||||
/// Rahmenaufbau in beide Richtungen:
|
||||
///
|
||||
/// FE FE <Länge> <Kommando> <Daten…> <Prüfsumme 2 Byte>
|
||||
///
|
||||
/// `Länge` zählt Kommando, Daten und Prüfsumme, die Gesamtlänge ist also
|
||||
/// `3 + Länge`. Die Prüfsumme ist die Summe aller vorangehenden Bytes,
|
||||
/// höherwertiges Byte zuerst.
|
||||
///
|
||||
/// Vor der ersten Abfrage muss einmal `BIND` geschickt werden. Steht „APP“ im
|
||||
/// Display der Box, verlangt sie dabei einen Tastendruck am Gerät.
|
||||
///
|
||||
/// Feldbelegung nach Gruni22/alpicool_ha_ble.
|
||||
enum AlpicoolProtocol {
|
||||
|
||||
static let header: [UInt8] = [0xFE, 0xFE]
|
||||
|
||||
enum Command: UInt8 {
|
||||
case bind = 0x00
|
||||
case query = 0x01
|
||||
case set = 0x02
|
||||
case reset = 0x04
|
||||
case setLeft = 0x05
|
||||
case setRight = 0x06
|
||||
}
|
||||
|
||||
/// Summe aller Bytes, auf 16 Bit beschnitten.
|
||||
static func checksum(_ bytes: [UInt8]) -> UInt16 {
|
||||
UInt16(truncatingIfNeeded: bytes.reduce(UInt32(0)) { $0 + UInt32($1) })
|
||||
}
|
||||
|
||||
static func packet(_ command: Command, data: [UInt8] = []) -> Data {
|
||||
var packet = header
|
||||
packet.append(UInt8(data.count + 3)) // Kommando + Daten + Prüfsumme
|
||||
packet.append(command.rawValue)
|
||||
packet.append(contentsOf: data)
|
||||
let sum = checksum(packet)
|
||||
packet.append(UInt8(sum >> 8))
|
||||
packet.append(UInt8(sum & 0xFF))
|
||||
return Data(packet)
|
||||
}
|
||||
|
||||
struct Frame {
|
||||
let command: UInt8
|
||||
/// Nutzdaten ohne Kommando und ohne Prüfsumme.
|
||||
let payload: [UInt8]
|
||||
}
|
||||
|
||||
/// Sucht vollständige Rahmen im Puffer.
|
||||
///
|
||||
/// Auf Stellbefehle antwortet die Box mit zwei Paketen in einer einzigen
|
||||
/// Benachrichtigung: erst ein Echo des Befehls, dann der volle Status.
|
||||
/// Deshalb wird in der Schleife weitergesucht, statt nach dem ersten
|
||||
/// Treffer abzubrechen.
|
||||
static func extractFrames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) {
|
||||
var frames: [Frame] = []
|
||||
var index = 0
|
||||
var consumed = 0
|
||||
|
||||
while index + 3 <= buffer.count {
|
||||
guard buffer[index] == 0xFE, buffer[index + 1] == 0xFE else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
let total = 3 + Int(buffer[index + 2])
|
||||
guard total >= 6, total <= 128 else { index += 1; continue }
|
||||
guard index + total <= buffer.count else { break } // Rest abwarten
|
||||
|
||||
let packet = Array(buffer[index..<(index + total)])
|
||||
let expected = checksum(Array(packet[0..<(total - 2)]))
|
||||
let actual = UInt16(packet[total - 2]) << 8 | UInt16(packet[total - 1])
|
||||
guard expected == actual else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
frames.append(Frame(command: packet[3],
|
||||
payload: Array(packet[4..<(total - 2)])))
|
||||
index += total
|
||||
consumed = index
|
||||
}
|
||||
let keepFrom = max(consumed, max(0, buffer.count - 128))
|
||||
return (frames, Array(buffer[keepFrom...]))
|
||||
}
|
||||
|
||||
static func signed(_ byte: UInt8) -> Int { Int(Int8(bitPattern: byte)) }
|
||||
|
||||
/// Womit diese Boxen einen nicht vorhandenen Fühler melden.
|
||||
static let missingSensorReading = -128
|
||||
|
||||
/// Pause zwischen den Teilstücken eines aufgeteilten Pakets, damit das
|
||||
/// Gerät sie wieder zusammensetzen kann.
|
||||
static let chunkDelay: TimeInterval = 0.15
|
||||
|
||||
/// Wieviel die Box je Schreibvorgang annimmt.
|
||||
///
|
||||
/// Das sind die 20 Nutzbytes der Standard-MTU – unabhängig davon, was auf
|
||||
/// der Verbindung ausgehandelt wurde. Ein längerer Schreibvorgang wird von
|
||||
/// diesen Boxen abgelehnt; belegt an einer Maentum/Plug-in Festival
|
||||
/// IceCube Dual, bei der genau deshalb das Ein- und Ausschalten scheiterte,
|
||||
/// während der kurze Temperaturbefehl durchging
|
||||
/// (Gruni22/alpicool_ha_ble#20). Das Ändern der Solltemperatur geht mit
|
||||
/// sieben Byte durch, der Einstellungsblock mit 31 nicht.
|
||||
static let maxWriteSize = 20
|
||||
|
||||
/// Zerlegt ein Paket in schreibbare Stücke.
|
||||
///
|
||||
/// Ohne ausgehandelte MTU nimmt BLE nur 20 Nutzbytes je Schreibvorgang an.
|
||||
/// Der Einstellungsblock einer Kühlbox ist mit bis zu 31 Byte länger und
|
||||
/// würde sonst stillschweigend verworfen.
|
||||
static func chunks(_ data: Data, limit: Int) -> [Data] {
|
||||
guard limit > 0 else { return [data] }
|
||||
guard data.count > limit else { return [data] }
|
||||
return stride(from: 0, to: data.count, by: limit).map { start in
|
||||
data.subdata(in: start..<min(start + limit, data.count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zustand einer Kühlbox – Messwerte und die Einstellungen, die sich ändern
|
||||
/// lassen.
|
||||
struct AlpicoolState: Equatable {
|
||||
var isLocked = false
|
||||
var isPoweredOn = true
|
||||
/// 0 = Max, 1 = Eco.
|
||||
var runMode = 0
|
||||
var batterySaver = 0
|
||||
|
||||
var leftTarget: Int?
|
||||
var leftCurrent: Int?
|
||||
var rightTarget: Int?
|
||||
var rightCurrent: Int?
|
||||
|
||||
var temperatureMin: Int?
|
||||
var temperatureMax: Int?
|
||||
var startDelayMinutes: Int?
|
||||
var returnDifference: Int?
|
||||
/// 0 = °C, 1 = °F.
|
||||
var unit = 0
|
||||
var runningStatus: Int?
|
||||
|
||||
var batteryPercent: Int?
|
||||
var batteryVolts: Double?
|
||||
|
||||
/// Die vollständige Nutzlast der letzten Statusantwort. Stellbefehle für
|
||||
/// Ein/Aus und Betriebsart schicken den gesamten Einstellungsblock zurück,
|
||||
/// deshalb wird er aufgehoben.
|
||||
var lastPayload: [UInt8] = []
|
||||
|
||||
/// Übersteuerung aus den Geräteeinstellungen.
|
||||
var zoneMode: FridgeZoneMode = .automatic
|
||||
|
||||
/// Die Einstellungsbytes der rechten Zone, für die Erkennung und die
|
||||
/// Diagnose. Ohne den Messwert – der wird getrennt beurteilt.
|
||||
var rightZoneBytes: [UInt8] = []
|
||||
|
||||
/// Ob die Box wirklich eine zweite Zone hat.
|
||||
///
|
||||
/// Die Nutzlastlänge allein taugt nicht: Einzonen-Boxen senden den langen
|
||||
/// Datensatz teils mit und füllen den zweiten Block auf. Zwei Anzeichen
|
||||
/// verraten das. Erstens meldet die Box für den fehlenden zweiten Fühler
|
||||
/// -128, den üblichen Platzhalter. Zweitens stehen die Einstellungen der
|
||||
/// rechten Zone dann auf lauter Nullen oder lauter 0xFF.
|
||||
///
|
||||
/// Das ist keine Frage der Anzeige allein: der Stellbefehl fällt für eine
|
||||
/// Box mit zwei Zonen länger aus, und die falsche Länge wird verworfen.
|
||||
var detectedDualZone: Bool {
|
||||
guard let current = rightCurrent,
|
||||
current != AlpicoolProtocol.missingSensorReading else { return false }
|
||||
guard !rightZoneBytes.isEmpty else { return false }
|
||||
return rightZoneBytes.contains { $0 != 0x00 } && rightZoneBytes.contains { $0 != 0xFF }
|
||||
}
|
||||
|
||||
var isDualZone: Bool {
|
||||
switch zoneMode {
|
||||
case .automatic: return detectedDualZone
|
||||
case .single: return false
|
||||
case .dual: return rightCurrent != nil
|
||||
}
|
||||
}
|
||||
var isEco: Bool { runMode == 1 }
|
||||
var usesFahrenheit: Bool { unit == 1 }
|
||||
var hasStatus: Bool { !lastPayload.isEmpty }
|
||||
|
||||
var unitSymbol: String { usesFahrenheit ? "°F" : "°C" }
|
||||
|
||||
/// Grenzen für den Sollwert. Meldet die Box keine brauchbaren, gelten
|
||||
/// die üblichen Werte der Baureihe.
|
||||
var targetRange: ClosedRange<Int> {
|
||||
let low = temperatureMin ?? (usesFahrenheit ? -22 : -30)
|
||||
let high = temperatureMax ?? (usesFahrenheit ? 68 : 20)
|
||||
return low < high ? low...high : (usesFahrenheit ? -22...68 : -30...20)
|
||||
}
|
||||
|
||||
mutating func apply(_ frame: AlpicoolProtocol.Frame) {
|
||||
// Nur Statusantworten auswerten; das Echo eines Stellbefehls ist kurz.
|
||||
guard frame.command == AlpicoolProtocol.Command.query.rawValue,
|
||||
frame.payload.count >= 18 else { return }
|
||||
let p = frame.payload
|
||||
lastPayload = p
|
||||
|
||||
isLocked = p[0] != 0
|
||||
isPoweredOn = p[1] != 0
|
||||
runMode = Int(p[2])
|
||||
batterySaver = Int(p[3])
|
||||
leftTarget = AlpicoolProtocol.signed(p[4])
|
||||
temperatureMax = AlpicoolProtocol.signed(p[5])
|
||||
temperatureMin = AlpicoolProtocol.signed(p[6])
|
||||
returnDifference = AlpicoolProtocol.signed(p[7])
|
||||
startDelayMinutes = Int(p[8])
|
||||
unit = Int(p[9])
|
||||
leftCurrent = AlpicoolProtocol.signed(p[14])
|
||||
batteryPercent = Int(p[15])
|
||||
batteryVolts = Double(p[16]) + Double(p[17]) / 10
|
||||
|
||||
if p.count >= 28 {
|
||||
rightTarget = AlpicoolProtocol.signed(p[18])
|
||||
rightCurrent = AlpicoolProtocol.signed(p[26])
|
||||
runningStatus = Int(p[27])
|
||||
rightZoneBytes = Array(p[18...25])
|
||||
} else {
|
||||
rightTarget = nil
|
||||
rightCurrent = nil
|
||||
rightZoneBytes = []
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Stand, der die Verbindung überdauert – ohne Messwerte.
|
||||
var settings: FridgeSettings? {
|
||||
guard hasStatus else { return nil }
|
||||
return FridgeSettings(isPoweredOn: isPoweredOn,
|
||||
isEco: isEco,
|
||||
isLocked: isLocked,
|
||||
isDualZone: isDualZone,
|
||||
usesFahrenheit: usesFahrenheit,
|
||||
leftTarget: leftTarget,
|
||||
rightTarget: isDualZone ? rightTarget : nil,
|
||||
updated: Date())
|
||||
}
|
||||
|
||||
/// Die Bytes, die ein Stellbefehl ändert.
|
||||
///
|
||||
/// Messwerte gehören nicht dazu: Temperatur und Spannung schwanken
|
||||
/// ohnehin, an ihnen liesse sich nicht ablesen, ob ein Befehl gewirkt hat.
|
||||
var settingsFingerprint: [UInt8] {
|
||||
guard lastPayload.count >= 18 else { return [] }
|
||||
var bytes = [lastPayload[0], lastPayload[1], lastPayload[2], lastPayload[4]]
|
||||
if lastPayload.count >= 28 { bytes.append(lastPayload[18]) }
|
||||
return bytes
|
||||
}
|
||||
|
||||
// MARK: - Stellbefehle
|
||||
|
||||
static func setTarget(zone: Zone, to value: Int) -> Data {
|
||||
AlpicoolProtocol.packet(zone == .left ? .setLeft : .setRight,
|
||||
data: [UInt8(bitPattern: Int8(clamping: value))])
|
||||
}
|
||||
|
||||
enum Zone { case left, right }
|
||||
|
||||
/// Baut den Einstellungsblock neu auf und ändert darin einzelne Bytes.
|
||||
/// Ein Teil-Update gibt es bei diesem Kommando nicht – die Box erwartet
|
||||
/// den kompletten Block, sonst überschreibt sie Einstellungen mit Nullen.
|
||||
func settingsCommand(poweredOn: Bool? = nil,
|
||||
eco: Bool? = nil,
|
||||
locked: Bool? = nil) -> Data? {
|
||||
guard lastPayload.count >= 18 else { return nil }
|
||||
let p = lastPayload
|
||||
|
||||
var data: [UInt8] = [
|
||||
locked.map { $0 ? 1 : 0 } ?? p[0],
|
||||
poweredOn.map { $0 ? 1 : 0 } ?? p[1],
|
||||
eco.map { $0 ? 1 : 0 } ?? p[2],
|
||||
p[3], // Batteriewächter
|
||||
p[4], // Sollwert links
|
||||
p[5], p[6], // Grenzen
|
||||
p[7], // Rückschaltdifferenz
|
||||
p[8], // Anlaufverzögerung
|
||||
p[9], // Einheit
|
||||
p[10], p[11], p[12], p[13], // Kompressordrehzahlen
|
||||
]
|
||||
|
||||
// Der zweite Block gehört nur an den Befehl, wenn die Box wirklich
|
||||
// zwei Zonen hat. Eine Einzonen-Box sendet den langen Datensatz teils
|
||||
// trotzdem – nimmt aber nur den kurzen Befehl an. Stimmt die Erkennung
|
||||
// im Einzelfall nicht, lässt sie sich in den Geräteeinstellungen von
|
||||
// Hand festlegen.
|
||||
if isDualZone, p.count >= 28 {
|
||||
data += [
|
||||
p[18], // Sollwert rechts
|
||||
0, 0,
|
||||
p[21], // Rückschaltdifferenz rechts
|
||||
p[22], p[23], p[24], p[25],
|
||||
0, 0, 0,
|
||||
]
|
||||
}
|
||||
return AlpicoolProtocol.packet(.set, data: data)
|
||||
}
|
||||
|
||||
// MARK: - Anzeige
|
||||
|
||||
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
|
||||
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
|
||||
let unit = unitSymbol
|
||||
|
||||
var metrics: [Metric] = [
|
||||
Metric("temp_left", isDualZone ? "Temperatur links" : "Temperatur",
|
||||
leftCurrent.map(Double.init), unit: unit, precision: 0, primary: true),
|
||||
Metric("target_left", isDualZone ? "Soll links" : "Solltemperatur",
|
||||
leftTarget.map(Double.init), unit: unit, precision: 0),
|
||||
]
|
||||
if isDualZone {
|
||||
metrics.append(Metric("temp_right", "Temperatur rechts",
|
||||
rightCurrent.map(Double.init), unit: unit, precision: 0))
|
||||
metrics.append(Metric("target_right", "Soll rechts",
|
||||
rightTarget.map(Double.init), unit: unit, precision: 0))
|
||||
}
|
||||
metrics.append(Metric("supply_voltage", "Bordspannung", batteryVolts, unit: "V", precision: 1))
|
||||
metrics.append(Metric("battery_percent", "Batterieanzeige",
|
||||
batteryPercent.map(Double.init), unit: "%", precision: 0))
|
||||
snapshot.metrics = metrics
|
||||
|
||||
if !isPoweredOn {
|
||||
snapshot.state = "Aus"
|
||||
} else if runningStatus == 1 {
|
||||
snapshot.state = isEco ? "Kühlt (Eco)" : "Kühlt (Max)"
|
||||
} else {
|
||||
snapshot.state = isEco ? "Eco" : "Max"
|
||||
}
|
||||
|
||||
var notes: [String] = []
|
||||
if isLocked { notes.append("Bedienfeld gesperrt") }
|
||||
snapshot.offReasons = notes
|
||||
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
/// Hält die GATT-Verbindung zu einem BMS, pollt die Werte und meldet fertige
|
||||
/// Snapshots zurück.
|
||||
///
|
||||
/// Zwei Dinge sind bei diesen Geräten nicht vorhersehbar und werden deshalb
|
||||
/// ausprobiert statt vorausgesetzt:
|
||||
///
|
||||
/// 1. **Über welche Charakteristiken gesprochen wird.** Im selben Dienst sehen
|
||||
/// oft mehrere Charakteristiken beschreibbar aus, nur eine nimmt aber
|
||||
/// wirklich Kommandos an. Die Session stellt alle sinnvollen Paare aus
|
||||
/// Schreib- und Benachrichtigungs-Charakteristik zusammen und arbeitet sie
|
||||
/// der Reihe nach ab, bis eines antwortet.
|
||||
/// 2. **Welches Protokoll gesprochen wird.** Auf jedem Paar werden Daly
|
||||
/// (klassisch und Modbus) und JBD/Xiaoxiang angefragt; der erste gültige
|
||||
/// Rahmen legt den Dialekt fest.
|
||||
final class BMSSession: NSObject {
|
||||
|
||||
/// Bekannte Paare, die zuerst versucht werden.
|
||||
private static let knownPairs: [(service: String, write: String, notify: String)] = [
|
||||
("FFF0", "FFF2", "FFF1"), // Daly und viele baugleiche Module
|
||||
("FF00", "FF02", "FF01"), // JBD / Xiaoxiang
|
||||
("FFE0", "FFE1", "FFE1"),
|
||||
("6E400001-B5A3-F393-E0A9-E50E24DCCA9E",
|
||||
"6E400002-B5A3-F393-E0A9-E50E24DCCA9E",
|
||||
"6E400003-B5A3-F393-E0A9-E50E24DCCA9E"), // Nordic UART
|
||||
]
|
||||
|
||||
/// Die Kühlboxen liegen nicht in einem der bekannten Dienste, ihre
|
||||
/// Charakteristiken sind aber eindeutig.
|
||||
private static let alpicoolWriteUUID = CBUUID(string: "00001235-0000-1000-8000-00805F9B34FB")
|
||||
private static let alpicoolNotifyUUID = CBUUID(string: "00001236-0000-1000-8000-00805F9B34FB")
|
||||
|
||||
enum Dialect: String {
|
||||
case unknown = "wird ermittelt"
|
||||
case dalyClassic = "Daly (klassisch)"
|
||||
case dalyModbus = "Daly (Modbus)"
|
||||
case jbd = "JBD / Xiaoxiang"
|
||||
case wattCycle = "WattCycle"
|
||||
case alpicool = "Alpicool-Kühlbox"
|
||||
}
|
||||
|
||||
/// 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"
|
||||
let unlock = auth == nil ? "" : ", Freischaltung über \(auth!.uuid.uuidString)"
|
||||
return "\(write.uuid.uuidString) → \(notify.uuid.uuidString), \(mode)\(unlock)"
|
||||
}
|
||||
}
|
||||
|
||||
let deviceID: UUID
|
||||
/// Die Queue, auf der CoreBluetooth arbeitet. Alle Zeitgeber und
|
||||
/// verzögerten Aufrufe laufen darauf, damit der Zustand dieser Klasse nur
|
||||
/// von einem Thread aus angefasst wird.
|
||||
private let queue: DispatchQueue
|
||||
private let peripheral: CBPeripheral
|
||||
private let onUpdate: (DeviceSnapshot) -> Void
|
||||
private let onStateChange: (DeviceLinkState) -> Void
|
||||
private let onDiagnostics: (BMSDiagnostics) -> Void
|
||||
/// Meldet Änderungen am Kühlbox-Zustand, damit die Bedienelemente folgen.
|
||||
var onFridgeState: ((AlpicoolState) -> Void)?
|
||||
|
||||
private var endpoints: [Endpoint] = []
|
||||
private var endpointIndex = 0
|
||||
private var pendingServices = 0
|
||||
|
||||
private(set) var dialect: Dialect = .unknown
|
||||
private var dalyState = DalyState()
|
||||
private var jbdState = JBDState()
|
||||
private var wattCycleState = WattCycleState()
|
||||
private(set) var alpicoolState = AlpicoolState()
|
||||
/// Aus den Geräteeinstellungen; übersteuert die automatische Erkennung.
|
||||
var fridgeZoneMode: FridgeZoneMode = .automatic {
|
||||
didSet { alpicoolState.zoneMode = fridgeZoneMode }
|
||||
}
|
||||
/// Ob die Kühlbox in dieser Sitzung schon angemeldet wurde.
|
||||
private var didBind = false
|
||||
/// Ob die Box die Anmeldung auch beantwortet hat. Abfragen nimmt sie
|
||||
/// teils auch unangemeldet an, Stellbefehle nicht – deshalb wird vor
|
||||
/// einem Befehl notfalls noch einmal angemeldet.
|
||||
private(set) var bindAcknowledged = false
|
||||
/// Ob vor einem Stellbefehl schon einmal nachgemeldet wurde. Jedes Mal
|
||||
/// anzumelden lässt die Box bei jedem Tastendruck erneut piepen.
|
||||
private var didRebindForControl = false
|
||||
private var buffer: [UInt8] = []
|
||||
private var pollTimer: DispatchSourceTimer?
|
||||
private var lastResponse: Data?
|
||||
private var lastCommand: Data?
|
||||
private var lastCommandAt: Date?
|
||||
/// Stellbefehle, die kamen, bevor der Kanal stand. Sie jetzt schon zu
|
||||
/// senden hiesse, sie an einen womöglich falschen Kandidaten zu schicken;
|
||||
/// sie fallen zu lassen hiesse, ein Tippen zu verschlucken.
|
||||
private var waitingControls: [(packet: Data, queuedAt: Date)] = []
|
||||
/// So lange darf ein Befehl warten, bevor er verfällt.
|
||||
private let controlLifetime: TimeInterval = 30
|
||||
private var receivedByteCount = 0
|
||||
private var sentFrameCount = 0
|
||||
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
|
||||
/// Ob auf diesem Kandidaten schon freigeschaltet wurde.
|
||||
private var didUnlock = false
|
||||
private var lastSendAt: Date?
|
||||
/// Was noch rausgeschrieben werden muss.
|
||||
///
|
||||
/// Ein Schreibvorgang ohne Bestätigung wird von iOS stillschweigend
|
||||
/// verworfen, wenn der Sendepuffer gerade voll ist. Deshalb wird nur
|
||||
/// geschrieben, solange iOS bereit ist, und der Rest wartet auf die
|
||||
/// Rückmeldung.
|
||||
private var outbox: [Data] = []
|
||||
/// Fehler des letzten bestätigten Schreibvorgangs, für die Diagnose.
|
||||
private var lastWriteError: String?
|
||||
/// Wieviele Schreibvorgänge das Gerät bestätigt hat.
|
||||
private var confirmedWrites = 0
|
||||
|
||||
/// 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 = 6
|
||||
|
||||
private var currentEndpoint: Endpoint? {
|
||||
endpoints.indices.contains(endpointIndex) ? endpoints[endpointIndex] : nil
|
||||
}
|
||||
|
||||
init(deviceID: UUID,
|
||||
peripheral: CBPeripheral,
|
||||
queue: DispatchQueue,
|
||||
onUpdate: @escaping (DeviceSnapshot) -> Void,
|
||||
onStateChange: @escaping (DeviceLinkState) -> Void,
|
||||
onDiagnostics: @escaping (BMSDiagnostics) -> Void) {
|
||||
self.deviceID = deviceID
|
||||
self.queue = queue
|
||||
self.peripheral = peripheral
|
||||
self.onUpdate = onUpdate
|
||||
self.onStateChange = onStateChange
|
||||
self.onDiagnostics = onDiagnostics
|
||||
super.init()
|
||||
peripheral.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - Lebenszyklus
|
||||
|
||||
func start() {
|
||||
onStateChange(.connecting)
|
||||
peripheral.discoverServices(nil)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
pollTimer?.cancel()
|
||||
pollTimer = nil
|
||||
if let notify = currentEndpoint?.notify, peripheral.state == .connected {
|
||||
peripheral.setNotifyValue(false, for: notify)
|
||||
}
|
||||
endpoints.removeAll()
|
||||
endpointIndex = 0
|
||||
dialect = .unknown
|
||||
buffer.removeAll()
|
||||
}
|
||||
|
||||
func handleDisconnect() {
|
||||
pollTimer?.cancel()
|
||||
pollTimer = nil
|
||||
endpoints.removeAll()
|
||||
endpointIndex = 0
|
||||
buffer.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Kandidaten
|
||||
|
||||
/// Stellt nach der Dienstsuche alle Paare zusammen: bekannte Kombinationen
|
||||
/// zuerst, danach jede andere Schreib-/Notify-Kombination im selben Dienst.
|
||||
private func buildEndpoints() {
|
||||
var candidates: [Endpoint] = []
|
||||
|
||||
for service in peripheral.services ?? [] {
|
||||
let characteristics = service.characteristics ?? []
|
||||
let writable = characteristics.filter {
|
||||
$0.properties.contains(.write) || $0.properties.contains(.writeWithoutResponse)
|
||||
}
|
||||
let notifying = characteristics.filter {
|
||||
$0.properties.contains(.notify) || $0.properties.contains(.indicate)
|
||||
}
|
||||
guard !writable.isEmpty, !notifying.isEmpty else { continue }
|
||||
|
||||
// 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 isFridgePair = write.uuid == Self.alpicoolWriteUUID
|
||||
&& notify.uuid == Self.alpicoolNotifyUUID
|
||||
let known = isFridgePair || Self.knownPairs.contains {
|
||||
CBUUID(string: $0.service) == service.uuid
|
||||
&& CBUUID(string: $0.write) == write.uuid
|
||||
&& CBUUID(string: $0.notify) == notify.uuid
|
||||
}
|
||||
// Beide Schreibarten anbieten, sofern das Gerät sie kann.
|
||||
if write.properties.contains(.writeWithoutResponse) {
|
||||
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, auth: auth,
|
||||
writeType: .withResponse, isKnownPair: known))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bekannte Paare nach vorn, der Rest in Fundreihenfolge. Die
|
||||
// Fundreihenfolge muss dabei erhalten bleiben: `sorted` allein
|
||||
// garantiert das nicht, und dann entschiede der Zufall, ob mit oder
|
||||
// ohne Bestätigung geschrieben wird.
|
||||
endpoints = candidates.enumerated()
|
||||
.sorted { lhs, rhs in
|
||||
lhs.element.isKnownPair == rhs.element.isKnownPair
|
||||
? lhs.offset < rhs.offset
|
||||
: lhs.element.isKnownPair
|
||||
}
|
||||
.map(\.element)
|
||||
endpointIndex = 0
|
||||
|
||||
guard !endpoints.isEmpty else {
|
||||
onStateChange(.failed("Keine passenden Bluetooth-Merkmale gefunden"))
|
||||
publishDiagnostics()
|
||||
return
|
||||
}
|
||||
activateCurrentEndpoint()
|
||||
}
|
||||
|
||||
private func activateCurrentEndpoint() {
|
||||
guard let endpoint = currentEndpoint else { return }
|
||||
silentRounds = 0
|
||||
isNotifyActive = false
|
||||
didUnlock = false
|
||||
didBind = false
|
||||
bindAcknowledged = false
|
||||
didRebindForControl = 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.
|
||||
queue.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
|
||||
/// begonnen – das Gerät kann zwischenzeitlich aufgewacht sein.
|
||||
private func advanceEndpoint() {
|
||||
guard let previous = currentEndpoint else { return }
|
||||
if peripheral.state == .connected {
|
||||
peripheral.setNotifyValue(false, for: previous.notify)
|
||||
}
|
||||
endpointIndex = (endpointIndex + 1) % endpoints.count
|
||||
onStateChange(.connecting)
|
||||
activateCurrentEndpoint()
|
||||
beginPolling()
|
||||
}
|
||||
|
||||
// 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
|
||||
queue.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()
|
||||
|
||||
queue.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
guard let self, self.activationToken == token else { return }
|
||||
self.beginPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginPolling() {
|
||||
pollTimer?.cancel()
|
||||
flushWaitingControls()
|
||||
poll()
|
||||
let interval = dialect == .unknown ? searchInterval : pollInterval
|
||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||
timer.schedule(deadline: .now() + interval, repeating: interval)
|
||||
timer.setEventHandler { [weak self] in self?.poll() }
|
||||
timer.resume()
|
||||
pollTimer = timer
|
||||
}
|
||||
|
||||
private func poll() {
|
||||
guard peripheral.state == .connected, currentEndpoint != nil else { return }
|
||||
|
||||
switch dialect {
|
||||
case .unknown:
|
||||
// Alle drei Protokolle anfragen; was antwortet, gewinnt.
|
||||
sendSequence([
|
||||
AlpicoolProtocol.packet(.bind),
|
||||
AlpicoolProtocol.packet(.query),
|
||||
WattCycleProtocol.requestFrame(.analog),
|
||||
DalyProtocol.requestFrame(.soc),
|
||||
JBDProtocol.requestFrame(.basicInfo),
|
||||
DalyProtocol.modbusReadFrame(),
|
||||
], spacing: 0.6, thenGiveUpAfter: 3)
|
||||
case .dalyClassic:
|
||||
sendSequence(DalyProtocol.Command.allCases.map { DalyProtocol.requestFrame($0) },
|
||||
spacing: 0.25, thenGiveUpAfter: 2)
|
||||
case .dalyModbus:
|
||||
sendSequence([DalyProtocol.modbusReadFrame()], spacing: 0.25, thenGiveUpAfter: 2)
|
||||
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)
|
||||
case .alpicool:
|
||||
// Die Anmeldung gilt für die Dauer der Verbindung.
|
||||
var frames: [Data] = []
|
||||
if !didBind {
|
||||
frames.append(AlpicoolProtocol.packet(.bind))
|
||||
didBind = true
|
||||
}
|
||||
frames.append(AlpicoolProtocol.packet(.query))
|
||||
sendSequence(frames, spacing: 0.3, thenGiveUpAfter: 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Kommandos leicht versetzt senden – manche Module verschlucken Anfragen,
|
||||
/// die zu dicht aufeinander folgen.
|
||||
private func sendSequence(_ frames: [Data], spacing: TimeInterval, thenGiveUpAfter grace: TimeInterval) {
|
||||
for (index, frame) in frames.enumerated() {
|
||||
queue.asyncAfter(deadline: .now() + Double(index) * spacing) { [weak self] in
|
||||
self?.send(frame)
|
||||
}
|
||||
}
|
||||
checkForSilence(after: Double(frames.count) * spacing + grace)
|
||||
}
|
||||
|
||||
/// Kommt nichts Brauchbares zurück, wird der nächste Kandidat versucht.
|
||||
private func checkForSilence(after delay: TimeInterval) {
|
||||
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self, self.peripheral.state == .connected else { return }
|
||||
guard !self.hasUsableData else {
|
||||
self.silentRounds = 0
|
||||
return
|
||||
}
|
||||
self.silentRounds += 1
|
||||
self.publishDiagnostics()
|
||||
|
||||
// Solange noch kein Protokoll steht, zügig weiterprobieren.
|
||||
let limit = self.dialect == .unknown ? 1 : 3
|
||||
guard self.silentRounds > limit else { return }
|
||||
|
||||
self.silentRounds = 0
|
||||
if self.endpoints.count > 1 {
|
||||
self.advanceEndpoint()
|
||||
} else {
|
||||
self.dialect = .unknown
|
||||
self.onStateChange(.failed("Keine Antwort vom BMS"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hasUsableData: Bool {
|
||||
dalyState.hasUsableData || jbdState.hasUsableData
|
||||
|| wattCycleState.hasUsableData || alpicoolState.hasStatus
|
||||
}
|
||||
|
||||
private func send(_ data: Data) {
|
||||
guard let endpoint = currentEndpoint, peripheral.state == .connected else { return }
|
||||
sentFrameCount += 1
|
||||
lastSendAt = Date()
|
||||
|
||||
let pieces = AlpicoolProtocol.chunks(data, limit: writeLimit(for: endpoint))
|
||||
|
||||
for (index, piece) in pieces.enumerated() {
|
||||
guard index > 0 else { enqueue(piece); continue }
|
||||
queue.asyncAfter(
|
||||
deadline: .now() + Double(index) * AlpicoolProtocol.chunkDelay
|
||||
) { [weak self] in
|
||||
self?.enqueue(piece)
|
||||
}
|
||||
}
|
||||
|
||||
// Sofort melden, sonst sieht die Diagnose sekundenlang nach Stillstand
|
||||
// aus, obwohl gerade gesucht wird.
|
||||
publishDiagnostics()
|
||||
}
|
||||
|
||||
private func enqueue(_ piece: Data) {
|
||||
outbox.append(piece)
|
||||
drainOutbox()
|
||||
}
|
||||
|
||||
/// Schreibt, solange iOS Schreibvorgänge annimmt.
|
||||
private func drainOutbox() {
|
||||
guard let endpoint = currentEndpoint, peripheral.state == .connected else {
|
||||
outbox.removeAll()
|
||||
return
|
||||
}
|
||||
while !outbox.isEmpty {
|
||||
if endpoint.writeType == .withoutResponse, !peripheral.canSendWriteWithoutResponse {
|
||||
// Der Rest geht raus, sobald iOS sich wieder meldet.
|
||||
return
|
||||
}
|
||||
peripheral.writeValue(outbox.removeFirst(),
|
||||
for: endpoint.write, type: endpoint.writeType)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wieviel je Schreibvorgang rausgeht.
|
||||
///
|
||||
/// Grundsätzlich das, was die Verbindung hergibt. Die Kühlboxen nehmen
|
||||
/// aber nur die 20 Byte der Standard-MTU an, auch wenn iOS eine grössere
|
||||
/// aushandelt und damit weit mehr erlauben würde. Ohne diese Grenze ginge
|
||||
/// der Einstellungsblock als ein Schreibvorgang raus – und die Box würde
|
||||
/// ihn ablehnen, während die kurzen Befehle durchgehen.
|
||||
private func writeLimit(for endpoint: Endpoint) -> Int {
|
||||
let negotiated = peripheral.maximumWriteValueLength(for: endpoint.writeType)
|
||||
guard dialect == .alpicool else { return negotiated }
|
||||
return min(negotiated, AlpicoolProtocol.maxWriteSize)
|
||||
}
|
||||
|
||||
// MARK: - Steuern
|
||||
|
||||
/// Schickt einen Stellbefehl und fragt kurz darauf den Zustand ab, damit
|
||||
/// die Anzeige dem Gerät folgt statt der Vermutung.
|
||||
func sendControl(_ packet: Data) {
|
||||
lastCommand = packet
|
||||
lastCommandAt = Date()
|
||||
|
||||
// Erst wenn der Dialekt steht, ist auch der richtige Kanal bekannt.
|
||||
guard dialect == .alpicool, currentEndpoint != nil,
|
||||
peripheral.state == .connected else {
|
||||
waitingControls.append((packet, Date()))
|
||||
if waitingControls.count > 4 {
|
||||
waitingControls.removeFirst(waitingControls.count - 4)
|
||||
}
|
||||
publishDiagnostics()
|
||||
return
|
||||
}
|
||||
|
||||
// Hat die Box die Anmeldung nie beantwortet, wird sie einmal je
|
||||
// Verbindung nachgeholt. Ein Stellbefehl an eine unangemeldete Box
|
||||
// wird sonst womöglich verworfen - jedes Mal anzumelden lässt die Box
|
||||
// aber bei jedem Tastendruck zusätzlich piepen.
|
||||
guard bindAcknowledged || didRebindForControl else {
|
||||
didRebindForControl = true
|
||||
send(AlpicoolProtocol.packet(.bind))
|
||||
queue.asyncAfter(deadline: .now() + 0.4) { [weak self] in
|
||||
self?.deliverControl(packet, attempt: 0)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
deliverControl(packet, attempt: 0)
|
||||
}
|
||||
|
||||
/// Schickt den Befehl und prüft, ob er gewirkt hat.
|
||||
///
|
||||
/// Manche Module nehmen nur eine der beiden Schreibarten an und melden das
|
||||
/// nicht – der Befehl verschwindet dann lautlos. Bleiben die Einstellungen
|
||||
/// der Box unverändert, wird deshalb einmal mit der anderen Art nachgesetzt.
|
||||
private func deliverControl(_ packet: Data, attempt: Int) {
|
||||
let before = alpicoolState.settingsFingerprint
|
||||
let token = activationToken
|
||||
send(packet)
|
||||
|
||||
// Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist.
|
||||
queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||
guard let self, self.dialect == .alpicool else { return }
|
||||
self.send(AlpicoolProtocol.packet(.query))
|
||||
}
|
||||
|
||||
guard attempt == 0 else { return }
|
||||
queue.asyncAfter(deadline: .now() + 3.5) { [weak self] in
|
||||
guard let self, self.activationToken == token,
|
||||
self.dialect == .alpicool, self.peripheral.state == .connected,
|
||||
self.alpicoolState.settingsFingerprint == before,
|
||||
let index = self.alternateWriteTypeIndex() else { return }
|
||||
self.endpointIndex = index
|
||||
self.publishDiagnostics()
|
||||
self.deliverControl(packet, attempt: 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Derselbe Kanal, nur mit der anderen Schreibart.
|
||||
private func alternateWriteTypeIndex() -> Int? {
|
||||
guard let current = currentEndpoint else { return nil }
|
||||
return endpoints.firstIndex {
|
||||
$0.write.uuid == current.write.uuid
|
||||
&& $0.notify.uuid == current.notify.uuid
|
||||
&& $0.writeType != current.writeType
|
||||
}
|
||||
}
|
||||
|
||||
/// Schickt raus, was während des Verbindungsaufbaus aufgelaufen ist.
|
||||
private func flushWaitingControls() {
|
||||
guard dialect == .alpicool, !waitingControls.isEmpty else { return }
|
||||
let due = waitingControls.filter {
|
||||
Date().timeIntervalSince($0.queuedAt) < controlLifetime
|
||||
}
|
||||
waitingControls.removeAll()
|
||||
for (index, entry) in due.enumerated() {
|
||||
queue.asyncAfter(deadline: .now() + Double(index) * 0.4) { [weak self] in
|
||||
self?.sendControl(entry.packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auswertung
|
||||
private func consume(_ data: Data) {
|
||||
lastResponse = data
|
||||
receivedByteCount += data.count
|
||||
buffer.append(contentsOf: [UInt8](data))
|
||||
if buffer.count > 512 { buffer.removeFirst(buffer.count - 512) }
|
||||
|
||||
// Steht der Dialekt fest, nur noch diesen prüfen. Bei jeder Antwort
|
||||
// alle fünf Parser durchzugehen belastet die Funk-Queue ohne Nutzen.
|
||||
switch dialect {
|
||||
case .alpicool: consumeAlpicool(); return
|
||||
case .wattCycle: consumeWattCycle(); return
|
||||
case .jbd: consumeJBD(); return
|
||||
case .dalyModbus: consumeDalyModbus(); return
|
||||
case .dalyClassic: consumeDalyClassic(); return
|
||||
case .unknown: break
|
||||
}
|
||||
|
||||
if consumeAlpicool() { return }
|
||||
if consumeWattCycle() { return }
|
||||
if consumeJBD() { return }
|
||||
if consumeDalyModbus() { return }
|
||||
if consumeDalyClassic() { return }
|
||||
|
||||
// Etwas kam an, ließ sich aber nicht zuordnen: für die Diagnose
|
||||
// sichtbar machen, damit sich das Protokoll bestimmen lässt.
|
||||
publishDiagnostics()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func consumeAlpicool() -> Bool {
|
||||
let (frames, remainder) = AlpicoolProtocol.extractFrames(from: buffer)
|
||||
guard !frames.isEmpty else { return false }
|
||||
buffer = remainder
|
||||
adopt(.alpicool)
|
||||
if frames.contains(where: { $0.command == AlpicoolProtocol.Command.bind.rawValue }) {
|
||||
bindAcknowledged = true
|
||||
}
|
||||
for frame in frames { alpicoolState.apply(frame) }
|
||||
alpicoolState.zoneMode = fridgeZoneMode
|
||||
onFridgeState?(alpicoolState)
|
||||
publish(alpicoolState.snapshot(deviceID: deviceID, rssi: nil),
|
||||
usable: alpicoolState.hasStatus)
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func consumeWattCycle() -> Bool {
|
||||
let (frames, remainder) = WattCycleProtocol.extractFrames(from: buffer)
|
||||
guard !frames.isEmpty else { return false }
|
||||
buffer = remainder
|
||||
adopt(.wattCycle)
|
||||
for frame in frames { wattCycleState.apply(frame) }
|
||||
publish(wattCycleState.snapshot(deviceID: deviceID, rssi: nil),
|
||||
usable: wattCycleState.hasUsableData)
|
||||
return true
|
||||
}
|
||||
|
||||
/// JBD: Start-, Endbyte und Prüfsumme machen den Rahmen eindeutig.
|
||||
@discardableResult
|
||||
private func consumeJBD() -> Bool {
|
||||
let (frames, remainder) = JBDProtocol.extractFrames(from: buffer)
|
||||
guard !frames.isEmpty else { return false }
|
||||
buffer = remainder
|
||||
adopt(.jbd)
|
||||
for frame in frames { jbdState.apply(frame) }
|
||||
publish(jbdState.snapshot(deviceID: deviceID, rssi: nil), usable: jbdState.hasUsableData)
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func consumeDalyModbus() -> Bool {
|
||||
guard let start = buffer.firstIndex(where: { $0 == 0xD2 }),
|
||||
let registers = DalyProtocol.parseModbusResponse(Array(buffer[start...]))
|
||||
else { return false }
|
||||
buffer.removeAll()
|
||||
adopt(.dalyModbus)
|
||||
dalyState.apply(registers: registers)
|
||||
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func consumeDalyClassic() -> Bool {
|
||||
let (frames, remainder) = DalyProtocol.extractA5Frames(from: buffer)
|
||||
guard !frames.isEmpty else { return false }
|
||||
buffer = remainder
|
||||
adopt(.dalyClassic)
|
||||
for frame in frames { dalyState.apply(frame) }
|
||||
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Erster verwertbarer Rahmen: Kandidat und Dialekt stehen fest, ab jetzt
|
||||
/// im normalen Takt abfragen.
|
||||
private func adopt(_ newDialect: Dialect) {
|
||||
silentRounds = 0
|
||||
guard dialect != newDialect else { return }
|
||||
dialect = newDialect
|
||||
beginPolling()
|
||||
}
|
||||
|
||||
private func publish(_ snapshot: DeviceSnapshot, usable: Bool) {
|
||||
publishDiagnostics()
|
||||
guard usable else { return }
|
||||
onStateChange(.live)
|
||||
onUpdate(snapshot)
|
||||
}
|
||||
|
||||
private func publishDiagnostics() {
|
||||
onDiagnostics(BMSDiagnostics(
|
||||
dialect: dialect.rawValue,
|
||||
endpointLabel: currentEndpoint?.label,
|
||||
endpointPosition: endpoints.isEmpty ? nil : .init(endpointIndex + 1, endpoints.count),
|
||||
serviceUUID: currentEndpoint?.write.service?.uuid.uuidString,
|
||||
isConnected: peripheral.state == .connected,
|
||||
isNotifyActive: isNotifyActive,
|
||||
isBound: dialect == .alpicool ? bindAcknowledged : nil,
|
||||
confirmedWrites: confirmedWrites,
|
||||
lastWriteError: lastWriteError,
|
||||
fridgePayloadHex: alpicoolState.lastPayload.isEmpty ? nil
|
||||
: alpicoolState.lastPayload.map { String(format: "%02X", $0) }.joined(separator: " "),
|
||||
gattSummary: gattSummary,
|
||||
sentFrames: sentFrameCount,
|
||||
receivedBytes: receivedByteCount,
|
||||
lastSendAt: lastSendAt,
|
||||
lastResponseHex: lastResponse.map { $0.map { String(format: "%02X", $0) }.joined(separator: " ") },
|
||||
lastCommandHex: lastCommand.map { $0.map { String(format: "%02X", $0) }.joined(separator: " ") },
|
||||
lastCommandAt: lastCommandAt,
|
||||
updated: Date()
|
||||
))
|
||||
}
|
||||
|
||||
/// Menschenlesbarer GATT-Baum für die Diagnose.
|
||||
private func appendToSummary(_ service: CBService) {
|
||||
gattSummary.append("Dienst \(service.uuid.uuidString)")
|
||||
for characteristic in service.characteristics ?? [] {
|
||||
var traits: [String] = []
|
||||
if characteristic.properties.contains(.read) { traits.append("read") }
|
||||
if characteristic.properties.contains(.write) { traits.append("write") }
|
||||
if characteristic.properties.contains(.writeWithoutResponse) { traits.append("write-nr") }
|
||||
if characteristic.properties.contains(.notify) { traits.append("notify") }
|
||||
if characteristic.properties.contains(.indicate) { traits.append("indicate") }
|
||||
gattSummary.append(" \(characteristic.uuid.uuidString) \(traits.joined(separator: ", "))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBPeripheralDelegate
|
||||
|
||||
extension BMSSession: CBPeripheralDelegate {
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
if let error {
|
||||
onStateChange(.failed(error.localizedDescription))
|
||||
return
|
||||
}
|
||||
let services = peripheral.services ?? []
|
||||
gattSummary.removeAll()
|
||||
pendingServices = services.count
|
||||
guard pendingServices > 0 else {
|
||||
onStateChange(.failed("Gerät bietet keine Bluetooth-Dienste an"))
|
||||
return
|
||||
}
|
||||
for service in services {
|
||||
peripheral.discoverCharacteristics(nil, for: service)
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral,
|
||||
didDiscoverCharacteristicsFor service: CBService,
|
||||
error: Error?) {
|
||||
appendToSummary(service)
|
||||
pendingServices -= 1
|
||||
// Erst wenn alle Dienste durch sind, steht die Kandidatenliste fest.
|
||||
guard pendingServices <= 0 else { return }
|
||||
buildEndpoints()
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral,
|
||||
didUpdateNotificationStateFor characteristic: CBCharacteristic,
|
||||
error: Error?) {
|
||||
if let error {
|
||||
// Dieser Kandidat lässt sich nicht abonnieren – nächsten versuchen.
|
||||
if endpoints.count > 1 {
|
||||
advanceEndpoint()
|
||||
} else {
|
||||
onStateChange(.failed(error.localizedDescription))
|
||||
}
|
||||
return
|
||||
}
|
||||
if characteristic.isNotifying, characteristic == currentEndpoint?.notify {
|
||||
isNotifyActive = true
|
||||
publishDiagnostics()
|
||||
unlockThenPoll()
|
||||
}
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral,
|
||||
didUpdateValueFor characteristic: CBCharacteristic,
|
||||
error: Error?) {
|
||||
guard error == nil, let value = characteristic.value, !value.isEmpty else { return }
|
||||
consume(value)
|
||||
}
|
||||
|
||||
/// Nur bei Schreibvorgängen mit Bestätigung. Ohne Bestätigung meldet iOS
|
||||
/// nichts zurück – auch keinen Fehler.
|
||||
func peripheral(_ peripheral: CBPeripheral,
|
||||
didWriteValueFor characteristic: CBCharacteristic,
|
||||
error: Error?) {
|
||||
if let error {
|
||||
lastWriteError = error.localizedDescription
|
||||
} else {
|
||||
lastWriteError = nil
|
||||
confirmedWrites += 1
|
||||
}
|
||||
publishDiagnostics()
|
||||
}
|
||||
|
||||
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
||||
drainOutbox()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Liest Felder beliebiger Bitbreite aus einem Byte-Array.
|
||||
///
|
||||
/// Victron packt die Felder seiner Werbedaten little-endian und bitweise ohne
|
||||
/// Byte-Ausrichtung: das erste Feld beginnt am niederwertigsten Bit von Byte 0,
|
||||
/// jedes weitere schliesst direkt an.
|
||||
struct BitReader {
|
||||
private let bytes: [UInt8]
|
||||
private var bitOffset = 0
|
||||
|
||||
init(_ bytes: [UInt8]) { self.bytes = bytes }
|
||||
|
||||
var bitsRemaining: Int { bytes.count * 8 - bitOffset }
|
||||
|
||||
/// Liest `width` Bits als vorzeichenlose Zahl. Gibt nil zurück, wenn die
|
||||
/// Daten zu kurz sind.
|
||||
mutating func read(_ width: Int) -> UInt32? {
|
||||
guard width > 0, width <= 32, bitsRemaining >= width else { return nil }
|
||||
var result: UInt32 = 0
|
||||
for i in 0..<width {
|
||||
let absolute = bitOffset + i
|
||||
let byte = bytes[absolute / 8]
|
||||
let bit = (byte >> UInt8(absolute % 8)) & 1
|
||||
result |= UInt32(bit) << UInt32(i)
|
||||
}
|
||||
bitOffset += width
|
||||
return result
|
||||
}
|
||||
|
||||
/// Wie `read`, liefert aber nil wenn alle Bits gesetzt sind – so markiert
|
||||
/// Victron "Wert nicht verfügbar".
|
||||
mutating func readOptional(_ width: Int) -> UInt32? {
|
||||
guard let raw = read(width) else { return nil }
|
||||
let notAvailable: UInt32 = width >= 32 ? .max : (1 << UInt32(width)) - 1
|
||||
return raw == notAvailable ? nil : raw
|
||||
}
|
||||
|
||||
/// Zweierkomplement-Feld. Der NA-Wert 0x7F..F wird zu nil.
|
||||
mutating func readOptionalSigned(_ width: Int) -> Int32? {
|
||||
guard width > 1, let raw = read(width) else { return nil }
|
||||
let notAvailable: UInt32 = (1 << UInt32(width - 1)) - 1
|
||||
if raw == notAvailable { return nil }
|
||||
let signBit: UInt32 = 1 << UInt32(width - 1)
|
||||
if raw & signBit != 0 {
|
||||
let mask: UInt32 = width >= 32 ? 0 : ~((1 << UInt32(width)) - 1)
|
||||
return Int32(bitPattern: raw | mask)
|
||||
}
|
||||
return Int32(bitPattern: raw)
|
||||
}
|
||||
|
||||
/// Skaliertes, optionales Feld ohne Vorzeichen.
|
||||
mutating func scaled(_ width: Int, _ factor: Double) -> Double? {
|
||||
readOptional(width).map { Double($0) * factor }
|
||||
}
|
||||
|
||||
/// Skaliertes, optionales Feld mit Vorzeichen.
|
||||
mutating func scaledSigned(_ width: Int, _ factor: Double) -> Double? {
|
||||
readOptionalSigned(width).map { Double($0) * factor }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
||||
import Foundation
|
||||
|
||||
/// Reine Protokoll-Logik für Daly-BMS – ohne CoreBluetooth, damit sie sich
|
||||
/// isoliert prüfen lässt.
|
||||
///
|
||||
/// Daly hat zwei Generationen im Umlauf:
|
||||
///
|
||||
/// * **Klassisch (`A5`)** – 13-Byte-Rahmen `A5 <adr> <cmd> 08 <8 Datenbytes> <Prüfsumme>`.
|
||||
/// Verbreitet bei den Smart-BMS mit dem blauen BLE-Stick, wie sie in vielen
|
||||
/// Bulltron-Akkus stecken.
|
||||
/// * **Neu (`D2`)** – Modbus-RTU über BLE, `D2 03 <Startregister> <Anzahl> <CRC16>`.
|
||||
///
|
||||
/// Welche Generation verbaut ist, erkennt `DalySession` anhand der Antwort.
|
||||
enum DalyProtocol {
|
||||
|
||||
// MARK: - Klassisches A5-Protokoll
|
||||
|
||||
enum Command: UInt8, CaseIterable {
|
||||
case soc = 0x90 // Spannung, Strom, Ladezustand
|
||||
case cellVoltageMinMax = 0x91
|
||||
case temperatureMinMax = 0x92
|
||||
case mosfetStatus = 0x93
|
||||
case statusInfo = 0x94
|
||||
case cellVoltages = 0x95
|
||||
case cellTemperatures = 0x96
|
||||
}
|
||||
|
||||
/// Adresse des Anfragenden. 0x80 = Bluetooth-Modul.
|
||||
static let hostAddress: UInt8 = 0x80
|
||||
|
||||
static func requestFrame(_ command: Command) -> Data {
|
||||
var frame: [UInt8] = [0xA5, hostAddress, command.rawValue, 0x08]
|
||||
frame.append(contentsOf: [UInt8](repeating: 0, count: 8))
|
||||
frame.append(frame.reduce(0) { UInt8(($0 &+ $1) & 0xFF) })
|
||||
return Data(frame)
|
||||
}
|
||||
|
||||
struct Frame {
|
||||
let address: UInt8
|
||||
let command: UInt8
|
||||
let payload: [UInt8] // immer 8 Bytes
|
||||
}
|
||||
|
||||
/// Sucht vollständige, prüfsummenkorrekte A5-Rahmen im Puffer und gibt sie
|
||||
/// zusammen mit dem unverbrauchten Rest zurück.
|
||||
static func extractA5Frames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) {
|
||||
var frames: [Frame] = []
|
||||
var index = 0
|
||||
var lastConsumed = 0
|
||||
|
||||
while index + 13 <= buffer.count {
|
||||
guard buffer[index] == 0xA5, buffer[index + 3] == 0x08 else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
let slice = Array(buffer[index..<(index + 13)])
|
||||
let checksum = slice[0..<12].reduce(UInt8(0)) { UInt8(($0 &+ $1) & 0xFF) }
|
||||
guard checksum == slice[12] else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
frames.append(Frame(address: slice[1],
|
||||
command: slice[2],
|
||||
payload: Array(slice[4..<12])))
|
||||
index += 13
|
||||
lastConsumed = index
|
||||
}
|
||||
// Angefangene Rahmen aufheben – BLE liefert Antworten oft gestückelt.
|
||||
let keepFrom = max(lastConsumed, max(0, buffer.count - 64))
|
||||
return (frames, Array(buffer[keepFrom...]))
|
||||
}
|
||||
|
||||
// MARK: - Modbus (D2)
|
||||
|
||||
/// Ein Lesekommando über alle interessanten Register.
|
||||
static func modbusReadFrame(start: UInt16 = 0, count: UInt16 = 62) -> Data {
|
||||
var frame: [UInt8] = [0xD2, 0x03,
|
||||
UInt8(start >> 8), UInt8(start & 0xFF),
|
||||
UInt8(count >> 8), UInt8(count & 0xFF)]
|
||||
let crc = crc16Modbus(frame)
|
||||
frame.append(UInt8(crc & 0xFF))
|
||||
frame.append(UInt8(crc >> 8))
|
||||
return Data(frame)
|
||||
}
|
||||
|
||||
static func crc16Modbus(_ bytes: [UInt8]) -> UInt16 {
|
||||
var crc: UInt16 = 0xFFFF
|
||||
for byte in bytes {
|
||||
crc ^= UInt16(byte)
|
||||
for _ in 0..<8 {
|
||||
if crc & 1 != 0 {
|
||||
crc = (crc >> 1) ^ 0xA001
|
||||
} else {
|
||||
crc >>= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc
|
||||
}
|
||||
|
||||
/// Prüft einen vollständigen Modbus-Antwortrahmen und liefert die
|
||||
/// Registerwerte. Gibt nil zurück, solange der Rahmen unvollständig ist.
|
||||
static func parseModbusResponse(_ buffer: [UInt8]) -> [UInt16]? {
|
||||
guard buffer.count >= 5, buffer[0] == 0xD2, buffer[1] == 0x03 else { return nil }
|
||||
let byteCount = Int(buffer[2])
|
||||
let total = 3 + byteCount + 2
|
||||
guard buffer.count >= total else { return nil }
|
||||
|
||||
let body = Array(buffer[0..<(3 + byteCount)])
|
||||
let expected = crc16Modbus(body)
|
||||
let actual = UInt16(buffer[3 + byteCount]) | (UInt16(buffer[4 + byteCount]) << 8)
|
||||
guard expected == actual else { return nil }
|
||||
|
||||
var registers: [UInt16] = []
|
||||
registers.reserveCapacity(byteCount / 2)
|
||||
var i = 3
|
||||
while i + 1 < 3 + byteCount {
|
||||
registers.append(UInt16(body[i]) << 8 | UInt16(body[i + 1]))
|
||||
i += 2
|
||||
}
|
||||
return registers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import Foundation
|
||||
|
||||
/// Sammelt die Antworten eines Daly-BMS. Das klassische Protokoll verteilt die
|
||||
/// Werte auf mehrere Rahmen, deshalb wird hier über Abfragerunden hinweg
|
||||
/// akkumuliert und erst am Ende ein Snapshot gebaut.
|
||||
struct DalyState {
|
||||
var totalVoltage: Double?
|
||||
var current: Double?
|
||||
var soc: Double?
|
||||
|
||||
var maxCellMillivolts: Int?
|
||||
var maxCellNumber: Int?
|
||||
var minCellMillivolts: Int?
|
||||
var minCellNumber: Int?
|
||||
|
||||
var maxTemperature: Double?
|
||||
var minTemperature: Double?
|
||||
|
||||
var chargeMOSOn: Bool?
|
||||
var dischargeMOSOn: Bool?
|
||||
var chargeDischargeStatus: UInt8?
|
||||
var remainingCapacityAh: Double?
|
||||
|
||||
var cellCount: Int?
|
||||
var temperatureSensorCount: Int?
|
||||
var cycles: Int?
|
||||
|
||||
/// Zellnummer (1-basiert) → Spannung in Millivolt.
|
||||
var cellMillivolts: [Int: Int] = [:]
|
||||
/// Sensornummer (1-basiert) → Temperatur in °C.
|
||||
var sensorTemperatures: [Int: Double] = [:]
|
||||
|
||||
/// Letzte Rohantwort, für die Diagnoseansicht.
|
||||
var lastRawResponse: Data?
|
||||
var usesModbus = false
|
||||
|
||||
// MARK: - Klassisches Protokoll
|
||||
|
||||
mutating func apply(_ frame: DalyProtocol.Frame) {
|
||||
let d = frame.payload
|
||||
func u16(_ i: Int) -> Int { Int(d[i]) << 8 | Int(d[i + 1]) }
|
||||
|
||||
switch frame.command {
|
||||
case 0x90:
|
||||
totalVoltage = Double(u16(0)) * 0.1
|
||||
// Strom mit Offset 30000, damit Entladung negativ dargestellt wird.
|
||||
current = Double(u16(4) - 30000) * 0.1
|
||||
soc = Double(u16(6)) * 0.1
|
||||
|
||||
case 0x91:
|
||||
maxCellMillivolts = u16(0)
|
||||
maxCellNumber = Int(d[2])
|
||||
minCellMillivolts = u16(3)
|
||||
minCellNumber = Int(d[5])
|
||||
|
||||
case 0x92:
|
||||
maxTemperature = Double(Int(d[0]) - 40)
|
||||
minTemperature = Double(Int(d[2]) - 40)
|
||||
|
||||
case 0x93:
|
||||
chargeDischargeStatus = d[0]
|
||||
chargeMOSOn = d[1] == 1
|
||||
dischargeMOSOn = d[2] == 1
|
||||
let capacityMilliAh = (UInt32(d[4]) << 24) | (UInt32(d[5]) << 16)
|
||||
| (UInt32(d[6]) << 8) | UInt32(d[7])
|
||||
remainingCapacityAh = Double(capacityMilliAh) / 1000
|
||||
|
||||
case 0x94:
|
||||
cellCount = Int(d[0])
|
||||
temperatureSensorCount = Int(d[1])
|
||||
cycles = u16(6)
|
||||
|
||||
case 0x95:
|
||||
// d[0] = Rahmennummer (1-basiert), danach drei Zellen à 2 Byte.
|
||||
let frameNumber = Int(d[0])
|
||||
guard frameNumber > 0 else { break }
|
||||
for slot in 0..<3 {
|
||||
let cell = (frameNumber - 1) * 3 + slot + 1
|
||||
let millivolts = u16(1 + slot * 2)
|
||||
if millivolts > 0 && millivolts < 6000 {
|
||||
cellMillivolts[cell] = millivolts
|
||||
}
|
||||
}
|
||||
|
||||
case 0x96:
|
||||
let frameNumber = Int(d[0])
|
||||
guard frameNumber > 0 else { break }
|
||||
for slot in 0..<7 {
|
||||
let sensor = (frameNumber - 1) * 7 + slot + 1
|
||||
let raw = Int(d[1 + slot])
|
||||
if raw != 0 {
|
||||
sensorTemperatures[sensor] = Double(raw - 40)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Modbus-Protokoll
|
||||
|
||||
/// Registerbelegung der neueren Daly-BMS.
|
||||
///
|
||||
/// Achtung: Dieses Mapping variiert zwischen Firmwareständen. Die
|
||||
/// Detailansicht zeigt deshalb die Rohantwort an, damit sich die Belegung
|
||||
/// am realen Gerät nachprüfen lässt.
|
||||
mutating func apply(registers: [UInt16]) {
|
||||
usesModbus = true
|
||||
func reg(_ i: Int) -> UInt16? { i < registers.count ? registers[i] : nil }
|
||||
|
||||
// Register 0–47: Zellspannungen in mV, unbenutzte Plätze sind 0.
|
||||
cellMillivolts.removeAll(keepingCapacity: true)
|
||||
for i in 0..<min(48, registers.count) {
|
||||
let millivolts = Int(registers[i])
|
||||
if millivolts > 500 && millivolts < 5000 {
|
||||
cellMillivolts[i + 1] = millivolts
|
||||
}
|
||||
}
|
||||
|
||||
// Register 48–55: Temperaturfühler mit Offset 40.
|
||||
sensorTemperatures.removeAll(keepingCapacity: true)
|
||||
for i in 48..<min(56, registers.count) {
|
||||
let raw = Int(registers[i])
|
||||
if raw > 0 && raw < 200 {
|
||||
sensorTemperatures[i - 47] = Double(raw - 40)
|
||||
}
|
||||
}
|
||||
|
||||
if let v = reg(56), v > 0 { totalVoltage = Double(v) * 0.1 }
|
||||
if let c = reg(57) { current = (Double(c) - 30000) * 0.1 }
|
||||
if let s = reg(58), s <= 1000 { soc = Double(s) * 0.1 }
|
||||
|
||||
maxCellMillivolts = cellMillivolts.values.max()
|
||||
minCellMillivolts = cellMillivolts.values.min()
|
||||
maxCellNumber = cellMillivolts.max(by: { $0.value < $1.value })?.key
|
||||
minCellNumber = cellMillivolts.min(by: { $0.value < $1.value })?.key
|
||||
maxTemperature = sensorTemperatures.values.max()
|
||||
minTemperature = sensorTemperatures.values.min()
|
||||
cellCount = cellMillivolts.isEmpty ? nil : cellMillivolts.count
|
||||
temperatureSensorCount = sensorTemperatures.isEmpty ? nil : sensorTemperatures.count
|
||||
}
|
||||
|
||||
// MARK: - Ausgabe
|
||||
|
||||
var hasUsableData: Bool {
|
||||
totalVoltage != nil || soc != nil || !cellMillivolts.isEmpty
|
||||
}
|
||||
|
||||
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: 1, primary: true),
|
||||
Metric("voltage", "Spannung", totalVoltage, unit: "V", precision: 2),
|
||||
Metric("current", "Strom", current, unit: "A", precision: 1),
|
||||
]
|
||||
if let v = totalVoltage, let a = current {
|
||||
metrics.append(Metric("power", "Leistung", v * a, unit: "W", precision: 0))
|
||||
}
|
||||
if let capacity = remainingCapacityAh {
|
||||
metrics.append(Metric("capacity", "Restkapazität", capacity, unit: "Ah", precision: 1))
|
||||
}
|
||||
if let maxV = maxCellMillivolts, let minV = minCellMillivolts {
|
||||
metrics.append(Metric("cell_delta", "Zell-Differenz", Double(maxV - minV), unit: "mV", precision: 0))
|
||||
metrics.append(Metric("cell_max", "Höchste Zelle" + numberSuffix(maxCellNumber),
|
||||
Double(maxV) / 1000, unit: "V", precision: 3))
|
||||
metrics.append(Metric("cell_min", "Niedrigste Zelle" + numberSuffix(minCellNumber),
|
||||
Double(minV) / 1000, unit: "V", precision: 3))
|
||||
}
|
||||
if let maxTemperature {
|
||||
metrics.append(Metric("temp_max", "Temperatur", maxTemperature, unit: "°C", precision: 0))
|
||||
}
|
||||
if let minTemperature, minTemperature != maxTemperature {
|
||||
metrics.append(Metric("temp_min", "Temperatur min.", minTemperature, unit: "°C", precision: 0))
|
||||
}
|
||||
if let cycles {
|
||||
metrics.append(Metric("cycles", "Ladezyklen", Double(cycles), unit: "", precision: 0))
|
||||
}
|
||||
snapshot.metrics = metrics
|
||||
|
||||
snapshot.state = stateText
|
||||
snapshot.cellVoltages = cellMillivolts
|
||||
.sorted { $0.key < $1.key }
|
||||
.map { Double($0.value) / 1000 }
|
||||
snapshot.temperatures = sensorTemperatures.sorted { $0.key < $1.key }.map(\.value)
|
||||
|
||||
var warnings: [String] = []
|
||||
if chargeMOSOn == false { warnings.append("Lade-MOSFET aus") }
|
||||
if dischargeMOSOn == false { warnings.append("Entlade-MOSFET aus") }
|
||||
snapshot.offReasons = warnings
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private func numberSuffix(_ number: Int?) -> String {
|
||||
number.map { " (Zelle \($0))" } ?? ""
|
||||
}
|
||||
|
||||
private var stateText: String? {
|
||||
if let status = chargeDischargeStatus {
|
||||
switch status {
|
||||
case 0: return "Ruhend"
|
||||
case 1: return "Lädt"
|
||||
case 2: return "Entlädt"
|
||||
default: break
|
||||
}
|
||||
}
|
||||
guard let current else { return nil }
|
||||
if current > 0.3 { return "Lädt" }
|
||||
if current < -0.3 { return "Entlädt" }
|
||||
return "Ruhend"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
|
||||
/// Protokoll der JBD-/Xiaoxiang-BMS, wie sie unter anderem in WattCycle-Akkus
|
||||
/// verbaut sind. Bekannt auch als „Smart BMS“ oder Overkill-Solar-Protokoll.
|
||||
///
|
||||
/// Rahmenaufbau:
|
||||
///
|
||||
/// Anfrage: DD A5 <Kommando> <Länge=00> <Prüfsumme 2 Byte> 77
|
||||
/// Antwort: DD <Kommando> <Status> <Länge> <Daten…> <Prüfsumme 2 Byte> 77
|
||||
///
|
||||
/// Die Prüfsumme ist `0x10000 − (Status + Länge + Daten)`, big-endian; in der
|
||||
/// Anfrage entsprechend über Kommando und Länge.
|
||||
enum JBDProtocol {
|
||||
|
||||
enum Command: UInt8, CaseIterable {
|
||||
case basicInfo = 0x03
|
||||
case cellVoltages = 0x04
|
||||
}
|
||||
|
||||
static func requestFrame(_ command: Command) -> Data {
|
||||
let checksum = checksum(over: [command.rawValue, 0x00])
|
||||
return Data([0xDD, 0xA5, command.rawValue, 0x00,
|
||||
UInt8(checksum >> 8), UInt8(checksum & 0xFF), 0x77])
|
||||
}
|
||||
|
||||
static func checksum(over bytes: [UInt8]) -> UInt16 {
|
||||
let sum = bytes.reduce(UInt32(0)) { $0 + UInt32($1) }
|
||||
return UInt16(truncatingIfNeeded: 0x1_0000 &- sum)
|
||||
}
|
||||
|
||||
struct Frame {
|
||||
let command: UInt8
|
||||
let payload: [UInt8]
|
||||
}
|
||||
|
||||
/// Sucht vollständige, prüfsummenkorrekte Rahmen im Puffer.
|
||||
static func extractFrames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) {
|
||||
var frames: [Frame] = []
|
||||
var index = 0
|
||||
var consumed = 0
|
||||
|
||||
while index + 7 <= buffer.count {
|
||||
guard buffer[index] == 0xDD else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
let length = Int(buffer[index + 3])
|
||||
let total = 4 + length + 3 // Kopf + Daten + Prüfsumme + 0x77
|
||||
guard index + total <= buffer.count else { break } // Rest abwarten
|
||||
|
||||
let frame = Array(buffer[index..<(index + total)])
|
||||
guard frame[total - 1] == 0x77 else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
let expected = checksum(over: Array(frame[2..<(4 + length)]))
|
||||
let actual = UInt16(frame[4 + length]) << 8 | UInt16(frame[5 + length])
|
||||
guard expected == actual else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
// Status ≠ 0 meldet einen Fehler; der Rahmen ist dann leer.
|
||||
if frame[2] == 0x00 {
|
||||
frames.append(Frame(command: frame[1],
|
||||
payload: Array(frame[4..<(4 + length)])))
|
||||
}
|
||||
index += total
|
||||
consumed = index
|
||||
}
|
||||
let keepFrom = max(consumed, max(0, buffer.count - 128))
|
||||
return (frames, Array(buffer[keepFrom...]))
|
||||
}
|
||||
|
||||
/// Klartext der Schutzabschaltungen aus der 16-Bit-Maske.
|
||||
static func protectionReasons(_ mask: UInt16) -> [String] {
|
||||
guard mask != 0 else { return [] }
|
||||
let table: [(UInt16, String)] = [
|
||||
(1 << 0, "Zellüberspannung"),
|
||||
(1 << 1, "Zellunterspannung"),
|
||||
(1 << 2, "Batterie Überspannung"),
|
||||
(1 << 3, "Batterie Unterspannung"),
|
||||
(1 << 4, "Ladetemperatur zu hoch"),
|
||||
(1 << 5, "Ladetemperatur zu niedrig"),
|
||||
(1 << 6, "Entladetemperatur zu hoch"),
|
||||
(1 << 7, "Entladetemperatur zu niedrig"),
|
||||
(1 << 8, "Ladestrom zu hoch"),
|
||||
(1 << 9, "Entladestrom zu hoch"),
|
||||
(1 << 10, "Kurzschluss"),
|
||||
(1 << 11, "Fehler im Messkreis"),
|
||||
(1 << 12, "MOSFET gesperrt"),
|
||||
]
|
||||
return table.filter { mask & $0.0 != 0 }.map(\.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sammelt die Antworten eines JBD-BMS.
|
||||
struct JBDState {
|
||||
var totalVoltage: Double?
|
||||
var current: Double?
|
||||
var remainingCapacityAh: Double?
|
||||
var nominalCapacityAh: Double?
|
||||
var cycles: Int?
|
||||
var soc: Double?
|
||||
var chargeMOSOn: Bool?
|
||||
var dischargeMOSOn: Bool?
|
||||
var protections: [String] = []
|
||||
var cellMillivolts: [Int] = []
|
||||
var temperatures: [Double] = []
|
||||
|
||||
var hasUsableData: Bool { totalVoltage != nil || soc != nil || !cellMillivolts.isEmpty }
|
||||
|
||||
mutating func apply(_ frame: JBDProtocol.Frame) {
|
||||
let d = frame.payload
|
||||
func u16(_ i: Int) -> Int { Int(d[i]) << 8 | Int(d[i + 1]) }
|
||||
func i16(_ i: Int) -> Int { Int(Int16(bitPattern: UInt16(u16(i)))) }
|
||||
|
||||
switch frame.command {
|
||||
case 0x03:
|
||||
guard d.count >= 23 else { return }
|
||||
totalVoltage = Double(u16(0)) * 0.01 // 10 mV je Schritt
|
||||
current = Double(i16(2)) * 0.01 // 10 mA, negativ = Entladung
|
||||
remainingCapacityAh = Double(u16(4)) * 0.01
|
||||
nominalCapacityAh = Double(u16(6)) * 0.01
|
||||
cycles = u16(8)
|
||||
protections = JBDProtocol.protectionReasons(UInt16(u16(16)))
|
||||
soc = Double(d[19])
|
||||
chargeMOSOn = d[20] & 0x01 != 0
|
||||
dischargeMOSOn = d[20] & 0x02 != 0
|
||||
|
||||
// Ab Byte 23 folgen die NTC-Fühler, je zwei Byte in Zehntel-Kelvin.
|
||||
let sensorCount = Int(d[22])
|
||||
var readings: [Double] = []
|
||||
for sensor in 0..<sensorCount {
|
||||
let offset = 23 + sensor * 2
|
||||
guard offset + 1 < d.count else { break }
|
||||
readings.append((Double(u16(offset)) - 2731) / 10)
|
||||
}
|
||||
temperatures = readings
|
||||
|
||||
case 0x04:
|
||||
var millivolts: [Int] = []
|
||||
var offset = 0
|
||||
while offset + 1 < d.count {
|
||||
millivolts.append(u16(offset))
|
||||
offset += 2
|
||||
}
|
||||
cellMillivolts = millivolts
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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", totalVoltage, unit: "V", precision: 2),
|
||||
Metric("current", "Strom", current, unit: "A", precision: 1),
|
||||
]
|
||||
if let v = totalVoltage, let a = current {
|
||||
metrics.append(Metric("power", "Leistung", v * a, unit: "W", precision: 0))
|
||||
}
|
||||
if let remainingCapacityAh {
|
||||
metrics.append(Metric("capacity", "Restkapazität", remainingCapacityAh, unit: "Ah", precision: 1))
|
||||
}
|
||||
if let nominalCapacityAh {
|
||||
metrics.append(Metric("capacity_nominal", "Nennkapazität", nominalCapacityAh, unit: "Ah", precision: 1))
|
||||
}
|
||||
if let maxV = cellMillivolts.max(), let minV = cellMillivolts.min() {
|
||||
metrics.append(Metric("cell_delta", "Zell-Differenz", Double(maxV - minV), unit: "mV", precision: 0))
|
||||
metrics.append(Metric("cell_max", "Höchste Zelle", Double(maxV) / 1000, unit: "V", precision: 3))
|
||||
metrics.append(Metric("cell_min", "Niedrigste Zelle", Double(minV) / 1000, unit: "V", precision: 3))
|
||||
}
|
||||
if let warmest = temperatures.max() {
|
||||
metrics.append(Metric("temp_max", "Temperatur", warmest, unit: "°C", precision: 0))
|
||||
}
|
||||
if let cycles {
|
||||
metrics.append(Metric("cycles", "Ladezyklen", Double(cycles), unit: "", precision: 0))
|
||||
}
|
||||
snapshot.metrics = metrics
|
||||
|
||||
snapshot.cellVoltages = cellMillivolts.map { Double($0) / 1000 }
|
||||
snapshot.temperatures = temperatures
|
||||
snapshot.fault = protections.isEmpty ? nil : protections.joined(separator: ", ")
|
||||
|
||||
var notes: [String] = []
|
||||
if chargeMOSOn == false { notes.append("Laden gesperrt") }
|
||||
if dischargeMOSOn == false { notes.append("Entladen gesperrt") }
|
||||
snapshot.offReasons = notes
|
||||
|
||||
if let current {
|
||||
if current > 0.3 { snapshot.state = "Lädt" }
|
||||
else if current < -0.3 { snapshot.state = "Entlädt" }
|
||||
else { snapshot.state = "Ruhend" }
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import Foundation
|
||||
|
||||
/// Dekodiert die "Instant Readout"-Werbedaten von Victron-Geräten.
|
||||
///
|
||||
/// Aufbau der Herstellerdaten (inkl. der zwei Bytes Company-ID, die
|
||||
/// CoreBluetooth mitliefert):
|
||||
///
|
||||
/// [0..1] E1 02 Company-ID 0x02E1 (Victron Energy)
|
||||
/// [2..3] 10 00 Record-Typ "Product Advertisement", 16 Bit
|
||||
/// [4..5] ll hh Produkt-ID, little-endian
|
||||
/// [6] rr Art des Datensatzes (Solarlader, DC/DC, …)
|
||||
/// [7..8] ll hh Nonce / Zähler, little-endian
|
||||
/// [9] kk Erstes Byte des Geräteschlüssels (Prüfbyte)
|
||||
/// [10..] Mit AES-128-CTR verschlüsselte Nutzdaten
|
||||
///
|
||||
/// Die Aufteilung ist an einem Orion XS belegt: Datensatztyp 0x0F passt zum
|
||||
/// Gerät, das Prüfbyte zum hinterlegten Schlüssel, und die verbleibenden
|
||||
/// 14 Byte entsprechen genau der Länge eines Orion-XS-Datensatzes.
|
||||
///
|
||||
/// Der Schlüssel stammt aus VictronConnect
|
||||
/// (Gerät → Zahnrad → ⋮ → Produkt-Info → Verschlüsselungsdaten).
|
||||
enum VictronAdvertisement {
|
||||
|
||||
static let companyIdentifier: UInt16 = 0x02E1
|
||||
|
||||
enum RecordType: UInt8 {
|
||||
case solarCharger = 0x01
|
||||
case batteryMonitor = 0x02
|
||||
case inverter = 0x03
|
||||
case dcdcConverter = 0x04
|
||||
case smartLithium = 0x05
|
||||
case inverterRS = 0x06
|
||||
case gxDevice = 0x07
|
||||
case acCharger = 0x08
|
||||
case smartBatteryProtect = 0x09
|
||||
case lynxSmartBMS = 0x0A
|
||||
case multiRS = 0x0B
|
||||
case veBus = 0x0C
|
||||
case dcEnergyMeter = 0x0D
|
||||
case orionXS = 0x0F
|
||||
}
|
||||
|
||||
enum DecodeError: Error, LocalizedError {
|
||||
case notVictron
|
||||
case malformed
|
||||
case keyMismatch(expected: UInt8, got: UInt8)
|
||||
case decryptionFailed
|
||||
case unsupportedRecord(UInt8)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notVictron: return "Kein Victron-Advertisement"
|
||||
case .malformed: return "Advertisement zu kurz"
|
||||
case .keyMismatch(let expected, let got):
|
||||
return String(format: "Schlüssel passt nicht: Das Gerät sendet 0x%02X als erstes Byte, "
|
||||
+ "der eingetragene Schlüssel beginnt mit 0x%02X.", expected, got)
|
||||
case .decryptionFailed: return "Entschlüsselung fehlgeschlagen"
|
||||
case .unsupportedRecord(let r):
|
||||
return String(format: "Datensatz-Typ 0x%02X wird nicht unterstützt", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Der unverschlüsselte Rahmen – lässt sich auch ohne Schlüssel lesen und
|
||||
/// wird beim Einrichten benutzt, um Victron-Geräte zu erkennen.
|
||||
struct Envelope {
|
||||
let productID: UInt16
|
||||
let recordType: UInt8
|
||||
let nonce: UInt16
|
||||
let keyCheckByte: UInt8
|
||||
let ciphertext: [UInt8]
|
||||
|
||||
var knownRecord: RecordType? { RecordType(rawValue: recordType) }
|
||||
|
||||
var productIDText: String { String(format: "0x%04X", productID) }
|
||||
}
|
||||
|
||||
static func envelope(from manufacturerData: Data) -> Envelope? {
|
||||
let bytes = [UInt8](manufacturerData)
|
||||
guard bytes.count >= 11 else { return nil }
|
||||
let company = UInt16(bytes[0]) | (UInt16(bytes[1]) << 8)
|
||||
guard company == companyIdentifier, bytes[2] == 0x10 else { return nil }
|
||||
return Envelope(
|
||||
productID: UInt16(bytes[4]) | (UInt16(bytes[5]) << 8),
|
||||
recordType: bytes[6],
|
||||
nonce: UInt16(bytes[7]) | (UInt16(bytes[8]) << 8),
|
||||
keyCheckByte: bytes[9],
|
||||
ciphertext: Array(bytes[10...])
|
||||
)
|
||||
}
|
||||
|
||||
/// Entschlüsselt und interpretiert ein Advertisement.
|
||||
static func decode(manufacturerData: Data,
|
||||
key: [UInt8],
|
||||
deviceID: UUID,
|
||||
rssi: Int?) throws -> DeviceSnapshot {
|
||||
guard let envelope = envelope(from: manufacturerData) else {
|
||||
throw DecodeError.notVictron
|
||||
}
|
||||
guard key.count == 16 else { throw DecodeError.malformed }
|
||||
guard key[0] == envelope.keyCheckByte else {
|
||||
throw DecodeError.keyMismatch(expected: envelope.keyCheckByte, got: key[0])
|
||||
}
|
||||
|
||||
// Zählerblock: Nonce little-endian in den ersten zwei Bytes, Rest null.
|
||||
var counter = [UInt8](repeating: 0, count: 16)
|
||||
counter[0] = UInt8(envelope.nonce & 0xFF)
|
||||
counter[1] = UInt8(envelope.nonce >> 8)
|
||||
|
||||
guard let plain = AESCounterMode.crypt(envelope.ciphertext, key: key, nonce: counter) else {
|
||||
throw DecodeError.decryptionFailed
|
||||
}
|
||||
|
||||
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
|
||||
|
||||
switch envelope.knownRecord {
|
||||
case .solarCharger: fill(solarCharger: plain, into: &snapshot)
|
||||
case .dcdcConverter: fill(dcdcConverter: plain, into: &snapshot)
|
||||
case .orionXS: fill(orionXS: plain, into: &snapshot)
|
||||
case .batteryMonitor: fill(batteryMonitor: plain, into: &snapshot)
|
||||
case .acCharger: fill(acCharger: plain, into: &snapshot)
|
||||
default: throw DecodeError.unsupportedRecord(envelope.recordType)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// MARK: - Datensätze
|
||||
|
||||
/// 0x01 – Solarladeregler (SmartSolar / BlueSolar MPPT).
|
||||
private static func fill(solarCharger bytes: [UInt8], into snapshot: inout DeviceSnapshot) {
|
||||
var r = BitReader(bytes)
|
||||
let state = r.readOptional(8)
|
||||
let error = r.readOptional(8)
|
||||
let batteryVoltage = r.scaledSigned(16, 0.01)
|
||||
let batteryCurrent = r.scaledSigned(16, 0.1)
|
||||
let yieldToday = r.scaled(16, 0.01)
|
||||
let pvPower = r.scaled(16, 1)
|
||||
let loadCurrent = r.scaled(9, 0.1)
|
||||
|
||||
snapshot.state = VictronCodes.deviceState(state)
|
||||
snapshot.fault = VictronCodes.chargerError(error)
|
||||
snapshot.metrics = [
|
||||
Metric("pv_power", "PV-Leistung", pvPower, unit: "W", precision: 0, primary: true),
|
||||
Metric("battery_voltage", "Batteriespannung", batteryVoltage, unit: "V", precision: 2),
|
||||
Metric("battery_current", "Ladestrom", batteryCurrent, unit: "A", precision: 1),
|
||||
Metric("yield_today", "Ertrag heute", yieldToday, unit: "kWh", precision: 2),
|
||||
Metric("load_current", "Laststrom", loadCurrent, unit: "A", precision: 1),
|
||||
]
|
||||
if let v = batteryVoltage, let a = batteryCurrent {
|
||||
snapshot.metrics.insert(
|
||||
Metric("battery_power", "Ladeleistung", v * a, unit: "W", precision: 0), at: 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// 0x04 – DC/DC-Wandler (Orion-TR Smart Ladebooster).
|
||||
private static func fill(dcdcConverter bytes: [UInt8], into snapshot: inout DeviceSnapshot) {
|
||||
var r = BitReader(bytes)
|
||||
let state = r.readOptional(8)
|
||||
let error = r.readOptional(8)
|
||||
let inputVoltage = r.scaled(16, 0.01)
|
||||
let outputVoltage = r.scaledSigned(16, 0.01)
|
||||
let offReason = r.readOptional(32)
|
||||
|
||||
snapshot.state = VictronCodes.deviceState(state)
|
||||
snapshot.fault = VictronCodes.chargerError(error)
|
||||
snapshot.offReasons = VictronCodes.offReasons(offReason)
|
||||
snapshot.metrics = [
|
||||
Metric("output_voltage", "Ausgang (Aufbaubatterie)", outputVoltage, unit: "V", precision: 2, primary: true),
|
||||
Metric("input_voltage", "Eingang (Starterbatterie)", inputVoltage, unit: "V", precision: 2),
|
||||
]
|
||||
}
|
||||
|
||||
/// 0x0F – Orion XS. Sendet im Gegensatz zum Orion-TR auch Ströme.
|
||||
private static func fill(orionXS bytes: [UInt8], into snapshot: inout DeviceSnapshot) {
|
||||
var r = BitReader(bytes)
|
||||
let state = r.readOptional(8)
|
||||
let error = r.readOptional(8)
|
||||
let outputVoltage = r.scaled(16, 0.01)
|
||||
let outputCurrent = r.scaledSigned(16, 0.1)
|
||||
let inputVoltage = r.scaled(16, 0.01)
|
||||
let inputCurrent = r.scaledSigned(16, 0.1)
|
||||
let offReason = r.readOptional(32)
|
||||
|
||||
snapshot.state = VictronCodes.deviceState(state)
|
||||
snapshot.fault = VictronCodes.chargerError(error)
|
||||
snapshot.offReasons = VictronCodes.offReasons(offReason)
|
||||
|
||||
var metrics: [Metric] = []
|
||||
if let v = outputVoltage, let a = outputCurrent {
|
||||
metrics.append(Metric("output_power", "Ladeleistung", v * a, unit: "W", precision: 0, primary: true))
|
||||
}
|
||||
metrics += [
|
||||
Metric("output_voltage", "Ausgang (Aufbaubatterie)", outputVoltage, unit: "V", precision: 2,
|
||||
primary: outputCurrent == nil),
|
||||
Metric("output_current", "Ladestrom", outputCurrent, unit: "A", precision: 1),
|
||||
Metric("input_voltage", "Eingang (Starterbatterie)", inputVoltage, unit: "V", precision: 2),
|
||||
Metric("input_current", "Eingangsstrom", inputCurrent, unit: "A", precision: 1),
|
||||
]
|
||||
snapshot.metrics = metrics
|
||||
}
|
||||
|
||||
/// 0x08 – AC-Ladegerät (Blue Smart IP65/IP22), falls im Camper verbaut.
|
||||
private static func fill(acCharger bytes: [UInt8], into snapshot: inout DeviceSnapshot) {
|
||||
var r = BitReader(bytes)
|
||||
let state = r.readOptional(8)
|
||||
let error = r.readOptional(8)
|
||||
let voltage1 = r.scaled(13, 0.01)
|
||||
let current1 = r.scaled(11, 0.1)
|
||||
let voltage2 = r.scaled(13, 0.01)
|
||||
let current2 = r.scaled(11, 0.1)
|
||||
let voltage3 = r.scaled(13, 0.01)
|
||||
let current3 = r.scaled(11, 0.1)
|
||||
let temperature = r.scaled(7, 1)
|
||||
let acCurrent = r.scaled(9, 0.1)
|
||||
|
||||
snapshot.state = VictronCodes.deviceState(state)
|
||||
snapshot.fault = VictronCodes.chargerError(error)
|
||||
snapshot.metrics = [
|
||||
Metric("out1_voltage", "Ausgang 1 Spannung", voltage1, unit: "V", precision: 2, primary: true),
|
||||
Metric("out1_current", "Ausgang 1 Strom", current1, unit: "A", precision: 1),
|
||||
Metric("out2_voltage", "Ausgang 2 Spannung", voltage2, unit: "V", precision: 2),
|
||||
Metric("out2_current", "Ausgang 2 Strom", current2, unit: "A", precision: 1),
|
||||
Metric("out3_voltage", "Ausgang 3 Spannung", voltage3, unit: "V", precision: 2),
|
||||
Metric("out3_current", "Ausgang 3 Strom", current3, unit: "A", precision: 1),
|
||||
Metric("ac_current", "AC-Eingangsstrom", acCurrent, unit: "A", precision: 1),
|
||||
]
|
||||
if let temperature {
|
||||
snapshot.temperatures = [temperature - 40]
|
||||
}
|
||||
}
|
||||
|
||||
/// 0x02 – Batteriewächter (SmartShunt, BMV).
|
||||
private static func fill(batteryMonitor bytes: [UInt8], into snapshot: inout DeviceSnapshot) {
|
||||
var r = BitReader(bytes)
|
||||
let timeToGo = r.scaled(16, 1) // Minuten
|
||||
let voltage = r.scaledSigned(16, 0.01)
|
||||
let alarm = r.readOptional(16)
|
||||
let auxRaw = r.read(16)
|
||||
let auxType = r.read(2)
|
||||
let current = r.scaledSigned(22, 0.001)
|
||||
let consumedAh = r.scaled(20, 0.1)
|
||||
let soc = r.scaled(10, 0.1)
|
||||
|
||||
snapshot.state = nil
|
||||
let alarms = VictronCodes.alarmReasons(alarm)
|
||||
snapshot.fault = alarms.isEmpty ? nil : alarms.joined(separator: ", ")
|
||||
|
||||
var metrics: [Metric] = [
|
||||
Metric("soc", "Ladezustand", soc, unit: "%", precision: 1, primary: true),
|
||||
Metric("voltage", "Spannung", voltage, unit: "V", precision: 2),
|
||||
Metric("current", "Strom", current, unit: "A", precision: 2),
|
||||
]
|
||||
if let v = voltage, let a = current {
|
||||
metrics.append(Metric("power", "Leistung", v * a, unit: "W", precision: 0))
|
||||
}
|
||||
// Entnommene Kapazität wird positiv gesendet, ist aber eine Entnahme.
|
||||
metrics.append(Metric("consumed", "Entnommen", consumedAh.map { -$0 }, unit: "Ah", precision: 1))
|
||||
if let timeToGo, timeToGo < 65535 {
|
||||
metrics.append(Metric("ttg", "Restlaufzeit", timeToGo / 60, unit: "h", precision: 1))
|
||||
}
|
||||
|
||||
// Der Hilfseingang ist je nach Konfiguration Starterbatterie,
|
||||
// Mittenspannung oder Temperatur.
|
||||
if let auxRaw, let auxType, auxRaw != 0xFFFF {
|
||||
switch auxType {
|
||||
case 0:
|
||||
let starter = Double(Int16(bitPattern: UInt16(auxRaw))) * 0.01
|
||||
metrics.append(Metric("aux_starter", "Starterbatterie", starter, unit: "V", precision: 2))
|
||||
case 1:
|
||||
metrics.append(Metric("aux_mid", "Mittenspannung", Double(auxRaw) * 0.01, unit: "V", precision: 2))
|
||||
case 2:
|
||||
snapshot.temperatures = [Double(auxRaw) * 0.01 - 273.15]
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
snapshot.metrics = metrics
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user