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
+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()
}
}