App: - New DeviceRole.solar with its own BLE session/protocol/state (SolarSession, VanAlignSolarProtocol, SolarState), mirroring the leveling sensor but deliberately not wired into the Live Activity. - BluetoothManager now keeps an in-memory history per metric (voltage, current, power) instead of a single primary-metric series; cleared on app restart by design, not persisted to disk. - DeviceDetailView shows a channel picker above the history chart when a device has more than one chartable metric. - AddDeviceView recognizes the solar service UUID during setup. - DemoData gets a simulated solar device so the integration can be checked in the simulator without hardware. Firmware: - Add esp32_ble_solar.yaml (Votronic solar charge controller over BLE) and simulated variants (esp32_ble_sim.yaml, esp32_ble_solar_sim.yaml) for testing without a vehicle. - esp32_ble.yaml: set flash_size/psram for the ESP32-S3 N16R8 module. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1061 lines
46 KiB
Swift
1061 lines
46 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
|
||
/// Der Solarladeregler bewirbt seinen Dienst ebenso.
|
||
var isSolarSensor = false
|
||
|
||
//var isVictron: Bool { victronRecordType != nil }
|
||
|
||
var displayName: String {
|
||
if let name, !name.isEmpty { return name }
|
||
return "Unbenanntes Gerät"
|
||
}
|
||
/*var displayName: String {
|
||
if let name, !name.isEmpty { return name }
|
||
return isVictron ? "Victron-Gerät" : "Unbenanntes Gerät"
|
||
}*/
|
||
|
||
var subtitle: String {
|
||
if isLevelSensor { return "VanAlign Neigungsmesser" }
|
||
if isSolarSensor { return "VanAlign Solarladeregler" }
|
||
return "Bluetooth-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
|
||
/// Nur bei Kühlboxen: ob die Box die Anmeldung beantwortet hat.
|
||
var isBound: Bool?
|
||
/// Wieviele Schreibvorgänge das Gerät bestätigt hat. Zählt nur mit, wenn
|
||
/// mit Bestätigung geschrieben wird.
|
||
var confirmedWrites: Int
|
||
/// Fehler des letzten bestätigten Schreibvorgangs.
|
||
var lastWriteError: String?
|
||
/// Nur bei Kühlboxen: die vollständige Nutzlast der letzten Statusantwort.
|
||
/// Daran hängt die Länge des Stellbefehls, deshalb gehört sie in die
|
||
/// Diagnose.
|
||
var fridgePayloadHex: String?
|
||
/// 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
|
||
}
|
||
|
||
/// Verlauf mehrerer Messgrössen eines Geräts, je Metrik-Schlüssel (siehe
|
||
/// `Metric.key`). Nur im Arbeitsspeicher: Sinn ist die Grafik während der
|
||
/// laufenden App-Sitzung, nicht ein dauerhaftes Log – ein Neustart der App
|
||
/// oder das Beenden im Hintergrund darf den Verlauf also verwerfen.
|
||
typealias DeviceHistory = [String: [HistorySample]]
|
||
|
||
/// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Geräten und hält
|
||
/// parallel die Verbindung zum Neigungsmesser.
|
||
///
|
||
/// **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: DeviceHistory] = [:]
|
||
//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 solarStates: [UUID: SolarState] = [:]
|
||
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: "fototeddy.VanAlignePro.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
|
||
|
||
/*/// Geräte, die nur verbunden werden, solange ihre Ansicht offen ist.
|
||
/// Schlüssel ist die Gerätekennung, gezählt wird nicht – eine Ansicht ist
|
||
/// zu einer Zeit offen.
|
||
private var requestedSessions: Set<UUID> = []*/
|
||
|
||
//private var bmsSessions: [UUID: BMSSession] = [:]
|
||
private var levelSessions: [UUID: LevelSession] = [:]
|
||
private var solarSessions: [UUID: SolarSession] = [:]
|
||
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
|
||
/// Nach dem Abbruch einer Verbindung, die stand, wird zügig nachgefasst -
|
||
/// das ist der Normalfall im Fahrzeug und kein Fehler.
|
||
private let quickRetryDelay: TimeInterval = 2
|
||
/// Ab dieser Dauer gilt eine Verbindung als zustandegekommen. Was sofort
|
||
/// wieder abbricht, zählt als Fehlschlag.
|
||
private let stableConnection: TimeInterval = 3
|
||
private var connectedSince: [UUID: Date] = [:]
|
||
|
||
/// Stellbefehle, die kamen, während die Verbindung weg war. Ohne das
|
||
/// verschwindet ein Tippen lautlos: die Sitzung ist dann nicht da, und der
|
||
/// Befehl fällt ins Leere.
|
||
//private var pendingControls: [UUID: [(packet: Data, queuedAt: Date)]] = [:]
|
||
/// So lange darf ein aufgehobener Befehl warten. Was älter ist, wird
|
||
/// verworfen - ein verspätetes Ausschalten würde nur überraschen.
|
||
//private let controlLifetime: TimeInterval = 30
|
||
/// Letzter bekannter Kühlbox-Zustand, auch über eine Trennung hinweg. Der
|
||
/// Einstellungsblock wird daraus gebaut.
|
||
//private var fridgeStateCache: [UUID: AlpicoolState] = [:]
|
||
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
|
||
/// Von aussen gesetzt, sobald die App bereit ist. Optional, damit
|
||
/// `BluetoothManager` nichts über Live Activities wissen muss, wenn keine
|
||
/// läuft.
|
||
var activityManager: LevelActivityManager?
|
||
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
|
||
|
||
solarStates[DemoData.solar.id] = DemoData.solarState
|
||
linkStates[DemoData.solar.id] = .live
|
||
record(DemoData.solarState.snapshot(deviceID: DemoData.solar.id, rssi: -58))
|
||
history[DemoData.solar.id] = DemoData.solarHistory()
|
||
/* for snapshot in DemoData.snapshots() {
|
||
snapshots[snapshot.deviceID] = snapshot
|
||
linkStates[snapshot.deviceID] = .live
|
||
// Die Kühlbox wird nur beim Öffnen verbunden – im Vorführbetrieb
|
||
// steht sie deshalb wie im Alltag auf „zuletzt gestellt“.
|
||
/*if snapshot.deviceID == DemoData.fridge.id,
|
||
let settings = DemoData.fridgeState.settings {
|
||
linkStates[snapshot.deviceID] = .idle
|
||
snapshots[snapshot.deviceID] = settings.snapshot(deviceID: snapshot.deviceID)
|
||
}*/
|
||
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,
|
||
sensorOrientation: device.sensorOrientation)
|
||
}
|
||
/*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) }
|
||
solarStates = solarStates.filter { known.contains($0.key) }
|
||
|
||
// Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin
|
||
// ihren zuletzt gestellten Stand.
|
||
/*for device in store.activeDevices where device.role.connectsOnDemand {
|
||
guard linkStates[device.id] != .live else { continue }
|
||
linkStates[device.id] = .idle
|
||
showLastKnownFridgeSettings(for: device.id)
|
||
}*/
|
||
|
||
queue.async { self.applyConfiguration(devices) }
|
||
}
|
||
|
||
func clearDiscoveries() {
|
||
discoveries.removeAll()
|
||
queue.async {
|
||
self.pendingDiscoveries.removeAll()
|
||
self.lastDiscoveryUpdate.removeAll()
|
||
}
|
||
}
|
||
|
||
// MARK: - Verbindung auf Anforderung (vom Hauptthread aufgerufen)
|
||
|
||
/*/// Verbindet ein Gerät, das sonst unverbunden bleibt – solange seine
|
||
/// Ansicht offen ist.
|
||
///
|
||
/// Für die Kühlbox: Jede Verbindung meldet sich an ihrem Display an, manche
|
||
/// Modelle verlangen dabei sogar einen Tastendruck. Dauerhaft verbunden zu
|
||
/// sein ist dort also nicht unsichtbar, sondern lästig.
|
||
func beginSession(for device: ConfiguredDevice) {
|
||
guard device.role.connectsOnDemand else { return }
|
||
let deviceID = device.id
|
||
queue.async {
|
||
self.requestedSessions.insert(deviceID)
|
||
guard let entry = self.managed.values.first(where: { $0.id == deviceID }) else { return }
|
||
// Der Wunsch des Benutzers hebt die Wartesperre auf.
|
||
self.clearBackOff(entry.peripheralID)
|
||
self.connectIfNeeded(entry)
|
||
}
|
||
}
|
||
|
||
/// Gibt das Gerät wieder frei. Der zuletzt gemeldete Stand bleibt in der
|
||
/// Übersicht stehen, damit dort nicht plötzlich eine Lücke klafft.
|
||
func endSession(for device: ConfiguredDevice) {
|
||
guard device.role.connectsOnDemand else { return }
|
||
let deviceID = device.id
|
||
queue.async {
|
||
self.requestedSessions.remove(deviceID)
|
||
guard let entry = self.managed.values.first(where: { $0.id == deviceID }) else { return }
|
||
self.bmsSessions[entry.peripheralID]?.stop()
|
||
self.bmsSessions[entry.peripheralID] = nil
|
||
self.disconnect(entry.peripheralID)
|
||
self.publish {
|
||
self.linkStates[deviceID] = .idle
|
||
self.showLastKnownFridgeSettings(for: deviceID)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Setzt an die Stelle der Live-Werte den zuletzt gestellten Stand.
|
||
///
|
||
/// Nur auf dem Hauptthread aufrufen: greift auf den Speicher zu.
|
||
private func showLastKnownFridgeSettings(for deviceID: UUID) {
|
||
guard let settings = store.lastFridgeSettings(for: deviceID) else {
|
||
snapshots[deviceID] = nil
|
||
return
|
||
}
|
||
snapshots[deviceID] = settings.snapshot(deviceID: deviceID)
|
||
// Der Zustand bleibt stehen, damit sich die Box beim nächsten Öffnen
|
||
// sofort bedienen lässt – die Schalter zeigen dann den letzten Stand.
|
||
}*/
|
||
|
||
// 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) {
|
||
sendFridgeSettings(for: deviceID) { _ in 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.
|
||
///
|
||
/// Fehlt die Verbindung gerade, wird der Befehl aufgehoben und ein Versuch
|
||
/// angestossen. Ein Tippen darf nicht daran scheitern, dass die Box sich
|
||
/// zwei Sekunden vorher abgemeldet hat.
|
||
private func sendFridgeSettings(for deviceID: UUID,
|
||
build: @escaping (AlpicoolState) -> Data?) {
|
||
queue.async {
|
||
guard let device = self.managed.values.first(where: { $0.id == deviceID })
|
||
else { return }
|
||
|
||
if let session = self.bmsSessions[device.peripheralID] {
|
||
if let packet = build(session.alpicoolState) { session.sendControl(packet) }
|
||
return
|
||
}
|
||
|
||
guard let known = self.fridgeStateCache[device.peripheralID],
|
||
let packet = build(known) else { return }
|
||
var waiting = self.pendingControls[device.peripheralID] ?? []
|
||
waiting.append((packet, Date()))
|
||
// Mehr als eine Handvoll ergibt keinen Sinn – wer mehrfach tippt,
|
||
// meint den letzten Stand.
|
||
if waiting.count > 4 { waiting.removeFirst(waiting.count - 4) }
|
||
self.pendingControls[device.peripheralID] = waiting
|
||
|
||
// Der Wunsch des Benutzers hebt die Wartesperre auf.
|
||
self.clearBackOff(device.peripheralID)
|
||
self.connectIfNeeded(device)
|
||
}
|
||
}*/
|
||
|
||
/*/// Gibt weiter, was während der Trennung aufgelaufen ist.
|
||
private func flushPendingControls(_ peripheralID: UUID, to session: BMSSession) {
|
||
guard let waiting = pendingControls.removeValue(forKey: peripheralID) else { return }
|
||
let fresh = waiting.filter { Date().timeIntervalSince($0.queuedAt) < controlLifetime }
|
||
for (index, entry) in fresh.enumerated() {
|
||
queue.asyncAfter(deadline: .now() + Double(index) * 0.4) { [weak session] in
|
||
session?.sendControl(entry.packet)
|
||
}
|
||
}
|
||
}*/
|
||
|
||
/// Nach einer Änderung der Einbaulage aufrufen.
|
||
///
|
||
/// Die Lage wandert zusätzlich ins Gerät. Dort gehört sie hin: Sie
|
||
/// beschreibt den Einbau, und Uhr wie Android-App finden sie dann vor,
|
||
/// ohne dass jemand sie ein zweites Mal bestimmen muss.
|
||
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
|
||
session.storeOrientation(orientation)
|
||
}
|
||
}
|
||
|
||
/// Übernimmt die Einbaulage, die das Gerät meldet.
|
||
///
|
||
/// Steht dort noch nichts, meldet das Gerät nichts – dann bleibt es bei der
|
||
/// örtlich gespeicherten Fassung, und die wird beim nächsten Bestimmen
|
||
/// hinaufgeschrieben.
|
||
private func adoptOrientation(_ orientation: SensorOrientation, for deviceID: UUID) {
|
||
guard var device = store.devices.first(where: { $0.id == deviceID }),
|
||
device.sensorOrientation != orientation else { return }
|
||
device.sensorOrientation = orientation
|
||
store.update(device)
|
||
}
|
||
|
||
/// 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]) {
|
||
// Mit `uniqueKeysWithValues` stürzt das bei doppelt eingerichteten
|
||
// Geräten (gleiches Peripheral, aus Versehen zweimal hinzugefügt) ab.
|
||
// Der letzte Eintrag gewinnt – besser eine überschriebene Dublette als
|
||
// ein Absturz der ganzen Funk-Schicht.
|
||
managed = Dictionary(devices.map { ($0.peripheralID, $0) }, uniquingKeysWith: { _, latest in latest })
|
||
|
||
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören.
|
||
let wanted = Set(devices.filter { shouldStayConnected($0) }.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)
|
||
}
|
||
for (peripheralID, session) in solarSessions where !wanted.contains(peripheralID) {
|
||
session.stop()
|
||
solarSessions[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()
|
||
connectedSince.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 (_, session) in solarSessions { session.stop() }
|
||
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
|
||
//bmsSessions.removeAll()
|
||
levelSessions.removeAll()
|
||
solarSessions.removeAll()
|
||
connectedPeripherals.removeAll()
|
||
connectedSince.removeAll()
|
||
//pendingControls.removeAll()
|
||
}
|
||
|
||
private func restartScan() {
|
||
guard central?.state == .poweredOn else { return }
|
||
central?.stopScan()
|
||
// Der Neigungsmesser bewirbt seinen Dienst dauerhaft, deshalb reichen
|
||
// auch hier Duplikate durch.
|
||
central?.scanForPeripherals(
|
||
withServices: nil,
|
||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
|
||
)
|
||
}
|
||
|
||
private func connectManagedPeripherals() {
|
||
guard central?.state == .poweredOn else { return }
|
||
for device in managed.values where shouldStayConnected(device) {
|
||
connectIfNeeded(device)
|
||
}
|
||
}
|
||
|
||
/// Ob dieses Gerät von sich aus verbunden gehalten wird. Es gibt keine
|
||
/// Geräte mehr, die nur auf Anforderung verbunden werden – der
|
||
/// Neigungsmesser wird immer verbunden gehalten, sobald er per GATT
|
||
/// ansprechbar ist.
|
||
private func shouldStayConnected(_ device: ManagedDevice) -> Bool {
|
||
return device.transport == .connect
|
||
}
|
||
/*/// Ob dieses Gerät von sich aus verbunden gehalten wird.
|
||
///
|
||
/// Geräte auf Anforderung – die Kühlbox – nur dann, wenn ihre Ansicht
|
||
/// gerade offen ist oder ein Stellbefehl darauf wartet.
|
||
private func shouldStayConnected(_ device: ManagedDevice) -> Bool {
|
||
guard device.transport == .connect else { return false }
|
||
guard device.role.connectsOnDemand else { return true }
|
||
return requestedSessions.contains(device.id)
|
||
|| pendingControls[device.peripheralID]?.isEmpty == false
|
||
}*/
|
||
|
||
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
|
||
var deviceHistory = self.history[snapshot.deviceID] ?? [:]
|
||
for metric in snapshot.metrics {
|
||
guard let value = metric.value else { continue }
|
||
var samples = deviceHistory[metric.key] ?? []
|
||
// Höchstens alle fünf Sekunden einen Punkt aufnehmen.
|
||
if let last = samples.last,
|
||
snapshot.timestamp.timeIntervalSince(last.time) < 5 { continue }
|
||
samples.append(HistorySample(time: snapshot.timestamp, value: value))
|
||
if samples.count > self.historyLimit {
|
||
samples.removeFirst(samples.count - self.historyLimit)
|
||
}
|
||
deviceHistory[metric.key] = samples
|
||
}
|
||
self.history[snapshot.deviceID] = deviceHistory
|
||
}
|
||
}
|
||
|
||
/*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 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(),
|
||
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 }
|
||
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
|
||
entry.isLevelSensor = services.contains(LevelSession.serviceUUID)
|
||
entry.isSolarSensor = services.contains(SolarSession.serviceUUID)
|
||
entry.looksLikeSupported = entry.isLevelSensor || entry.isSolarSensor
|
||
pendingDiscoveries[peripheral.identifier] = entry
|
||
}
|
||
/*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
|
||
|
||
// Der Neigungsmesser wird per Verbindung angesprochen: gesehen, aber
|
||
// noch nicht verbunden -> jetzt verbinden.
|
||
if device.transport == .connect, connectedPeripherals[identifier] == nil,
|
||
shouldStayConnected(device) {
|
||
connectIfNeeded(device)
|
||
}
|
||
/*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. Ausser bei Geräten auf Anforderung: Die Kühlbox piept
|
||
// bei jeder Verbindung, und dass sie in Reichweite ist, ist kein
|
||
// Grund, sie anzufassen.
|
||
if connectedPeripherals[identifier] == nil, shouldStayConnected(device) {
|
||
connectIfNeeded(device)
|
||
}
|
||
}*/
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||
guard let device = managed[peripheral.identifier] else { return }
|
||
connectedPeripherals[peripheral.identifier] = peripheral
|
||
connectedSince[peripheral.identifier] = Date()
|
||
|
||
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
|
||
self?.activityManager?.update(deviceID: device.id, state: state)
|
||
}
|
||
},
|
||
onDeviceOrientation: { [weak self] orientation in
|
||
// Im Gerät steht, wie der Sensor eingebaut ist – für alle
|
||
// Clients dasselbe. Also übernehmen statt überschreiben.
|
||
self?.publish { self?.adoptOrientation(orientation, for: device.id) }
|
||
}
|
||
)
|
||
session.orientation = device.sensorOrientation
|
||
levelSessions[peripheral.identifier] = session
|
||
session.start()
|
||
return
|
||
}
|
||
|
||
if device.role == .solar {
|
||
// Bewusst kein Draht zu `activityManager`: Der Solarertrag soll
|
||
// nicht in der Live Activity/CarPlay auftauchen, die ist dem
|
||
// Neigungsmesser vorbehalten.
|
||
let session = SolarSession(
|
||
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 }
|
||
},
|
||
onSolarState: { [weak self] state in
|
||
self?.publish { self?.solarStates[device.id] = state }
|
||
}
|
||
)
|
||
solarSessions[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?.fridgeStateCache[peripheral.identifier] = state
|
||
self?.publish {
|
||
self?.fridgeStates[device.id] = state
|
||
// Für die Übersicht aufheben: Sie zeigt den Stand auch dann,
|
||
// wenn längst niemand mehr verbunden ist.
|
||
if let settings = state.settings {
|
||
self?.store.setLastFridgeSettings(settings, for: device.id)
|
||
}
|
||
}
|
||
}
|
||
session.fridgeZoneMode = device.fridgeZoneMode
|
||
bmsSessions[peripheral.identifier] = session
|
||
session.start()
|
||
flushPendingControls(peripheral.identifier, to: session)*/
|
||
}
|
||
|
||
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
|
||
solarSessions[peripheral.identifier]?.handleDisconnect()
|
||
solarSessions[peripheral.identifier] = nil
|
||
connectedPeripherals[peripheral.identifier] = nil
|
||
|
||
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
|
||
.map { Date().timeIntervalSince($0) } ?? 0
|
||
|
||
if let device = managed[peripheral.identifier] {
|
||
publish { self.linkStates[device.id] = .searching }
|
||
if lifetime >= stableConnection {
|
||
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
|
||
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
|
||
// Gerät minutenlang nicht bedienbar.
|
||
clearBackOff(peripheral.identifier)
|
||
nextConnectAttempt[peripheral.identifier] =
|
||
Date().addingTimeInterval(quickRetryDelay)
|
||
} else {
|
||
// Sofort wieder abgebrochen: das zählt wie ein Fehlschlag. So
|
||
// entsteht kein Wechselspiel aus Verbinden und Trennen, wenn
|
||
// etwa eine andere App das Gerät belegt.
|
||
backOff(peripheral.identifier)
|
||
}
|
||
}
|
||
/*if let device = managed[peripheral.identifier], !shouldStayConnected(device) {
|
||
// Auf Anforderung verbunden und niemand schaut mehr hin: nicht
|
||
// wieder anklopfen, sondern den letzten Stand stehenlassen.
|
||
let deviceID = device.id
|
||
publish {
|
||
self.linkStates[deviceID] = .idle
|
||
self.showLastKnownFridgeSettings(for: deviceID)
|
||
}
|
||
} else if let device = managed[peripheral.identifier] {
|
||
publish { self.linkStates[device.id] = .searching }
|
||
if lifetime >= stableConnection {
|
||
clearBackOff(peripheral.identifier)
|
||
nextConnectAttempt[peripheral.identifier] =
|
||
Date().addingTimeInterval(quickRetryDelay)
|
||
} else {
|
||
backOff(peripheral.identifier)
|
||
}
|
||
}*/
|
||
}
|
||
}
|