forked from fritob/Camper-Monitor
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>
401 lines
16 KiB
Swift
401 lines
16 KiB
Swift
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
|
||
|
||
/// 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 currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
|
||
FridgeControls(device: currentDevice, state: fridge)
|
||
}
|
||
|
||
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 {
|
||
keySection
|
||
diagnosticsSection
|
||
} else {
|
||
bmsDiagnosticsSection
|
||
}
|
||
|
||
settingsSection
|
||
}
|
||
.navigationTitle(currentDevice.name)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.onDisappear(perform: saveName)
|
||
.onAppear {
|
||
editedName = currentDevice.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 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)
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
} 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)
|
||
}
|
||
}
|
||
}
|
||
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 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.
|
||
guard !trimmed.isEmpty, trimmed != currentDevice.name else { return }
|
||
var updated = currentDevice
|
||
updated.name = trimmed
|
||
store.update(updated)
|
||
}
|
||
}
|