diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift index b20a0d7..2fb2b33 100644 --- a/CamperMonitor/Views/DeviceDetailView.swift +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -9,6 +9,8 @@ struct DeviceDetailView: View { @Environment(\.dismiss) private var dismiss @State private var editedName = "" + /// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der + /// Schlüssel auf einer eigenen Seite. @State private var keyInput = "" @State private var showDeleteConfirmation = false @State private var didCopyReport = false @@ -40,6 +42,7 @@ struct DeviceDetailView: View { var body: some View { List { statusSection + if needsKeyAttention { keyPrompt } if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus { FridgeControls(device: currentDevice, state: fridge) @@ -102,7 +105,6 @@ struct DeviceDetailView: View { } if currentDevice.role.transport == .advertisement { - keySection if showsTechnicalDetails { diagnosticsSection } } else if showsTechnicalDetails { bmsDiagnosticsSection @@ -148,13 +150,18 @@ struct DeviceDetailView: View { Label(reason, systemImage: "pause.circle") .foregroundStyle(.orange) } - if let snapshot { + // Solange die Werte frisch sind, sagt das Alter nichts, was der + // Verbindungspunkt nicht schon zeigt. Erst wenn sie stehenbleiben, + // ist es die eigentliche Nachricht. + if let snapshot, snapshot.isStale || showsTechnicalDetails { LabeledContent("Aktualisiert vor") { Text(snapshot.timestamp, style: .relative) } .foregroundStyle(.secondary) } - if let rssi = snapshot?.rssi { + // Der Empfangspegel hilft beim Suchen eines Geräts, im Alltag + // sagt er nichts – deshalb nur bei eingeblendeter Diagnose. + if showsTechnicalDetails, let rssi = snapshot?.rssi { LabeledContent("Signal", value: "\(rssi) dBm") .foregroundStyle(.secondary) } @@ -201,21 +208,33 @@ struct DeviceDetailView: View { } } - private var keySection: some View { + /// Ohne passenden Schlüssel bleibt das Gerät stumm – das ist dann keine + /// Nebensache, sondern das Einzige, was zu tun ist. + private var needsKeyAttention: Bool { + guard currentDevice.role.transport == .advertisement else { return false } + if keyBytesAgree == false { return true } + // Kommen Werte an, passt der Schlüssel offensichtlich – dann ist hier + // nichts zu tun und nichts zu melden. + if snapshot != nil, linkState == .live { return false } + return store.victronKeyText(for: device.id)?.hexBytes?.count != 16 + } + + private var keyPrompt: some View { Section { - TextField("32 Hex-Zeichen", text: $keyInput) - .font(.system(.body, design: .monospaced)) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .onSubmit(saveKey) - Button("Schlüssel speichern", action: saveKey) - .disabled(keyInput.hexBytes?.count != 16) - } header: { - Text("Verschlüsselungsschlüssel") + NavigationLink { + VictronKeyView(device: currentDevice) + } label: { + Label(keyBytesAgree == false + ? "Schlüssel passt nicht zum Gerät" + : "Verschlüsselungsschlüssel eintragen", + systemImage: "key.horizontal.fill") + .foregroundStyle(.orange) + } } footer: { - Text("In VictronConnect: Gerät öffnen → Zahnrad → ⋮ → Produkt-Info → " - + "„Instant Readout“ einschalten → Verschlüsselungsdaten anzeigen. " - + "Der Schlüssel ist 16 Byte lang (32 Hex-Zeichen).") + Text(keyBytesAgree == false + ? "Der hinterlegte Schlüssel stammt von einem anderen Victron-Gerät." + : "Victron-Geräte senden ihre Werte verschlüsselt. Ohne den " + + "Schlüssel aus VictronConnect bleibt die Anzeige leer.") } } @@ -226,16 +245,6 @@ struct DeviceDetailView: View { private var diagnosticsSection: some View { if let info = bluetooth.diagnostics[device.id] { Section { - LabeledContent("Gerät sendet als erstes Schlüsselbyte") { - Text(info.expectedKeyText) - .font(.body.monospaced()) - .foregroundStyle(keyBytesAgree == false ? .red : .primary) - } - LabeledContent("Eingetragener Schlüssel beginnt mit") { - Text(enteredKeyText) - .font(.body.monospaced()) - .foregroundStyle(keyBytesAgree == false ? .red : .secondary) - } LabeledContent("Datensatz", value: info.recordName) LabeledContent("Produkt-ID") { Text(info.productIDText).font(.body.monospaced()) @@ -250,13 +259,7 @@ struct DeviceDetailView: View { } header: { Text("Diagnose") } footer: { - if keyBytesAgree == false { - Text("Die beiden Bytes müssen übereinstimmen. Tun sie das nicht, " - + "stammt der Schlüssel von einem anderen Victron-Gerät – in " - + "VictronConnect prüfen, ob wirklich dieses Gerät geöffnet war.") - } else { - Text("Diese Werte sendet das Gerät unverschlüsselt mit.") - } + Text("Diese Werte sendet das Gerät unverschlüsselt mit.") } } } @@ -268,9 +271,9 @@ struct DeviceDetailView: View { return expected == entered } - private var enteredKeyText: String { - guard let byte = keyInput.hexBytes?.first else { return "–" } - return String(format: "0x%02X", byte) + private var keyStatusText: String { + if store.victronKeyText(for: device.id)?.hexBytes?.count != 16 { return "fehlt" } + return keyBytesAgree == false ? "passt nicht" : "hinterlegt" } /// Alles auf einmal, zum Weitergeben. Einzeln abzutippen ist zuviel @@ -452,6 +455,13 @@ struct DeviceDetailView: View { } } } + if currentDevice.role.transport == .advertisement { + NavigationLink { + VictronKeyView(device: currentDevice) + } label: { + LabeledContent("Verschlüsselung", value: keyStatusText) + } + } LabeledContent("Bluetooth-ID") { Text(currentDevice.peripheralID.uuidString.prefix(8) + "…") .font(.caption.monospaced()) @@ -465,11 +475,6 @@ struct DeviceDetailView: View { // MARK: - Aktionen - private func saveKey() { - store.setVictronKey(keyInput, for: device.id) - bluetooth.refreshConfiguration() - } - private func saveName() { let trimmed = editedName.trimmingCharacters(in: .whitespaces) // Ein leeres Feld beim Tippen darf den Namen nicht löschen. diff --git a/CamperMonitor/Views/VictronKeyView.swift b/CamperMonitor/Views/VictronKeyView.swift new file mode 100644 index 0000000..166f837 --- /dev/null +++ b/CamperMonitor/Views/VictronKeyView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +/// Eingabe des Victron-Verschlüsselungsschlüssels. +/// +/// Der Schlüssel wird einmal eingetragen und danach nie wieder angefasst – +/// deshalb steht er hier und nicht in der Geräteübersicht. Nur solange er +/// fehlt oder nicht passt, weist die Übersicht darauf hin. +struct VictronKeyView: View { + let device: ConfiguredDevice + + @Environment(DeviceStore.self) private var store + @Environment(BluetoothManager.self) private var bluetooth + + @State private var keyInput = "" + + var body: some View { + List { + Section { + TextField("32 Hex-Zeichen", text: $keyInput) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onSubmit(save) + Button("Schlüssel speichern", action: save) + .disabled(keyInput.hexBytes?.count != 16) + } footer: { + Text("In VictronConnect: Gerät öffnen → Zahnrad → ⋮ → Produkt-Info → " + + "„Instant Readout“ einschalten → Verschlüsselungsdaten anzeigen. " + + "Der Schlüssel ist 16 Byte lang (32 Hex-Zeichen).") + } + + // Das erste Byte sendet das Gerät unverschlüsselt mit. Stimmt es + // nicht mit dem eingetragenen überein, gehört der Schlüssel zu + // einem anderen Victron-Gerät – der häufigste Fehler überhaupt. + if let expected = bluetooth.diagnostics[device.id]?.expectedKeyText { + Section { + LabeledContent("Gerät sendet") { + Text(expected) + .font(.body.monospaced()) + .foregroundStyle(bytesAgree == false ? .red : .primary) + } + LabeledContent("Eingetragen") { + Text(enteredKeyText) + .font(.body.monospaced()) + .foregroundStyle(bytesAgree == false ? .red : .secondary) + } + } header: { + Text("Erstes Schlüsselbyte") + } footer: { + if bytesAgree == false { + Text("Die beiden Bytes müssen übereinstimmen. Tun sie das " + + "nicht, stammt der Schlüssel von einem anderen " + + "Victron-Gerät – in VictronConnect prüfen, ob wirklich " + + "dieses Gerät geöffnet war.") + } else { + Text("Zum Vergleichen: dieses Byte sendet das Gerät " + + "unverschlüsselt mit.") + } + } + } + } + .navigationTitle("Verschlüsselung") + .navigationBarTitleDisplayMode(.inline) + .onAppear { keyInput = store.victronKeyText(for: device.id) ?? "" } + } + + /// nil, solange kein vollständiger Schlüssel eingetragen ist. + private var bytesAgree: Bool? { + guard let expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte, + let entered = keyInput.hexBytes?.first else { return nil } + return expected == entered + } + + private var enteredKeyText: String { + guard let byte = keyInput.hexBytes?.first else { return "–" } + return String(format: "0x%02X", byte) + } + + private func save() { + store.setVictronKey(keyInput, for: device.id) + bluetooth.refreshConfiguration() + } +}