diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index 168bf02..073934e 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -139,6 +139,22 @@ final class BluetoothManager: NSObject { 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 @@ -208,6 +224,8 @@ final class BluetoothManager: NSObject { 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() } @@ -215,6 +233,7 @@ final class BluetoothManager: NSObject { func clearDiscoveries() { discoveries.removeAll() pendingDiscoveries.removeAll() + lastDiscoveryUpdate.removeAll() } private func startDiscoveryFlush() { @@ -337,7 +356,13 @@ final class BluetoothManager: NSObject { rssi: Int) { // Zuerst den unverschlüsselten Rahmen festhalten – gerade wenn der // Schlüssel nicht passt, ist das die einzige verwertbare Information. - if let envelope = VictronAdvertisement.envelope(from: manufacturerData) { + // 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, @@ -445,12 +470,23 @@ extension BluetoothManager: CBCentralManagerDelegate { advertisementData: [String: Any], rssi RSSI: NSNumber) { let rssi = RSSI.intValue + let now = Date() + let identifier = peripheral.identifier - if isDiscovering { + if isDiscovering, + now.timeIntervalSince(lastDiscoveryUpdate[identifier] ?? .distantPast) + >= minimumDiscoveryInterval { + lastDiscoveryUpdate[identifier] = now updateDiscovery(peripheral: peripheral, advertisementData: advertisementData, rssi: rssi) } - guard let device = store.device(withPeripheralID: peripheral.identifier) else { return } + 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: diff --git a/CamperMonitor/Views/AddDeviceView.swift b/CamperMonitor/Views/AddDeviceView.swift index b8c27ea..b63653f 100644 --- a/CamperMonitor/Views/AddDeviceView.swift +++ b/CamperMonitor/Views/AddDeviceView.swift @@ -199,7 +199,15 @@ private struct ConfigureDeviceView: View { } else if discovery.looksLikeSupported { role = .bms } - name = discovery.name?.isEmpty == false ? discovery.name! : role.title + // Funknamen wie "WTaEaAA25342229" taugen nicht als Anzeigename. Die + // Art des Geräts ist der bessere Vorschlag; der Funkname steht + // ohnehin darüber unter "Gefunden als". + if let advertised = discovery.name, advertised.count <= 20, + advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil { + name = advertised + } else { + name = role.title + } } private func save() { diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift index a959693..61a9a1a 100644 --- a/CamperMonitor/Views/DeviceDetailView.swift +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -12,6 +12,12 @@ struct DeviceDetailView: View { @State private var keyInput = "" @State private var showDeleteConfirmation = false + /// Immer der aktuelle Stand aus dem Speicher – `device` ist die + /// Momentaufnahme beim Öffnen und veraltet nach jeder Änderung. + private var currentDevice: ConfiguredDevice { + store.devices.first { $0.id == device.id } ?? device + } + private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] } private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching } private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] } @@ -20,8 +26,8 @@ struct DeviceDetailView: View { List { statusSection - if device.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus { - FridgeControls(device: device, state: fridge) + if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus { + FridgeControls(device: currentDevice, state: fridge) } if let snapshot, !snapshot.metrics.isEmpty { @@ -75,7 +81,7 @@ struct DeviceDetailView: View { } } - if device.role.transport == .advertisement { + if currentDevice.role.transport == .advertisement { keySection diagnosticsSection } else { @@ -84,10 +90,11 @@ struct DeviceDetailView: View { settingsSection } - .navigationTitle(device.name) + .navigationTitle(currentDevice.name) .navigationBarTitleDisplayMode(.inline) + .onDisappear(perform: saveName) .onAppear { - editedName = device.name + editedName = currentDevice.name keyInput = store.victronKeyText(for: device.id) ?? "" } .confirmationDialog("Gerät entfernen?", @@ -332,17 +339,28 @@ struct DeviceDetailView: View { private var settingsSection: some View { Section("Einstellungen") { - TextField("Name", text: $editedName) - .onSubmit(saveName) - Button("Namen übernehmen", action: saveName) - .disabled(editedName.trimmingCharacters(in: .whitespaces).isEmpty - || editedName == device.name) - LabeledContent("Typ", value: device.role.title) - if device.role == .fridge { + HStack { + Text("Name") + Spacer() + // Beim Abschluss der Eingabe und beim Verlassen der Ansicht + // gesichert – bei jedem Tastendruck zu speichern hiesse, die + // ganze Geräteliste je Zeichen neu zu schreiben. + TextField("Gerätename", text: $editedName) + .multilineTextAlignment(.trailing) + .submitLabel(.done) + .onSubmit(saveName) + } + if let advertised = currentDevice.advertisedName, !advertised.isEmpty { + LabeledContent("Gefunden als") { + Text(advertised).font(.caption).foregroundStyle(.secondary) + } + } + LabeledContent("Typ", value: currentDevice.role.title) + if currentDevice.role == .fridge { Picker("Kühlzonen", selection: Binding( - get: { device.fridgeZoneMode }, + get: { currentDevice.fridgeZoneMode }, set: { mode in - var updated = device + var updated = currentDevice updated.fridgeZoneMode = mode store.update(updated) bluetooth.updateFridgeZoneMode(for: updated) @@ -354,7 +372,7 @@ struct DeviceDetailView: View { } } LabeledContent("Bluetooth-ID") { - Text(device.peripheralID.uuidString.prefix(8) + "…") + Text(currentDevice.peripheralID.uuidString.prefix(8) + "…") .font(.caption.monospaced()) .foregroundStyle(.secondary) } @@ -372,8 +390,11 @@ struct DeviceDetailView: View { } private func saveName() { - var updated = device - updated.name = editedName.trimmingCharacters(in: .whitespaces) + let trimmed = editedName.trimmingCharacters(in: .whitespaces) + // Ein leeres Feld beim Tippen darf den Namen nicht löschen. + guard !trimmed.isEmpty, trimmed != currentDevice.name else { return } + var updated = currentDevice + updated.name = trimmed store.update(updated) } }