forked from fritob/Camper-Monitor
solar-integration (aus dem separaten VanAligneiOS-Repo) und dev_watch haben unabhängige Git-Historien, decken aber überlappende und sich ergänzende Funktionen ab. Übernommen aus solar-integration: Votronic- Solar-ESP-Anbindung samt Geräterolle, die Live-Activity/Widget-Extension fürs Sperrbildschirm/Dynamic-Island/CarPlay, das Querformat-Layout für Libelle/Fahrzeug-Ansicht und Ausrichtungs-Assistent, sowie die mehreren Fahrzeuggrafik-Stile (Vanster/California). Beibehalten aus dev_watch: alle zusätzlichen Geräteprotokolle (Daly-/JBD-BMS, Alpicool- Kühlbox, WattCycle, Victron), die dort zwischenzeitlich entstanden. Die Xcode-Projektdatei wurde von Hand um die neue Widget-Extension samt SharedActivity-Gruppe erweitert (Datei-synchronisierte Gruppen, kein App-Group-Entitlement nötig). Build für App, Watch und Widget-Extension geprüft (Debug und Release). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
516 lines
23 KiB
Swift
516 lines
23 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
|
||
@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
|
||
}
|
||
|
||
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
|
||
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
|
||
|
||
/// 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 samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
|
||
|
||
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 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 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)
|
||
}
|
||
}
|