Files
VanAligneiOS/CamperMonitor/Bluetooth/BluetoothManager.swift
T
fototeddyandClaude Sonnet 5 bfe2b00a5c Add Live Activity for level sensor (surfaces on CarPlay automatically)
Replaces the ActivityKit boilerplate with a real lock screen/Dynamic
Island UI driven by live pitch/roll readings, toggled from the
leveling device's detail screen. Since iOS 17 shows any running Live
Activity on CarPlay's dashboard without a dedicated CarPlay app entitlement,
this covers the requested CarPlay use case without one.

Also fixes the widget extension never being embedded into the app
(missing Embed Foundation Extensions build phase and target
dependency), which meant no widget or Live Activity could ever have
rendered regardless of code changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:39:01 +02:00

1004 lines
43 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
/// 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
/* 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
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
}
/*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)
}
}*/
}
}