Files
Camper-Monitor/CamperMonitor/Bluetooth/BluetoothManager.swift
T
BiasFandClaude Opus 5 d7cad6945d Namen leichter ändern, Oberfläche entlasten
Umbenennen war zwar möglich, aber praktisch unauffindbar: das Eingabe-
feld trug nur einen Platzhalter, und übernommen wurde erst über einen
zusätzlichen Knopf. Jetzt steht "Name" als Beschriftung davor, der Knopf
ist weg, gesichert wird beim Abschluss der Eingabe und beim Verlassen der
Ansicht. Der Funkname steht darunter als "Gefunden als", damit das Gerät
weiter zuzuordnen ist.

Beim Einrichten wird nicht mehr der Funkname vorgeschlagen - Namen wie
"WTaEaAA25342229" taugen nicht als Anzeigename -, sondern die Art des
Geräts, sofern der Funkname kryptisch wirkt.

Die Detailansicht arbeitete auf einer Momentaufnahme des Geräts. Nach dem
Umbenennen oder nach dem Umstellen der Kühlzonen zeigte sie deshalb
weiter die alten Werte; sie liest den Stand jetzt aus dem Speicher.

Gegen die träge Oberfläche: CoreBluetooth meldet jedes Advertisement
einzeln und auf dem Hauptthread, und gescannt wird mit Duplikaten über
alle Geräte in Reichweite. Victron sendet mehrmals je Sekunde, dazu
kommt alles andere in Funkreichweite. Ausgewertet wird jetzt höchstens
einmal je Sekunde und Gerät, die Geräteliste beim Einrichten alle zwei
Sekunden, die Rohdaten der Diagnose alle drei. Ein Advertisement mehr
ändert die Anzeige ohnehin nicht, kostet aber Entschlüsselung und eine
Neuzeichnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 14:34:07 +02:00

546 lines
22 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
var isVictron: Bool { victronRecordType != nil }
var displayName: String {
if let name, !name.isEmpty { return name }
return isVictron ? "Victron-Gerät" : "Unbenanntes Gerät"
}
var subtitle: String {
if let type = victronRecordType {
return "Victron · " + Self.victronRecordName(type)
}
if looksLikeSupported { return "Sieht nach BMS oder Kühlbox aus" }
return "Bluetooth-Gerät"
}
static func victronRecordName(_ type: UInt8) -> String {
switch VictronAdvertisement.RecordType(rawValue: type) {
case .solarCharger: return "Solarladeregler"
case .batteryMonitor: return "Batteriewächter"
case .inverter: return "Wechselrichter"
case .dcdcConverter: return "DC/DC-Lader (Orion-TR)"
case .orionXS: return "Orion XS"
case .acCharger: return "AC-Ladegerät"
case .smartLithium: return "Smart Lithium"
case .smartBatteryProtect: return "Battery Protect"
case .lynxSmartBMS: return "Lynx Smart BMS"
case .dcEnergyMeter: return "DC-Energiezähler"
default: return String(format: "Typ 0x%02X", type)
}
}
}
/// Was ein Victron-Gerät unverschlüsselt mitsendet. Wird unabhängig vom
/// Schlüssel gefüllt und hilft, eine falsche Eingabe einzugrenzen.
struct VictronDiagnostics: Hashable {
var productID: UInt16
var recordType: UInt8
var expectedKeyFirstByte: UInt8
var nonce: UInt16
var payloadLength: Int
var rawHex: String
var updated: Date
var recordName: String { Discovery.victronRecordName(recordType) }
var productIDText: String { String(format: "0x%04X", productID) }
var expectedKeyText: String { String(format: "0x%02X", expectedKeyFirstByte) }
}
/// Was bei der BMS-Verbindung erkannt wurde. Zeigt, ob und welches Protokoll
/// greift, und gibt die letzte Rohantwort zum Nachsehen aus.
struct BMSDiagnostics: Hashable {
var dialect: String
/// Welche Schreib-/Notify-Kombination gerade versucht wird.
var endpointLabel: String?
/// Der wievielte von wie vielen Kandidaten das ist.
var endpointPosition: Pair?
var serviceUUID: String?
var isConnected: Bool
/// Ob das Gerät das Abonnieren der Notify-Charakteristik bestätigt hat.
var isNotifyActive: Bool
/// Vollständiger Dienst-/Merkmalsbaum des Geräts.
var gattSummary: [String]
var sentFrames: Int
var receivedBytes: Int
var lastSendAt: Date?
var lastResponseHex: String?
/// Zuletzt abgeschickter Stellbefehl, damit sich prüfen lässt, ob er das
/// Gerät überhaupt erreicht hat.
var lastCommandHex: String?
var lastCommandAt: Date?
var updated: Date
struct Pair: Hashable {
var index: Int
var total: Int
init(_ index: Int, _ total: Int) { self.index = index; self.total = total }
}
}
/// Ein Messpunkt für die Verlaufsgrafik.
struct HistorySample: Identifiable, Hashable {
let id = UUID()
let time: Date
let value: Double
}
/// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Victron-Werbedaten und
/// hält parallel die Verbindung zum Daly-BMS.
@Observable
final class BluetoothManager: NSObject {
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] = [:]
/// Bedienzustand der Kühlboxen, damit die Schalter dem Gerät folgen.
private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
private(set) var isBluetoothReady = false
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
/// Solange true, werden alle gefundenen Peripherals gesammelt.
var isDiscovering = false {
didSet {
restartScan()
isDiscovering ? startDiscoveryFlush() : stopDiscoveryFlush()
}
}
private let store: DeviceStore
private var central: CBCentralManager?
private var bmsSessions: [UUID: BMSSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
private var reconnectTimer: Timer?
/// Advertisements treffen mehrmals pro Sekunde und Gerät ein. Sie werden
/// hier gesammelt und nur im Sekundentakt an die Ansicht durchgereicht
/// sonst baut sich die Liste unter dem Finger ständig neu auf.
private var pendingDiscoveries: [UUID: Discovery] = [:]
private var discoveryFlushTimer: Timer?
/// Wann ein Peripheral zuletzt ausgewertet wurde.
///
/// CoreBluetooth meldet jedes Advertisement einzeln und auf dem
/// Hauptthread. Victron sendet mehrmals je Sekunde, dazu kommt alles
/// andere in Funkreichweite auf einem Stellplatz schnell hunderte
/// Ereignisse pro Sekunde, die der Oberfläche die Zeit zum Zeichnen
/// nehmen. Öfter als hier festgelegt wird deshalb nichts verarbeitet.
private var lastHandledAdvertisement: [UUID: Date] = [:]
private let minimumAdvertisementInterval: TimeInterval = 0.9
/// Für die Geräteliste reicht ein gröberer Takt.
private let minimumDiscoveryInterval: TimeInterval = 2.0
/// Die Rohdatenanzeige der Diagnose muss nicht live mitlaufen.
private var lastDiagnosticsUpdate: [UUID: Date] = [:]
private var lastDiscoveryUpdate: [UUID: Date] = [:]
private let minimumDiagnosticsInterval: TimeInterval = 3.0
/// Wieviele Messpunkte je Gerät im Verlauf behalten werden.
private let historyLimit = 720
/// Im Demo-Modus wird nichts gefunkt, die Werte kommen aus `DemoData`.
private let isDemo = DemoData.isEnabled
init(store: DeviceStore) {
self.store = store
super.init()
if isDemo {
loadDemoData()
return
}
central = CBCentralManager(delegate: self, queue: .main)
}
private func loadDemoData() {
isBluetoothReady = true
bluetoothStatusText = "Demo-Modus"
fridgeStates[DemoData.fridge.id] = DemoData.fridgeState
for snapshot in DemoData.snapshots() {
snapshots[snapshot.deviceID] = snapshot
linkStates[snapshot.deviceID] = .live
if let value = snapshot.primaryMetric?.value {
history[snapshot.deviceID] = DemoData.history(for: snapshot.deviceID, around: value)
}
}
}
// MARK: - Steuerung
func start() {
guard !isDemo, central?.state == .poweredOn else { return }
restartScan()
connectManagedPeripherals()
scheduleReconnects()
}
func stop() {
guard !isDemo else { return }
central?.stopScan()
reconnectTimer?.invalidate()
reconnectTimer = nil
stopDiscoveryFlush()
for (_, session) in bmsSessions { session.stop() }
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
bmsSessions.removeAll()
connectedPeripherals.removeAll()
}
/// Nach Änderungen an der Geräteliste aufrufen.
func refreshConfiguration() {
guard !isDemo else { return }
let managed = Set(store.activeDevices.filter { $0.role.transport == .connect }.map(\.peripheralID))
for (peripheralID, session) in bmsSessions where !managed.contains(peripheralID) {
session.stop()
bmsSessions[peripheralID] = nil
if let peripheral = connectedPeripherals[peripheralID] {
central?.cancelPeripheralConnection(peripheral)
connectedPeripherals[peripheralID] = nil
}
}
let known = Set(store.activeDevices.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) }
lastHandledAdvertisement.removeAll()
lastDiagnosticsUpdate.removeAll()
connectManagedPeripherals()
restartScan()
}
func clearDiscoveries() {
discoveries.removeAll()
pendingDiscoveries.removeAll()
lastDiscoveryUpdate.removeAll()
}
private func startDiscoveryFlush() {
discoveryFlushTimer?.invalidate()
discoveryFlushTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [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 }
self.discoveries = self.pendingDiscoveries
}
}
private func stopDiscoveryFlush() {
discoveryFlushTimer?.invalidate()
discoveryFlushTimer = nil
}
// MARK: - Scannen
private func restartScan() {
guard !isDemo, central?.state == .poweredOn else { return }
central?.stopScan()
// Victron sendet seine Werte im Advertisement, also müssen auch
// Wiederholungen durchgereicht werden.
central?.scanForPeripherals(
withServices: nil,
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
)
}
private func connectManagedPeripherals() {
guard !isDemo, central?.state == .poweredOn else { return }
for device in store.activeDevices where device.role.transport == .connect {
connectIfNeeded(device)
}
}
private func connectIfNeeded(_ device: ConfiguredDevice) {
let peripheralID = device.peripheralID
if let existing = connectedPeripherals[peripheralID],
existing.state == .connected || existing.state == .connecting {
return
}
guard let peripheral = central?.retrievePeripherals(withIdentifiers: [peripheralID]).first else {
linkStates[device.id] = .searching
return
}
connectedPeripherals[peripheralID] = peripheral
linkStates[device.id] = .connecting
central?.connect(peripheral, options: nil)
}
/// Verbindungen fallen im Fahrzeug regelmässig weg deshalb regelmässig
/// nachfassen statt nur auf das Disconnect-Ereignis zu reagieren.
private func scheduleReconnects() {
reconnectTimer?.invalidate()
reconnectTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in
self?.connectManagedPeripherals()
}
}
/// Nach einer Änderung der Zoneneinstellung aufrufen.
func updateFridgeZoneMode(for device: ConfiguredDevice) {
guard let session = bmsSessions[device.peripheralID] else { return }
session.fridgeZoneMode = device.fridgeZoneMode
fridgeStates[device.id] = session.alpicoolState
}
// MARK: - Kühlbox steuern
private func fridgeSession(for deviceID: UUID) -> BMSSession? {
guard let device = store.devices.first(where: { $0.id == deviceID }) else { return nil }
return bmsSessions[device.peripheralID]
}
/// Solltemperatur einer Zone setzen.
func setFridgeTarget(_ celsius: Int, zone: AlpicoolState.Zone, for deviceID: UUID) {
fridgeSession(for: deviceID)?
.sendControl(AlpicoolState.setTarget(zone: zone, to: celsius))
}
/// Kühlbox ein- oder ausschalten.
func setFridgePower(_ on: Bool, for deviceID: UUID) {
guard let session = fridgeSession(for: deviceID),
let packet = session.alpicoolState.settingsCommand(poweredOn: on) else { return }
session.sendControl(packet)
}
/// Zwischen sparsamem und schnellem Kühlen umschalten.
func setFridgeEco(_ eco: Bool, for deviceID: UUID) {
guard let session = fridgeSession(for: deviceID),
let packet = session.alpicoolState.settingsCommand(eco: eco) else { return }
session.sendControl(packet)
}
/// Bedienfeld der Box sperren oder freigeben.
func setFridgeLock(_ locked: Bool, for deviceID: UUID) {
guard let session = fridgeSession(for: deviceID),
let packet = session.alpicoolState.settingsCommand(locked: locked) else { return }
session.sendControl(packet)
}
// MARK: - Auswertung
private func record(_ snapshot: DeviceSnapshot) {
snapshots[snapshot.deviceID] = snapshot
guard let primary = snapshot.primaryMetric, let value = primary.value else { return }
var samples = 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 > historyLimit { samples.removeFirst(samples.count - historyLimit) }
history[snapshot.deviceID] = samples
}
private func handleVictronAdvertisement(_ manufacturerData: Data,
device: ConfiguredDevice,
rssi: Int) {
// Zuerst 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 needsDiagnostics = diagnostics[device.id] == nil
|| Date().timeIntervalSince(lastDiagnosticsUpdate[device.id] ?? .distantPast)
>= minimumDiagnosticsInterval
if needsDiagnostics, let envelope = VictronAdvertisement.envelope(from: manufacturerData) {
lastDiagnosticsUpdate[device.id] = Date()
diagnostics[device.id] = 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: Date()
)
}
guard let key = store.victronKeyBytes(for: device.id) else {
linkStates[device.id] = .needsKey
return
}
do {
let snapshot = try VictronAdvertisement.decode(manufacturerData: manufacturerData,
key: key,
deviceID: device.id,
rssi: rssi)
linkStates[device.id] = .live
record(snapshot)
} catch let error as VictronAdvertisement.DecodeError {
switch error {
case .notVictron:
break // Fremdes Advertisement desselben Peripherals, ignorieren.
default:
linkStates[device.id] = .failed(error.localizedDescription)
}
} catch {
linkStates[device.id] = .failed(error.localizedDescription)
}
}
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
}
entry.looksLikeSupported = 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
extension BluetoothManager: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state {
case .poweredOn:
isBluetoothReady = true
bluetoothStatusText = "Bereit"
start()
case .poweredOff:
isBluetoothReady = false
bluetoothStatusText = "Bluetooth ist ausgeschaltet"
case .unauthorized:
isBluetoothReady = false
bluetoothStatusText = "Bluetooth-Zugriff wurde nicht erlaubt"
case .unsupported:
isBluetoothReady = false
bluetoothStatusText = "Dieses Gerät unterstützt kein Bluetooth LE"
default:
isBluetoothReady = false
bluetoothStatusText = "Bluetooth wird gestartet…"
}
}
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 isDiscovering,
now.timeIntervalSince(lastDiscoveryUpdate[identifier] ?? .distantPast)
>= minimumDiscoveryInterval {
lastDiscoveryUpdate[identifier] = now
updateDiscovery(peripheral: peripheral, advertisementData: advertisementData, rssi: rssi)
}
guard let device = store.device(withPeripheralID: identifier) else { return }
// Ein Advertisement mehr ändert die Anzeige nicht, kostet aber
// Entschlüsselung und eine Neuzeichnung.
guard now.timeIntervalSince(lastHandledAdvertisement[identifier] ?? .distantPast)
>= minimumAdvertisementInterval else { return }
lastHandledAdvertisement[identifier] = now
switch device.role.transport {
case .advertisement:
guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
else { return }
handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi)
case .connect:
// Das BMS wurde gesehen falls die Verbindung fehlt, jetzt aufbauen.
if connectedPeripherals[peripheral.identifier] == nil {
connectIfNeeded(device)
}
}
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
guard let device = store.device(withPeripheralID: peripheral.identifier) else { return }
connectedPeripherals[peripheral.identifier] = peripheral
let session = BMSSession(
deviceID: device.id,
peripheral: peripheral,
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
onStateChange: { [weak self] state in self?.linkStates[device.id] = state },
onDiagnostics: { [weak self] info in self?.bmsDiagnostics[device.id] = info }
)
session.onFridgeState = { [weak self] state in self?.fridgeStates[device.id] = state }
session.fridgeZoneMode = device.fridgeZoneMode
bmsSessions[peripheral.identifier] = session
session.start()
}
func centralManager(_ central: CBCentralManager,
didFailToConnect peripheral: CBPeripheral,
error: Error?) {
if let device = store.device(withPeripheralID: peripheral.identifier) {
linkStates[device.id] = .failed(error?.localizedDescription ?? "Verbindung fehlgeschlagen")
}
connectedPeripherals[peripheral.identifier] = nil
}
func centralManager(_ central: CBCentralManager,
didDisconnectPeripheral peripheral: CBPeripheral,
error: Error?) {
bmsSessions[peripheral.identifier]?.handleDisconnect()
bmsSessions[peripheral.identifier] = nil
connectedPeripherals[peripheral.identifier] = nil
if let device = store.device(withPeripheralID: peripheral.identifier) {
linkStates[device.id] = .searching
// Direkt wieder anfragen; iOS stellt die Verbindung her, sobald das
// Gerät wieder in Reichweite ist.
connectIfNeeded(device)
}
}
}