first commit

This commit is contained in:
fototeddy
2026-09-04 21:30:51 +02:00
commit da64b1b6bd
160 changed files with 20664 additions and 0 deletions
@@ -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)
}
}*/
}
}