Files
Camper-Monitor/CamperMonitor/Views/DeviceDetailView.swift
T
BiasFandClaude Opus 5 1b9486aad4 Neigungsmesser VanAlign einbinden
Das ESPHome-Projekt VanAlign Pro misst über einen MPU6050 die Längs- und
Querneigung des Fahrzeugs und stellt sie über Bluetooth bereit. Damit
lässt sich der Camper beim Parken ausrichten.

Der Neigungsmesser bewirbt seinen Dienst, wird beim Einrichten also
sicher erkannt und die Geräteart vorbelegt. Anders als bei den übrigen
verbundenen Geräten gibt es hier kein Rahmenprotokoll: jede Messgrösse
liegt in einer eigenen Charakteristik als Float. LevelSession ist deshalb
eine eigene, deutlich einfachere Sitzungsart neben BMSSession - ohne
Protokollerkennung und ohne Kandidatensuche.

Bevorzugt werden die Werte abonniert; bietet das Gerät das nicht an, wird
im Takt abgefragt. Damit läuft die App auch mit der alten Firmware 1.0,
die nur Lesen kennt.

Die Detailansicht zeigt eine Libelle: die Blase wandert dorthin, wo das
Fahrzeug höher steht. Grün bis 0,5 Grad, orange bis zwei, darüber rot -
die Akzentfarbe der App ist selbst grün, "schief" hätte sonst genauso
ausgesehen wie "eben". Die Ringe sind ein echter Massstab (Toleranz und
zwei Grad), sonst sagt die Blasenlage nichts über die verbleibende
Abweichung. Dazu die Ansage in Worten, welche Seite höher steht.

Die Winkel werden fest little-endian gelesen. Die Web-Oberfläche des
Ursprungsprojekts probiert zusätzlich die umgekehrte Reihenfolge, falls
die erste unplausibel wirkt. Das ist nicht nur unnötig, sondern
schädlich: ein vertauschter Float von 4,25 Grad liest sich als etwa 0,0
und wirkt damit völlig plausibel - der Fehler bliebe unbemerkt. Ein Test
hält das fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:05:34 +02:00

406 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 {
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)
}
}