diff --git a/README.md b/README.md index 7ee4769..6d73d44 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,21 @@ iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest: | Batterie-BMS (Bulltron/Daly, WattCycle) | GATT-Verbindung, alle 5 s abgefragt | SoC, Spannung, Strom, Restkapazität, alle Einzelzellspannungen, Zelldifferenz, Temperaturen, Zyklen, MOSFET-Status | | Kompressor-Kühlbox (Alpicool und Baugleiche) | GATT-Verbindung, lesen **und** stellen | Ist- und Solltemperatur je Zone, Betriebsart, Kompressorstatus, Bordspannung, Batterieanzeige | | Neigungsmesser (VanAlign Pro) | GATT-Verbindung | Längs- und Querneigung als Libelle, Kalibrierung aus der App | +| Votronic-Solarladeregler (über eigene ESP32-Bridge) | GATT-Verbindung, im Takt abgefragt | PV-Leistung, PV-Spannung/-Strom, Batteriespannung, Reglertemperatur, Lade-/Reglerstatus | Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt. +Victron- und Votronic-Solarladeregler sind zwei getrennte Geräterollen, absichtlich +nicht zusammengelegt: Der Victron-Regler meldet sich passiv und verschlüsselt im +Advertisement, der Votronic-Regler hängt über eine eigene ESP32-Bridge (siehe +unten) an einer GATT-Verbindung – grundverschiedene Übertragungswege für +grundverschiedene Hardware. In Code und Einstellungen tragen beide deshalb +konsequent `victron`/`votronic` im Namen. + +Der Neigungsmesser zeigt sich zusätzlich als **Live Activity** auf Sperrbildschirm, +Dynamic Island und – seit iOS 26 automatisch – im CarPlay-Dashboard, siehe +[Live Activity](#live-activity). + Dazu gehört eine **Apple-Watch-App**: Übersicht, Nivellierung samt Ausrichtungs-Assistent mit Vibration und die Steuerung der Kühlbox am Handgelenk. Den Neigungsmesser funkt die Uhr selbst an, alles Übrige kommt @@ -19,7 +31,9 @@ Handgelenk. Den Neigungsmesser funkt die Uhr selbst an, alles Übrige kommt [Apple Watch](#apple-watch). Die Firmware des Neigungsmessers liegt mit im Projekt, siehe -[firmware/vanalign](firmware/vanalign/README.md). +[firmware/vanalign](firmware/vanalign/README.md). Der Votronic-Solarregler hängt +an einer zweiten, unabhängigen ESP32-Bridge – Firmware und BLE-Dienst dafür +liegen als `firmware/vanalign/esp32_ble_solar.yaml` im selben Ordner. ## Bauen und installieren @@ -29,10 +43,12 @@ open /Users/fritob/GIT/Camper-Management/VanControl.xcodeproj Dann in Xcode: -1. Target `VanControl` → **Signing & Capabilities** → dein Apple-Team auswählen. - Die Bundle-ID `de.fritob.VanControl` ggf. anpassen, falls sie schon vergeben ist. - Dasselbe beim Target `VanControlWatch`; dessen Bundle-ID muss die des - iPhones mit angehängtem `.watchkitapp` bleiben. +1. Bei allen vier Targets – `VanControl`, `VanControlWatch`, + `VanControlComplication`, `VanControlLiveActivityExtension` – unter + **Signing & Capabilities** dein Apple-Team auswählen. Die Bundle-IDs ggf. + anpassen, falls sie schon vergeben sind; die der Watch-App muss die des + iPhones mit angehängtem `.watchkitapp` bleiben, die der Komplikation + zusätzlich `.levelwidget`. 2. iPhone per Kabel anschließen, oben als Ziel wählen, ⌘R. Das Schema `VanControl` baut die Watch-App mit und bettet sie ein. Dafür @@ -181,10 +197,17 @@ hinweg gemerkt: In beiden Fällen: grün heißt eben (bis 0,5°), orange bis zwei Grad, darüber rot. Dazu steht in Worten, welche Seite höher steht. -Die Fahrzeugzeichnungen stammen aus dem Ursprungsprojekt VanAlign Pro. Sie -liegen als Schablonen im Asset-Katalog und werden je nach Abweichung -eingefärbt; die Helligkeit des Originals wurde dafür in Deckkraft übersetzt, -damit Fenster und Konturen beim Einfärben erhalten bleiben. +Die Fahrzeugansicht kennt mehrere Grafikstile (aktuell Vanster und +California), einstellbar je Fahrzeugprofil. Die Zeichnungen stammen aus dem +Ursprungsprojekt VanAlign Pro. Sie liegen als Schablonen im Asset-Katalog und +werden je nach Abweichung eingefärbt; die Helligkeit des Originals wurde +dafür in Deckkraft übersetzt, damit Fenster und Konturen beim Einfärben +erhalten bleiben. + +Im Querformat rücken Libelle bzw. Fahrzeugansicht und die übrigen Angaben +nebeneinander statt untereinander – gedacht fürs Handy in der Halterung beim +Rangieren, wo man beiläufig hinschaut statt zu scrollen. Das gilt auch für +den Ausrichtungs-Assistenten weiter unten. **Vor der ersten Nutzung die Einbaulage bestimmen.** Sitzt der Sensor quer, gedreht oder kopfüber, meldet er längs und quer vertauscht oder mit falschem @@ -273,6 +296,27 @@ Längsneigung bei 3,50 m Radstand dazu, braucht die tiefste Ecke 16,1 cm, ihre Nachbarn 9,2 und 7,0 cm, und das höchststehende Rad bleibt liegen. Die Maße stehen im Fahrzeugprofil, erreichbar über das ⓘ in der Fahrzeugliste. +### Live Activity + +In der Nivellierungs-Ansicht lässt sich eine Live Activity einschalten, die +die Neigung auch bei gesperrtem Bildschirm zeigt: Sperrbildschirm, Dynamic +Island und – seit iOS 26, ohne eigenes CarPlay-Ziel nötig – automatisch im +CarPlay-Dashboard. Der Schalter ist deaktiviert, solange keine Messwerte +anliegen, sonst startete die Anzeige gleich mit einem veralteten Stand. + +Die Aktualisierung ist bewusst auf höchstens einmal je Sekunde gedrosselt. +Der Neigungsmesser liefert deutlich öfter, und stiesse jede Messung sofort +eine Aktualisierung an, drosselt iOS das von sich aus zunehmend stärker – +sichtbar vor allem in CarPlay, dessen Dashboard ohnehin zurückhaltender +aktualisiert als Sperrbildschirm oder Dynamic Island. Innerhalb der +Sperrfrist eingehende Messwerte werden nicht verworfen, sondern der +jeweils neueste für ihr Ende vorgemerkt. + +Ein kurzer Verbindungsabbruch beendet die Aktivität nicht sofort – erst nach +zwölf Sekunden ohne Verbindung, und meldet sich das Gerät vorher zurück, +läuft sie unverändert weiter. War sie wegen einer längeren Trennung wirklich +beendet, startet sie bei der nächsten Verbindung automatisch neu. + ## Apple Watch Die Uhr zeigt dasselbe wie das Dashboard, nur auf das eingedampft, wofür man @@ -392,8 +436,9 @@ und die Prüfsummen beider Daly-Dialekte. ## Aufbau ``` -firmware/vanalign/ Firmware des Neigungsmessers (ESPHome) +firmware/vanalign/ Firmware der beiden ESP32 im Fahrzeug (ESPHome) ├── esp32_ble.yaml Neigungsmessung und Bluetooth-Schnittstelle +├── esp32_ble_solar.yaml Zweiter ESP32: Votronic-Solarregler über BLE └── experimente/ Nicht für den Betrieb nötig Shared/ In iPhone- und Watch-App übersetzt @@ -404,15 +449,22 @@ Shared/ In iPhone- und Watch-App übersetzt │ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart │ ├── DeviceSnapshot.swift Messwerte in Anzeigeform │ ├── LevelState.swift Neigung, Toleranz und Klartext dazu -│ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes +│ ├── VictronCodes.swift Klartexte für Zustands-/Fehlercodes +│ └── VehicleGraphicStyle.swift Grafikstile der Fahrzeugansicht (Vanster/California) ├── Bluetooth/ │ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung -│ └── LevelSession.swift Verbindung zum Neigungsmesser (iPhone und Uhr) +│ ├── LevelSession.swift Verbindung zum Neigungsmesser (iPhone und Uhr) +│ ├── VotronicSolarESPProtocol.swift Votronic-Solarregler: Charakteristiken, Statusbits +│ └── VotronicSolarESPSession.swift GATT-Verbindung zur Solar-Bridge, im Takt abgefragt ├── VehicleTilt.swift Überhöhung und Farben der Fahrzeugansicht ├── WheelLiftPlan.swift Keilhöhen als Draufsicht auf die vier Räder └── WatchLink/ └── WatchLink.swift Datensatz und Befehle zwischen iPhone und Uhr +SharedActivity/ In Haupt-App und Live-Activity-Extension übersetzt +├── LevelActivityAttributes.swift Inhalt der Live Activity (ActivityKit) +└── LevelDirectionFormatting.swift "H 1.8°" statt Vorzeichen, für App und Extension gleich + VanControl/ ├── Bluetooth/ │ ├── BluetoothManager.swift Zentraler Scan, Verbindungen, Verlauf @@ -425,8 +477,11 @@ VanControl/ │ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung │ ├── AlpicoolProtocol.swift Kühlboxen: Auswertung und Stellbefehle │ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage +├── LiveActivity/ +│ └── LevelActivityManager.swift Startet/aktualisiert/beendet die Live Activity, drosselt auf 1 Hz ├── Store/ │ ├── DeviceStore.swift Geräteliste, Persistenz +│ ├── DemoData.swift Erfundene Werte für den Demo-Modus │ └── KeychainStore.swift Victron-Schlüssel ├── Watch/ │ └── PhoneWatchLink.swift Sendet den Stand, nimmt Befehle der Uhr an @@ -436,9 +491,11 @@ VanControl/ ├── DeviceDetailView.swift Alle Werte, Verlauf, Zellspannungen, Diagnose ├── ProfilesView.swift Fahrzeuge anlegen und verwalten ├── FridgeControls.swift Bedienelemente der Kühlbox - ├── LevelView.swift Libelle und Kalibrierung + ├── LevelView.swift Libelle, Kalibrierung, Live-Activity-Schalter + ├── LevelSetupView.swift Einbaulage und Nullpunkt ├── AlignmentAssistantView.swift Ausrichtungs-Assistent fürs Rangieren ├── VehicleTiltView.swift Neigung am Fahrzeug dargestellt + ├── VictronKeyView.swift Verschlüsselungsschlüssel eintragen ├── SettingsView.swift App-Einstellungen ├── SensorSetupView.swift Assistent für die Einbaulage └── AddDeviceView.swift Scannen und Einrichten @@ -457,8 +514,16 @@ VanControlWatch/ watchOS-App ├── WatchDeviceDetailView.swift Alle Werte eines Geräts └── WatchFridgeControls.swift Kühlbox stellen +VanControlComplication/ Zifferblatt-Komplikation (watchOS) +└── LevelComplication.swift Tippt auf die Nivellierung, ohne eigenen Messwert + +VanControlLiveActivity/ Live-Activity-Extension (WidgetKit) +├── VanControlLiveActivityBundle.swift Einstieg der Extension +└── VanControlLiveActivity.swift Ansicht für Sperrbildschirm, Dynamic Island, CarPlay + Config/ -└── VanControl-Info.plist Nur der Hintergrundbetrieb für die Uhr +├── VanControl-Info.plist Nur der Hintergrundbetrieb für die Uhr +└── VanControlComplication-Info.plist Anzeigename der Komplikation ``` ## Bekannte Grenzen @@ -478,6 +543,9 @@ Config/ die iPhone-App zu schliessen – dann gehört der Sensor der Uhr. * **Auf der Uhr muss beim ersten Start Bluetooth erlaubt werden**, sonst bleibt die Nivellierung dort leer. +* **Die Live Activity aktualisiert höchstens einmal je Sekunde.** Öfter + drosselt iOS lokale Aktualisierungen ohnehin von sich aus, zunehmend + stärker – sichtbar vor allem in CarPlay. Siehe [Live Activity](#live-activity). * **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel diff --git a/VanControl/Store/DemoData.swift b/VanControl/Store/DemoData.swift index f92740c..21d8729 100644 --- a/VanControl/Store/DemoData.swift +++ b/VanControl/Store/DemoData.swift @@ -3,18 +3,30 @@ import Foundation /// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne /// Fahrzeug und ohne Bluetooth prüfen lassen. /// -/// Nur in Debug-Builds und nur, wenn beim Start `CAMPER_DEMO=1` gesetzt ist: +/// Nur in Debug-Builds, auf zwei Wegen einzuschalten: /// -/// xcrun simctl launch --terminate-running-process \ -/// booted de.fritob.VanControl -/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor +/// * Umgebungsvariable `CAMPER_DEMO=1` beim Start – praktisch im Simulator: +/// +/// xcrun simctl launch --terminate-running-process \ +/// booted de.fritob.VanControl +/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor +/// +/// * Schalter in den Einstellungen (`SettingsView`) – der einzige Weg auf +/// einem lokal installierten Build ohne Xcode-Verbindung, da sich dort +/// keine Umgebungsvariable setzen lässt. Wirkt erst nach einem Neustart der +/// App, weil `DeviceStore` und `BluetoothManager` den Stand nur beim Start +/// lesen. /// /// Im normalen Betrieb wird hiervon nichts ausgeführt. enum DemoData { + /// Schlüssel für den Einstellungen-Schalter, siehe `SettingsView`. + static let enabledKey = "demoModeEnabled" + static var isEnabled: Bool { #if DEBUG - return ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1" + if ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1" { return true } + return UserDefaults.standard.bool(forKey: enabledKey) #else return false #endif diff --git a/VanControl/Views/DeviceCard.swift b/VanControl/Views/DeviceCard.swift index 11d00f6..5883086 100644 --- a/VanControl/Views/DeviceCard.swift +++ b/VanControl/Views/DeviceCard.swift @@ -13,6 +13,8 @@ struct DeviceCard: View { if isShowingLastSettings, let snapshot { lastSettings(for: snapshot) + } else if let snapshot, snapshot.primaryMetric != nil, device.role == .leveling { + levelReadout(for: snapshot) } else if let snapshot, let primary = snapshot.primaryMetric { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(primary.formatted) @@ -115,6 +117,21 @@ struct DeviceCard: View { } } + /// Für die Nivellierung: Front/Heck und links/rechts nebeneinander, groß, + /// ohne "Längsneigung"/"Querneigung"-Beschriftung – die Richtung steht ja + /// schon im vorangestellten Buchstaben. + private func levelReadout(for snapshot: DeviceSnapshot) -> some View { + let pitch = snapshot.metrics.first(where: { $0.key == "pitch" })?.value + let roll = snapshot.metrics.first(where: { $0.key == "roll" })?.value + return HStack(spacing: 24) { + Text(LevelDirectionFormatting.pitchTile(pitch)) + Text(LevelDirectionFormatting.rollTile(roll)) + } + .font(.system(size: 36, weight: .semibold, design: .rounded)) + .contentTransition(.numericText()) + .foregroundStyle(snapshot.isStale ? .secondary : .primary) + } + private func secondaryValues(for snapshot: DeviceSnapshot) -> some View { let others = snapshot.metrics .filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil } diff --git a/VanControl/Views/LevelSetupView.swift b/VanControl/Views/LevelSetupView.swift index 876e317..daf0c81 100644 --- a/VanControl/Views/LevelSetupView.swift +++ b/VanControl/Views/LevelSetupView.swift @@ -25,6 +25,8 @@ struct LevelSetupView: View { store.devices.first { $0.id == device.id } ?? device } + private let graphicColumns = [GridItem(.adaptive(minimum: 76), spacing: 12)] + var body: some View { List { Section { @@ -80,6 +82,44 @@ struct LevelSetupView: View { } footer: { Text(calibrationHint) } + + Section { + LazyVGrid(columns: graphicColumns, spacing: 12) { + ForEach(VehicleGraphicStyle.allCases) { style in + Button { + var updated = currentDevice + updated.vehicleGraphicStyle = style + store.update(updated) + } label: { + VStack(spacing: 4) { + Image(style.sideImageName) + .resizable() + .scaledToFit() + .padding(10) + .frame(width: 60, height: 60) + .background(currentDevice.vehicleGraphicStyle == style + ? Color.accentColor.opacity(0.18) + : Color.secondary.opacity(0.08), + in: .rect(cornerRadius: 12)) + .foregroundStyle(currentDevice.vehicleGraphicStyle == style + ? Color.accentColor : Color.primary) + Text(style.title) + .font(.caption2) + .foregroundStyle(currentDevice.vehicleGraphicStyle == style + ? Color.accentColor : Color.secondary) + } + } + .buttonStyle(.plain) + .accessibilityLabel(style.title) + } + } + .padding(.vertical, 4) + } header: { + Text("Fahrzeuggrafik") + } footer: { + Text("Wird in der Fahrzeug-Darstellung des Neigungsmessers verwendet. " + + "Weitere Grafiken folgen.") + } } .navigationTitle("Neigungsmesser einrichten") .navigationBarTitleDisplayMode(.inline) diff --git a/VanControl/Views/SettingsView.swift b/VanControl/Views/SettingsView.swift index aeb5b44..055ac0a 100644 --- a/VanControl/Views/SettingsView.swift +++ b/VanControl/Views/SettingsView.swift @@ -4,12 +4,28 @@ import SwiftUI /// betrifft, steht bei diesem Gerät. struct SettingsView: View { @AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false + #if DEBUG + @AppStorage(DemoData.enabledKey) private var demoModeEnabled = false + #endif @Environment(PhoneWatchLink.self) private var watch @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { List { + #if DEBUG + Section { + Toggle("Demo-Modus", isOn: $demoModeEnabled) + } header: { + Text("Entwicklung") + } footer: { + Text("Füllt die App mit erfundenen Fahrzeugen und Messwerten, um " + + "Ansichten ohne echte Sensoren zu prüfen. Wirkt erst nach " + + "einem Neustart der App – einmal beenden (im App-Umschalter " + + "nach oben wischen) und wieder öffnen.") + } + #endif + Section { Toggle("Diagnose anzeigen", isOn: $showDiagnostics) } header: {