Solar-Integration und dev_watch-Geräte zusammenführen

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>
This commit is contained in:
fototeddy
2026-09-06 21:00:14 +02:00
co-authored by Claude Sonnet 5
parent 812874baef
commit e9b9c5bcd5
42 changed files with 2592 additions and 214 deletions
+102 -79
View File
@@ -7,6 +7,7 @@ struct DeviceDetailView: View {
@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
@@ -37,104 +38,126 @@ struct DeviceDetailView: View {
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 {
List {
statusSection
if needsKeyAttention { keyPrompt }
// 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 == .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 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 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 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.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 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 currentDevice.role.transport == .advertisement {
if showsTechnicalDetails { diagnosticsSection }
} else if showsTechnicalDetails {
bmsDiagnosticsSection
}
if showsCompactLevelLayout { statusSection }
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()
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.")
}
} message: {
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
}
}