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>
This commit is contained in:
BiasF
2026-08-30 14:34:07 +02:00
co-authored by Claude Opus 5
parent 3bece5b785
commit d7cad6945d
3 changed files with 86 additions and 21 deletions
+39 -3
View File
@@ -139,6 +139,22 @@ final class BluetoothManager: NSObject {
private var pendingDiscoveries: [UUID: Discovery] = [:] private var pendingDiscoveries: [UUID: Discovery] = [:]
private var discoveryFlushTimer: Timer? 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. /// Wieviele Messpunkte je Gerät im Verlauf behalten werden.
private let historyLimit = 720 private let historyLimit = 720
@@ -208,6 +224,8 @@ final class BluetoothManager: NSObject {
diagnostics = diagnostics.filter { known.contains($0.key) } diagnostics = diagnostics.filter { known.contains($0.key) }
bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) } bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
fridgeStates = fridgeStates.filter { known.contains($0.key) } fridgeStates = fridgeStates.filter { known.contains($0.key) }
lastHandledAdvertisement.removeAll()
lastDiagnosticsUpdate.removeAll()
connectManagedPeripherals() connectManagedPeripherals()
restartScan() restartScan()
} }
@@ -215,6 +233,7 @@ final class BluetoothManager: NSObject {
func clearDiscoveries() { func clearDiscoveries() {
discoveries.removeAll() discoveries.removeAll()
pendingDiscoveries.removeAll() pendingDiscoveries.removeAll()
lastDiscoveryUpdate.removeAll()
} }
private func startDiscoveryFlush() { private func startDiscoveryFlush() {
@@ -337,7 +356,13 @@ final class BluetoothManager: NSObject {
rssi: Int) { rssi: Int) {
// Zuerst den unverschlüsselten Rahmen festhalten gerade wenn der // Zuerst den unverschlüsselten Rahmen festhalten gerade wenn der
// Schlüssel nicht passt, ist das die einzige verwertbare Information. // 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( diagnostics[device.id] = VictronDiagnostics(
productID: envelope.productID, productID: envelope.productID,
recordType: envelope.recordType, recordType: envelope.recordType,
@@ -445,12 +470,23 @@ extension BluetoothManager: CBCentralManagerDelegate {
advertisementData: [String: Any], advertisementData: [String: Any],
rssi RSSI: NSNumber) { rssi RSSI: NSNumber) {
let rssi = RSSI.intValue 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) 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 { switch device.role.transport {
case .advertisement: case .advertisement:
+9 -1
View File
@@ -199,7 +199,15 @@ private struct ConfigureDeviceView: View {
} else if discovery.looksLikeSupported { } else if discovery.looksLikeSupported {
role = .bms 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() { private func save() {
+37 -16
View File
@@ -12,6 +12,12 @@ struct DeviceDetailView: View {
@State private var keyInput = "" @State private var keyInput = ""
@State private var showDeleteConfirmation = false @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 snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching } private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] } private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
@@ -20,8 +26,8 @@ struct DeviceDetailView: View {
List { List {
statusSection statusSection
if device.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus { if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
FridgeControls(device: device, state: fridge) FridgeControls(device: currentDevice, state: fridge)
} }
if let snapshot, !snapshot.metrics.isEmpty { if let snapshot, !snapshot.metrics.isEmpty {
@@ -75,7 +81,7 @@ struct DeviceDetailView: View {
} }
} }
if device.role.transport == .advertisement { if currentDevice.role.transport == .advertisement {
keySection keySection
diagnosticsSection diagnosticsSection
} else { } else {
@@ -84,10 +90,11 @@ struct DeviceDetailView: View {
settingsSection settingsSection
} }
.navigationTitle(device.name) .navigationTitle(currentDevice.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onDisappear(perform: saveName)
.onAppear { .onAppear {
editedName = device.name editedName = currentDevice.name
keyInput = store.victronKeyText(for: device.id) ?? "" keyInput = store.victronKeyText(for: device.id) ?? ""
} }
.confirmationDialog("Gerät entfernen?", .confirmationDialog("Gerät entfernen?",
@@ -332,17 +339,28 @@ struct DeviceDetailView: View {
private var settingsSection: some View { private var settingsSection: some View {
Section("Einstellungen") { Section("Einstellungen") {
TextField("Name", text: $editedName) 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) .onSubmit(saveName)
Button("Namen übernehmen", action: saveName) }
.disabled(editedName.trimmingCharacters(in: .whitespaces).isEmpty if let advertised = currentDevice.advertisedName, !advertised.isEmpty {
|| editedName == device.name) LabeledContent("Gefunden als") {
LabeledContent("Typ", value: device.role.title) Text(advertised).font(.caption).foregroundStyle(.secondary)
if device.role == .fridge { }
}
LabeledContent("Typ", value: currentDevice.role.title)
if currentDevice.role == .fridge {
Picker("Kühlzonen", selection: Binding( Picker("Kühlzonen", selection: Binding(
get: { device.fridgeZoneMode }, get: { currentDevice.fridgeZoneMode },
set: { mode in set: { mode in
var updated = device var updated = currentDevice
updated.fridgeZoneMode = mode updated.fridgeZoneMode = mode
store.update(updated) store.update(updated)
bluetooth.updateFridgeZoneMode(for: updated) bluetooth.updateFridgeZoneMode(for: updated)
@@ -354,7 +372,7 @@ struct DeviceDetailView: View {
} }
} }
LabeledContent("Bluetooth-ID") { LabeledContent("Bluetooth-ID") {
Text(device.peripheralID.uuidString.prefix(8) + "") Text(currentDevice.peripheralID.uuidString.prefix(8) + "")
.font(.caption.monospaced()) .font(.caption.monospaced())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -372,8 +390,11 @@ struct DeviceDetailView: View {
} }
private func saveName() { private func saveName() {
var updated = device let trimmed = editedName.trimmingCharacters(in: .whitespaces)
updated.name = 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) store.update(updated)
} }
} }