forked from fritob/Camper-Monitor
App-Namen im iOS-Projekt auf VanControl vereinheitlichen
CamperMonitor (Haupt-Repo) und VanAligneiOS (aus dem gemergten solar-integration-Branch) liefen unter zwei verschiedenen internen Namen, obwohl die App nach aussen längst einheitlich "VanControl Pro" heisst. Jetzt durchgängig VanControl: - Ordner: CamperMonitor/, CamperMonitorWatch/, CamperMonitorComplication/, VanAligneiOSWidget/ → VanControl/, VanControlWatch/, VanControlComplication/, VanControlWidget/ - Xcode-Projekt: CamperMonitor.xcodeproj → VanControl.xcodeproj, alle Targets/Schemes/Produktnamen entsprechend umbenannt - Bundle-Identifier auf Wunsch mitgeändert: de.s0.fototeddy.VanControl* (App noch nicht veröffentlicht); dabei auch die WKCompanionAppBundleIdentifier-Werte korrigiert, die noch das alte de.fritob-Präfix statt des tatsächlichen de.s0.fototeddy-Präfixes trugen - Swift-Dateien/Typen: CamperMonitorApp → VanControlApp, VanAligneiOSWidget* → VanControlWidget* - Config/*-Info.plist umbenannt, README.md/Tools/README.md/run-tests.sh auf die neuen Pfade angepasst Bewusst unverändert: firmware/vanalign und alle Bezüge auf "VanAlign" als Namen der Neigungsmesser-Hardware (eigenständiges Produkt, kein App-Name) sowie der komplette Android/-Ordner. Build (App, Watch, Debug) und Protokoll-Testlauf grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
83ea85f3b8
commit
303a9735d0
@@ -0,0 +1,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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user