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:
fototeddy
2026-09-06 21:25:28 +02:00
co-authored by Claude Sonnet 5
parent 83ea85f3b8
commit 303a9735d0
79 changed files with 190 additions and 190 deletions
+46
View File
@@ -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))
}
}
+344
View File
@@ -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
}
}
+783
View File
@@ -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()
}
}
+61
View File
@@ -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
+123
View File
@@ -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
}
}
+214
View File
@@ -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 047: 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 4855: 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"
}
}
+200
View File
@@ -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
}
}