Zwei Dinge. Die Kühlbox piepte dauernd und die Bedienung wurde zäh. Ursache war ein Fehler in der Verbindungslogik: nach einem fehlgeschlagenen Versuch wurde der Eintrag freigegeben, und das nächste Advertisement löste sofort den nächsten aus - bei laufendem Scan bis zu einmal je Sekunde. Geräte quittieren jeden Versuch, Kühlboxen mit einem Piepton. Nebenher wechselte der Verbindungszustand im selben Takt, was die Oberfläche in eine Dauerneuzeichnung trieb. Ein Versuch wird jetzt für eine Weile gesperrt, mit wachsendem Abstand von fünf bis sechzig Sekunden. Auch nach einem Trennen durch die Gegenseite wird nicht sofort neu angeklopft - trennt ein Gerät von sich aus, etwa weil eine andere App verbunden ist, entstünde sonst ein Wechselspiel aus Verbinden und Trennen. Beim Umkonfigurieren werden die Sperren zurückgesetzt, damit ein neu eingerichtetes Gerät sofort drankommt. Dazu entlastet: bei jeder Antwort wurden alle fünf Protokollparser durchprobiert, auch wenn längst feststand, welches Protokoll gilt. Steht der Dialekt, läuft nur noch dieser. Zweitens der Neigungsmesser: je nach Einbaulage meldet er längs und quer vertauscht oder mit falschem Vorzeichen. Statt die Lage aus einer Liste raten zu lassen, misst der neue Assistent sie - zweimal kippen, einmal um jede Achse, und aus der Reaktion ergibt sich die Zuordnung. Schräges oder zu schwaches Kippen wird erkannt und gemeldet, statt eine zufällige Zuordnung zu liefern. Die Sitzung führt die Rohwerte des Sensors weiter mit, weil der Assistent sie unumgerechnet braucht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
741 lines
30 KiB
Swift
741 lines
30 KiB
Swift
import CoreBluetooth
|
||
import Foundation
|
||
import Observation
|
||
|
||
/// Ein während des Einrichtens gefundenes Gerät.
|
||
struct Discovery: Identifiable, Hashable {
|
||
let id: UUID
|
||
var name: String?
|
||
/// Geglättet, damit die Signalanzeige nicht flackert.
|
||
var rssi: Int
|
||
/// Sortierschlüssel: die Reihenfolge des Auftauchens ist stabil,
|
||
/// die Signalstärke wäre es nicht.
|
||
var firstSeen: Date
|
||
var lastSeen: Date
|
||
var victronRecordType: UInt8?
|
||
var victronProductID: UInt16?
|
||
var looksLikeSupported: Bool
|
||
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
|
||
var isLevelSensor = false
|
||
|
||
var isVictron: Bool { victronRecordType != nil }
|
||
|
||
var displayName: String {
|
||
if let name, !name.isEmpty { return name }
|
||
return isVictron ? "Victron-Gerät" : "Unbenanntes Gerät"
|
||
}
|
||
|
||
var subtitle: String {
|
||
if let type = victronRecordType {
|
||
return "Victron · " + Self.victronRecordName(type)
|
||
}
|
||
if isLevelSensor { return "VanAlign Neigungsmesser" }
|
||
if looksLikeSupported { return "Sieht nach BMS oder Kühlbox aus" }
|
||
return "Bluetooth-Gerät"
|
||
}
|
||
|
||
static func victronRecordName(_ type: UInt8) -> String {
|
||
switch VictronAdvertisement.RecordType(rawValue: type) {
|
||
case .solarCharger: return "Solarladeregler"
|
||
case .batteryMonitor: return "Batteriewächter"
|
||
case .inverter: return "Wechselrichter"
|
||
case .dcdcConverter: return "DC/DC-Lader (Orion-TR)"
|
||
case .orionXS: return "Orion XS"
|
||
case .acCharger: return "AC-Ladegerät"
|
||
case .smartLithium: return "Smart Lithium"
|
||
case .smartBatteryProtect: return "Battery Protect"
|
||
case .lynxSmartBMS: return "Lynx Smart BMS"
|
||
case .dcEnergyMeter: return "DC-Energiezähler"
|
||
default: return String(format: "Typ 0x%02X", type)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Was ein Victron-Gerät unverschlüsselt mitsendet. Wird unabhängig vom
|
||
/// Schlüssel gefüllt und hilft, eine falsche Eingabe einzugrenzen.
|
||
struct VictronDiagnostics: Hashable {
|
||
var productID: UInt16
|
||
var recordType: UInt8
|
||
var expectedKeyFirstByte: UInt8
|
||
var nonce: UInt16
|
||
var payloadLength: Int
|
||
var rawHex: String
|
||
var updated: Date
|
||
|
||
var recordName: String { Discovery.victronRecordName(recordType) }
|
||
var productIDText: String { String(format: "0x%04X", productID) }
|
||
var expectedKeyText: String { String(format: "0x%02X", expectedKeyFirstByte) }
|
||
}
|
||
|
||
/// Was bei der BMS-Verbindung erkannt wurde. Zeigt, ob und welches Protokoll
|
||
/// greift, und gibt die letzte Rohantwort zum Nachsehen aus.
|
||
struct BMSDiagnostics: Hashable {
|
||
var dialect: String
|
||
/// Welche Schreib-/Notify-Kombination gerade versucht wird.
|
||
var endpointLabel: String?
|
||
/// Der wievielte von wie vielen Kandidaten das ist.
|
||
var endpointPosition: Pair?
|
||
var serviceUUID: String?
|
||
var isConnected: Bool
|
||
/// Ob das Gerät das Abonnieren der Notify-Charakteristik bestätigt hat.
|
||
var isNotifyActive: Bool
|
||
/// Vollständiger Dienst-/Merkmalsbaum des Geräts.
|
||
var gattSummary: [String]
|
||
var sentFrames: Int
|
||
var receivedBytes: Int
|
||
var lastSendAt: Date?
|
||
var lastResponseHex: String?
|
||
/// Zuletzt abgeschickter Stellbefehl, damit sich prüfen lässt, ob er das
|
||
/// Gerät überhaupt erreicht hat.
|
||
var lastCommandHex: String?
|
||
var lastCommandAt: Date?
|
||
var updated: Date
|
||
|
||
struct Pair: Hashable {
|
||
var index: Int
|
||
var total: Int
|
||
init(_ index: Int, _ total: Int) { self.index = index; self.total = total }
|
||
}
|
||
}
|
||
|
||
|
||
/// Ein Messpunkt für die Verlaufsgrafik.
|
||
struct HistorySample: Identifiable, Hashable {
|
||
let id = UUID()
|
||
let time: Date
|
||
let value: Double
|
||
}
|
||
|
||
/// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Victron-Werbedaten und
|
||
/// hält parallel die Verbindung zum Daly-BMS.
|
||
/// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Victron-Werbedaten und
|
||
/// hält parallel die Verbindungen zu BMS und Kühlbox.
|
||
///
|
||
/// **Threading.** CoreBluetooth arbeitet auf einer eigenen seriellen Queue,
|
||
/// nicht auf dem Hauptthread. Advertisements treffen einzeln ein – bei
|
||
/// aktivem Duplikat-Scan über alle Geräte in Reichweite sind das leicht
|
||
/// hunderte je Sekunde, die sonst mit dem Zeichnen der Oberfläche um
|
||
/// dieselbe Zeit konkurrieren.
|
||
///
|
||
/// Daraus folgt eine strikte Trennung:
|
||
///
|
||
/// * Alles unter „nur auf `queue`“ wird ausschließlich dort angefasst –
|
||
/// Verbindungen, Puffer, Zeitgeber.
|
||
/// * Die veröffentlichten Werte werden ausschließlich auf dem Hauptthread
|
||
/// geschrieben, über `publish`.
|
||
/// * Die Geräteliste wird nicht aus der Funk-Queue heraus im Speicher
|
||
/// nachgeschlagen, sondern als Momentaufnahme hinübergereicht.
|
||
@Observable
|
||
final class BluetoothManager: NSObject {
|
||
|
||
// MARK: - Veröffentlicht (nur auf dem Hauptthread schreiben)
|
||
|
||
private(set) var snapshots: [UUID: DeviceSnapshot] = [:]
|
||
private(set) var linkStates: [UUID: DeviceLinkState] = [:]
|
||
private(set) var discoveries: [UUID: Discovery] = [:]
|
||
private(set) var history: [UUID: [HistorySample]] = [:]
|
||
private(set) var diagnostics: [UUID: VictronDiagnostics] = [:]
|
||
private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
|
||
private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
|
||
private(set) var levelStates: [UUID: LevelState] = [:]
|
||
private(set) var isBluetoothReady = false
|
||
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
|
||
|
||
/// Solange true, werden alle gefundenen Peripherals gesammelt.
|
||
var isDiscovering = false {
|
||
didSet {
|
||
let wanted = isDiscovering
|
||
queue.async { self.setDiscovering(wanted) }
|
||
}
|
||
}
|
||
|
||
// MARK: - Nur auf `queue`
|
||
|
||
private let queue = DispatchQueue(label: "de.fritob.CamperMonitor.bluetooth")
|
||
private var central: CBCentralManager?
|
||
|
||
/// Momentaufnahme der eingerichteten Geräte, damit die Funk-Queue nicht in
|
||
/// den Speicher greifen muss.
|
||
private struct ManagedDevice {
|
||
let id: UUID
|
||
let peripheralID: UUID
|
||
let transport: DeviceTransport
|
||
let role: DeviceRole
|
||
let fridgeZoneMode: FridgeZoneMode
|
||
let sensorOrientation: SensorOrientation
|
||
let victronKey: [UInt8]?
|
||
}
|
||
private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID
|
||
|
||
private var bmsSessions: [UUID: BMSSession] = [:]
|
||
private var levelSessions: [UUID: LevelSession] = [:]
|
||
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
|
||
private var reconnectTimer: DispatchSourceTimer?
|
||
|
||
/// Frühester Zeitpunkt für den nächsten Verbindungsversuch je Gerät.
|
||
///
|
||
/// Ohne diese Sperre entsteht ein Verbindungssturm: schlägt ein Versuch
|
||
/// fehl, ist der Eintrag wieder frei, und das nächste Advertisement löst
|
||
/// sofort den nächsten aus - bei aktivem Scan bis zu einmal je Sekunde.
|
||
/// Geräte quittieren jeden Versuch, Kühlboxen etwa mit einem Piepton.
|
||
private var nextConnectAttempt: [UUID: Date] = [:]
|
||
private var connectFailures: [UUID: Int] = [:]
|
||
private let firstRetryDelay: TimeInterval = 5
|
||
private let longestRetryDelay: TimeInterval = 60
|
||
private var discoveryFlushTimer: DispatchSourceTimer?
|
||
private var discovering = false
|
||
|
||
/// Advertisements treffen mehrmals je Sekunde und Gerät ein. Öfter als
|
||
/// hier festgelegt wird nichts ausgewertet – für die Anzeige ändert ein
|
||
/// Advertisement mehr nichts, es kostet aber Entschlüsselung und eine
|
||
/// Neuzeichnung.
|
||
private var pendingDiscoveries: [UUID: Discovery] = [:]
|
||
private var lastHandledAdvertisement: [UUID: Date] = [:]
|
||
private var lastDiscoveryUpdate: [UUID: Date] = [:]
|
||
private var lastDiagnosticsUpdate: [UUID: Date] = [:]
|
||
private let minimumAdvertisementInterval: TimeInterval = 0.9
|
||
private let minimumDiscoveryInterval: TimeInterval = 2.0
|
||
private let minimumDiagnosticsInterval: TimeInterval = 3.0
|
||
|
||
// MARK: - Sonstiges
|
||
|
||
private let store: DeviceStore
|
||
private let isDemo = DemoData.isEnabled
|
||
/// Wieviele Messpunkte je Gerät im Verlauf behalten werden.
|
||
private let historyLimit = 720
|
||
|
||
init(store: DeviceStore) {
|
||
self.store = store
|
||
super.init()
|
||
if isDemo {
|
||
loadDemoData()
|
||
return
|
||
}
|
||
central = CBCentralManager(delegate: self, queue: queue)
|
||
refreshConfiguration()
|
||
}
|
||
|
||
/// Führt Änderungen an den veröffentlichten Werten auf dem Hauptthread aus.
|
||
private func publish(_ work: @escaping () -> Void) {
|
||
if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
|
||
}
|
||
|
||
private func loadDemoData() {
|
||
isBluetoothReady = true
|
||
bluetoothStatusText = "Demo-Modus"
|
||
fridgeStates[DemoData.fridge.id] = DemoData.fridgeState
|
||
levelStates[DemoData.level.id] = DemoData.levelState
|
||
for snapshot in DemoData.snapshots() {
|
||
snapshots[snapshot.deviceID] = snapshot
|
||
linkStates[snapshot.deviceID] = .live
|
||
if let value = snapshot.primaryMetric?.value {
|
||
history[snapshot.deviceID] = DemoData.history(for: snapshot.deviceID, around: value)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Steuerung (vom Hauptthread aufgerufen)
|
||
|
||
func start() {
|
||
guard !isDemo else { return }
|
||
queue.async { self.startScanning() }
|
||
}
|
||
|
||
func stop() {
|
||
guard !isDemo else { return }
|
||
queue.async { self.stopEverything() }
|
||
}
|
||
|
||
/// Nach Änderungen an der Geräteliste oder den Schlüsseln aufrufen.
|
||
func refreshConfiguration() {
|
||
guard !isDemo else { return }
|
||
|
||
// Momentaufnahme auf dem Hauptthread ziehen, wo der Speicher lebt.
|
||
let devices = store.activeDevices.map { device in
|
||
ManagedDevice(id: device.id,
|
||
peripheralID: device.peripheralID,
|
||
transport: device.role.transport,
|
||
role: device.role,
|
||
fridgeZoneMode: device.fridgeZoneMode,
|
||
sensorOrientation: device.sensorOrientation,
|
||
victronKey: store.victronKeyBytes(for: device.id))
|
||
}
|
||
let known = Set(devices.map(\.id))
|
||
snapshots = snapshots.filter { known.contains($0.key) }
|
||
linkStates = linkStates.filter { known.contains($0.key) }
|
||
history = history.filter { known.contains($0.key) }
|
||
diagnostics = diagnostics.filter { known.contains($0.key) }
|
||
bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
|
||
fridgeStates = fridgeStates.filter { known.contains($0.key) }
|
||
levelStates = levelStates.filter { known.contains($0.key) }
|
||
|
||
queue.async { self.applyConfiguration(devices) }
|
||
}
|
||
|
||
func clearDiscoveries() {
|
||
discoveries.removeAll()
|
||
queue.async {
|
||
self.pendingDiscoveries.removeAll()
|
||
self.lastDiscoveryUpdate.removeAll()
|
||
}
|
||
}
|
||
|
||
// MARK: - Kühlbox steuern (vom Hauptthread aufgerufen)
|
||
|
||
func updateFridgeZoneMode(for device: ConfiguredDevice) {
|
||
let deviceID = device.id, mode = device.fridgeZoneMode
|
||
queue.async {
|
||
guard let session = self.session(for: deviceID) else { return }
|
||
session.fridgeZoneMode = mode
|
||
let state = session.alpicoolState
|
||
self.publish { self.fridgeStates[deviceID] = state }
|
||
}
|
||
}
|
||
|
||
func setFridgeTarget(_ celsius: Int, zone: AlpicoolState.Zone, for deviceID: UUID) {
|
||
queue.async {
|
||
self.session(for: deviceID)?
|
||
.sendControl(AlpicoolState.setTarget(zone: zone, to: celsius))
|
||
}
|
||
}
|
||
|
||
func setFridgePower(_ on: Bool, for deviceID: UUID) {
|
||
sendFridgeSettings(for: deviceID) { $0.settingsCommand(poweredOn: on) }
|
||
}
|
||
|
||
func setFridgeEco(_ eco: Bool, for deviceID: UUID) {
|
||
sendFridgeSettings(for: deviceID) { $0.settingsCommand(eco: eco) }
|
||
}
|
||
|
||
func setFridgeLock(_ locked: Bool, for deviceID: UUID) {
|
||
sendFridgeSettings(for: deviceID) { $0.settingsCommand(locked: locked) }
|
||
}
|
||
|
||
/// Der Einstellungsblock wird aus dem zuletzt empfangenen Zustand gebaut –
|
||
/// und zwar auf der Funk-Queue, wo dieser Zustand lebt.
|
||
private func sendFridgeSettings(for deviceID: UUID,
|
||
build: @escaping (AlpicoolState) -> Data?) {
|
||
queue.async {
|
||
guard let session = self.session(for: deviceID),
|
||
let packet = build(session.alpicoolState) else { return }
|
||
session.sendControl(packet)
|
||
}
|
||
}
|
||
|
||
/// Nach einer Änderung der Einbaulage aufrufen.
|
||
func updateSensorOrientation(for device: ConfiguredDevice) {
|
||
let deviceID = device.id, orientation = device.sensorOrientation
|
||
queue.async {
|
||
guard let session = self.levelSession(for: deviceID) else { return }
|
||
session.orientation = orientation
|
||
}
|
||
}
|
||
|
||
/// Setzt die aktuelle Lage des Fahrzeugs als neue Null.
|
||
func calibrateLevel(for deviceID: UUID) {
|
||
queue.async { self.levelSession(for: deviceID)?.calibrate() }
|
||
}
|
||
|
||
/// Verwirft die Kalibrierung.
|
||
func resetLevelCalibration(for deviceID: UUID) {
|
||
queue.async { self.levelSession(for: deviceID)?.resetCalibration() }
|
||
}
|
||
|
||
private func levelSession(for deviceID: UUID) -> LevelSession? {
|
||
guard let entry = managed.values.first(where: { $0.id == deviceID }) else { return nil }
|
||
return levelSessions[entry.peripheralID]
|
||
}
|
||
|
||
private func session(for deviceID: UUID) -> BMSSession? {
|
||
guard let entry = managed.values.first(where: { $0.id == deviceID }) else { return nil }
|
||
return bmsSessions[entry.peripheralID]
|
||
}
|
||
|
||
// MARK: - Ablauf auf der Funk-Queue
|
||
|
||
private func applyConfiguration(_ devices: [ManagedDevice]) {
|
||
managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) })
|
||
|
||
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören.
|
||
let wanted = Set(devices.filter { $0.transport == .connect }.map(\.peripheralID))
|
||
for (peripheralID, session) in bmsSessions where !wanted.contains(peripheralID) {
|
||
session.stop()
|
||
bmsSessions[peripheralID] = nil
|
||
disconnect(peripheralID)
|
||
}
|
||
for (peripheralID, session) in levelSessions where !wanted.contains(peripheralID) {
|
||
session.stop()
|
||
levelSessions[peripheralID] = nil
|
||
disconnect(peripheralID)
|
||
}
|
||
// Einstellungen an bestehende Sitzungen weiterreichen.
|
||
for device in devices {
|
||
bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode
|
||
levelSessions[device.peripheralID]?.orientation = device.sensorOrientation
|
||
}
|
||
lastHandledAdvertisement.removeAll()
|
||
lastDiagnosticsUpdate.removeAll()
|
||
nextConnectAttempt.removeAll()
|
||
connectFailures.removeAll()
|
||
|
||
startScanning()
|
||
}
|
||
|
||
private func disconnect(_ peripheralID: UUID) {
|
||
guard let peripheral = connectedPeripherals[peripheralID] else { return }
|
||
central?.cancelPeripheralConnection(peripheral)
|
||
connectedPeripherals[peripheralID] = nil
|
||
}
|
||
|
||
private func setDiscovering(_ wanted: Bool) {
|
||
guard discovering != wanted else { return }
|
||
discovering = wanted
|
||
if wanted {
|
||
startDiscoveryFlush()
|
||
} else {
|
||
discoveryFlushTimer?.cancel()
|
||
discoveryFlushTimer = nil
|
||
}
|
||
restartScan()
|
||
}
|
||
|
||
private func startScanning() {
|
||
guard central?.state == .poweredOn else { return }
|
||
restartScan()
|
||
connectManagedPeripherals()
|
||
scheduleReconnects()
|
||
}
|
||
|
||
private func stopEverything() {
|
||
central?.stopScan()
|
||
reconnectTimer?.cancel(); reconnectTimer = nil
|
||
discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
|
||
for (_, session) in bmsSessions { session.stop() }
|
||
for (_, session) in levelSessions { session.stop() }
|
||
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
|
||
bmsSessions.removeAll()
|
||
levelSessions.removeAll()
|
||
connectedPeripherals.removeAll()
|
||
}
|
||
|
||
private func restartScan() {
|
||
guard central?.state == .poweredOn else { return }
|
||
central?.stopScan()
|
||
// Victron sendet seine Werte im Advertisement, also müssen auch
|
||
// Wiederholungen durchgereicht werden.
|
||
central?.scanForPeripherals(
|
||
withServices: nil,
|
||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
|
||
)
|
||
}
|
||
|
||
private func connectManagedPeripherals() {
|
||
guard central?.state == .poweredOn else { return }
|
||
for device in managed.values where device.transport == .connect {
|
||
connectIfNeeded(device)
|
||
}
|
||
}
|
||
|
||
private func connectIfNeeded(_ device: ManagedDevice) {
|
||
if let existing = connectedPeripherals[device.peripheralID],
|
||
existing.state == .connected || existing.state == .connecting {
|
||
return
|
||
}
|
||
// Nach einem Fehlschlag eine Weile Ruhe geben, sonst wird das Gerät
|
||
// im Sekundentakt angeklopft.
|
||
if let earliest = nextConnectAttempt[device.peripheralID], Date() < earliest {
|
||
return
|
||
}
|
||
guard let peripheral = central?
|
||
.retrievePeripherals(withIdentifiers: [device.peripheralID]).first else {
|
||
publish { self.linkStates[device.id] = .searching }
|
||
return
|
||
}
|
||
connectedPeripherals[device.peripheralID] = peripheral
|
||
publish { self.linkStates[device.id] = .connecting }
|
||
central?.connect(peripheral, options: nil)
|
||
}
|
||
|
||
/// Sperrt weitere Versuche für eine Weile. Der Abstand wächst mit jedem
|
||
/// Fehlschlag, damit ein dauerhaft unerreichbares Gerät nicht endlos
|
||
/// angeklopft wird.
|
||
private func backOff(_ peripheralID: UUID) {
|
||
let failures = (connectFailures[peripheralID] ?? 0) + 1
|
||
connectFailures[peripheralID] = failures
|
||
let delay = min(firstRetryDelay * pow(2, Double(failures - 1)), longestRetryDelay)
|
||
nextConnectAttempt[peripheralID] = Date().addingTimeInterval(delay)
|
||
}
|
||
|
||
private func clearBackOff(_ peripheralID: UUID) {
|
||
connectFailures[peripheralID] = nil
|
||
nextConnectAttempt[peripheralID] = nil
|
||
}
|
||
|
||
/// Verbindungen fallen im Fahrzeug regelmässig weg – deshalb regelmässig
|
||
/// nachfassen statt nur auf das Disconnect-Ereignis zu reagieren.
|
||
private func scheduleReconnects() {
|
||
reconnectTimer?.cancel()
|
||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||
timer.schedule(deadline: .now() + 15, repeating: 15)
|
||
timer.setEventHandler { [weak self] in self?.connectManagedPeripherals() }
|
||
timer.resume()
|
||
reconnectTimer = timer
|
||
}
|
||
|
||
private func startDiscoveryFlush() {
|
||
discoveryFlushTimer?.cancel()
|
||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||
timer.schedule(deadline: .now() + 1, repeating: 1)
|
||
timer.setEventHandler { [weak self] in
|
||
guard let self else { return }
|
||
// Verschwundene Geräte erst nach einer Weile fallen lassen, damit
|
||
// ein Eintrag nicht wegen eines verpassten Pakets verschwindet.
|
||
let cutoff = Date().addingTimeInterval(-20)
|
||
self.pendingDiscoveries = self.pendingDiscoveries.filter { $0.value.lastSeen > cutoff }
|
||
let current = self.pendingDiscoveries
|
||
self.publish { self.discoveries = current }
|
||
}
|
||
timer.resume()
|
||
discoveryFlushTimer = timer
|
||
}
|
||
|
||
// MARK: - Auswertung (auf der Funk-Queue)
|
||
|
||
private func record(_ snapshot: DeviceSnapshot) {
|
||
publish {
|
||
self.snapshots[snapshot.deviceID] = snapshot
|
||
guard let primary = snapshot.primaryMetric, let value = primary.value else { return }
|
||
var samples = self.history[snapshot.deviceID] ?? []
|
||
// Höchstens alle fünf Sekunden einen Punkt aufnehmen.
|
||
if let last = samples.last, snapshot.timestamp.timeIntervalSince(last.time) < 5 { return }
|
||
samples.append(HistorySample(time: snapshot.timestamp, value: value))
|
||
if samples.count > self.historyLimit {
|
||
samples.removeFirst(samples.count - self.historyLimit)
|
||
}
|
||
self.history[snapshot.deviceID] = samples
|
||
}
|
||
}
|
||
|
||
private func handleVictronAdvertisement(_ manufacturerData: Data,
|
||
device: ManagedDevice,
|
||
rssi: Int) {
|
||
// Den unverschlüsselten Rahmen festhalten – gerade wenn der Schlüssel
|
||
// nicht passt, ist das die einzige verwertbare Information. Der
|
||
// Hex-String kostet mehr als die Auswertung selbst, deshalb nur
|
||
// gelegentlich, und immer wenn noch gar nichts angezeigt werden kann.
|
||
let now = Date()
|
||
if now.timeIntervalSince(lastDiagnosticsUpdate[device.id] ?? .distantPast)
|
||
>= minimumDiagnosticsInterval,
|
||
let envelope = VictronAdvertisement.envelope(from: manufacturerData) {
|
||
lastDiagnosticsUpdate[device.id] = now
|
||
let info = VictronDiagnostics(
|
||
productID: envelope.productID,
|
||
recordType: envelope.recordType,
|
||
expectedKeyFirstByte: envelope.keyCheckByte,
|
||
nonce: envelope.nonce,
|
||
payloadLength: envelope.ciphertext.count,
|
||
rawHex: manufacturerData.map { String(format: "%02X", $0) }.joined(separator: " "),
|
||
updated: now
|
||
)
|
||
publish { self.diagnostics[device.id] = info }
|
||
}
|
||
|
||
guard let key = device.victronKey else {
|
||
publish { self.linkStates[device.id] = .needsKey }
|
||
return
|
||
}
|
||
do {
|
||
let snapshot = try VictronAdvertisement.decode(manufacturerData: manufacturerData,
|
||
key: key,
|
||
deviceID: device.id,
|
||
rssi: rssi)
|
||
publish { self.linkStates[device.id] = .live }
|
||
record(snapshot)
|
||
} catch let error as VictronAdvertisement.DecodeError {
|
||
switch error {
|
||
case .notVictron:
|
||
break // Fremdes Advertisement desselben Peripherals, ignorieren.
|
||
default:
|
||
let message = error.localizedDescription
|
||
publish { self.linkStates[device.id] = .failed(message) }
|
||
}
|
||
} catch {
|
||
let message = error.localizedDescription
|
||
publish { self.linkStates[device.id] = .failed(message) }
|
||
}
|
||
}
|
||
|
||
private func updateDiscovery(peripheral: CBPeripheral,
|
||
advertisementData: [String: Any],
|
||
rssi: Int) {
|
||
let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
|
||
let envelope = manufacturerData.flatMap { VictronAdvertisement.envelope(from: $0) }
|
||
let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name
|
||
|
||
let existing = pendingDiscoveries[peripheral.identifier]
|
||
var entry = existing ?? Discovery(
|
||
id: peripheral.identifier,
|
||
name: name,
|
||
rssi: rssi,
|
||
firstSeen: Date(),
|
||
lastSeen: Date(),
|
||
victronRecordType: nil,
|
||
victronProductID: nil,
|
||
looksLikeSupported: false
|
||
)
|
||
// Gleitender Mittelwert über die letzten Messungen.
|
||
entry.rssi = existing.map { ($0.rssi * 3 + rssi) / 4 } ?? rssi
|
||
entry.lastSeen = Date()
|
||
if let name, !name.isEmpty { entry.name = name }
|
||
if let envelope {
|
||
entry.victronRecordType = envelope.recordType
|
||
entry.victronProductID = envelope.productID
|
||
}
|
||
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
||
entry.isLevelSensor = services.contains(LevelSession.serviceUUID)
|
||
entry.looksLikeSupported = entry.isLevelSensor
|
||
|| Self.looksLikeSupported(name: entry.name)
|
||
pendingDiscoveries[peripheral.identifier] = entry
|
||
}
|
||
|
||
/// Grobe Namensprüfung für die Vorauswahl in der Geräteliste. Nur ein
|
||
/// Hinweis – auswählbar ist über "Alle" weiterhin jedes Gerät.
|
||
private static func looksLikeSupported(name: String?) -> Bool {
|
||
guard let name = name?.lowercased() else { return false }
|
||
let needles = [
|
||
"daly", "bms", "bulltron", "wattcycle", // Batterien
|
||
"alpicool", "icecube", "ice cube", "fridge", "cool", // Kühlboxen
|
||
]
|
||
return name.hasPrefix("dl-") || name.hasPrefix("wt")
|
||
|| needles.contains { name.contains($0) }
|
||
}
|
||
}
|
||
|
||
// MARK: - CBCentralManagerDelegate (alles auf der Funk-Queue)
|
||
|
||
extension BluetoothManager: CBCentralManagerDelegate {
|
||
|
||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||
let ready: Bool, text: String
|
||
switch central.state {
|
||
case .poweredOn: ready = true; text = "Bereit"
|
||
case .poweredOff: ready = false; text = "Bluetooth ist ausgeschaltet"
|
||
case .unauthorized: ready = false; text = "Bluetooth-Zugriff wurde nicht erlaubt"
|
||
case .unsupported: ready = false; text = "Dieses Gerät unterstützt kein Bluetooth LE"
|
||
default: ready = false; text = "Bluetooth wird gestartet…"
|
||
}
|
||
publish {
|
||
self.isBluetoothReady = ready
|
||
self.bluetoothStatusText = text
|
||
}
|
||
if central.state == .poweredOn { startScanning() }
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager,
|
||
didDiscover peripheral: CBPeripheral,
|
||
advertisementData: [String: Any],
|
||
rssi RSSI: NSNumber) {
|
||
let rssi = RSSI.intValue
|
||
let now = Date()
|
||
let identifier = peripheral.identifier
|
||
|
||
if discovering,
|
||
now.timeIntervalSince(lastDiscoveryUpdate[identifier] ?? .distantPast)
|
||
>= minimumDiscoveryInterval {
|
||
lastDiscoveryUpdate[identifier] = now
|
||
updateDiscovery(peripheral: peripheral, advertisementData: advertisementData, rssi: rssi)
|
||
}
|
||
|
||
guard let device = managed[identifier] else { return }
|
||
guard now.timeIntervalSince(lastHandledAdvertisement[identifier] ?? .distantPast)
|
||
>= minimumAdvertisementInterval else { return }
|
||
lastHandledAdvertisement[identifier] = now
|
||
|
||
switch device.transport {
|
||
case .advertisement:
|
||
guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
|
||
else { return }
|
||
handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi)
|
||
|
||
case .connect:
|
||
// Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt aufbauen.
|
||
if connectedPeripherals[identifier] == nil {
|
||
connectIfNeeded(device)
|
||
}
|
||
}
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||
guard let device = managed[peripheral.identifier] else { return }
|
||
connectedPeripherals[peripheral.identifier] = peripheral
|
||
clearBackOff(peripheral.identifier)
|
||
|
||
if device.role == .leveling {
|
||
let session = LevelSession(
|
||
deviceID: device.id,
|
||
peripheral: peripheral,
|
||
queue: queue,
|
||
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
|
||
onStateChange: { [weak self] state in
|
||
self?.publish { self?.linkStates[device.id] = state }
|
||
},
|
||
onLevelState: { [weak self] state in
|
||
self?.publish { self?.levelStates[device.id] = state }
|
||
}
|
||
)
|
||
session.orientation = device.sensorOrientation
|
||
levelSessions[peripheral.identifier] = session
|
||
session.start()
|
||
return
|
||
}
|
||
|
||
let session = BMSSession(
|
||
deviceID: device.id,
|
||
peripheral: peripheral,
|
||
queue: queue,
|
||
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
|
||
onStateChange: { [weak self] state in
|
||
self?.publish { self?.linkStates[device.id] = state }
|
||
},
|
||
onDiagnostics: { [weak self] info in
|
||
self?.publish { self?.bmsDiagnostics[device.id] = info }
|
||
}
|
||
)
|
||
session.onFridgeState = { [weak self] state in
|
||
self?.publish { self?.fridgeStates[device.id] = state }
|
||
}
|
||
session.fridgeZoneMode = device.fridgeZoneMode
|
||
bmsSessions[peripheral.identifier] = session
|
||
session.start()
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager,
|
||
didFailToConnect peripheral: CBPeripheral,
|
||
error: Error?) {
|
||
if let device = managed[peripheral.identifier] {
|
||
let message = error?.localizedDescription ?? "Verbindung fehlgeschlagen"
|
||
publish { self.linkStates[device.id] = .failed(message) }
|
||
}
|
||
connectedPeripherals[peripheral.identifier] = nil
|
||
backOff(peripheral.identifier)
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager,
|
||
didDisconnectPeripheral peripheral: CBPeripheral,
|
||
error: Error?) {
|
||
bmsSessions[peripheral.identifier]?.handleDisconnect()
|
||
bmsSessions[peripheral.identifier] = nil
|
||
levelSessions[peripheral.identifier]?.handleDisconnect()
|
||
levelSessions[peripheral.identifier] = nil
|
||
connectedPeripherals[peripheral.identifier] = nil
|
||
|
||
if let device = managed[peripheral.identifier] {
|
||
publish { self.linkStates[device.id] = .searching }
|
||
// Nicht sofort wieder anklopfen. Trennt ein Gerät von sich aus -
|
||
// etwa weil eine andere App verbunden ist -, entstünde sonst ein
|
||
// Wechselspiel aus Verbinden und Trennen.
|
||
backOff(peripheral.identifier)
|
||
}
|
||
}
|
||
}
|