Files
VanAligneiOS/CamperMonitor/Views/DeviceDetailView.swift
T
fototeddyandClaude Sonnet 5 8d975d2c4d Add landscape layouts for level display, vehicle view, and alignment assistant
Rotating the phone into landscape while leveling used to leave a
portrait-shaped column of controls that needed scrolling to take in.
All three level-integration screens now branch on verticalSizeClass:
the bubble/vehicle status in the device screen and the full alignment
assistant (advice, readings, and wedge heights) lay out side-by-side
instead of stacked, sized to fit without scrolling.

The device screen also measures its actual available height via a
GeometryReader and feeds it to the level display so the bubble/vehicle
graphic scales to fill the landscape screen rather than being capped at
a fixed size, and the connection-status section moves to the bottom of
the list in landscape so it doesn't compete with the display for space.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:37:15 +02:00

555 lines
25 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
@Environment(\.verticalSizeClass) private var verticalSizeClass
@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
}
/// Nur solange die App läuft: siehe `DeviceHistory`.
@State private var selectedHistoryMetricKey: String?
/// iPhone im Querformat meldet eine kompakte Höhe das ist das
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
private var isLandscape: Bool { verticalSizeClass == .compact }
/// Nur der Neigungsmesser bekommt die Querformat-Sonderbehandlung
/// (Verbindungsstatus ans Ende, Anzeige auf Bildschirmhöhe) andere
/// Sensoren behalten ihre bisherige Reihenfolge und Grösse.
private var showsCompactLevelLayout: Bool { isLandscape && currentDevice.role == .leveling }
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
private var deviceHistory: DeviceHistory { bluetooth.history[device.id] ?? [:] }
/// Nur Metriken, zu denen sich bereits ein Verlauf mit mehr als einem
/// Punkt angesammelt hat sonst gäbe es nichts zu zeichnen.
private var chartableMetrics: [Metric] {
(snapshot?.metrics ?? []).filter { (deviceHistory[$0.key]?.count ?? 0) > 1 }
}
private var selectedMetric: Metric? {
if let key = selectedHistoryMetricKey,
let metric = chartableMetrics.first(where: { $0.key == key }) {
return metric
}
return chartableMetrics.first(where: \.isPrimary) ?? chartableMetrics.first
}
private var samples: [HistorySample] {
guard let selectedMetric else { return [] }
return deviceHistory[selectedMetric.key] ?? []
}
var body: some View {
// Die Höhe der Libelle/Fahrzeug-Anzeige im Querformat richtet sich
// nach der tatsächlich verfügbaren Bildschirmhöhe, nicht nach einem
// festen Wert deshalb misst ein GeometryReader die Liste von aussen.
GeometryReader { geometry in
List {
// Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort
// sichtbar sein, ohne erst am Verbindungsstatus
// vorbeizuscrollen der rutscht dort ganz ans Ende. Gilt
// nur für den Neigungsmesser, siehe `showsCompactLevelLayout`.
if !showsCompactLevelLayout { 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(),
availableHeight: geometry.size.height)
}
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 let metric = selectedMetric, samples.count > 1 {
Section {
if chartableMetrics.count > 1 {
Picker("Messgrösse", selection: Binding(
get: { metric.key },
set: { selectedHistoryMetricKey = $0 }
)) {
ForEach(chartableMetrics) { candidate in
Text(candidate.label).tag(candidate.key)
}
}
.pickerStyle(.segmented)
.listRowInsets(EdgeInsets())
.padding(.horizontal)
.padding(.top, 4)
}
Chart(samples) { sample in
AreaMark(x: .value("Zeit", sample.time),
y: .value(metric.label, sample.value))
.foregroundStyle(.tint.opacity(0.15))
LineMark(x: .value("Zeit", sample.time),
y: .value(metric.label, sample.value))
.foregroundStyle(.tint)
.interpolationMethod(.monotone)
}
.chartYAxisLabel(metric.unit)
.frame(height: 180)
.padding(.vertical, 8)
} header: {
Text("Verlauf \(metric.label)")
} footer: {
Text("Nur für die laufende Sitzung wird beim Neustart der App verworfen.")
}
}
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 showsCompactLevelLayout { statusSection }
/* 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)
}
}