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 = "" @State private var keyInput = "" @State private var showDeleteConfirmation = false 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 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.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 device.role.transport == .advertisement { keySection diagnosticsSection } else { bmsDiagnosticsSection } settingsSection } .navigationTitle(device.name) .navigationBarTitleDisplayMode(.inline) .onAppear { editedName = device.name keyInput = store.victronKeyText(for: device.id) ?? "" } .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) } if let snapshot { LabeledContent("Aktualisiert vor") { Text(snapshot.timestamp, style: .relative) } .foregroundStyle(.secondary) } if 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() } } } } private var keySection: 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") } 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).") } } /// 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("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()) } VStack(alignment: .leading, spacing: 4) { Text("Rohdaten") Text(info.rawHex) .font(.caption.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) } } 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.") } } } } /// 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 enteredKeyText: String { guard let byte = keyInput.hexBytes?.first else { return "–" } return String(format: "0x%02X", byte) } /// 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 service = info.serviceUUID { LabeledContent("Dienst") { Text(service).font(.caption.monospaced()).foregroundStyle(.secondary) } } if let hex = info.lastResponseHex { VStack(alignment: .leading, spacing: 4) { Text("Letzte Antwort") Text(hex) .font(.caption.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) } } } header: { Text("Diagnose") } footer: { Text("Die App probiert Daly (klassisch und Modbus) sowie JBD/Xiaoxiang " + "durch und übernimmt, was antwortet. Bleibt es bei „wird ermittelt“, " + "spricht das BMS ein anderes Protokoll – dann hilft die Rohantwort weiter.") } } } 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) LabeledContent("Bluetooth-ID") { Text(device.peripheralID.uuidString.prefix(8) + "…") .font(.caption.monospaced()) .foregroundStyle(.secondary) } Button("Gerät entfernen", role: .destructive) { showDeleteConfirmation = true } } } // MARK: - Aktionen private func saveKey() { store.setVictronKey(keyInput, for: device.id) bluetooth.refreshConfiguration() } private func saveName() { var updated = device updated.name = editedName.trimmingCharacters(in: .whitespaces) store.update(updated) } }