first commit
This commit is contained in:
@@ -0,0 +1,996 @@
|
||||
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 "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" }
|
||||
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
|
||||
}
|
||||
|
||||
/// 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: [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: "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 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
|
||||
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
|
||||
// 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) }
|
||||
|
||||
// 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]) {
|
||||
managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) })
|
||||
|
||||
// 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)
|
||||
}
|
||||
// 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 (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
|
||||
//bmsSessions.removeAll()
|
||||
levelSessions.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
|
||||
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 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.looksLikeSupported = entry.isLevelSensor
|
||||
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 }
|
||||
},
|
||||
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
|
||||
}
|
||||
|
||||
/*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
|
||||
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)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user