import Charts import SwiftUI struct DeviceDetailView: View { let device: ConfiguredDevice @Environment(DeviceStore.self) private var store @Environment(BluetoothManager.self) private var bluetooth @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 @AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false /// Ob die technischen Angaben eingeblendet werden. /// /// Im Alltag stören sie nur. Meldet ein Gerät aber einen Fehler oder fehlt /// der Schlüssel, sind sie genau das, was weiterhilft – dann werden sie /// unabhängig von der Einstellung gezeigt. private var showsTechnicalDetails: Bool { if showDiagnostics { return true } switch linkState { case .failed, .needsKey: return true default: return 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] ?? [] } 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) } if currentDevice.role == .leveling { LevelControls(device: currentDevice, state: bluetooth.levelStates[device.id] ?? LevelState()) } if let snapshot, !snapshot.metrics.isEmpty { Section("Messwerte") { ForEach(snapshot.metrics) { metric in LabeledContent(metric.label) { Text(metric.formattedWithUnit) .monospacedDigit() .foregroundStyle(metric.value == nil ? .secondary : .primary) } } } } if samples.count > 1, let primary = snapshot?.primaryMetric { Section("Verlauf – \(primary.label)") { Chart(samples) { sample in AreaMark(x: .value("Zeit", sample.time), y: .value(primary.label, sample.value)) .foregroundStyle(.tint.opacity(0.15)) LineMark(x: .value("Zeit", sample.time), y: .value(primary.label, sample.value)) .foregroundStyle(.tint) .interpolationMethod(.monotone) } .chartYAxisLabel(primary.unit) .frame(height: 180) .padding(.vertical, 8) } } if let snapshot, !snapshot.cellVoltages.isEmpty { cellSection(snapshot.cellVoltages) } if let snapshot, !snapshot.info.isEmpty { Section("Gerät") { ForEach(snapshot.info) { item in LabeledContent(item.label, value: item.value) } } } if let snapshot, snapshot.temperatures.count > 1 { Section("Temperaturen") { ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in LabeledContent("Fühler \(index + 1)") { Text(String(format: "%.0f °C", value)).monospacedDigit() } } } } if currentDevice.role.transport == .advertisement { if showsTechnicalDetails { diagnosticsSection } } else if showsTechnicalDetails { bmsDiagnosticsSection } settingsSection } .navigationTitle(currentDevice.name) .navigationBarTitleDisplayMode(.inline) .onDisappear { saveName() // Die Kühlbox wird nur verbunden, solange man sie ansieht – jede // Verbindung meldet sich an ihrem Display an. bluetooth.endSession(for: currentDevice) } .onAppear { editedName = currentDevice.name keyInput = store.victronKeyText(for: device.id) ?? "" bluetooth.beginSession(for: currentDevice) } .confirmationDialog("Gerät entfernen?", isPresented: $showDeleteConfirmation, titleVisibility: .visible) { Button("Entfernen", role: .destructive) { store.remove(device) bluetooth.refreshConfiguration() dismiss() } } message: { Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.") } } // MARK: - Abschnitte private var statusSection: some View { Section { LabeledContent("Verbindung") { StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true) } if let state = snapshot?.state { LabeledContent("Zustand", value: state) } if let fault = snapshot?.fault { Label(fault, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.red) } ForEach(snapshot?.offReasons ?? [], id: \.self) { reason in Label(reason, systemImage: "pause.circle") .foregroundStyle(.orange) } // 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) } // 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) } } } private func cellSection(_ voltages: [Double]) -> some View { let minimum = voltages.min() ?? 0 let maximum = voltages.max() ?? 0 return Section("Zellspannungen") { Chart(Array(voltages.enumerated()), id: \.offset) { index, voltage in // Kategoriale x-Achse: sonst stehen die Balken zwischen den // Beschriftungen statt darüber. BarMark( x: .value("Zelle", "\(index + 1)"), y: .value("Spannung", voltage) ) .foregroundStyle(voltage == maximum ? Color.orange : voltage == minimum ? Color.blue : Color.accentColor) // Die Zellnummer als Beschriftung am Balken statt über die // x-Achse – die blendet Swift Charts in der Liste aus. // Ab neun Zellen wird es zu eng, dann ordnet die Liste zu. .annotation(position: .bottom, alignment: .center) { if voltages.count <= 8 { Text("\(index + 1)") .font(.caption2) .foregroundStyle(.secondary) } } } .chartXAxis(.hidden) // Der interessante Bereich sind die letzten Millivolt, nicht die // absolute Spannung – deshalb eng um die Messwerte zoomen. .chartYScale(domain: (minimum - 0.05)...(maximum + 0.05)) .chartYAxisLabel("V") .frame(height: 160) .padding(.vertical, 8) ForEach(Array(voltages.enumerated()), id: \.offset) { index, voltage in LabeledContent("Zelle \(index + 1)") { Text(String(format: "%.3f V", voltage)).monospacedDigit() } } } } /// 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 { 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(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.") } } /// Zeigt, was das Gerät unverschlüsselt sendet. Wichtigster Wert ist das /// erste Schlüsselbyte: stimmt es nicht mit der Eingabe überein, gehört der /// Schlüssel zu einem anderen Victron-Gerät. @ViewBuilder private var diagnosticsSection: some View { if let info = bluetooth.diagnostics[device.id] { Section { LabeledContent("Datensatz", value: info.recordName) LabeledContent("Produkt-ID") { Text(info.productIDText).font(.body.monospaced()) } VStack(alignment: .leading, spacing: 4) { Text("Rohdaten") Text(info.rawHex) .font(.caption.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) } } header: { Text("Diagnose") } footer: { Text("Diese Werte sendet das Gerät unverschlüsselt mit.") } } } /// nil, solange kein vollständiger Schlüssel eingetragen ist. private var keyBytesAgree: Bool? { guard let expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte, let entered = keyInput.hexBytes?.first else { return nil } return expected == entered } 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 /// verlangt, und gerade der Merkmalsbaum ist zu lang dafür. private func report(_ info: BMSDiagnostics) -> String { var lines = [ "Gerät: \(currentDevice.name) (\(currentDevice.role.title))", "Protokoll: \(info.dialect)", "Verbunden: \(info.isConnected ? "ja" : "nein")", "Empfang abonniert: \(info.isNotifyActive ? "ja" : "nein")", ] if let position = info.endpointPosition { lines.append("Weg: \(position.index) von \(position.total)") } if let endpoint = info.endpointLabel { lines.append("Merkmal: \(endpoint)") } if let isBound = info.isBound { lines.append("Angemeldet: \(isBound ? "ja" : "nein")") } lines.append("Gesendet: \(info.sentFrames) · empfangen: \(info.receivedBytes) Byte" + " · bestätigt: \(info.confirmedWrites)") if let writeError = info.lastWriteError { lines.append("Schreibfehler: \(writeError)") } if let command = info.lastCommandHex { lines.append("Letzter Stellbefehl: \(command)") } if let hex = info.lastResponseHex { lines.append("Letzte Antwort: \(hex)") } if let payload = info.fridgePayloadHex { lines.append("Statusdaten: \(payload)") } if !info.gattSummary.isEmpty { lines.append("Merkmale:") lines.append(contentsOf: info.gattSummary) } return lines.joined(separator: "\n") } /// Welches Protokoll das BMS spricht und was zuletzt ankam. @ViewBuilder private var bmsDiagnosticsSection: some View { if let info = bluetooth.bmsDiagnostics[device.id] { Section { LabeledContent("Erkanntes Protokoll", value: info.dialect) if let position = info.endpointPosition { LabeledContent("Verbindungsweg", value: "\(position.index) von \(position.total)") } if let endpoint = info.endpointLabel { VStack(alignment: .leading, spacing: 4) { Text("Aktueller Weg") Text(endpoint) .font(.caption.monospaced()) .foregroundStyle(.secondary) } } LabeledContent("Verbunden") { Label(info.isConnected ? "ja" : "nein", systemImage: info.isConnected ? "checkmark.circle" : "xmark.circle") .foregroundStyle(info.isConnected ? .green : .red) } LabeledContent("Empfang abonniert") { Label(info.isNotifyActive ? "ja" : "nein", systemImage: info.isNotifyActive ? "checkmark.circle" : "xmark.circle") .foregroundStyle(info.isNotifyActive ? .green : .orange) } if let isBound = info.isBound { LabeledContent("Angemeldet") { Label(isBound ? "ja" : "nein", systemImage: isBound ? "checkmark.circle" : "xmark.circle") .foregroundStyle(isBound ? .green : .orange) } } if info.confirmedWrites > 0 { LabeledContent("Schreibvorgänge bestätigt", value: "\(info.confirmedWrites)") } if let writeError = info.lastWriteError { LabeledContent("Letzter Schreibfehler") { Text(writeError) .font(.caption) .foregroundStyle(.red) } } LabeledContent("Gesendet / empfangen", value: "\(info.sentFrames) Anfragen / \(info.receivedBytes) Byte") if let lastSendAt = info.lastSendAt { LabeledContent("Zuletzt gesendet vor") { Text(lastSendAt, style: .relative) } .foregroundStyle(.secondary) } if let command = info.lastCommandHex { VStack(alignment: .leading, spacing: 4) { HStack { Text("Letzter Stellbefehl") Spacer() if let at = info.lastCommandAt { Text(at, style: .relative).foregroundStyle(.secondary) } } Text(command) .font(.caption.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) } } if let hex = info.lastResponseHex { VStack(alignment: .leading, spacing: 4) { Text("Letzte Antwort") Text(hex) .font(.caption.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) } } if let payload = info.fridgePayloadHex { VStack(alignment: .leading, spacing: 4) { Text("Statusdaten der Box (\(payload.split(separator: " ").count) Byte)") Text(payload) .font(.caption.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) } } Button { UIPasteboard.general.string = report(info) didCopyReport = true } label: { Label(didCopyReport ? "Diagnose kopiert" : "Diagnose kopieren", systemImage: didCopyReport ? "checkmark" : "doc.on.doc") } } header: { Text("Diagnose") } footer: { Text("Die App probiert alle Schreib-/Empfangs-Kombinationen des Geräts " + "durch und fragt auf jeder Daly (klassisch und Modbus) sowie " + "JBD/Xiaoxiang an. Der „Verbindungsweg“ zählt dabei hoch. " + "Bleibt „empfangen“ am Ende bei 0 Byte, nimmt das BMS auf keinem " + "Weg Kommandos an; kommen Bytes an, ohne dass ein Protokoll " + "erkannt wird, spricht es ein noch unbekanntes.") } if !info.gattSummary.isEmpty { Section { Text(info.gattSummary.joined(separator: "\n")) .font(.caption2.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) } header: { Text("Bluetooth-Merkmale des Geräts") } } } } private var settingsSection: some View { Section("Einstellungen") { 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: { currentDevice.fridgeZoneMode }, set: { mode in var updated = currentDevice updated.fridgeZoneMode = mode store.update(updated) bluetooth.updateFridgeZoneMode(for: updated) } )) { ForEach(FridgeZoneMode.allCases) { mode in Text(mode.title).tag(mode) } } } 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()) .foregroundStyle(.secondary) } Button("Gerät entfernen", role: .destructive) { showDeleteConfirmation = true } } } // MARK: - Aktionen private func saveName() { 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) } }