forked from fritob/Camper-Monitor
init
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user