12 Commits
Author SHA1 Message Date
fototeddyandClaude Sonnet 5 8d58ea6e49 Export-Compliance: ITSAppUsesNonExemptEncryption setzen
App Store Connect fragt beim Hochladen nach Verschlüsselung, wenn der
Info.plist-Schlüssel fehlt. Die App entschlüsselt Victron-Advertisements
per AES-128-CTR über CommonCrypto – ein Standardalgorithmus über eine
vom Betriebssystem gestellte Standardbibliothek, nur Nebenzweck der App
und damit nach US-Exportrecht typischerweise ausgenommen. Schlüssel auf
false gesetzt, damit die Abfrage bei künftigen Uploads entfällt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 22:53:39 +02:00
fototeddyandClaude Sonnet 5 141e321a6f Watch-Target: MARKETING_VERSION-Komma auf Punkt korrigieren
Beim Setzen der Versionsnummer in Xcode ist bei den beiden
VanControlWatch-Konfigurationen "0,1" statt "0.1" hereingerutscht,
vermutlich durch die deutsche Locale beim Zahlen-Eingabefeld. App Store
Connect erwartet ein Punkt-Format; mit dem Komma wäre das beim
Archivieren/Hochladen vermutlich angeeckt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 22:34:44 +02:00
fototeddyandClaude Sonnet 5 35f4e6fbcb Demo-Modus auch in Release-/TestFlight-Builds verfügbar machen
Der Einstellungen-Schalter für den Demo-Modus war in #if DEBUG
eingeschlossen und damit in Archiven (Release-Konfiguration, wie sie
Product → Archive und damit jeder TestFlight-Build verwendet) komplett
herauskompiliert. TestFlight-Tester konnten den Demo-Modus dadurch nicht
nutzen.

DemoData.isEnabled und der Schalter in SettingsView sind jetzt
konfigurationsunabhängig; nur die CAMPER_DEMO-Umgebungsvariable bleibt
auf Debug-Builds beschränkt, da sie ohnehin nur über Xcode/simctl
gesetzt werden kann.

Build (Debug + Release) und Tests grün; per strings(1) im
Release-Binary verifiziert, dass der Demo-Modus-Code tatsächlich
mitkompiliert wird.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 22:33:11 +02:00
fototeddyandClaude Sonnet 5 8e5ee6927e README aktualisieren: Demo-Modus, Votronic-Quelle, keine persönlichen Pfade
* Demo-Modus-Abschnitt an den tatsächlichen Code angeglichen: Schalter in
  den Einstellungen (für lokal installierte Builds ohne Xcode) und
  Umgebungsvariable, beide dokumentiert.
* Quelle für den Votronic-Solarregler ergänzt (syssi/esphome-votronic),
  eigener Abschnitt analog zu Kühlbox/WattCycle.
* Persönlichen Pfad aus der Bauanleitung entfernt (open VanControl.xcodeproj
  statt eines lokalen Nutzerpfads); Beispiel-Bundle-ID im Demo-Modus-Befehl
  durch Platzhalter ersetzt.

Zusätzlich: Symbolliste für Fahrzeugprofile auf Wunsch exakt auf die aus
solar-integration zurückgesetzt (car, car.side, suv.side, truck.pickup.side,
box.truck, bus), statt sie mit dev_watchs Liste zusammenzuführen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 22:11:36 +02:00
fototeddyandClaude Sonnet 5 eed8f33e30 Demo-Modus: Persistenz und Kühlbox-Bedienung reparieren, Live Activity aktivieren
Drei vom Nutzer im Testbuild gefundene Fehler:

* DeviceStore.save() schrieb auch im Demo-Modus in UserDefaults. Jede
  Änderung während aktivem Demo-Modus (z.B. Fahrzeuggrafik oder
  Einbaulage ändern) persistierte damit die erfundenen Fahrzeuge/Geräte
  dauerhaft – sie blieben nach dem Deaktivieren des Demo-Modus stehen.
  save() ist jetzt im Demo-Modus ein no-op.

* Die Kühlbox-Schalter (Ein/Aus, Eco/Max, Solltemperatur, Sperre) liefen
  über sendFridgeSettings(), das eine echte Bluetooth-Verbindung
  voraussetzt (`managed`-Eintrag) – im Demo-Modus gibt es die nie, die
  Schalter taten also nichts. Im Demo-Modus wird der angezeigte
  AlpicoolState jetzt direkt geändert, ohne den Umweg über einen
  Stellbefehl.

* Der Live-Activity-Schalter liess sich nicht einschalten, weil dem
  Haupt-App-Target der Info.plist-Schlüssel NSSupportsLiveActivities
  fehlte – Activity.request() wirft ohne ihn, wird aber vom Aufrufer
  stillschweigend abgefangen, sodass der Schalter beim Antippen einfach
  auf Aus zurückspringt.

Build (App + Watch) und Tests grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 22:05:37 +02:00
fototeddyandClaude Sonnet 5 b956324234 Beim ursprünglichen Merge übersehene Dateien nachziehen
Drei Dateien aus solar-integration wurden beim Zusammenführen mit
dev_watch übersehen, weil sie fälschlich als "identisch zwischen den
Branches" eingestuft wurden:

* DemoData.isEnabled kannte nur noch den Umgebungsvariablen-Weg
  (CAMPER_DEMO=1). Der Schalter in den Einstellungen – der einzige Weg,
  Demo-Daten auf einem lokal installierten Build ohne Xcode-Verbindung
  einzuschalten – fehlte ganz, SettingsView.swift war nie zusammengeführt
  worden.
* LevelSetupView.swift fehlte der komplette Abschnitt "Fahrzeuggrafik" –
  die Kachel-Auswahl zwischen den VehicleGraphicStyle-Varianten
  (Vanster/California). Das Feld existierte im Modell und wurde beim
  Rendern gelesen, war aber nirgends einstellbar.
* DeviceCard.swift zeigte die Nivellierungs-Kachel wie jedes andere
  Gerät (Hauptwert + Nebenwerte) statt der grossen Front/Heck- und
  Links/Rechts-Anzeige mit Richtungsbuchstaben.

Anlass: Im Testbuild fehlte deshalb sowohl der Demo-Modus als auch die
Fahrzeuggrafik-Auswahl. Als Konsequenz systematisch alle beim Merge als
"bereits identisch" eingestuften Dateien erneut gegengeprüft – keine
weiteren Lücken gefunden.

Build (App + Watch) und Protokoll-Tests grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:48:40 +02:00
fototeddyandClaude Sonnet 5 ed026ce580 Live Activity vor iOS-Throttling schützen (langsame Anzeige in CarPlay)
LevelActivityManager.update() stiess bei jeder Messung sofort
activity.update() an – der Neigungsmesser liefert per Notify oder
0,5s-Poll deutlich öfter als einmal pro Sekunde. Bei dieser Frequenz
drosselt iOS lokale Live-Activity-Aktualisierungen zunehmend selbst,
was in CarPlay auffiel: Das Dashboard zeigt neue Werte dort spürbar
verzögert an, weil es ohnehin zurückhaltender aktualisiert als
Sperrbildschirm oder Dynamic Island und die Drosselung dadurch sichtbar
wird.

Jetzt wird höchstens einmal pro Sekunde tatsächlich aktualisiert;
Messungen innerhalb der Sperrfrist werden nicht verworfen, sondern der
neueste Stand fürs Fristende vorgemerkt (wie schon beim
Verbindungsabbruch mit `pendingEnd` gehandhabt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:39:08 +02:00
fototeddyandClaude Sonnet 5 59076076e8 Widget-Extension in LiveActivity umbenennen, da kein Widget mehr drin ist
Nach dem Entfernen der Beispiel-Widget/-Control-Dateien enthielt
VanControlWidget(Extension) nur noch die Live Activity – der Name war
damit irreführend. Jetzt VanControlLiveActivity(Extension) durchgängig:
Ordner, Target, Produkt, Bundle-ID-Suffix, Dateien
(VanControlLiveActivity.swift, VanControlLiveActivityBundle.swift) und
Anzeigename ("VanControl Live Activity").

Build (App Debug+Release, Watch) und Protokoll-Tests grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:34:40 +02:00
fototeddyandClaude Sonnet 5 003222bf30 Beispiel-Widget und Beispiel-Control aus der Widget-Extension entfernen
VanControlWidget.swift, VanControlWidgetControl.swift und AppIntent.swift
waren unverändertes Xcode-Vorlagenmaterial ("Favorite Emoji", "This is
an example widget", ein Timer-Control ohne echte Funktion) – nie an die
App angebunden. Die Extension bleibt bestehen, weil die Live Activity
(VanControlWidgetLiveActivity, echte Neigungswerte) dort hinein muss;
das Bundle enthält jetzt nur noch sie.

Build (Debug + Release) grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:30:10 +02:00
fototeddyandClaude Sonnet 5 303a9735d0 App-Namen im iOS-Projekt auf VanControl vereinheitlichen
CamperMonitor (Haupt-Repo) und VanAligneiOS (aus dem gemergten
solar-integration-Branch) liefen unter zwei verschiedenen internen
Namen, obwohl die App nach aussen längst einheitlich "VanControl Pro"
heisst. Jetzt durchgängig VanControl:

- Ordner: CamperMonitor/, CamperMonitorWatch/, CamperMonitorComplication/,
  VanAligneiOSWidget/ → VanControl/, VanControlWatch/,
  VanControlComplication/, VanControlWidget/
- Xcode-Projekt: CamperMonitor.xcodeproj → VanControl.xcodeproj, alle
  Targets/Schemes/Produktnamen entsprechend umbenannt
- Bundle-Identifier auf Wunsch mitgeändert: de.s0.fototeddy.VanControl*
  (App noch nicht veröffentlicht); dabei auch die
  WKCompanionAppBundleIdentifier-Werte korrigiert, die noch das alte
  de.fritob-Präfix statt des tatsächlichen de.s0.fototeddy-Präfixes
  trugen
- Swift-Dateien/Typen: CamperMonitorApp → VanControlApp,
  VanAligneiOSWidget* → VanControlWidget*
- Config/*-Info.plist umbenannt, README.md/Tools/README.md/run-tests.sh
  auf die neuen Pfade angepasst

Bewusst unverändert: firmware/vanalign und alle Bezüge auf "VanAlign"
als Namen der Neigungsmesser-Hardware (eigenständiges Produkt, kein
App-Name) sowie der komplette Android/-Ordner.

Build (App, Watch, Debug) und Protokoll-Testlauf grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:25:28 +02:00
fototeddyandClaude Sonnet 5 83ea85f3b8 DeviceRole-Fälle für Victron- und Votronic-Solar eindeutig benennen
.solarCharger und .solar sahen im Code zu ähnlich aus, um auf einen
Blick zu erkennen, welches der Victron-Solarladeregler (passives
BLE-Advertisement) und welches der Votronic-Solarladeregler (aktive
GATT-Verbindung über die ESP32-Bridge) ist. Jetzt .victronSolarCharger
und .votronicSolar, samt allen abgeleiteten Bezeichnern in DemoData,
WatchDemo und Tests. Die rawValues bleiben "solarCharger"/"solar", damit
bereits gespeicherte Geräte beim Decodieren nicht auf einen unbekannten
Rollen-Wert treffen.

VictronAdvertisement.RecordType.solarCharger (das rohe Protokollfeld)
bleibt unverändert – schon durch den Typnamen eindeutig, und passend
zur offiziellen Victron-Bezeichnung des Datensatztyps.

run-tests.sh fehlten VehicleGraphicStyle.swift und VehicleTilt.swift,
seit ConfiguredDevice sie referenziert – ergänzt, Testlauf grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:08:28 +02:00
fototeddyandClaude Sonnet 5 e9b9c5bcd5 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>
2026-09-06 21:00:14 +02:00
87 changed files with 2875 additions and 403 deletions
+127 -25
View File
@@ -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 | | 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 | | 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 | | 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. 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 Dazu gehört eine **Apple-Watch-App**: Übersicht, Nivellierung samt
Ausrichtungs-Assistent mit Vibration und die Steuerung der Kühlbox am Ausrichtungs-Assistent mit Vibration und die Steuerung der Kühlbox am
Handgelenk. Den Neigungsmesser funkt die Uhr selbst an, alles Übrige kommt Handgelenk. Den Neigungsmesser funkt die Uhr selbst an, alles Übrige kommt
@@ -19,23 +31,27 @@ Handgelenk. Den Neigungsmesser funkt die Uhr selbst an, alles Übrige kommt
[Apple Watch](#apple-watch). [Apple Watch](#apple-watch).
Die Firmware des Neigungsmessers liegt mit im Projekt, siehe 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 ## Bauen und installieren
```bash ```bash
open /Users/fritob/GIT/Camper-Management/CamperMonitor.xcodeproj open VanControl.xcodeproj
``` ```
Dann in Xcode: Dann in Xcode:
1. Target `CamperMonitor`**Signing & Capabilities** → dein Apple-Team auswählen. 1. Bei allen vier Targets `VanControl`, `VanControlWatch`,
Die Bundle-ID `de.fritob.CamperMonitor` ggf. anpassen, falls sie schon vergeben ist. `VanControlComplication`, `VanControlLiveActivityExtension` unter
Dasselbe beim Target `CamperMonitorWatch`; dessen Bundle-ID muss die des **Signing & Capabilities** dein Apple-Team auswählen. Die Bundle-IDs ggf.
iPhones mit angehängtem `.watchkitapp` bleiben. 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. 2. iPhone per Kabel anschließen, oben als Ziel wählen, ⌘R.
Das Schema `CamperMonitor` baut die Watch-App mit und bettet sie ein. Dafür Das Schema `VanControl` baut die Watch-App mit und bettet sie ein. Dafür
muss die watchOS-Plattform in Xcode installiert sein sonst bricht schon das muss die watchOS-Plattform in Xcode installiert sein sonst bricht schon das
Übersetzen des Symbolkatalogs ab: Übersetzen des Symbolkatalogs ab:
@@ -161,6 +177,21 @@ sie mit, was einen Quervergleich zu Batterie und Solarregler erlaubt.
Protokoll und Feldbelegung stammen aus Protokoll und Feldbelegung stammen aus
[Gruni22/alpicool_ha_ble](https://github.com/Gruni22/alpicool_ha_ble). [Gruni22/alpicool_ha_ble](https://github.com/Gruni22/alpicool_ha_ble).
### Votronic-Solarladeregler
Anders als der Victron-Regler hängt der Votronic-Regler nicht selbst am
BLE-Advertising er wird über eine eigene, unabhängige ESP32-Bridge
ausgelesen (siehe `firmware/vanalign/esp32_ble_solar.yaml`). Die Bridge
bewirbt ihren eigenen Dienst, wird beim Einrichten also automatisch erkannt
und die Art vorbelegt, genau wie beim Neigungsmesser.
Die Bridge liest den Regler über dessen Displaylink-Port (UART) mit der
externen ESPHome-Komponente
[syssi/esphome-votronic](https://github.com/syssi/esphome-votronic) aus und
stellt PV-Leistung, PV-Spannung/-Strom, Batteriespannung, Reglertemperatur
sowie Lade- und Reglerstatus über eigene Charakteristiken bereit reine
Lesewerte, im Takt abgefragt wie beim Neigungsmesser.
### Nivellierung ### Nivellierung
Der Neigungsmesser [VanAlign Pro](https://github.com/) ist ein ESP32 mit Der Neigungsmesser [VanAlign Pro](https://github.com/) ist ein ESP32 mit
@@ -181,10 +212,17 @@ hinweg gemerkt:
In beiden Fällen: grün heißt eben (bis 0,5°), orange bis zwei Grad, darüber 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. rot. Dazu steht in Worten, welche Seite höher steht.
Die Fahrzeugzeichnungen stammen aus dem Ursprungsprojekt VanAlign Pro. Sie Die Fahrzeugansicht kennt mehrere Grafikstile (aktuell Vanster und
liegen als Schablonen im Asset-Katalog und werden je nach Abweichung California), einstellbar je Fahrzeugprofil. Die Zeichnungen stammen aus dem
eingefärbt; die Helligkeit des Originals wurde dafür in Deckkraft übersetzt, Ursprungsprojekt VanAlign Pro. Sie liegen als Schablonen im Asset-Katalog und
damit Fenster und Konturen beim Einfärben erhalten bleiben. 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, **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 gedreht oder kopfüber, meldet er längs und quer vertauscht oder mit falschem
@@ -273,6 +311,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 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. 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 ## Apple Watch
Die Uhr zeigt dasselbe wie das Dashboard, nur auf das eingedampft, wofür man Die Uhr zeigt dasselbe wie das Dashboard, nur auf das eingedampft, wofür man
@@ -370,14 +429,33 @@ eingerichtet wurden, wandern beim Update automatisch ins erste Profil.
## Ohne Fahrzeug ansehen ## Ohne Fahrzeug ansehen
Ein Demo-Modus füllt die App mit erfundenen Werten, damit sich die Ansichten Ein Demo-Modus füllt die App mit erfundenen Fahrzeugen und Messwerten, damit
ohne Bluetooth prüfen lassen. In Xcode unter *Product → Scheme → Edit Scheme → sich die Ansichten ohne Bluetooth prüfen lassen auch ohne die Hardware zur
Run → Arguments* die Umgebungsvariable `CAMPER_DEMO` auf `1` setzen. Er greift Hand zu haben. Auf zwei Wegen einzuschalten:
nur in Debug-Builds.
Die Watch-App kennt denselben Schalter und braucht dann kein iPhone: Im Schema * **Schalter in den Einstellungen** (*Einstellungen → Entwicklung →
`CamperMonitorWatch` dieselbe Variable setzen. Die Neigung wandert dort Demo-Modus*) in jeder Build-Konfiguration verfügbar, auch in TestFlight-
langsam hin und her, sonst hätte der Ausrichtungs-Assistent nichts zu zeigen. und App-Store-Builds. Der Weg für jeden lokal installierten Build ohne
Xcode-Verbindung. Wirkt erst nach einem Neustart der App (im
App-Umschalter nach oben wischen, dann neu öffnen), weil `DeviceStore` und
`BluetoothManager` den Stand nur beim Start lesen.
* **Umgebungsvariable** `CAMPER_DEMO=1` nur in Debug-Builds, in Xcode unter
*Product → Scheme → Edit Scheme → Run → Arguments*, oder im Simulator
direkt:
```bash
xcrun simctl launch --terminate-running-process booted <deine-bundle-id>
# mit SIMCTL_CHILD_CAMPER_DEMO=1 davor
```
Solange der Demo-Modus läuft, schreibt die App nichts in die echte
Geräteliste Änderungen an den erfundenen Fahrzeugen/Geräten verschwinden
beim Ausschalten wieder.
Die Watch-App kennt nur den Umgebungsvariablen-Weg und braucht dann kein
iPhone: Im Schema `VanControlWatch` dieselbe Variable setzen. Die Neigung
wandert dort langsam hin und her, sonst hätte der Ausrichtungs-Assistent
nichts zu zeigen.
## Protokolle prüfen ## Protokolle prüfen
@@ -392,8 +470,9 @@ und die Prüfsummen beider Daly-Dialekte.
## Aufbau ## 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.yaml Neigungsmessung und Bluetooth-Schnittstelle
├── esp32_ble_solar.yaml Zweiter ESP32: Votronic-Solarregler über BLE
└── experimente/ Nicht für den Betrieb nötig └── experimente/ Nicht für den Betrieb nötig
Shared/ In iPhone- und Watch-App übersetzt Shared/ In iPhone- und Watch-App übersetzt
@@ -404,16 +483,23 @@ Shared/ In iPhone- und Watch-App übersetzt
│ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart │ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart
│ ├── DeviceSnapshot.swift Messwerte in Anzeigeform │ ├── DeviceSnapshot.swift Messwerte in Anzeigeform
│ ├── LevelState.swift Neigung, Toleranz und Klartext dazu │ ├── 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/ ├── Bluetooth/
│ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung │ ├── 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 ├── VehicleTilt.swift Überhöhung und Farben der Fahrzeugansicht
├── WheelLiftPlan.swift Keilhöhen als Draufsicht auf die vier Räder ├── WheelLiftPlan.swift Keilhöhen als Draufsicht auf die vier Räder
└── WatchLink/ └── WatchLink/
└── WatchLink.swift Datensatz und Befehle zwischen iPhone und Uhr └── WatchLink.swift Datensatz und Befehle zwischen iPhone und Uhr
CamperMonitor/ 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/ ├── Bluetooth/
│ ├── BluetoothManager.swift Zentraler Scan, Verbindungen, Verlauf │ ├── BluetoothManager.swift Zentraler Scan, Verbindungen, Verlauf
│ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten │ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten
@@ -425,8 +511,11 @@ CamperMonitor/
│ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung │ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung
│ ├── AlpicoolProtocol.swift Kühlboxen: Auswertung und Stellbefehle │ ├── AlpicoolProtocol.swift Kühlboxen: Auswertung und Stellbefehle
│ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage │ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage
├── LiveActivity/
│ └── LevelActivityManager.swift Startet/aktualisiert/beendet die Live Activity, drosselt auf 1 Hz
├── Store/ ├── Store/
│ ├── DeviceStore.swift Geräteliste, Persistenz │ ├── DeviceStore.swift Geräteliste, Persistenz
│ ├── DemoData.swift Erfundene Werte für den Demo-Modus
│ └── KeychainStore.swift Victron-Schlüssel │ └── KeychainStore.swift Victron-Schlüssel
├── Watch/ ├── Watch/
│ └── PhoneWatchLink.swift Sendet den Stand, nimmt Befehle der Uhr an │ └── PhoneWatchLink.swift Sendet den Stand, nimmt Befehle der Uhr an
@@ -436,14 +525,16 @@ CamperMonitor/
├── DeviceDetailView.swift Alle Werte, Verlauf, Zellspannungen, Diagnose ├── DeviceDetailView.swift Alle Werte, Verlauf, Zellspannungen, Diagnose
├── ProfilesView.swift Fahrzeuge anlegen und verwalten ├── ProfilesView.swift Fahrzeuge anlegen und verwalten
├── FridgeControls.swift Bedienelemente der Kühlbox ├── 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 ├── AlignmentAssistantView.swift Ausrichtungs-Assistent fürs Rangieren
├── VehicleTiltView.swift Neigung am Fahrzeug dargestellt ├── VehicleTiltView.swift Neigung am Fahrzeug dargestellt
├── VictronKeyView.swift Verschlüsselungsschlüssel eintragen
├── SettingsView.swift App-Einstellungen ├── SettingsView.swift App-Einstellungen
├── SensorSetupView.swift Assistent für die Einbaulage ├── SensorSetupView.swift Assistent für die Einbaulage
└── AddDeviceView.swift Scannen und Einrichten └── AddDeviceView.swift Scannen und Einrichten
CamperMonitorWatch/ watchOS-App VanControlWatch/ watchOS-App
├── CamperWatchApp.swift Einstieg, meldet dem iPhone das Hinschauen ├── CamperWatchApp.swift Einstieg, meldet dem iPhone das Hinschauen
├── PhoneLink.swift Gegenstelle zum iPhone ├── PhoneLink.swift Gegenstelle zum iPhone
├── WatchLevelRadio.swift Eigene Bluetooth-Verbindung zum Neigungsmesser ├── WatchLevelRadio.swift Eigene Bluetooth-Verbindung zum Neigungsmesser
@@ -457,8 +548,16 @@ CamperMonitorWatch/ watchOS-App
├── WatchDeviceDetailView.swift Alle Werte eines Geräts ├── WatchDeviceDetailView.swift Alle Werte eines Geräts
└── WatchFridgeControls.swift Kühlbox stellen └── 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/ Config/
── CamperMonitor-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 ## Bekannte Grenzen
@@ -478,6 +577,9 @@ Config/
die iPhone-App zu schliessen dann gehört der Sensor der Uhr. die iPhone-App zu schliessen dann gehört der Sensor der Uhr.
* **Auf der Uhr muss beim ersten Start Bluetooth erlaubt werden**, sonst bleibt * **Auf der Uhr muss beim ersten Start Bluetooth erlaubt werden**, sonst bleibt
die Nivellierung dort leer. 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 * **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen
Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind
gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel
@@ -0,0 +1,55 @@
import Foundation
/// VotronicSolarESP zweiter ESP32 im Fahrzeug, liest einen
/// Votronic-Solarladeregler aus und stellt die Werte über einen eigenen
/// BLE-Dienst bereit. Siehe `firmware/vanalign/esp32_ble_solar.yaml`.
///
/// Wie beim Neigungsmesser: kein Rahmenprotokoll, jede Messgrösse liegt in
/// einer eigenen Charakteristik, alle sind reine Lesewerte, der Client fragt
/// sie im Takt ab.
enum VotronicSolarESPProtocol {
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
static let serviceUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A001"
static let batteryVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A101"
static let pvVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A102"
static let pvCurrentUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A103"
static let pvPowerUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A104"
static let controllerTempUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A105"
/// Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
/// Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
static let statusFlagsUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A106"
/// Liest einen Messwert aus vier Bytes, little-endian wie beim
/// Neigungsmesser legt die Firmware den Float per `memcpy` ab.
static func float(from data: Data) -> Double? {
guard data.count >= 4 else { return nil }
var raw: UInt32 = 0
for (index, byte) in data.prefix(4).enumerated() {
raw |= UInt32(byte) << UInt32(8 * index)
}
let value = Float(bitPattern: raw)
guard value.isFinite else { return nil }
return Double(value)
}
struct StatusFlags {
var isBatteryCharging = false
var isBatteryDischarging = false
var isControllerActive = false
var isCurrentLimited = false
var isAESActive = false
}
static func statusFlags(from data: Data) -> StatusFlags? {
guard let byte = data.first else { return nil }
var flags = StatusFlags()
flags.isBatteryCharging = byte & (1 << 0) != 0
flags.isBatteryDischarging = byte & (1 << 1) != 0
flags.isControllerActive = byte & (1 << 2) != 0
flags.isCurrentLimited = byte & (1 << 3) != 0
flags.isAESActive = byte & (1 << 4) != 0
return flags
}
}
@@ -0,0 +1,160 @@
import CoreBluetooth
import Foundation
/// Hält die Verbindung zum Solarladeregler.
///
/// Einfacher noch als `LevelSession`: die Firmware bietet für keine
/// Charakteristik `notify` an (siehe `esp32_ble_solar.yaml`), es wird also
/// immer im Takt abgefragt statt abonniert.
final class VotronicSolarESPSession: NSObject {
static let serviceUUID = CBUUID(string: VotronicSolarESPProtocol.serviceUUID)
private static let batteryVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.batteryVoltageUUID)
private static let pvVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.pvVoltageUUID)
private static let pvCurrentUUID = CBUUID(string: VotronicSolarESPProtocol.pvCurrentUUID)
private static let pvPowerUUID = CBUUID(string: VotronicSolarESPProtocol.pvPowerUUID)
private static let controllerTempUUID = CBUUID(string: VotronicSolarESPProtocol.controllerTempUUID)
private static let statusFlagsUUID = CBUUID(string: VotronicSolarESPProtocol.statusFlagsUUID)
let deviceID: UUID
private let queue: DispatchQueue
private let peripheral: CBPeripheral
private let onUpdate: (DeviceSnapshot) -> Void
private let onStateChange: (DeviceLinkState) -> Void
private let onVotronicSolarESPState: (VotronicSolarESPState) -> Void
private var characteristics: [CBUUID: CBCharacteristic] = [:]
private var state = VotronicSolarESPState()
private var pollTimer: DispatchSourceTimer?
/// Reicht für einen Solarregler, dessen Werte sich über Sekunden ändern
/// deutlich seltener als beim Ausrichten mit dem Neigungsmesser.
var pollInterval: TimeInterval = 5
init(deviceID: UUID,
peripheral: CBPeripheral,
queue: DispatchQueue,
onUpdate: @escaping (DeviceSnapshot) -> Void,
onStateChange: @escaping (DeviceLinkState) -> Void,
onVotronicSolarESPState: @escaping (VotronicSolarESPState) -> Void) {
self.deviceID = deviceID
self.queue = queue
self.peripheral = peripheral
self.onUpdate = onUpdate
self.onStateChange = onStateChange
self.onVotronicSolarESPState = onVotronicSolarESPState
super.init()
peripheral.delegate = self
}
// MARK: - Lebenszyklus
func start() {
onStateChange(.connecting)
peripheral.discoverServices([Self.serviceUUID])
}
func stop() {
pollTimer?.cancel()
pollTimer = nil
characteristics.removeAll()
}
func handleDisconnect() {
pollTimer?.cancel()
pollTimer = nil
characteristics.removeAll()
}
// MARK: - Abfrage
private func startPolling() {
guard pollTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now(), repeating: pollInterval)
timer.setEventHandler { [weak self] in self?.readAll() }
timer.resume()
pollTimer = timer
}
private func readAll() {
guard peripheral.state == .connected else { return }
for characteristic in characteristics.values where characteristic.properties.contains(.read) {
peripheral.readValue(for: characteristic)
}
}
private func publish() {
guard state.hasReading else { return }
onStateChange(.live)
onVotronicSolarESPState(state)
onUpdate(state.snapshot(deviceID: deviceID, rssi: nil))
}
}
// MARK: - CBPeripheralDelegate
extension VotronicSolarESPSession: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
onStateChange(.failed(error.localizedDescription))
return
}
guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
onStateChange(.failed("Solarladeregler-Dienst nicht gefunden"))
return
}
peripheral.discoverCharacteristics(
[Self.batteryVoltageUUID, Self.pvVoltageUUID, Self.pvCurrentUUID,
Self.pvPowerUUID, Self.controllerTempUUID, Self.statusFlagsUUID],
for: service
)
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
guard error == nil, let found = service.characteristics else {
onStateChange(.failed(error?.localizedDescription ?? "Keine Merkmale gefunden"))
return
}
for characteristic in found {
characteristics[characteristic.uuid] = characteristic
}
guard !characteristics.isEmpty else {
onStateChange(.failed("Solarwerte nicht gefunden"))
return
}
startPolling()
readAll()
}
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard error == nil, let value = characteristic.value else { return }
switch characteristic.uuid {
case Self.batteryVoltageUUID:
state.batteryVoltage = VotronicSolarESPProtocol.float(from: value)
case Self.pvVoltageUUID:
state.pvVoltage = VotronicSolarESPProtocol.float(from: value)
case Self.pvCurrentUUID:
state.pvCurrent = VotronicSolarESPProtocol.float(from: value)
case Self.pvPowerUUID:
state.pvPower = VotronicSolarESPProtocol.float(from: value)
case Self.controllerTempUUID:
state.controllerTemperature = VotronicSolarESPProtocol.float(from: value)
case Self.statusFlagsUUID:
guard let flags = VotronicSolarESPProtocol.statusFlags(from: value) else { return }
state.isBatteryCharging = flags.isBatteryCharging
state.isBatteryDischarging = flags.isBatteryDischarging
state.isControllerActive = flags.isControllerActive
state.isCurrentLimited = flags.isCurrentLimited
default:
return
}
publish()
}
}
+21 -4
View File
@@ -4,33 +4,46 @@ import Foundation
/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird. /// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable { enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
case chargeBooster case chargeBooster
case solarCharger /// Victron-Solarladeregler (SmartSolar/BlueSolar MPPT), passiv über das
/// verschlüsselte BLE-Advertisement gelesen.
///
/// Der rawValue bleibt `"solarCharger"`, damit bereits gespeicherte
/// Geräte beim Decodieren nicht auf einen unbekannten Rollen-Wert
/// treffen siehe `ConfiguredDevice.init(from:)`.
case victronSolarCharger = "solarCharger"
case batteryMonitor case batteryMonitor
case bms case bms
case fridge case fridge
case leveling case leveling
/// Votronic-Solarladeregler, über eine ESP32-Bridge per GATT-Verbindung
/// angesprochen (siehe `VotronicSolarESPSession`).
///
/// Der rawValue bleibt `"solar"`, siehe `victronSolarCharger` oben.
case votronicSolar = "solar"
var id: String { rawValue } var id: String { rawValue }
var title: String { var title: String {
switch self { switch self {
case .chargeBooster: return "Ladebooster" case .chargeBooster: return "Ladebooster"
case .solarCharger: return "Solarladeregler" case .victronSolarCharger: return "Solarladeregler"
case .batteryMonitor: return "Batteriemonitor" case .batteryMonitor: return "Batteriemonitor"
case .bms: return "Batterie / BMS" case .bms: return "Batterie / BMS"
case .fridge: return "Kühlbox" case .fridge: return "Kühlbox"
case .leveling: return "Nivellierung" case .leveling: return "Nivellierung"
case .votronicSolar: return "VotronicSolarESP"
} }
} }
var symbol: String { var symbol: String {
switch self { switch self {
case .chargeBooster: return "bolt.car" case .chargeBooster: return "bolt.car"
case .solarCharger: return "sun.max" case .victronSolarCharger: return "sun.max"
case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent" case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
case .bms: return "battery.100percent.bolt" case .bms: return "battery.100percent.bolt"
case .fridge: return "refrigerator" case .fridge: return "refrigerator"
case .leveling: return "level" case .leveling: return "level"
case .votronicSolar: return "sun.max"
} }
} }
@@ -50,7 +63,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
/// braucht eine echte GATT-Verbindung. /// braucht eine echte GATT-Verbindung.
var transport: DeviceTransport { var transport: DeviceTransport {
switch self { switch self {
case .bms, .fridge, .leveling: return .connect case .bms, .fridge, .leveling, .votronicSolar: return .connect
default: return .advertisement default: return .advertisement
} }
} }
@@ -98,6 +111,8 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
var fridgeZoneMode: FridgeZoneMode = .automatic var fridgeZoneMode: FridgeZoneMode = .automatic
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt. /// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
var sensorOrientation = SensorOrientation.identity var sensorOrientation = SensorOrientation.identity
/// Nur für den Neigungsmesser: welche Fahrzeuggrafik die Fahrzeug-Ansicht zeigt.
var vehicleGraphicStyle = VehicleGraphicStyle.vanster
init(id: UUID = UUID(), init(id: UUID = UUID(),
name: String, name: String,
@@ -128,5 +143,7 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
?? .automatic ?? .automatic
sensorOrientation = try container.decodeIfPresent(SensorOrientation.self, sensorOrientation = try container.decodeIfPresent(SensorOrientation.self,
forKey: .sensorOrientation) ?? .identity forKey: .sensorOrientation) ?? .identity
vehicleGraphicStyle = try container.decodeIfPresent(VehicleGraphicStyle.self,
forKey: .vehicleGraphicStyle) ?? .vanster
} }
} }
+2 -3
View File
@@ -15,7 +15,7 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
init(id: UUID = UUID(), init(id: UUID = UUID(),
name: String, name: String,
symbol: String = "box.truck", symbol: String = "suv.side",
trackWidth: Double? = nil, trackWidth: Double? = nil,
wheelbase: Double? = nil) { wheelbase: Double? = nil) {
self.id = id self.id = id
@@ -47,7 +47,6 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft ein nicht /// Alle Namen gegen NSImage(systemSymbolName:) geprüft ein nicht
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen. /// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
static let symbols = [ static let symbols = [
"box.truck", "truck.pickup.side", "bus", "bus.doubledecker", "car", "car.side", "suv.side", "truck.pickup.side", "box.truck", "bus",
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
] ]
} }
+52
View File
@@ -0,0 +1,52 @@
import Foundation
/// Welche Silhouette die Fahrzeug-Ansicht des Neigungsmessers zeigt.
///
/// Jeder Fall ist ein zusammengehöriges Bilderset aus Seiten- und
/// Heckansicht in Assets.xcassets, beide als Vorlage ("template")
/// exportiert, damit `foregroundStyle` sie einfärben kann. Im Picker wird
/// nur die Seitenansicht gezeigt.
enum VehicleGraphicStyle: String, CaseIterable, Identifiable, Codable, Sendable {
case vanster
case california
var id: String { rawValue }
var title: String {
switch self {
case .vanster: return "Vanster"
case .california: return "California"
}
}
var sideImageName: String {
switch self {
case .vanster: return "VansterSide"
case .california: return "CaliforniaSide"
}
}
/// Für California liegt noch keine eigene Heckansicht vor, deshalb
/// vorerst die von Vanster.
var rearImageName: String {
switch self {
case .vanster, .california: return "VansterRear"
}
}
/// Seitenverhältnis der jeweiligen Bilder ohne das würde eine Grafik
/// mit anderen Proportionen in der gemeinsamen Bildhülle verzerrt oder
/// ungewollt verkleinert erscheinen.
var sideAspect: Double {
switch self {
case .vanster: return VehicleTilt.sideAspect
case .california: return 1
}
}
var rearAspect: Double {
switch self {
case .vanster, .california: return VehicleTilt.rearAspect
}
}
}
+48
View File
@@ -0,0 +1,48 @@
import Foundation
/// Zustand des Solarladereglers.
struct VotronicSolarESPState: Equatable, Codable, Sendable {
var batteryVoltage: Double?
var pvVoltage: Double?
var pvCurrent: Double?
var pvPower: Double?
var controllerTemperature: Double?
var isBatteryCharging: Bool?
var isBatteryDischarging: Bool?
var isControllerActive: Bool?
var isCurrentLimited: Bool?
var hasReading: Bool {
batteryVoltage != nil || pvVoltage != nil || pvCurrent != nil || pvPower != nil
}
/// Kurzer Klartext, wie bei den übrigen Geräten als "Zustand" angezeigt.
var stateText: String? {
guard isControllerActive != nil else { return nil }
if isBatteryCharging == true { return "Lädt" }
if isControllerActive == true { return "Aktiv" }
return "Inaktiv"
}
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
snapshot.metrics = [
Metric("pv_power", "Solarleistung", pvPower, unit: "W", precision: 0, primary: true),
Metric("pv_voltage", "PV-Spannung", pvVoltage, unit: "V", precision: 1),
Metric("pv_current", "PV-Strom", pvCurrent, unit: "A", precision: 1),
Metric("battery_voltage", "Batteriespannung", batteryVoltage, unit: "V", precision: 2),
]
if let controllerTemperature {
snapshot.metrics.append(
Metric("controller_temperature", "Reglertemperatur", controllerTemperature,
unit: "°C", precision: 0)
)
}
snapshot.state = stateText
if isCurrentLimited == true {
snapshot.offReasons = ["PV-Strombegrenzung aktiv"]
}
return snapshot
}
}
@@ -0,0 +1,23 @@
import ActivityKit
import Foundation
/// Inhalt der Neigungsmesser-Live-Activity.
///
/// Die App und die Widget-Extension kompilieren diese Datei je für sich in
/// ihr eigenes Modul sie darf deshalb nicht von `Shared/` abhängen, sonst
/// müsste die Extension auch Bluetooth- und WatchConnectivity-Code mitbauen.
/// Werte wie `isLevel` und `instruction` kommen darum schon fertig berechnet
/// aus `LevelState` an, die Extension zeigt nur an.
struct LevelActivityAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var pitch: Double?
var roll: Double?
var isLevel: Bool
var instruction: String?
var isCalibrated: Bool
var updatedAt: Date
}
/// Name des Neigungsmessers, wie in der App vergeben.
var deviceName: String
}
@@ -0,0 +1,30 @@
import Foundation
/// Formatiert Neigungswerte mit vorangestelltem Richtungsbuchstaben statt
/// Vorzeichen "H 1.8°" statt "1.8°", "L 0.9°" statt "-0.9°".
///
/// Gemeinsam für App und Live-Activity-Widget, deshalb hier statt in
/// `Shared/`, das die Extension nicht mitkompiliert (siehe
/// `LevelActivityAttributes.swift`).
enum LevelDirectionFormatting {
/// "1.8°" Betrag ohne Vorzeichen, für Stellen, die die Richtung schon
/// im Wort oder Buchstaben ausdrücken.
static func magnitude(_ value: Double?) -> String {
guard let value else { return "" }
return String(format: "%.1f°", abs(value))
}
/// Längsneigung als Kachel: positiv (Heck höher) "H", negativ (Front
/// höher) "F". Muss zum Vorzeichen von `LevelState.pitch` passen.
static func pitchTile(_ value: Double?) -> String {
guard let value else { return "" }
return "\(value >= 0 ? "H" : "F") \(magnitude(value))"
}
/// Querneigung als Kachel: positiv (rechts höher) "R", negativ (links
/// höher) "L". Muss zum Vorzeichen von `LevelState.roll` passen.
static func rollTile(_ value: Double?) -> String {
guard let value else { return "" }
return "\(value >= 0 ? "R" : "L") \(magnitude(value))"
}
}
+10 -10
View File
@@ -76,22 +76,22 @@ var advertisement: [UInt8] = [0xE1, 0x02, 0x10, 0x00, 0x4C, 0xA0, 0x01,
UInt8(nonce & 0xFF), UInt8(nonce >> 8), deviceKey[0]] UInt8(nonce & 0xFF), UInt8(nonce >> 8), deviceKey[0]]
advertisement += encrypted advertisement += encrypted
let solar = try? VictronAdvertisement.decode(manufacturerData: Data(advertisement), let victronSolar = try? VictronAdvertisement.decode(manufacturerData: Data(advertisement),
key: deviceKey, key: deviceKey,
deviceID: UUID(), deviceID: UUID(),
rssi: -55) rssi: -55)
func value(_ snapshot: DeviceSnapshot?, _ key: String) -> Double? { func value(_ snapshot: DeviceSnapshot?, _ key: String) -> Double? {
snapshot?.metrics.first { $0.key == key }?.value snapshot?.metrics.first { $0.key == key }?.value
} }
check("Advertisement wird dekodiert", solar != nil) check("Advertisement wird dekodiert", victronSolar != nil)
checkEqual("Zustand als Klartext", solar?.state, "Konstantstrom (Bulk)") checkEqual("Zustand als Klartext", victronSolar?.state, "Konstantstrom (Bulk)")
checkEqual("kein Fehler gemeldet", solar?.fault, nil) checkEqual("kein Fehler gemeldet", victronSolar?.fault, nil)
checkEqual("Batteriespannung", value(solar, "battery_voltage").map(round2), 13.45) checkEqual("Batteriespannung", value(victronSolar, "battery_voltage").map(round2), 13.45)
checkEqual("Ladestrom", value(solar, "battery_current").map { ($0 * 10).rounded() / 10 }, 15.2) checkEqual("Ladestrom", value(victronSolar, "battery_current").map { ($0 * 10).rounded() / 10 }, 15.2)
checkEqual("Tagesertrag", value(solar, "yield_today"), 2.34) checkEqual("Tagesertrag", value(victronSolar, "yield_today"), 2.34)
checkEqual("PV-Leistung", value(solar, "pv_power"), 210) checkEqual("PV-Leistung", value(victronSolar, "pv_power"), 210)
checkEqual("Laststrom bleibt leer (NA)", value(solar, "load_current"), nil) checkEqual("Laststrom bleibt leer (NA)", value(victronSolar, "load_current"), nil)
checkEqual("PV-Leistung ist der Hauptwert", solar?.primaryMetric?.key, "pv_power") checkEqual("PV-Leistung ist der Hauptwert", victronSolar?.primaryMetric?.key, "pv_power")
var wrongKey = deviceKey; wrongKey[0] = 0x00 var wrongKey = deviceKey; wrongKey[0] = 0x00
do { do {
+8 -2
View File
@@ -13,7 +13,7 @@ Anschließend skaliert es auf 1024×1024 ohne Alphakanal.
```bash ```bash
swiftc -O -o /tmp/make-app-icon Tools/make-app-icon.swift swiftc -O -o /tmp/make-app-icon Tools/make-app-icon.swift
/tmp/make-app-icon logo.png CamperMonitor/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png /tmp/make-app-icon logo.png VanControl/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
``` ```
Nur nötig, wenn `logo.png` sich ändert das erzeugte Icon liegt im Repo. Nur nötig, wenn `logo.png` sich ändert das erzeugte Icon liegt im Repo.
@@ -32,5 +32,11 @@ gefüllte Form, in der die Details erhalten bleiben.
```bash ```bash
swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift
sips --resampleHeight 600 heckansicht.png --out /tmp/rear.png sips --resampleHeight 600 heckansicht.png --out /tmp/rear.png
/tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png /tmp/make-vehicle-art /tmp/rear.png VanControl/Assets.xcassets/VansterRear.imageset/VansterRear.png
``` ```
Jedes Fahrzeug-Grafikset (siehe `VehicleGraphicStyle`) hat sein eigenes
Bildpaar `<Name>Side`/`<Name>Rear`. Hat die Vorlage schon einen
transparenten Hintergrund, geht die Quelldatei direkt ins Skript. Liegt sie
auf weißem Grund, muss der Rand vorher weg sonst wird die ganze Fläche
mitgefärbt.
@@ -7,8 +7,13 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* CamperMonitorWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; AA0000000000000000000027 /* VanControlWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* VanControlWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
AA0000000000000000000042 /* CamperMonitorComplication.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000032 /* CamperMonitorComplication.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; AA0000000000000000000042 /* VanControlComplication.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000032 /* VanControlComplication.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
AA0000000000000000000046 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000053 /* ActivityKit.framework */; };
AA0000000000000000000047 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000052 /* SwiftUI.framework */; };
AA0000000000000000000048 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000051 /* WidgetKit.framework */; };
AA0000000000000000000049 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000053 /* ActivityKit.framework */; };
AA0000000000000000000050 /* VanControlLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000054 /* VanControlLiveActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -17,14 +22,21 @@
containerPortal = AA0000000000000000000009 /* Project object */; containerPortal = AA0000000000000000000009 /* Project object */;
proxyType = 1; proxyType = 1;
remoteGlobalIDString = AA0000000000000000000022; remoteGlobalIDString = AA0000000000000000000022;
remoteInfo = CamperMonitorWatch; remoteInfo = VanControlWatch;
}; };
AA0000000000000000000043 /* PBXContainerItemProxy */ = { AA0000000000000000000043 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = AA0000000000000000000009 /* Project object */; containerPortal = AA0000000000000000000009 /* Project object */;
proxyType = 1; proxyType = 1;
remoteGlobalIDString = AA0000000000000000000037; remoteGlobalIDString = AA0000000000000000000037;
remoteInfo = CamperMonitorComplication; remoteInfo = VanControlComplication;
};
AA0000000000000000000063 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA0000000000000000000009 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AA0000000000000000000062;
remoteInfo = VanControlLiveActivityExtension;
}; };
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
@@ -35,7 +47,7 @@
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16; dstSubfolderSpec = 16;
files = ( files = (
AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */, AA0000000000000000000027 /* VanControlWatch.app in Embed Watch Content */,
); );
name = "Embed Watch Content"; name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -46,7 +58,18 @@
dstPath = ""; dstPath = "";
dstSubfolderSpec = 13; dstSubfolderSpec = 13;
files = ( files = (
AA0000000000000000000042 /* CamperMonitorComplication.appex in Embed Foundation Extensions */, AA0000000000000000000042 /* VanControlComplication.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000065 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
AA0000000000000000000050 /* VanControlLiveActivityExtension.appex in Embed Foundation Extensions */,
); );
name = "Embed Foundation Extensions"; name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -54,22 +77,36 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
AA0000000000000000000001 /* CamperMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; AA0000000000000000000001 /* VanControl.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanControl.app; sourceTree = BUILT_PRODUCTS_DIR; };
AA0000000000000000000016 /* CamperMonitorWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitorWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; AA0000000000000000000016 /* VanControlWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanControlWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
AA0000000000000000000030 /* CamperMonitor-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitor-Info.plist"; sourceTree = "<group>"; }; AA0000000000000000000030 /* VanControl-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "VanControl-Info.plist"; sourceTree = "<group>"; };
AA0000000000000000000032 /* CamperMonitorComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CamperMonitorComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; }; AA0000000000000000000032 /* VanControlComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VanControlComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; };
AA0000000000000000000045 /* CamperMonitorComplication-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitorComplication-Info.plist"; sourceTree = "<group>"; }; AA0000000000000000000045 /* VanControlComplication-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "VanControlComplication-Info.plist"; sourceTree = "<group>"; };
AA0000000000000000000051 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
AA0000000000000000000052 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
AA0000000000000000000053 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
AA0000000000000000000054 /* VanControlLiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VanControlLiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
AA0000000000000000000055 /* Exceptions for "VanControlLiveActivity" folder in "VanControlLiveActivityExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = AA0000000000000000000062 /* VanControlLiveActivityExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
AA0000000000000000000002 /* CamperMonitor */ = { AA0000000000000000000002 /* VanControl */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
path = CamperMonitor; path = VanControl;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
AA0000000000000000000017 /* CamperMonitorWatch */ = { AA0000000000000000000017 /* VanControlWatch */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
path = CamperMonitorWatch; path = VanControlWatch;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
AA0000000000000000000018 /* Shared */ = { AA0000000000000000000018 /* Shared */ = {
@@ -77,9 +114,22 @@
path = Shared; path = Shared;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
AA0000000000000000000033 /* CamperMonitorComplication */ = { AA0000000000000000000033 /* VanControlComplication */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
path = CamperMonitorComplication; path = VanControlComplication;
sourceTree = "<group>";
};
AA0000000000000000000056 /* VanControlLiveActivity */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
AA0000000000000000000055 /* Exceptions for "VanControlLiveActivity" folder in "VanControlLiveActivityExtension" target */,
);
path = VanControlLiveActivity;
sourceTree = "<group>";
};
AA0000000000000000000057 /* SharedActivity */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = SharedActivity;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
@@ -89,6 +139,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
AA0000000000000000000049 /* ActivityKit.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -106,17 +157,30 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AA0000000000000000000058 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
AA0000000000000000000047 /* SwiftUI.framework in Frameworks */,
AA0000000000000000000048 /* WidgetKit.framework in Frameworks */,
AA0000000000000000000046 /* ActivityKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
AA0000000000000000000004 = { AA0000000000000000000004 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AA0000000000000000000002 /* CamperMonitor */, AA0000000000000000000002 /* VanControl */,
AA0000000000000000000017 /* CamperMonitorWatch */, AA0000000000000000000017 /* VanControlWatch */,
AA0000000000000000000033 /* CamperMonitorComplication */, AA0000000000000000000033 /* VanControlComplication */,
AA0000000000000000000018 /* Shared */, AA0000000000000000000018 /* Shared */,
AA0000000000000000000057 /* SharedActivity */,
AA0000000000000000000031 /* Config */, AA0000000000000000000031 /* Config */,
AA0000000000000000000056 /* VanControlLiveActivity */,
AA0000000000000000000059 /* Frameworks */,
AA0000000000000000000005 /* Products */, AA0000000000000000000005 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@@ -124,9 +188,10 @@
AA0000000000000000000005 /* Products */ = { AA0000000000000000000005 /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AA0000000000000000000001 /* CamperMonitor.app */, AA0000000000000000000001 /* VanControl.app */,
AA0000000000000000000016 /* CamperMonitorWatch.app */, AA0000000000000000000016 /* VanControlWatch.app */,
AA0000000000000000000032 /* CamperMonitorComplication.appex */, AA0000000000000000000032 /* VanControlComplication.appex */,
AA0000000000000000000054 /* VanControlLiveActivityExtension.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -134,41 +199,54 @@
AA0000000000000000000031 /* Config */ = { AA0000000000000000000031 /* Config */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AA0000000000000000000030 /* CamperMonitor-Info.plist */, AA0000000000000000000030 /* VanControl-Info.plist */,
AA0000000000000000000045 /* CamperMonitorComplication-Info.plist */, AA0000000000000000000045 /* VanControlComplication-Info.plist */,
); );
path = Config; path = Config;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
AA0000000000000000000059 /* Frameworks */ = {
isa = PBXGroup;
children = (
AA0000000000000000000051 /* WidgetKit.framework */,
AA0000000000000000000052 /* SwiftUI.framework */,
AA0000000000000000000053 /* ActivityKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
AA0000000000000000000006 /* CamperMonitor */ = { AA0000000000000000000006 /* VanControl */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "CamperMonitor" */; buildConfigurationList = AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "VanControl" */;
buildPhases = ( buildPhases = (
AA0000000000000000000007 /* Sources */, AA0000000000000000000007 /* Sources */,
AA0000000000000000000003 /* Frameworks */, AA0000000000000000000003 /* Frameworks */,
AA0000000000000000000008 /* Resources */, AA0000000000000000000008 /* Resources */,
AA0000000000000000000026 /* Embed Watch Content */, AA0000000000000000000026 /* Embed Watch Content */,
AA0000000000000000000065 /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
AA0000000000000000000029 /* PBXTargetDependency */, AA0000000000000000000029 /* PBXTargetDependency */,
AA0000000000000000000064 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
AA0000000000000000000002 /* CamperMonitor */, AA0000000000000000000002 /* VanControl */,
AA0000000000000000000018 /* Shared */, AA0000000000000000000018 /* Shared */,
AA0000000000000000000057 /* SharedActivity */,
); );
name = CamperMonitor; name = VanControl;
productName = CamperMonitor; productName = VanControl;
productReference = AA0000000000000000000001 /* CamperMonitor.app */; productReference = AA0000000000000000000001 /* VanControl.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
AA0000000000000000000022 /* CamperMonitorWatch */ = { AA0000000000000000000022 /* VanControlWatch */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "CamperMonitorWatch" */; buildConfigurationList = AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "VanControlWatch" */;
buildPhases = ( buildPhases = (
AA0000000000000000000019 /* Sources */, AA0000000000000000000019 /* Sources */,
AA0000000000000000000020 /* Frameworks */, AA0000000000000000000020 /* Frameworks */,
@@ -181,17 +259,17 @@
AA0000000000000000000044 /* PBXTargetDependency */, AA0000000000000000000044 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
AA0000000000000000000017 /* CamperMonitorWatch */, AA0000000000000000000017 /* VanControlWatch */,
AA0000000000000000000018 /* Shared */, AA0000000000000000000018 /* Shared */,
); );
name = CamperMonitorWatch; name = VanControlWatch;
productName = CamperMonitorWatch; productName = VanControlWatch;
productReference = AA0000000000000000000016 /* CamperMonitorWatch.app */; productReference = AA0000000000000000000016 /* VanControlWatch.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
AA0000000000000000000037 /* CamperMonitorComplication */ = { AA0000000000000000000037 /* VanControlComplication */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "CamperMonitorComplication" */; buildConfigurationList = AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "VanControlComplication" */;
buildPhases = ( buildPhases = (
AA0000000000000000000034 /* Sources */, AA0000000000000000000034 /* Sources */,
AA0000000000000000000035 /* Frameworks */, AA0000000000000000000035 /* Frameworks */,
@@ -203,11 +281,32 @@
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
AA0000000000000000000018 /* Shared */, AA0000000000000000000018 /* Shared */,
AA0000000000000000000033 /* CamperMonitorComplication */, AA0000000000000000000033 /* VanControlComplication */,
); );
name = CamperMonitorComplication; name = VanControlComplication;
productName = CamperMonitorComplication; productName = VanControlComplication;
productReference = AA0000000000000000000032 /* CamperMonitorComplication.appex */; productReference = AA0000000000000000000032 /* VanControlComplication.appex */;
productType = "com.apple.product-type.app-extension";
};
AA0000000000000000000062 /* VanControlLiveActivityExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000068 /* Build configuration list for PBXNativeTarget "VanControlLiveActivityExtension" */;
buildPhases = (
AA0000000000000000000060 /* Sources */,
AA0000000000000000000058 /* Frameworks */,
AA0000000000000000000061 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
AA0000000000000000000056 /* VanControlLiveActivity */,
AA0000000000000000000057 /* SharedActivity */,
);
name = VanControlLiveActivityExtension;
productName = VanControlLiveActivityExtension;
productReference = AA0000000000000000000054 /* VanControlLiveActivityExtension.appex */;
productType = "com.apple.product-type.app-extension"; productType = "com.apple.product-type.app-extension";
}; };
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
@@ -229,9 +328,12 @@
AA0000000000000000000037 = { AA0000000000000000000037 = {
CreatedOnToolsVersion = 26.6; CreatedOnToolsVersion = 26.6;
}; };
AA0000000000000000000062 = {
CreatedOnToolsVersion = 26.6;
}; };
}; };
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */; };
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "VanControl" */;
developmentRegion = en; developmentRegion = en;
hasScannedForEncodings = 0; hasScannedForEncodings = 0;
knownRegions = ( knownRegions = (
@@ -246,9 +348,10 @@
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
AA0000000000000000000006 /* CamperMonitor */, AA0000000000000000000006 /* VanControl */,
AA0000000000000000000022 /* CamperMonitorWatch */, AA0000000000000000000022 /* VanControlWatch */,
AA0000000000000000000037 /* CamperMonitorComplication */, AA0000000000000000000037 /* VanControlComplication */,
AA0000000000000000000062 /* VanControlLiveActivityExtension */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -275,6 +378,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AA0000000000000000000061 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -299,20 +409,32 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AA0000000000000000000060 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
AA0000000000000000000029 /* PBXTargetDependency */ = { AA0000000000000000000029 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
platformFilter = ios; platformFilter = ios;
target = AA0000000000000000000022 /* CamperMonitorWatch */; target = AA0000000000000000000022 /* VanControlWatch */;
targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */; targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */;
}; };
AA0000000000000000000044 /* PBXTargetDependency */ = { AA0000000000000000000044 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = AA0000000000000000000037 /* CamperMonitorComplication */; target = AA0000000000000000000037 /* VanControlComplication */;
targetProxy = AA0000000000000000000043 /* PBXContainerItemProxy */; targetProxy = AA0000000000000000000043 /* PBXContainerItemProxy */;
}; };
AA0000000000000000000064 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = AA0000000000000000000062 /* VanControlLiveActivityExtension */;
targetProxy = AA0000000000000000000063 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@@ -379,12 +501,14 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = 6AP73NYV5W; DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist"; INFOPLIST_FILE = "Config/VanControl-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro"; INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
@@ -393,8 +517,8 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor; PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -410,12 +534,14 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = 6AP73NYV5W; DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist"; INFOPLIST_FILE = "Config/VanControl-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro"; INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
@@ -424,8 +550,8 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor; PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -440,18 +566,18 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6AP73NYV5W; DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro"; INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.s0.fototeddy.VanControl;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp; PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos; SDKROOT = watchos;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -472,18 +598,18 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6AP73NYV5W; DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro"; INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.s0.fototeddy.VanControl;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp; PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos; SDKROOT = watchos;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -502,17 +628,17 @@
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6AP73NYV5W; DEVELOPMENT_TEAM = V5C6Q86XJR;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist"; INFOPLIST_FILE = "Config/VanControlComplication-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung; INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp.levelwidget; PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.watchkitapp.levelwidget;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos; SDKROOT = watchos;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -531,17 +657,17 @@
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6AP73NYV5W; DEVELOPMENT_TEAM = V5C6Q86XJR;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist"; INFOPLIST_FILE = "Config/VanControlComplication-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung; INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp.levelwidget; PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.watchkitapp.levelwidget;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos; SDKROOT = watchos;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -555,10 +681,68 @@
}; };
name = Release; name = Release;
}; };
AA0000000000000000000066 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V5C6Q86XJR;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = VanControlLiveActivity/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Live Activity";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.VanControlLiveActivity;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA0000000000000000000067 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V5C6Q86XJR;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = VanControlLiveActivity/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Live Activity";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.VanControlLiveActivity;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */ = { AA0000000000000000000010 /* Build configuration list for PBXProject "VanControl" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
AA0000000000000000000012 /* Debug */, AA0000000000000000000012 /* Debug */,
@@ -567,7 +751,7 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "CamperMonitor" */ = { AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "VanControl" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
AA0000000000000000000014 /* Debug */, AA0000000000000000000014 /* Debug */,
@@ -576,7 +760,7 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "CamperMonitorWatch" */ = { AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "VanControlWatch" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
AA0000000000000000000023 /* Debug */, AA0000000000000000000023 /* Debug */,
@@ -585,7 +769,7 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "CamperMonitorComplication" */ = { AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "VanControlComplication" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
AA0000000000000000000038 /* Debug */, AA0000000000000000000038 /* Debug */,
@@ -594,6 +778,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
AA0000000000000000000068 /* Build configuration list for PBXNativeTarget "VanControlLiveActivityExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000066 /* Debug */,
AA0000000000000000000067 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = AA0000000000000000000009 /* Project object */; rootObject = AA0000000000000000000009 /* Project object */;
@@ -6,9 +6,9 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000006" BlueprintIdentifier = "AA0000000000000000000006"
BuildableName = "CamperMonitor.app" BuildableName = "VanControl.app"
BlueprintName = "CamperMonitor" BlueprintName = "VanControl"
ReferencedContainer = "container:CamperMonitor.xcodeproj"> ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
@@ -21,9 +21,9 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000006" BlueprintIdentifier = "AA0000000000000000000006"
BuildableName = "CamperMonitor.app" BuildableName = "VanControl.app"
BlueprintName = "CamperMonitor" BlueprintName = "VanControl"
ReferencedContainer = "container:CamperMonitor.xcodeproj"> ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
</LaunchAction> </LaunchAction>
@@ -32,9 +32,9 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000006" BlueprintIdentifier = "AA0000000000000000000006"
BuildableName = "CamperMonitor.app" BuildableName = "VanControl.app"
BlueprintName = "CamperMonitor" BlueprintName = "VanControl"
ReferencedContainer = "container:CamperMonitor.xcodeproj"> ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
</ProfileAction> </ProfileAction>
@@ -15,9 +15,9 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022" BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "CamperMonitorWatch.app" BuildableName = "VanControlWatch.app"
BlueprintName = "CamperMonitorWatch" BlueprintName = "VanControlWatch"
ReferencedContainer = "container:CamperMonitor.xcodeproj"> ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
@@ -45,9 +45,9 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022" BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "CamperMonitorWatch.app" BuildableName = "VanControlWatch.app"
BlueprintName = "CamperMonitorWatch" BlueprintName = "VanControlWatch"
ReferencedContainer = "container:CamperMonitor.xcodeproj"> ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
</LaunchAction> </LaunchAction>
@@ -62,9 +62,9 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022" BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "CamperMonitorWatch.app" BuildableName = "VanControlWatch.app"
BlueprintName = "CamperMonitorWatch" BlueprintName = "VanControlWatch"
ReferencedContainer = "container:CamperMonitor.xcodeproj"> ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
</ProfileAction> </ProfileAction>

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

@@ -0,0 +1,10 @@
{
"images" : [
{
"filename" : "CaliforniaSide.png",
"idiom" : "universal"
}
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "template-rendering-intent" : "template" }
}
@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VehicleSide.png", "filename" : "VansterRear.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VehicleRear.png", "filename" : "VansterSide.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

@@ -17,6 +17,8 @@ struct Discovery: Identifiable, Hashable {
var looksLikeSupported: Bool var looksLikeSupported: Bool
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar. /// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
var isLevelSensor = false var isLevelSensor = false
/// Der Solarladeregler bewirbt seinen Dienst ebenso.
var isVotronicSolarESPSensor = false
var isVictron: Bool { victronRecordType != nil } var isVictron: Bool { victronRecordType != nil }
@@ -30,6 +32,7 @@ struct Discovery: Identifiable, Hashable {
return "Victron · " + Self.victronRecordName(type) return "Victron · " + Self.victronRecordName(type)
} }
if isLevelSensor { return "VanAlign Neigungsmesser" } if isLevelSensor { return "VanAlign Neigungsmesser" }
if isVotronicSolarESPSensor { return "VotronicSolarESP" }
if looksLikeSupported { return "Sieht nach BMS oder Kühlbox aus" } if looksLikeSupported { return "Sieht nach BMS oder Kühlbox aus" }
return "Bluetooth-Gerät" return "Bluetooth-Gerät"
} }
@@ -149,9 +152,15 @@ final class BluetoothManager: NSObject {
private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:] private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
private(set) var fridgeStates: [UUID: AlpicoolState] = [:] private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
private(set) var levelStates: [UUID: LevelState] = [:] private(set) var levelStates: [UUID: LevelState] = [:]
private(set) var votronicSolarESPStates: [UUID: VotronicSolarESPState] = [:]
private(set) var isBluetoothReady = false private(set) var isBluetoothReady = false
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…" private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
/// Verbindet die Live Activity des Neigungsmessers mit dem Funkgeschehen
/// gesetzt von `VanControlApp`, damit diese Schicht nichts über
/// ActivityKit wissen muss, wenn niemand zuhört.
var activityManager: LevelActivityManager?
/// Solange true, werden alle gefundenen Peripherals gesammelt. /// Solange true, werden alle gefundenen Peripherals gesammelt.
var isDiscovering = false { var isDiscovering = false {
didSet { didSet {
@@ -162,7 +171,7 @@ final class BluetoothManager: NSObject {
// MARK: - Nur auf `queue` // MARK: - Nur auf `queue`
private let queue = DispatchQueue(label: "de.fritob.CamperMonitor.bluetooth") private let queue = DispatchQueue(label: "de.fritob.VanControl.bluetooth")
private var central: CBCentralManager? private var central: CBCentralManager?
/// Momentaufnahme der eingerichteten Geräte, damit die Funk-Queue nicht in /// Momentaufnahme der eingerichteten Geräte, damit die Funk-Queue nicht in
@@ -185,6 +194,7 @@ final class BluetoothManager: NSObject {
private var bmsSessions: [UUID: BMSSession] = [:] private var bmsSessions: [UUID: BMSSession] = [:]
private var levelSessions: [UUID: LevelSession] = [:] private var levelSessions: [UUID: LevelSession] = [:]
private var votronicSolarESPSessions: [UUID: VotronicSolarESPSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:] private var connectedPeripherals: [UUID: CBPeripheral] = [:]
private var reconnectTimer: DispatchSourceTimer? private var reconnectTimer: DispatchSourceTimer?
@@ -309,6 +319,7 @@ final class BluetoothManager: NSObject {
bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) } bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
fridgeStates = fridgeStates.filter { known.contains($0.key) } fridgeStates = fridgeStates.filter { known.contains($0.key) }
levelStates = levelStates.filter { known.contains($0.key) } levelStates = levelStates.filter { known.contains($0.key) }
votronicSolarESPStates = votronicSolarESPStates.filter { known.contains($0.key) }
// Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin // Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin
// ihren zuletzt gestellten Stand. // ihren zuletzt gestellten Stand.
@@ -383,6 +394,10 @@ final class BluetoothManager: NSObject {
// MARK: - Kühlbox steuern (vom Hauptthread aufgerufen) // MARK: - Kühlbox steuern (vom Hauptthread aufgerufen)
func updateFridgeZoneMode(for device: ConfiguredDevice) { func updateFridgeZoneMode(for device: ConfiguredDevice) {
guard !isDemo else {
applyDemoFridgeChange(for: device.id) { $0.zoneMode = device.fridgeZoneMode }
return
}
let deviceID = device.id, mode = device.fridgeZoneMode let deviceID = device.id, mode = device.fridgeZoneMode
queue.async { queue.async {
guard let session = self.session(for: deviceID) else { return } guard let session = self.session(for: deviceID) else { return }
@@ -393,21 +408,51 @@ final class BluetoothManager: NSObject {
} }
func setFridgeTarget(_ celsius: Int, zone: AlpicoolState.Zone, for deviceID: UUID) { func setFridgeTarget(_ celsius: Int, zone: AlpicoolState.Zone, for deviceID: UUID) {
guard !isDemo else {
applyDemoFridgeChange(for: deviceID) { state in
switch zone {
case .left: state.leftTarget = celsius
case .right: state.rightTarget = celsius
}
}
return
}
sendFridgeSettings(for: deviceID) { _ in AlpicoolState.setTarget(zone: zone, to: celsius) } sendFridgeSettings(for: deviceID) { _ in AlpicoolState.setTarget(zone: zone, to: celsius) }
} }
func setFridgePower(_ on: Bool, for deviceID: UUID) { func setFridgePower(_ on: Bool, for deviceID: UUID) {
guard !isDemo else {
applyDemoFridgeChange(for: deviceID) { $0.isPoweredOn = on }
return
}
sendFridgeSettings(for: deviceID) { $0.settingsCommand(poweredOn: on) } sendFridgeSettings(for: deviceID) { $0.settingsCommand(poweredOn: on) }
} }
func setFridgeEco(_ eco: Bool, for deviceID: UUID) { func setFridgeEco(_ eco: Bool, for deviceID: UUID) {
guard !isDemo else {
applyDemoFridgeChange(for: deviceID) { $0.runMode = eco ? 1 : 0 }
return
}
sendFridgeSettings(for: deviceID) { $0.settingsCommand(eco: eco) } sendFridgeSettings(for: deviceID) { $0.settingsCommand(eco: eco) }
} }
func setFridgeLock(_ locked: Bool, for deviceID: UUID) { func setFridgeLock(_ locked: Bool, for deviceID: UUID) {
guard !isDemo else {
applyDemoFridgeChange(for: deviceID) { $0.isLocked = locked }
return
}
sendFridgeSettings(for: deviceID) { $0.settingsCommand(locked: locked) } sendFridgeSettings(for: deviceID) { $0.settingsCommand(locked: locked) }
} }
/// Im Demo-Modus gibt es keine Box, die einen Stellbefehl bestätigt die
/// Änderung wird direkt im angezeigten Zustand nachgezogen, auf dem
/// Hauptthread, wo `fridgeStates` lebt.
private func applyDemoFridgeChange(for deviceID: UUID, _ change: (inout AlpicoolState) -> Void) {
var state = fridgeStates[deviceID] ?? DemoData.fridgeState
change(&state)
fridgeStates[deviceID] = state
}
/// Der Einstellungsblock wird aus dem zuletzt empfangenen Zustand gebaut /// Der Einstellungsblock wird aus dem zuletzt empfangenen Zustand gebaut
/// und zwar auf der Funk-Queue, wo dieser Zustand lebt. /// und zwar auf der Funk-Queue, wo dieser Zustand lebt.
/// ///
@@ -516,6 +561,11 @@ final class BluetoothManager: NSObject {
levelSessions[peripheralID] = nil levelSessions[peripheralID] = nil
disconnect(peripheralID) disconnect(peripheralID)
} }
for (peripheralID, session) in votronicSolarESPSessions where !wanted.contains(peripheralID) {
session.stop()
votronicSolarESPSessions[peripheralID] = nil
disconnect(peripheralID)
}
// Einstellungen an bestehende Sitzungen weiterreichen. // Einstellungen an bestehende Sitzungen weiterreichen.
for device in devices { for device in devices {
bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode
@@ -561,9 +611,11 @@ final class BluetoothManager: NSObject {
discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
for (_, session) in bmsSessions { session.stop() } for (_, session) in bmsSessions { session.stop() }
for (_, session) in levelSessions { session.stop() } for (_, session) in levelSessions { session.stop() }
for (_, session) in votronicSolarESPSessions { session.stop() }
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) } for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
bmsSessions.removeAll() bmsSessions.removeAll()
levelSessions.removeAll() levelSessions.removeAll()
votronicSolarESPSessions.removeAll()
connectedPeripherals.removeAll() connectedPeripherals.removeAll()
connectedSince.removeAll() connectedSince.removeAll()
pendingControls.removeAll() pendingControls.removeAll()
@@ -755,7 +807,8 @@ final class BluetoothManager: NSObject {
} }
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [] let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
entry.isLevelSensor = services.contains(LevelSession.serviceUUID) entry.isLevelSensor = services.contains(LevelSession.serviceUUID)
entry.looksLikeSupported = entry.isLevelSensor entry.isVotronicSolarESPSensor = services.contains(VotronicSolarESPSession.serviceUUID)
entry.looksLikeSupported = entry.isLevelSensor || entry.isVotronicSolarESPSensor
|| Self.looksLikeSupported(name: entry.name) || Self.looksLikeSupported(name: entry.name)
pendingDiscoveries[peripheral.identifier] = entry pendingDiscoveries[peripheral.identifier] = entry
} }
@@ -842,10 +895,23 @@ extension BluetoothManager: CBCentralManagerDelegate {
queue: queue, queue: queue,
onUpdate: { [weak self] snapshot in self?.record(snapshot) }, onUpdate: { [weak self] snapshot in self?.record(snapshot) },
onStateChange: { [weak self] state in onStateChange: { [weak self] state in
self?.publish { self?.linkStates[device.id] = state } self?.publish {
self?.linkStates[device.id] = state
if state == .live {
// Verbindung wieder da: ein anstehendes Ende
// verwerfen oder eine zwischenzeitlich doch schon
// beendete Live Activity wieder aufnehmen.
self?.activityManager?.handleReconnect(
deviceID: device.id,
state: self?.levelStates[device.id] ?? LevelState())
}
}
}, },
onLevelState: { [weak self] state in onLevelState: { [weak self] state in
self?.publish { self?.levelStates[device.id] = state } self?.publish {
self?.levelStates[device.id] = state
self?.activityManager?.update(deviceID: device.id, state: state)
}
}, },
onDeviceOrientation: { [weak self] orientation in onDeviceOrientation: { [weak self] orientation in
// Im Gerät steht, wie der Sensor eingebaut ist für alle // Im Gerät steht, wie der Sensor eingebaut ist für alle
@@ -859,6 +925,27 @@ extension BluetoothManager: CBCentralManagerDelegate {
return return
} }
if device.role == .votronicSolar {
// Bewusst kein Draht zu `activityManager`: Der Solarertrag soll
// nicht in der Live Activity/CarPlay auftauchen, die ist dem
// Neigungsmesser vorbehalten.
let session = VotronicSolarESPSession(
deviceID: device.id,
peripheral: peripheral,
queue: queue,
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
onStateChange: { [weak self] state in
self?.publish { self?.linkStates[device.id] = state }
},
onVotronicSolarESPState: { [weak self] state in
self?.publish { self?.votronicSolarESPStates[device.id] = state }
}
)
votronicSolarESPSessions[peripheral.identifier] = session
session.start()
return
}
let session = BMSSession( let session = BMSSession(
deviceID: device.id, deviceID: device.id,
peripheral: peripheral, peripheral: peripheral,
@@ -906,6 +993,8 @@ extension BluetoothManager: CBCentralManagerDelegate {
bmsSessions[peripheral.identifier] = nil bmsSessions[peripheral.identifier] = nil
levelSessions[peripheral.identifier]?.handleDisconnect() levelSessions[peripheral.identifier]?.handleDisconnect()
levelSessions[peripheral.identifier] = nil levelSessions[peripheral.identifier] = nil
votronicSolarESPSessions[peripheral.identifier]?.handleDisconnect()
votronicSolarESPSessions[peripheral.identifier] = nil
connectedPeripherals[peripheral.identifier] = nil connectedPeripherals[peripheral.identifier] = nil
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier) let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
@@ -920,7 +1009,13 @@ extension BluetoothManager: CBCentralManagerDelegate {
self.showLastKnownFridgeSettings(for: deviceID) self.showLastKnownFridgeSettings(for: deviceID)
} }
} else if let device = managed[peripheral.identifier] { } else if let device = managed[peripheral.identifier] {
publish { self.linkStates[device.id] = .searching } let deviceName = store.devices.first { $0.id == device.id }?.name ?? ""
publish {
self.linkStates[device.id] = .searching
// Erst nach einer Gnadenfrist wirklich beenden sonst
// flackerte die Live Activity bei jedem kurzen Funkloch.
self.activityManager?.handleDisconnect(deviceID: device.id, deviceName: deviceName)
}
if lifetime >= stableConnection { if lifetime >= stableConnection {
// Die Verbindung stand und ist weggefallen - im Fahrzeug der // Die Verbindung stand und ist weggefallen - im Fahrzeug der
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das // Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
@@ -0,0 +1,167 @@
import ActivityKit
import Foundation
import Observation
/// Startet, aktualisiert und beendet die Live Activity des Neigungsmessers.
///
/// Es läuft höchstens eine Aktivität gleichzeitig mehr als einen
/// Neigungsmesser gibt es im aktiven Profil ohnehin nicht. Seit iOS 26 zeigt
/// CarPlay eine laufende Live Activity automatisch im Dashboard an; ein
/// eigenes CarPlay-App-Target braucht es dafür nicht die Inhalte kommen
/// aber nicht von der Sperrbildschirm-Ansicht, sondern von der
/// `.small`-Aktivitätsfamilie (siehe `VanControlLiveActivity`).
@Observable
final class LevelActivityManager {
private(set) var trackedDeviceID: UUID?
@ObservationIgnored private var activity: Activity<LevelActivityAttributes>?
/// Wie lange nach einem Verbindungsabbruch gewartet wird, bevor die
/// Aktivität wirklich endet. Verbindungen fallen im Fahrzeug regelmässig
/// kurz weg ohne diese Gnadenfrist flackerte die Anzeige bei jedem
/// kurzen Funkloch aus und wieder ein.
static let disconnectGrace: TimeInterval = 12
/// Wie oft die Live Activity höchstens tatsächlich aktualisiert wird.
///
/// Der Neigungsmesser liefert Werte deutlich öfter (abonniert bei jeder
/// Änderung, sonst alle 0,5 s abgefragt). Stösst man ActivityKit im
/// selben Takt an, drosselt iOS nach kurzer Zeit selbst immer stärker
/// die Anzeige hinkt dann sichtbar hinterher, in CarPlay besonders
/// deutlich, weil das Dashboard ohnehin zurückhaltender aktualisiert als
/// Sperrbildschirm oder Dynamic Island. Ein fester Mindestabstand hält
/// die tatsächlichen Aktualisierungen unter dem, was das System duldet.
static let minimumUpdateInterval: TimeInterval = 1
@ObservationIgnored private var lastPushedAt: Date?
/// Ein während der Sperrfrist aufgelaufener Messwert, der nachgeholt
/// wird, sobald die Frist um ist sonst bliebe die Anzeige bei
/// schnellen Änderungen auf einem Zwischenstand stehen.
@ObservationIgnored private var pendingUpdate: Task<Void, Never>?
@ObservationIgnored private var pendingEnd: Task<Void, Never>?
/// Gerät, dessen Aktivität wegen einer länger anhaltenden Trennung
/// tatsächlich beendet wurde bei der nächsten Wiederverbindung wird sie
/// automatisch neu gestartet, damit das kein bewusster Nutzer-Stop war.
@ObservationIgnored private var deviceToResume: (id: UUID, name: String)?
/// Ob gerade irgendeine Aktivität läuft die App muss dafür im
/// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die
/// Anzeige beim ersten Sperren des Bildschirms ein.
var isActive: Bool { trackedDeviceID != nil }
func isActive(for deviceID: UUID) -> Bool {
trackedDeviceID == deviceID && activity != nil
}
func start(deviceID: UUID, deviceName: String, state: LevelState) {
pendingEnd?.cancel()
pendingEnd = nil
deviceToResume = nil
pendingUpdate?.cancel()
pendingUpdate = nil
lastPushedAt = nil
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
endActivity()
let attributes = LevelActivityAttributes(deviceName: deviceName)
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
do {
activity = try Activity.request(attributes: attributes, content: content)
trackedDeviceID = deviceID
lastPushedAt = Date()
} catch {
// Kann z.B. an fehlender Nutzerfreigabe liegen dann bleibt es
// einfach bei der Anzeige in der App, es gibt sonst nichts zu tun.
}
}
/// Wird bei jeder neuen Messung aufgerufen, unabhängig davon, ob gerade
/// eine Aktivität läuft kein Aufwand ohne aktive Anzeige.
///
/// Innerhalb der Sperrfrist wird nicht einfach verworfen, sondern der
/// neueste Stand für ihr Ende vorgemerkt ein älterer vorgemerkter
/// Aufruf verfällt dabei. So kommt bei schnellen Änderungen immer der
/// zuletzt gemessene Wert an, nur eben gesammelt statt einzeln.
func update(deviceID: UUID, state: LevelState) {
guard trackedDeviceID == deviceID, activity != nil else { return }
pendingUpdate?.cancel()
pendingUpdate = nil
let elapsed = lastPushedAt.map { Date().timeIntervalSince($0) } ?? .infinity
guard elapsed < Self.minimumUpdateInterval else {
push(state)
return
}
let delay = Self.minimumUpdateInterval - elapsed
pendingUpdate = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard let self, !Task.isCancelled, self.trackedDeviceID == deviceID else { return }
self.pendingUpdate = nil
self.push(state)
}
}
private func push(_ state: LevelState) {
guard let activity else { return }
lastPushedAt = Date()
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
Task { await activity.update(content) }
}
/// Bewusstes Beenden durch den Nutzer anders als bei einem
/// Verbindungsabbruch soll das bei der nächsten Verbindung nicht
/// automatisch wieder aufleben.
func end() {
pendingEnd?.cancel()
pendingEnd = nil
deviceToResume = nil
endActivity()
}
/// BLE-Verbindung weg: nicht sofort beenden, sondern erst nach einer
/// kurzen Gnadenfrist meldet sich das Gerät vorher zurück
/// (`handleReconnect`), passiert gar nichts.
func handleDisconnect(deviceID: UUID, deviceName: String) {
guard trackedDeviceID == deviceID, activity != nil, pendingEnd == nil else { return }
pendingEnd = Task { [weak self] in
try? await Task.sleep(for: .seconds(Self.disconnectGrace))
guard let self, !Task.isCancelled, self.trackedDeviceID == deviceID else { return }
self.deviceToResume = (deviceID, deviceName)
self.endActivity()
self.pendingEnd = nil
}
}
/// BLE-Verbindung wieder da: ein noch anstehendes Ende verwerfen, oder
/// falls die Gnadenfrist schon abgelaufen und die Aktivität wirklich
/// beendet war sie mit dem letzten bekannten Stand neu starten.
func handleReconnect(deviceID: UUID, state: LevelState) {
if pendingEnd != nil, trackedDeviceID == deviceID {
pendingEnd?.cancel()
pendingEnd = nil
return
}
guard let resume = deviceToResume, resume.id == deviceID else { return }
deviceToResume = nil
start(deviceID: deviceID, deviceName: resume.name, state: state)
}
private func endActivity() {
pendingUpdate?.cancel()
pendingUpdate = nil
guard let activity else { return }
trackedDeviceID = nil
self.activity = nil
Task { await activity.end(nil, dismissalPolicy: .immediate) }
}
private static func contentState(from state: LevelState) -> LevelActivityAttributes.ContentState {
LevelActivityAttributes.ContentState(
pitch: state.pitch,
roll: state.roll,
isLevel: state.isLevel,
instruction: state.instruction,
isCalibrated: state.isCalibrated,
updatedAt: Date()
)
}
}
@@ -3,21 +3,29 @@ import Foundation
/// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne /// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne
/// Fahrzeug und ohne Bluetooth prüfen lassen. /// Fahrzeug und ohne Bluetooth prüfen lassen.
/// ///
/// Nur in Debug-Builds und nur, wenn beim Start `CAMPER_DEMO=1` gesetzt ist: /// Auf zwei Wegen einzuschalten:
///
/// * Schalter in den Einstellungen (`SettingsView`) in jeder
/// Build-Konfiguration verfügbar, auch in TestFlight- und
/// App-Store-Builds. Der Weg für jeden lokal installierten Build ohne
/// Xcode-Verbindung. Wirkt erst nach einem Neustart der App, weil
/// `DeviceStore` und `BluetoothManager` den Stand nur beim Start lesen.
/// * Umgebungsvariable `CAMPER_DEMO=1` beim Start nur in Debug-Builds,
/// praktisch im Simulator:
/// ///
/// xcrun simctl launch --terminate-running-process \ /// xcrun simctl launch --terminate-running-process \
/// booted de.fritob.CamperMonitor /// booted de.fritob.VanControl
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor /// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
///
/// Im normalen Betrieb wird hiervon nichts ausgeführt.
enum DemoData { enum DemoData {
/// Schlüssel für den Einstellungen-Schalter, siehe `SettingsView`.
static let enabledKey = "demoModeEnabled"
static var isEnabled: Bool { static var isEnabled: Bool {
#if DEBUG #if DEBUG
return ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1" if ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1" { return true }
#else
return false
#endif #endif
return UserDefaults.standard.bool(forKey: enabledKey)
} }
static let mainProfile = Profile(id: Profile.defaultID, name: "Kastenwagen", static let mainProfile = Profile(id: Profile.defaultID, name: "Kastenwagen",
@@ -34,9 +42,9 @@ enum DemoData {
name: "Ladebooster", role: .chargeBooster, profileID: Profile.defaultID, name: "Ladebooster", role: .chargeBooster, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000B1")!) peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000B1")!)
static let solar = ConfiguredDevice( static let victronSolar = ConfiguredDevice(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000050")!, id: UUID(uuidString: "00000000-0000-0000-0000-000000000050")!,
name: "Solar Dach", role: .solarCharger, profileID: Profile.defaultID, name: "Solar Dach", role: .victronSolarCharger, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-000000000051")!) peripheralID: UUID(uuidString: "00000000-0000-0000-0000-000000000051")!)
static let battery = ConfiguredDevice( static let battery = ConfiguredDevice(
@@ -44,9 +52,9 @@ enum DemoData {
name: "Bulltron 200 Ah", role: .bms, profileID: Profile.defaultID, name: "Bulltron 200 Ah", role: .bms, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000A1")!) peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000A1")!)
static let caravanSolar = ConfiguredDevice( static let caravanVictronSolar = ConfiguredDevice(
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C3")!, id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C3")!,
name: "Solar Wohnwagen", role: .solarCharger, profileID: secondProfile.id, name: "Solar Wohnwagen", role: .victronSolarCharger, profileID: secondProfile.id,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000C4")!) peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000C4")!)
static let fridge = ConfiguredDevice( static let fridge = ConfiguredDevice(
@@ -59,8 +67,13 @@ enum DemoData {
name: "Nivellierung", role: .leveling, profileID: Profile.defaultID, name: "Nivellierung", role: .leveling, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000E1")!) peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000E1")!)
static let votronicSolar = ConfiguredDevice(
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000D0")!,
name: "VotronicSolarESP", role: .votronicSolar, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000D1")!)
static var devices: [ConfiguredDevice] { static var devices: [ConfiguredDevice] {
[solar, booster, battery, fridge, level, caravanSolar] [victronSolar, booster, battery, fridge, level, votronicSolar, caravanVictronSolar]
} }
/// Leicht schräg stehend, damit die Libelle etwas zu zeigen hat. /// Leicht schräg stehend, damit die Libelle etwas zu zeigen hat.
@@ -83,10 +96,24 @@ enum DemoData {
return state return state
} }
/// Mittags, gute Sonne.
static var votronicSolarESPState: VotronicSolarESPState {
var state = VotronicSolarESPState()
state.batteryVoltage = 13.9
state.pvVoltage = 19.4
state.pvCurrent = 6.2
state.pvPower = 120
state.controllerTemperature = 34
state.isBatteryCharging = true
state.isControllerActive = true
state.isCurrentLimited = false
return state
}
static func snapshots() -> [DeviceSnapshot] { static func snapshots() -> [DeviceSnapshot] {
var solarSnapshot = DeviceSnapshot(deviceID: solar.id, timestamp: Date(), rssi: -58) var victronSolarSnapshot = DeviceSnapshot(deviceID: victronSolar.id, timestamp: Date(), rssi: -58)
solarSnapshot.state = "Konstantspannung (Absorption)" victronSolarSnapshot.state = "Konstantspannung (Absorption)"
solarSnapshot.metrics = [ victronSolarSnapshot.metrics = [
Metric("pv_power", "PV-Leistung", 284, unit: "W", precision: 0, primary: true), Metric("pv_power", "PV-Leistung", 284, unit: "W", precision: 0, primary: true),
Metric("battery_power", "Ladeleistung", 262, unit: "W", precision: 0), Metric("battery_power", "Ladeleistung", 262, unit: "W", precision: 0),
Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2), Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2),
@@ -120,7 +147,7 @@ enum DemoData {
Metric("cycles", "Ladezyklen", 143, unit: "", precision: 0), Metric("cycles", "Ladezyklen", 143, unit: "", precision: 0),
] ]
var caravanSnapshot = DeviceSnapshot(deviceID: caravanSolar.id, timestamp: Date(), rssi: -77) var caravanSnapshot = DeviceSnapshot(deviceID: caravanVictronSolar.id, timestamp: Date(), rssi: -77)
caravanSnapshot.state = "Erhaltung (Float)" caravanSnapshot.state = "Erhaltung (Float)"
caravanSnapshot.metrics = [ caravanSnapshot.metrics = [
Metric("pv_power", "PV-Leistung", 62, unit: "W", precision: 0, primary: true), Metric("pv_power", "PV-Leistung", 62, unit: "W", precision: 0, primary: true),
@@ -128,9 +155,10 @@ enum DemoData {
Metric("battery_current", "Ladestrom", 4.4, unit: "A", precision: 1), Metric("battery_current", "Ladestrom", 4.4, unit: "A", precision: 1),
] ]
return [solarSnapshot, boosterSnapshot, batterySnapshot, return [victronSolarSnapshot, boosterSnapshot, batterySnapshot,
fridgeState.snapshot(deviceID: fridge.id, rssi: -66), fridgeState.snapshot(deviceID: fridge.id, rssi: -66),
levelState.snapshot(deviceID: level.id, rssi: -70), levelState.snapshot(deviceID: level.id, rssi: -70),
votronicSolarESPState.snapshot(deviceID: votronicSolar.id, rssi: -58),
caravanSnapshot] caravanSnapshot]
} }
@@ -244,7 +244,12 @@ final class DeviceStore {
} }
} }
/// Im Demo-Modus wird nichts geschrieben sonst überlebten die erfundenen
/// Fahrzeuge und Geräte das Ausschalten des Demo-Modus, weil z.B. das
/// Ändern der Fahrzeuggrafik oder der Einbaulage ganz normal `save()`
/// aufruft.
private func save() { private func save() {
guard !DemoData.isEnabled else { return }
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
if let data = try? JSONEncoder().encode(devices) { if let data = try? JSONEncoder().encode(devices) {
defaults.set(data, forKey: Self.devicesKey) defaults.set(data, forKey: Self.devicesKey)
@@ -5,7 +5,7 @@ import Security
/// UserDefaults, deshalb Keychain. /// UserDefaults, deshalb Keychain.
enum KeychainStore { enum KeychainStore {
private static let service = "de.fritob.CamperMonitor.victronKeys" private static let service = "de.fritob.VanControl.victronKeys"
static func setKey(_ hex: String?, for deviceID: UUID) { static func setKey(_ hex: String?, for deviceID: UUID) {
let account = deviceID.uuidString let account = deviceID.uuidString
@@ -1,18 +1,22 @@
import SwiftUI import SwiftUI
@main @main
struct CamperMonitorApp: App { struct VanControlApp: App {
@State private var store: DeviceStore @State private var store: DeviceStore
@State private var bluetooth: BluetoothManager @State private var bluetooth: BluetoothManager
@State private var watch: PhoneWatchLink @State private var watch: PhoneWatchLink
@State private var levelActivity: LevelActivityManager
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
init() { init() {
let store = DeviceStore() let store = DeviceStore()
let bluetooth = BluetoothManager(store: store) let bluetooth = BluetoothManager(store: store)
let levelActivity = LevelActivityManager()
bluetooth.activityManager = levelActivity
_store = State(initialValue: store) _store = State(initialValue: store)
_bluetooth = State(initialValue: bluetooth) _bluetooth = State(initialValue: bluetooth)
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth)) _watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
_levelActivity = State(initialValue: levelActivity)
} }
var body: some Scene { var body: some Scene {
@@ -21,6 +25,7 @@ struct CamperMonitorApp: App {
.environment(store) .environment(store)
.environment(bluetooth) .environment(bluetooth)
.environment(watch) .environment(watch)
.environment(levelActivity)
.task { watch.activate() } .task { watch.activate() }
} }
.onChange(of: scenePhase) { _, phase in .onChange(of: scenePhase) { _, phase in
@@ -38,7 +43,11 @@ struct CamperMonitorApp: App {
case .active: case .active:
bluetooth.start() bluetooth.start()
case .background: case .background:
if !watch.wantsLiveUpdates { bluetooth.stop() } // Läuft gerade eine Live Activity, muss der Neigungsmesser
// auch mit dunklem Bildschirm weiter Werte liefern sonst
// friert die Anzeige auf Sperrbildschirm und CarPlay beim
// ersten Wegdrücken der App ein.
if !watch.wantsLiveUpdates && !levelActivity.isActive { bluetooth.stop() }
default: default:
break break
} }
@@ -127,7 +127,7 @@ private struct ConfigureDeviceView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var name: String = "" @State private var name: String = ""
@State private var role: DeviceRole = .solarCharger @State private var role: DeviceRole = .victronSolarCharger
@State private var key: String = "" @State private var key: String = ""
private var needsKey: Bool { role.transport == .advertisement } private var needsKey: Bool { role.transport == .advertisement }
@@ -188,13 +188,15 @@ private struct ConfigureDeviceView: View {
private func prefill() { private func prefill() {
if let recordType = discovery.victronRecordType { if let recordType = discovery.victronRecordType {
switch VictronAdvertisement.RecordType(rawValue: recordType) { switch VictronAdvertisement.RecordType(rawValue: recordType) {
case .solarCharger: role = .solarCharger case .solarCharger: role = .victronSolarCharger
case .dcdcConverter, .orionXS: role = .chargeBooster case .dcdcConverter, .orionXS: role = .chargeBooster
case .batteryMonitor: role = .batteryMonitor case .batteryMonitor: role = .batteryMonitor
default: role = .solarCharger default: role = .victronSolarCharger
} }
} else if discovery.isLevelSensor { } else if discovery.isLevelSensor {
role = .leveling role = .leveling
} else if discovery.isVotronicSolarESPSensor {
role = .votronicSolar
} else if let name = discovery.name?.lowercased(), } else if let name = discovery.name?.lowercased(),
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) { ["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
role = .fridge role = .fridge
@@ -206,6 +208,8 @@ private struct ConfigureDeviceView: View {
// ohnehin darüber unter "Gefunden als". // ohnehin darüber unter "Gefunden als".
if discovery.isLevelSensor { if discovery.isLevelSensor {
name = "Nivellierung" name = "Nivellierung"
} else if discovery.isVotronicSolarESPSensor {
name = role.title
} else if let advertised = discovery.name, advertised.count <= 20, } else if let advertised = discovery.name, advertised.count <= 20,
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil { advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
name = advertised name = advertised
@@ -11,6 +11,7 @@ struct AlignmentAssistantView: View {
@Environment(BluetoothManager.self) private var bluetooth @Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var assistant = AlignmentAssistant() @State private var assistant = AlignmentAssistant()
@State private var didAnnounceTarget = false @State private var didAnnounceTarget = false
@@ -18,49 +19,19 @@ struct AlignmentAssistantView: View {
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() } private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
/// 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 }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { Group {
VStack(spacing: 24) { if isLandscape {
Picker("Darstellung", selection: $displayStyle) { landscapeLayout
ForEach(LevelDisplayStyle.allCases) { style in } else {
Text(style.title).tag(style) portraitLayout
} }
} }
.pickerStyle(.segmented)
.padding(.top, 8)
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: 320)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll)
}
if !isLive { disconnectedBanner }
adviceBanner
readings
if let best = assistant.best, let gain = assistant.improvementAtBest,
let seconds = assistant.timeSinceBest {
bestPointCard(best: best, gain: gain, seconds: seconds)
}
wedgeSection
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
assistant.reset()
didAnnounceTarget = false
}
.buttonStyle(.bordered)
.padding(.bottom, 24)
}
.padding(.horizontal)
.frame(maxWidth: .infinity)
}
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.navigationTitle("Ausrichtungs-Assistent") .navigationTitle("Ausrichtungs-Assistent")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -95,10 +66,94 @@ struct AlignmentAssistantView: View {
} }
} }
// MARK: - Layouts
private var portraitLayout: some View {
ScrollView {
VStack(spacing: 24) {
picker.padding(.top, 8)
display
if !isLive { disconnectedBanner }
adviceBanner
readings
if let best = assistant.best, let gain = assistant.improvementAtBest,
let seconds = assistant.timeSinceBest {
bestPointCard(best: best, gain: gain, seconds: seconds)
}
wedgeSection
resetButton
.padding(.bottom, 24)
}
.padding(.horizontal)
.frame(maxWidth: .infinity)
}
}
/// Anzeige links, alles zum Rangieren Nötige rechts ohne Scrollen, denn
/// im Querformat schaut man beiläufig hin, nicht in Ruhe. Die
/// Bestpunkt-Karte und der ausführliche Verbindungs-Hinweis bleiben dafür
/// dem Hochformat vorbehalten; die Keilhöhen bleiben in jedem Fall sichtbar.
private var landscapeLayout: some View {
HStack(alignment: .top, spacing: 16) {
VStack(spacing: 8) {
picker
display
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
VStack(spacing: 8) {
if !isLive { compactDisconnectedBanner }
adviceBanner
readings
wedgeSection
resetButton
}
.frame(maxWidth: .infinity)
}
.padding(12)
}
// MARK: - Bausteine // MARK: - Bausteine
private var isLive: Bool { bluetooth.linkStates[device.id] == .live } private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
private var picker: some View {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var display: some View {
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: isLandscape ? 220 : 320, maxHeight: isLandscape ? 160 : .infinity)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll,
style: device.vehicleGraphicStyle, compact: isLandscape)
}
}
private var resetButton: some View {
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
assistant.reset()
didAnnounceTarget = false
}
.buttonStyle(.bordered)
}
/// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne /// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne
/// Hinweis sähe das aus, als hinge die App man rangiert dann nach einem /// Hinweis sähe das aus, als hinge die App man rangiert dann nach einem
/// Wert, der längst nicht mehr gilt. /// Wert, der längst nicht mehr gilt.
@@ -120,40 +175,54 @@ struct AlignmentAssistantView: View {
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16)) .background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16))
} }
/// Kurzform für Querformat: derselbe Hinweis in einer Zeile.
private var compactDisconnectedBanner: some View {
Label("Nicht verbunden Anzeige steht still", systemImage: "antenna.radiowaves.left.and.right.slash")
.font(.caption.weight(.medium))
.foregroundStyle(.orange)
.lineLimit(1)
.minimumScaleFactor(0.8)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 10))
}
private var adviceBanner: some View { private var adviceBanner: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: assistant.hasReachedTarget Image(systemName: assistant.hasReachedTarget
? "checkmark.circle.fill" : assistant.trend.symbol) ? "checkmark.circle.fill" : assistant.trend.symbol)
.font(.title) .font(isLandscape ? .title2 : .title)
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor) .foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
Text(assistant.advice) Text(assistant.advice)
.font(.title3.weight(.medium)) .font(isLandscape ? .subheadline.weight(.medium) : .title3.weight(.medium))
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.padding() .padding(isLandscape ? 10 : 16)
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15) .background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
: Color(.secondarySystemGroupedBackground), : Color(.secondarySystemGroupedBackground),
in: .rect(cornerRadius: 16)) in: .rect(cornerRadius: 16))
} }
private var readings: some View { private var readings: some View {
HStack(spacing: 12) { HStack(spacing: isLandscape ? 8 : 12) {
reading("Längs", state.pitch) reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", state.roll) reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
reading("Gesamt", assistant.current?.deviation) if !isLandscape {
reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation))
}
} }
} }
private func reading(_ title: String, _ value: Double?) -> some View { private func reading(_ title: String, _ text: String) -> some View {
VStack(spacing: 4) { VStack(spacing: isLandscape ? 1 : 4) {
Text(value.map { String(format: "%.1f°", $0) } ?? "") Text(text)
.font(.title2.weight(.semibold).monospacedDigit()) .font((isLandscape ? Font.callout : .title2).weight(.semibold).monospacedDigit())
Text(title) Text(title)
.font(.caption) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 12) .padding(.vertical, isLandscape ? 6 : 12)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12)) .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
} }
@@ -187,23 +256,25 @@ struct AlignmentAssistantView: View {
trackWidth: width, wheelbase: base) trackWidth: width, wheelbase: base)
}() }()
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: isLandscape ? 6 : 10) {
Label("Auffahrkeile", systemImage: "triangle.fill") Label("Auffahrkeile", systemImage: "triangle.fill")
.font(.headline) .font(isLandscape ? .subheadline.weight(.semibold) : .headline)
if profile?.trackWidth == nil || profile?.wheelbase == nil { if profile?.trackWidth == nil || profile?.wheelbase == nil {
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt " Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
+ "sich beim Fahrzeug hinterlegen.") + "sich beim Fahrzeug hinterlegen.")
.font(.callout) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if let lift, !lift.isNegligible { } else if let lift, !lift.isNegligible {
WheelLiftPlan(lift: lift) WheelLiftPlan(lift: lift, isCompact: isLandscape)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
if !isLandscape {
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad " Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.") + "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
}
} else { } else {
Text("Keine Keile nötig.") Text("Keine Keile nötig.")
.font(.callout) .font(.callout)
@@ -211,7 +282,7 @@ struct AlignmentAssistantView: View {
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding() .padding(isLandscape ? 10 : 16)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16)) .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
} }
@@ -229,4 +300,3 @@ struct AlignmentAssistantView: View {
#endif #endif
} }
} }
@@ -13,6 +13,8 @@ struct DeviceCard: View {
if isShowingLastSettings, let snapshot { if isShowingLastSettings, let snapshot {
lastSettings(for: 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 { } else if let snapshot, let primary = snapshot.primaryMetric {
HStack(alignment: .firstTextBaseline, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(primary.formatted) 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 { private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
let others = snapshot.metrics let others = snapshot.metrics
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil } .filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
@@ -7,6 +7,7 @@ struct DeviceDetailView: View {
@Environment(DeviceStore.self) private var store @Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth @Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var editedName = "" @State private var editedName = ""
/// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der /// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der
@@ -37,11 +38,29 @@ struct DeviceDetailView: View {
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] } private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching } 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] ?? [] } private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
var body: some View { 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 { List {
statusSection // 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 needsKeyAttention { keyPrompt }
if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus { if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
@@ -50,7 +69,8 @@ struct DeviceDetailView: View {
if currentDevice.role == .leveling { if currentDevice.role == .leveling {
LevelControls(device: currentDevice, LevelControls(device: currentDevice,
state: bluetooth.levelStates[device.id] ?? LevelState()) state: bluetooth.levelStates[device.id] ?? LevelState(),
availableHeight: geometry.size.height)
} }
if let snapshot, !snapshot.metrics.isEmpty { if let snapshot, !snapshot.metrics.isEmpty {
@@ -94,6 +114,8 @@ struct DeviceDetailView: View {
} }
} }
if showsCompactLevelLayout { statusSection }
if let snapshot, snapshot.temperatures.count > 1 { if let snapshot, snapshot.temperatures.count > 1 {
Section("Temperaturen") { Section("Temperaturen") {
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
@@ -137,6 +159,7 @@ struct DeviceDetailView: View {
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.") Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
} }
} }
}
// MARK: - Abschnitte // MARK: - Abschnitte
@@ -25,6 +25,8 @@ struct LevelSetupView: View {
store.devices.first { $0.id == device.id } ?? device store.devices.first { $0.id == device.id } ?? device
} }
private let graphicColumns = [GridItem(.adaptive(minimum: 76), spacing: 12)]
var body: some View { var body: some View {
List { List {
Section { Section {
@@ -80,6 +82,44 @@ struct LevelSetupView: View {
} footer: { } footer: {
Text(calibrationHint) 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") .navigationTitle("Neigungsmesser einrichten")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -104,29 +104,59 @@ struct LevelBubble: View {
struct LevelControls: View { struct LevelControls: View {
let device: ConfiguredDevice let device: ConfiguredDevice
let state: LevelState let state: LevelState
/// Höhe der umgebenden Liste, von `DeviceDetailView` per `GeometryReader`
/// gemessen. Nur im Querformat gebraucht, um die Anzeige auf
/// Bildschirmhöhe zu bringen statt sie auf einen festen Wert zu kappen.
var availableHeight: CGFloat?
@Environment(BluetoothManager.self) private var bluetooth @Environment(BluetoothManager.self) private var bluetooth
@Environment(DeviceStore.self) private var store @Environment(DeviceStore.self) private var store
@Environment(LevelActivityManager.self) private var levelActivity
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var showAssistant = false @State private var showAssistant = false
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble @AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
/// 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 }
/// Verfügbare Höhe abzüglich grober Reserve für Listenränder und die
/// eigene vertikale Auffüllung genug, um praktisch den ganzen
/// Bildschirm zu nutzen, ohne über den unteren Rand hinauszuschiessen.
private var landscapeContentHeight: CGFloat {
max(150, (availableHeight ?? 350) - 56)
}
var body: some View { var body: some View {
Section { Section {
VStack(spacing: 16) { if isLandscape {
Picker("Darstellung", selection: $displayStyle) { HStack(alignment: .center, spacing: 20) {
ForEach(LevelDisplayStyle.allCases) { style in display
Text(style.title).tag(style) .frame(maxWidth: .infinity)
}
}
.pickerStyle(.segmented)
switch displayStyle { VStack(spacing: 12) {
case .bubble: picker
LevelBubble(pitch: state.pitch, roll: state.roll) if let instruction = state.instruction {
.frame(maxHeight: 220) Label(instruction,
case .vehicle: systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
VehicleTiltView(pitch: state.pitch, roll: state.roll) .font(.subheadline.weight(.semibold))
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
.multilineTextAlignment(.center)
} }
HStack(spacing: 16) {
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
}
}
.frame(maxWidth: .infinity)
}
.frame(height: landscapeContentHeight)
.padding(.vertical, 4)
} else {
VStack(spacing: 16) {
picker
display
if let instruction = state.instruction { if let instruction = state.instruction {
Label(instruction, Label(instruction,
@@ -137,13 +167,14 @@ struct LevelControls: View {
} }
HStack(spacing: 24) { HStack(spacing: 24) {
reading("Längs", state.pitch) reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", state.roll) reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
} }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 8) .padding(.vertical, 8)
} }
}
Section { Section {
Button { Button {
@@ -177,6 +208,29 @@ struct LevelControls: View {
} }
} }
Section {
Toggle(isOn: liveActivityBinding) {
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Live Activity")
Text("Sperrbildschirm, Dynamic Island und CarPlay")
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "widget.small")
}
}
// Ohne laufende Messwerte gäbe es nichts anzuzeigen und beim
// Umschalten hätte die Activity sofort einen veralteten Stand.
.disabled(!isLive || !state.hasReading)
} footer: {
Text(!isLive
? "Braucht laufende Messwerte. Der Neigungsmesser ist gerade nicht verbunden."
: "Zeigt die Neigung, solange sie läuft auch bei gesperrtem Bildschirm. "
+ "Ist das iPhone mit CarPlay verbunden, erscheint sie automatisch auch dort.")
}
// Einbaulage und Nullpunkt liegen auf einer eigenen Seite. Direkt unter // Einbaulage und Nullpunkt liegen auf einer eigenen Seite. Direkt unter
// der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt. // der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt.
Section { Section {
@@ -194,17 +248,52 @@ struct LevelControls: View {
} }
} }
private var picker: some View {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var display: some View {
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxHeight: isLandscape ? landscapeContentHeight : 220)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll,
style: device.vehicleGraphicStyle, compact: isLandscape,
compactPanelHeight: max(60, landscapeContentHeight - 40))
}
}
private var isLive: Bool { bluetooth.linkStates[device.id] == .live } private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
private var liveActivityBinding: Binding<Bool> {
Binding(
get: { levelActivity.isActive(for: device.id) },
set: { isOn in
if isOn {
levelActivity.start(deviceID: device.id, deviceName: device.name, state: state)
} else {
levelActivity.end()
}
}
)
}
/// Was auf der Einrichtungsseite zu holen ist der Nullpunkt zuerst, denn /// Was auf der Einrichtungsseite zu holen ist der Nullpunkt zuerst, denn
/// ohne ihn stimmt die Anzeige nicht. /// ohne ihn stimmt die Anzeige nicht.
private var setupSummary: String { private var setupSummary: String {
state.isKnownUncalibrated ? "Nullpunkt fehlt" : device.sensorOrientation.summary state.isKnownUncalibrated ? "Nullpunkt fehlt" : device.sensorOrientation.summary
} }
private func reading(_ title: String, _ value: Double?) -> some View { private func reading(_ title: String, _ text: String) -> some View {
VStack(spacing: 2) { VStack(spacing: 2) {
Text(value.map { String(format: "%.1f°", $0) } ?? "") Text(text)
.font(.title2.weight(.semibold).monospacedDigit()) .font(.title2.weight(.semibold).monospacedDigit())
Text(title) Text(title)
.font(.caption) .font(.caption)
@@ -145,7 +145,7 @@ private struct ProfileEditView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var name = "" @State private var name = ""
@State private var symbol = "box.truck" @State private var symbol = "suv.side"
@State private var trackWidth = "" @State private var trackWidth = ""
@State private var wheelbase = "" @State private var wheelbase = ""
@@ -4,12 +4,24 @@ import SwiftUI
/// betrifft, steht bei diesem Gerät. /// betrifft, steht bei diesem Gerät.
struct SettingsView: View { struct SettingsView: View {
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false @AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
@AppStorage(DemoData.enabledKey) private var demoModeEnabled = false
@Environment(PhoneWatchLink.self) private var watch @Environment(PhoneWatchLink.self) private var watch
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
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.")
}
Section { Section {
Toggle("Diagnose anzeigen", isOn: $showDiagnostics) Toggle("Diagnose anzeigen", isOn: $showDiagnostics)
} header: { } header: {
@@ -15,22 +15,29 @@ import SwiftUI
struct VehicleTiltView: View { struct VehicleTiltView: View {
let pitch: Double? let pitch: Double?
let roll: Double? let roll: Double?
var style: VehicleGraphicStyle = .vanster
/// Für Querformat: beide Ansichten nebeneinander statt untereinander,
/// kleinere Schrift, ohne Überhöhungs-Hinweis muss ohne Scrollen in die
/// Bildschirmhöhe passen.
var compact = false
/// Bildhöhe je Panel im Querformat vom Aufrufer an die tatsächlich
/// verfügbare Bildschirmhöhe angepasst, statt fest verdrahtet.
var compactPanelHeight: CGFloat = 62
var body: some View { var body: some View {
if compact {
HStack(alignment: .top, spacing: 16) {
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
}
} else {
VStack(spacing: 20) { VStack(spacing: 20) {
tiltPanel(image: "VehicleSide", tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
angle: pitch, lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
title: "Längs", tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
lowerLabel: "Front", lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
upperLabel: "Heck",
aspect: VehicleTilt.sideAspect)
tiltPanel(image: "VehicleRear",
angle: roll,
title: "Quer",
lowerLabel: "links",
upperLabel: "rechts",
aspect: VehicleTilt.rearAspect)
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten. // Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
Text(String(format: "Neigung %.0f-fach überhöht dargestellt " Text(String(format: "Neigung %.0f-fach überhöht dargestellt "
@@ -42,6 +49,7 @@ struct VehicleTiltView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
}
private func tiltPanel(image: String, private func tiltPanel(image: String,
angle: Double?, angle: Double?,
@@ -49,13 +57,13 @@ struct VehicleTiltView: View {
lowerLabel: String, lowerLabel: String,
upperLabel: String, upperLabel: String,
aspect: Double) -> some View { aspect: Double) -> some View {
VStack(spacing: 8) { VStack(spacing: compact ? 4 : 8) {
HStack { HStack {
Text(title) Text(title)
.font(.subheadline.weight(.medium)) .font(compact ? .caption2.weight(.medium) : .subheadline.weight(.medium))
Spacer() Spacer()
Text(angle.map { String(format: "%.1f°", $0) } ?? "") Text(angle.map { String(format: "%.1f°", $0) } ?? "")
.font(.subheadline.weight(.semibold).monospacedDigit()) .font((compact ? Font.caption2 : .subheadline).weight(.semibold).monospacedDigit())
.foregroundStyle(VehicleTilt.colour(for: angle)) .foregroundStyle(VehicleTilt.colour(for: angle))
} }
@@ -75,14 +83,14 @@ struct VehicleTiltView: View {
.animation(.spring(duration: 0.4), value: angle) .animation(.spring(duration: 0.4), value: angle)
.opacity(angle == nil ? 0.3 : 1) .opacity(angle == nil ? 0.3 : 1)
} }
.frame(height: 110) .frame(height: compact ? compactPanelHeight : 110)
HStack { HStack {
Text(lowerLabel) Text(lowerLabel)
Spacer() Spacer()
Text(upperLabel) Text(upperLabel)
} }
.font(.caption2) .font(compact ? .system(size: 9) : .caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -76,7 +76,7 @@ struct LevelComplicationView: View {
@main @main
struct LevelComplication: Widget { struct LevelComplication: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: "de.fritob.CamperMonitor.complication.level", StaticConfiguration(kind: "de.fritob.VanControl.complication.level",
provider: LevelComplicationProvider()) { _ in provider: LevelComplicationProvider()) { _ in
LevelComplicationView() LevelComplicationView()
// Der Tipp landet nicht irgendwo in der App, sondern auf der // Der Tipp landet nicht irgendwo in der App, sondern auf der
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,288 @@
//
// VanControlLiveActivity.swift
// VanControlLiveActivity
//
// Created by Christopher Helberg on 02.09.26.
//
import ActivityKit
import WidgetKit
import SwiftUI
private struct LevelIcon: View {
let isLevel: Bool
let hasReading: Bool
var body: some View {
Image(systemName: hasReading ? (isLevel ? "checkmark.circle.fill" : "level") : "level")
.foregroundStyle(hasReading ? (isLevel ? .green : .orange) : .secondary)
}
}
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
private let levelTolerance: Double = 0.5
/// Grün bis Toleranz, orange bis 2°, sonst rot dieselbe Skala für Blase
/// und Text, wie in der App (`VehicleTilt.colour`). Hier dupliziert, weil
/// die Extension `Shared/` nicht mitkompiliert (siehe
/// `LevelActivityAttributes.swift`).
private func levelColour(forDeviation deviation: Double?) -> Color {
guard let deviation else { return .white.opacity(0.4) }
if deviation <= levelTolerance { return .green }
if deviation <= 2 { return .orange }
return .red
}
/// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte
/// Dynamic Island dieselbe Optik wie `LevelBubble` in der App, aber ohne
/// deren Abhängigkeit auf `Shared/Models/LevelState.swift`, das in der
/// Extension nicht mitkompiliert wird.
private struct LevelBubbleGlyph: View {
let pitch: Double?
let roll: Double?
let isLevel: Bool
var diameter: CGFloat = 54
/// Bis zu welcher Neigung die Blase ausschlägt, wie in der App-Libelle.
private let range: Double = 6
private var hasReading: Bool { pitch != nil || roll != nil }
private var bubbleColor: Color {
guard let pitch, let roll else { return .white.opacity(0.4) }
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
}
var body: some View {
let radius = diameter / 2
let bubble = diameter * 0.22
let travel = radius - bubble / 2 - 3
let offsetX = clamped(roll) / range * travel
let offsetY = clamped(pitch) / range * travel
let toleranceRadius = max(levelTolerance / range * travel, bubble * 0.55)
ZStack {
Circle()
.fill(Color.white.opacity(0.1))
Circle()
.strokeBorder(Color.white.opacity(0.3), lineWidth: 1)
Circle()
.strokeBorder(isLevel ? Color.green : Color.white.opacity(0.25),
lineWidth: isLevel ? 2 : 1)
.frame(width: toleranceRadius * 2, height: toleranceRadius * 2)
Circle()
.fill(bubbleColor)
.frame(width: bubble, height: bubble)
// Positiver Pitch heisst: Heck steht höher, die Blase wandert
// also nach unten wie bei der echten Wasserwaage in der App.
.offset(x: offsetX, y: offsetY)
.opacity(hasReading ? 1 : 0.3)
}
.frame(width: diameter, height: diameter)
}
private func clamped(_ value: Double?) -> Double {
guard let value else { return 0 }
return min(max(value, -range), range)
}
}
struct VanControlLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: LevelActivityAttributes.self) { context in
LevelActivityContent(attributes: context.attributes, state: context.state)
.activityBackgroundTint(Color(white: 0.08))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
// Die Dynamic Island bleibt bewusst so klein wie möglich Libelle
// und Zahlenwerte gehören auf Sperrbildschirm/CarPlay, hier reicht
// ein Blick auf Icon und, aufgeklappt, die Kurzanweisung.
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
.font(.title3)
}
DynamicIslandExpandedRegion(.bottom) {
Text(context.state.instruction ?? context.attributes.deviceName)
.font(.caption)
.foregroundStyle(context.state.isLevel ? .green : .primary)
.lineLimit(1)
}
} compactLeading: {
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
} compactTrailing: {
EmptyView()
} minimal: {
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
}
.keylineTint(context.state.isLevel ? .green : .orange)
}
.supplementalActivityFamilies([.small])
}
}
/// Wählt je nach Darstellungsgrösse zwischen Sperrbildschirm- und
/// Kompaktansicht.
///
/// Ohne `.small`-Familie fällt CarPlay (wie die Smart Stack der Uhr) auf
/// `compactLeading`/`compactTrailing` der Dynamic Island zurück dort steht
/// nur das generische Symbol und, weil `compactTrailing` leer bleibt, gar
/// kein Text. Erst die `.small`-Familie liefert CarPlay Libelle und Text.
private struct LevelActivityContent: View {
let attributes: LevelActivityAttributes
let state: LevelActivityAttributes.ContentState
@Environment(\.activityFamily) private var activityFamily
var body: some View {
switch activityFamily {
case .small:
CompactLevelView(attributes: attributes, state: state)
default:
LockScreenLevelView(attributes: attributes, state: state)
}
}
}
/// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus
/// ein Blick-Symbol statt eines ganzen Satzes zum Lesen im Vorbeifahren.
///
/// Gezeigt wird nur, welche Seite höher steht: Buchstabe (Längsneigung),
/// Pfeil, Buchstabe (Querneigung) z.B. "H L" für "Heck und links stehen
/// höher". Ein einzelner Pfeil in der Mitte reicht, weil ohnehin nur die
/// höher stehende Seite benannt wird ("höher" ist implizit "nach oben").
private struct CompactLevelView: View {
let attributes: LevelActivityAttributes
let state: LevelActivityAttributes.ContentState
private var hasReading: Bool { state.pitch != nil || state.roll != nil }
/// "H"/"F", nur wenn ausserhalb der Toleranz.
private var pitchLetter: String? {
guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
return pitch >= 0 ? "H" : "F"
}
/// "R"/"L", nur wenn ausserhalb der Toleranz.
private var rollLetter: String? {
guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
return roll >= 0 ? "R" : "L"
}
/// Dieselbe Skala wie die Blase, über die grössere der beiden Neigungen.
private var statusColor: Color {
guard let pitch = state.pitch, let roll = state.roll else { return .white.opacity(0.4) }
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
}
var body: some View {
HStack(spacing: 14) {
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40)
Group {
if !hasReading {
Text("")
} else if state.isLevel {
Image(systemName: "checkmark.circle.fill")
} else {
HStack(spacing: 6) {
if let pitchLetter { Text(pitchLetter) }
Image(systemName: "arrow.up")
if let rollLetter { Text(rollLetter) }
}
}
}
// Skalierbarer Textstil statt fester Punktgrösse: CarPlay-
// Bildschirme unterscheiden sich in Grösse, die exakten Masse
// eines Displays sind aber nicht abfragbar. `minimumScaleFactor`
// lässt die Schrift so gross wie möglich, aber so klein wie
// nötig werden, um in den vom System zugeteilten Platz zu passen.
.font(.title2.weight(.bold))
.minimumScaleFactor(0.5)
.lineLimit(1)
.foregroundStyle(statusColor)
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
/// Sperrbildschirm- und Banner-Ansicht.
private struct LockScreenLevelView: View {
let attributes: LevelActivityAttributes
let state: LevelActivityAttributes.ContentState
/// Längszeile, z.B. "Heck steht höher 1.8°" nur wenn ausserhalb der
/// Toleranz, genau wie bei `LevelState.instruction` in der App.
private var pitchLine: String? {
guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
let label = pitch >= 0 ? "Heck steht höher" : "Front steht höher"
return "\(label) \(LevelDirectionFormatting.magnitude(pitch))"
}
/// Querzeile, z.B. "links steht höher 0.9°".
private var rollLine: String? {
guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
let label = roll >= 0 ? "rechts steht höher" : "links steht höher"
return "\(label) \(LevelDirectionFormatting.magnitude(roll))"
}
var body: some View {
HStack(spacing: 20) {
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 64)
VStack(alignment: .leading, spacing: 6) {
Text(attributes.deviceName)
.font(.caption)
.foregroundStyle(.secondary)
if pitchLine == nil && rollLine == nil {
Text(state.pitch == nil && state.roll == nil ? "Keine Messwerte" : "Steht eben")
.font(.title3.weight(.semibold))
.foregroundStyle(state.isLevel ? .green : .white)
} else {
if let pitchLine {
Text(pitchLine).font(.title3.weight(.semibold))
}
if let rollLine {
Text(rollLine).font(.title3.weight(.semibold))
}
}
}
Spacer(minLength: 0)
}
.padding(16)
.foregroundStyle(.white)
}
}
extension LevelActivityAttributes {
fileprivate static var preview: LevelActivityAttributes {
LevelActivityAttributes(deviceName: "Nivellierung")
}
}
extension LevelActivityAttributes.ContentState {
fileprivate static var level: LevelActivityAttributes.ContentState {
LevelActivityAttributes.ContentState(pitch: 0.1, roll: -0.2, isLevel: true,
instruction: "Steht eben", isCalibrated: true,
updatedAt: .now)
}
fileprivate static var tilted: LevelActivityAttributes.ContentState {
LevelActivityAttributes.ContentState(pitch: 2.4, roll: -1.1, isLevel: false,
instruction: "Heck steht höher, links steht höher",
isCalibrated: true, updatedAt: .now)
}
}
#Preview("Notification", as: .content, using: LevelActivityAttributes.preview) {
VanControlLiveActivity()
} contentStates: {
LevelActivityAttributes.ContentState.level
LevelActivityAttributes.ContentState.tilted
}
@@ -0,0 +1,16 @@
//
// VanControlLiveActivityBundle.swift
// VanControlLiveActivity
//
// Created by Christopher Helberg on 02.09.26.
//
import WidgetKit
import SwiftUI
@main
struct VanControlLiveActivityBundle: WidgetBundle {
var body: some Widget {
VanControlLiveActivity()
}
}

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VehicleSide.png", "filename" : "VansterRear.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VehicleRear.png", "filename" : "VansterSide.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

@@ -14,9 +14,9 @@ struct WatchVehicleView: View {
/// Die angeschriebenen Gradzahlen bleiben echt. /// Die angeschriebenen Gradzahlen bleiben echt.
var body: some View { var body: some View {
VStack(spacing: 8) { VStack(spacing: 8) {
panel(image: "VehicleSide", angle: pitch, title: "Längs", panel(image: "VansterSide", angle: pitch, title: "Längs",
lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect) lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect)
panel(image: "VehicleRear", angle: roll, title: "Quer", panel(image: "VansterRear", angle: roll, title: "Quer",
lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect) lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect)
} }
} }
@@ -4,7 +4,7 @@ import Foundation
/// ohne Fahrzeug prüfen lassen. Eingeschaltet über `CAMPER_DEMO=1`: /// ohne Fahrzeug prüfen lassen. Eingeschaltet über `CAMPER_DEMO=1`:
/// ///
/// xcrun simctl launch --terminate-running-process booted \ /// xcrun simctl launch --terminate-running-process booted \
/// de.fritob.CamperMonitor.watchkitapp /// de.fritob.VanControl.watchkitapp
/// # mit SIMCTL_CHILD_CAMPER_DEMO=1 davor /// # mit SIMCTL_CHILD_CAMPER_DEMO=1 davor
/// ///
/// Im normalen Betrieb wird hiervon nichts ausgeführt. /// Im normalen Betrieb wird hiervon nichts ausgeführt.
@@ -13,7 +13,7 @@ enum WatchDemo {
private static let profile = Profile(name: "Kastenwagen", symbol: "box.truck", private static let profile = Profile(name: "Kastenwagen", symbol: "box.truck",
trackWidth: 1.85, wheelbase: 3.50) trackWidth: 1.85, wheelbase: 3.50)
private static let solarID = UUID(uuidString: "00000000-0000-0000-0000-000000000050")! private static let victronSolarID = UUID(uuidString: "00000000-0000-0000-0000-000000000050")!
private static let batteryID = UUID(uuidString: "00000000-0000-0000-0000-0000000000A0")! private static let batteryID = UUID(uuidString: "00000000-0000-0000-0000-0000000000A0")!
private static let fridgeID = UUID(uuidString: "00000000-0000-0000-0000-0000000000F0")! private static let fridgeID = UUID(uuidString: "00000000-0000-0000-0000-0000000000F0")!
private static let levelID = UUID(uuidString: "00000000-0000-0000-0000-0000000000E0")! private static let levelID = UUID(uuidString: "00000000-0000-0000-0000-0000000000E0")!
@@ -30,9 +30,9 @@ enum WatchDemo {
level.pitchOffset = 0.4 level.pitchOffset = 0.4
level.rollOffset = -0.2 level.rollOffset = -0.2
var solar = DeviceSnapshot(deviceID: solarID, timestamp: now, rssi: -58) var victronSolar = DeviceSnapshot(deviceID: victronSolarID, timestamp: now, rssi: -58)
solar.state = "Konstantspannung (Absorption)" victronSolar.state = "Konstantspannung (Absorption)"
solar.metrics = [ victronSolar.metrics = [
Metric("pv_power", "PV-Leistung", 284 + 40 * swing, unit: "W", precision: 0, primary: true), Metric("pv_power", "PV-Leistung", 284 + 40 * swing, unit: "W", precision: 0, primary: true),
Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2), Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2),
Metric("battery_current", "Ladestrom", 18.6, unit: "A", precision: 1), Metric("battery_current", "Ladestrom", 18.6, unit: "A", precision: 1),
@@ -67,8 +67,8 @@ enum WatchDemo {
WatchDevice(id: batteryID, name: "Bulltron 200 Ah", role: .bms, WatchDevice(id: batteryID, name: "Bulltron 200 Ah", role: .bms,
link: .live, snapshot: battery, level: nil, link: .live, snapshot: battery, level: nil,
orientation: nil, fridge: nil), orientation: nil, fridge: nil),
WatchDevice(id: solarID, name: "Solar Dach", role: .solarCharger, WatchDevice(id: victronSolarID, name: "Solar Dach", role: .victronSolarCharger,
link: .live, snapshot: solar, level: nil, link: .live, snapshot: victronSolar, level: nil,
orientation: nil, fridge: nil), orientation: nil, fridge: nil),
WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge, WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge,
link: .live, snapshot: fridge, level: nil, orientation: nil, link: .live, snapshot: fridge, level: nil, orientation: nil,
@@ -46,7 +46,7 @@ final class WatchLevelRadio: NSObject {
} }
} }
private let queue = DispatchQueue(label: "de.fritob.CamperMonitor.watch.ble") private let queue = DispatchQueue(label: "de.fritob.VanControl.watch.ble")
private var central: CBCentralManager? private var central: CBCentralManager?
private var peripheral: CBPeripheral? private var peripheral: CBPeripheral?
private var session: LevelSession? private var session: LevelSession?
+332
View File
@@ -0,0 +1,332 @@
# VanAlign Pro - Neigungsmessung über BLE (SIMULATION)
#
# Kopie von esp32_ble.yaml für den Fall, dass gerade kein MPU6050 zum
# Anschliessen vorhanden ist. Der `platform: mpu6050`-Sensor sowie der
# i2c-Bus wurden entfernt und durch Template-Sensoren ersetzt, die
# plausible, sich langsam ändernde Beschleunigungswerte erzeugen (ein
# gedachter Sensor, der gemütlich hin- und herschaukelt). Pitch/Roll,
# Kalibrierung und die BLE-Charakteristiken funktionieren dadurch exakt wie
# im Original - nur eben ohne angeschlossene Hardware.
#
# Name und Friendly Name sind bewusst auf "-sim" abgeändert, damit dieses
# Gerät im Netzwerk/BLE nicht mit einem echten VanAlign-Gerät kollidiert.
#
# Sobald wieder ein echter MPU6050 verfügbar ist, einfach esp32_ble.yaml
# weiterverwenden - diese Datei ist nur zum Testen der App/BLE-Anbindung.
esphome:
name: vanalign-sim
friendly_name: "VanAlign Pro (Sim)"
esp32:
board: esp32-s3-devkitc-1
flash_size: 16MB
framework:
type: esp-idf
# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
psram:
mode: octal
speed: 80MHz
logger:
level: WARN
espnow:
channel: 1
sensor:
# Simulierte Rohwerte anstelle des physischen MPU6050. Die Sensor-Lage
# (Pitch/Roll) wandert langsam und stetig, wie es ein tatsächlich leicht
# schaukelndes Fahrzeug/Werkstück tun würde (Perioden ~75s/~113s).
- platform: template
name: "MPU6050 Accel X (Sim)"
id: accel_x
internal: true
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
float pitch_rad = (15.0f * sin(t / 12.0f)) * 3.14159265f / 180.0f;
float roll_rad = (10.0f * sin(t / 18.0f + 1.0f)) * 3.14159265f / 180.0f;
return -9.80665f * sin(roll_rad) * cos(pitch_rad);
- platform: template
name: "MPU6050 Accel Y (Sim)"
id: accel_y
internal: true
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
float pitch_rad = (15.0f * sin(t / 12.0f)) * 3.14159265f / 180.0f;
return 9.80665f * sin(pitch_rad);
- platform: template
name: "MPU6050 Accel Z (Sim)"
id: accel_z
internal: true
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
float pitch_rad = (15.0f * sin(t / 12.0f)) * 3.14159265f / 180.0f;
float roll_rad = (10.0f * sin(t / 18.0f + 1.0f)) * 3.14159265f / 180.0f;
return 9.80665f * cos(roll_rad) * cos(pitch_rad);
# Gyro-Werte werden nur zur Anzeige simuliert (kleine Winkelgeschwindigkeit
# passend zur Schaukelbewegung oben, kein realer Bezug nötig).
- platform: template
name: "MPU6050 Gyro X-Achse"
id: mpu_gyro_x
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
return (10.0f / 18.0f) * cos(t / 18.0f + 1.0f) * 3.14159265f / 180.0f;
- platform: template
name: "MPU6050 Gyro Y-Achse"
id: mpu_gyro_y
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
return (15.0f / 12.0f) * cos(t / 12.0f) * 3.14159265f / 180.0f;
- platform: template
name: "MPU6050 Gyro Z-Achse"
id: mpu_gyro_z
update_interval: 0.1s
lambda: |-
return 0.0f;
- platform: template
name: "Neigung Pitch"
id: pitch
icon: mdi:caravan
unit_of_measurement: "°"
accuracy_decimals: 1
update_interval: 0.1s
lambda: |-
if (isnan(id(accel_x).state) || isnan(id(accel_y).state) || isnan(id(accel_z).state)) {
return NAN;
}
float raw = atan2(id(accel_y).state, sqrt(pow(id(accel_x).state, 2) + pow(id(accel_z).state, 2))) * (180.0 / 3.14159265);
return raw - id(pitch_offset); // Offset wird hier subtrahiert
filters:
- sliding_window_moving_average:
window_size: 8
send_every: 1
- exponential_moving_average:
alpha: 0.2
- platform: template
name: "Neigung Roll"
id: roll
icon: mdi:axis-x-rotate-clockwise
unit_of_measurement: "°"
accuracy_decimals: 1
update_interval: 0.1s
lambda: |-
if (isnan(id(accel_x).state) || isnan(id(accel_z).state)) {
return NAN;
}
float raw = atan2(-id(accel_x).state, id(accel_z).state) * (180.0 / 3.14159265);
return raw - id(roll_offset); // Offset wird hier subtrahiert
filters:
- sliding_window_moving_average:
window_size: 8
send_every: 1
- exponential_moving_average:
alpha: 0.2
globals:
- id: pitch_offset
type: float
restore_value: yes
initial_value: '0.0'
- id: roll_offset
type: float
restore_value: yes
initial_value: '0.0'
# Die Einbaulage liegt im Gerät, nicht in den Apps: Sie beschreibt, wie der
# Sensor im Fahrzeug sitzt eine Eigenschaft des Einbaus, nicht des Telefons.
# Damit sehen iPhone, Uhr und Android dasselbe, ohne sie je einzeln zu
# bestimmen. Angewandt wird sie weiterhin in den Apps; das Gerät verwahrt sie
# nur, sonst rechneten ältere Clients die Korrektur ein zweites Mal.
- id: orientation_version
type: uint8_t
restore_value: yes
initial_value: '0'
- id: orientation_source
type: uint8_t
restore_value: yes
initial_value: '0'
- id: orientation_invert_long
type: bool
restore_value: yes
initial_value: 'false'
- id: orientation_invert_lat
type: bool
restore_value: yes
initial_value: 'false'
- id: orientation_twist
type: float
restore_value: yes
initial_value: '0.0'
- id: enable_captive
type: bool
restore_value: yes
initial_value: 'false'
esp32_ble_server:
services:
- uuid: 2a24b789-7aab-4535-af3e-ee76a35cc42d
advertise: true
characteristics:
- id: pitch_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233424
description: "Pitch"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pitch).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: roll_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233425
description: "Roll"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(roll).state;
memcpy(v.data(), &val, sizeof(float));
return v;
# Die gespeicherten Nullpunkte, zwei Floats. Daran erkennen die Apps,
# ob überhaupt schon kalibriert wurde.
- id: offsets_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233426
description: "Kalibrier-Offsets"
read: true
value: !lambda |-
std::vector<unsigned char> v(2 * sizeof(float));
float p = id(pitch_offset);
float r = id(roll_offset);
memcpy(v.data(), &p, sizeof(float));
memcpy(v.data() + sizeof(float), &r, sizeof(float));
return v;
# Die Einbaulage, acht Byte:
#
# 0 Version, 1 = gültig gesetzt, 0 = nie geschrieben
# 1 Längsachse: 0 = Pitch des Sensors, 1 = Roll des Sensors
# 2 längs umgekehrt (0/1)
# 3 quer umgekehrt (0/1)
# 4..7 Verdrehung um die Hochachse, float32, Grad
- id: orientation_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233428
description: "Einbaulage"
read: true
write: true
value: !lambda |-
std::vector<unsigned char> v(8, 0);
v[0] = id(orientation_version);
v[1] = id(orientation_source);
v[2] = id(orientation_invert_long) ? 1 : 0;
v[3] = id(orientation_invert_lat) ? 1 : 0;
float t = id(orientation_twist);
memcpy(v.data() + 4, &t, sizeof(float));
return v;
on_write:
then:
- lambda: |-
if (x.size() < 8 || x[0] != 1) {
ESP_LOGW("vanalign", "Einbaulage verworfen: %d Byte, Version %d",
(int) x.size(), x.empty() ? -1 : (int) x[0]);
return;
}
float t;
memcpy(&t, x.data() + 4, sizeof(float));
if (!std::isfinite(t) || fabsf(t) > 180.0f) {
ESP_LOGW("vanalign", "Einbaulage verworfen: Verdrehung %.1f", t);
return;
}
id(orientation_version) = 1;
id(orientation_source) = x[1];
id(orientation_invert_long) = x[2] != 0;
id(orientation_invert_lat) = x[3] != 0;
id(orientation_twist) = t;
ESP_LOGI("vanalign", "Einbaulage gespeichert: Quelle=%d laengs=%d quer=%d verdreht=%.1f",
(int) x[1], (int) x[2], (int) x[3], t);
- id: calib_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233427
description: "Kalibriere Neigung"
write: true
on_write:
then:
- lambda: |-
bool reset = !x.empty() && (x[0] == 0x00 || x[0] == '0');
if (reset) {
id(reset_calibration).execute();
} else {
id(calibrate_level).execute();
}
#web_server:
# port: 80
#ota:
# platform: web_server
#wifi:
# ap:
# ssid: "VanAlign-Setup"
# password: "kalibrierung"
script:
# Die aktuelle Lage wird zur neuen Null. Knopf und Bluetooth laufen hier
# zusammen, damit sie nicht auseinanderdriften.
- id: calibrate_level
then:
- lambda: |-
if (isnan(id(accel_x).state) || isnan(id(accel_y).state) || isnan(id(accel_z).state)) {
ESP_LOGW("vanalign", "Kalibrierung abgebrochen: keine Sensorwerte");
return;
}
id(pitch_offset) = atan2(id(accel_y).state, sqrt(pow(id(accel_x).state, 2) + pow(id(accel_z).state, 2))) * (180.0 / 3.14159265);
id(roll_offset) = atan2(-id(accel_x).state, id(accel_z).state) * (180.0 / 3.14159265);
ESP_LOGI("vanalign", "Kalibriert: pitch_offset=%.2f roll_offset=%.2f", id(pitch_offset), id(roll_offset));
- id: reset_calibration
then:
- lambda: |-
id(pitch_offset) = 0.0f;
id(roll_offset) = 0.0f;
ESP_LOGI("vanalign", "Kalibrierung zurückgesetzt");
button:
- platform: template
name: "Kalibriere Neigung"
id: calib_button
on_press:
- script.execute: calibrate_level
- platform: template
name: "Kalibrierung zurücksetzen"
id: calib_reset_button
on_press:
- script.execute: reset_calibration
- platform: restart
name: "ESP Restart"
text_sensor:
- platform: template
name: "Firmware Version"
id: firmware_version
icon: mdi:tag
lambda: |-
return {"v1.0.2-sim"};
+233
View File
@@ -0,0 +1,233 @@
# VanAlign Solar - Votronic Solarladeregler über BLE
#
# Zweiter ESP32 im Fahrzeug, unabhängig vom Neigungssensor (esp32_ble.yaml).
# Liest den Votronic-Solarladeregler über den Displaylink-Port (UART) mit der
# externen Komponente github://syssi/esphome-votronic aus und stellt die
# Werte - genau wie beim Neigungssensor - über einen eigenen BLE-Service
# bereit. Zusätzlich (für Debug-Zwecke) läuft WLAN mit und die Werte werden
# auch auf dem eingebauten Webserver (Port 80) angezeigt - MQTT und die API
# sind weiterhin nicht enthalten. WLAN-Zugangsdaten liegen in secrets.yaml
# (lokal anzulegen, ist per .gitignore ausgeschlossen).
#
# Verkabelung: UART TX=GPIO4, RX=GPIO5 an den Displaylink-Port des Reglers,
# Baudrate 1000 (kein Tippfehler - das Votronic-Protokoll nutzt diese
# ungewöhnlich niedrige Rate). Board/Pins ggf. an die tatsächlich verbaute
# Hardware anpassen, hier als ESP32-S3-DevKitC-1 wie beim Neigungssensor
# angenommen.
#
# BLE-Service 05c9a349-2b8e-4b1d-9c9d-c247e9a6a001 (wird beworben):
#
# Charakteristik UUID (Ende) Inhalt
# Batteriespg. ...a101 Float32 LE, Volt
# PV-Spannung ...a102 Float32 LE, Volt
# PV-Strom ...a103 Float32 LE, Ampere
# PV-Leistung ...a104 Float32 LE, Watt
# Reglertemp. ...a105 Float32 LE, °C
# Statusflags ...a106 1 Byte, Bitmaske: Bit0 Batterie lädt,
# Bit1 Batterie entlädt, Bit2 PV-Regler
# aktiv, Bit3 PV-Strombegrenzung, Bit4 AES
# PV-Modus-ID ...a107 1 Byte, roher Wert aus pv_mode_setting_id
# Batteriestatus ...a108 1 Byte, rohe Bitmaske aus dem Regler
# Reglerstatus ...a109 1 Byte, rohe Bitmaske aus dem Regler
#
# Alle Charakteristiken sind reine Lesewerte, wie beim Neigungssensor fragt
# der Client sie im Takt ab.
esphome:
name: vanalign-solar
friendly_name: "VanAlign Solar"
esp32:
board: esp32-s3-devkitc-1
flash_size: 16MB
framework:
type: esp-idf
# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
psram:
mode: octal
speed: 80MHz
logger:
level: WARN
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
web_server:
port: 80
external_components:
- source: github://syssi/esphome-votronic@main
refresh: 0s
uart:
- id: uart_0
baud_rate: 1000
tx_pin: GPIO4
rx_pin: GPIO5
votronic:
- id: votronic0
uart_id: uart_0
rx_timeout: 150ms
throttle: 2s
sensor:
- platform: votronic
votronic_id: votronic0
battery_computer_battery_voltage:
name: "Batteriespannung"
id: battery_voltage
pv_voltage:
name: "PV Spannung"
id: pv_voltage
pv_current:
name: "PV Strom"
id: pv_current
pv_power:
name: "PV Leistung"
id: pv_power
pv_controller_temperature:
name: "Reglertemperatur"
id: pv_temperature
pv_mode_setting_id:
name: "PV Modus-ID"
id: pv_mode_id
pv_battery_status_bitmask:
name: "PV Batteriestatus (Bitmaske)"
id: pv_battery_status_bitmask
pv_controller_status_bitmask:
name: "PV Reglerstatus (Bitmaske)"
id: pv_controller_status_bitmask
binary_sensor:
- platform: votronic
votronic_id: votronic0
battery_computer_charging:
name: "Batterie lädt"
id: battery_charging
battery_computer_discharging:
name: "Batterie entlädt"
id: battery_discharging
pv_controller_active:
name: "PV Regler aktiv"
id: pv_active
pv_current_reduction:
name: "PV Strombegrenzung"
id: pv_current_reduction
pv_aes_active:
name: "PV AES aktiv"
id: pv_aes_active
text_sensor:
- platform: votronic
votronic_id: votronic0
pv_mode_setting:
name: "PV Modus"
pv_battery_status:
name: "PV Batteriestatus"
pv_controller_status:
name: "PV Reglerstatus"
- platform: template
name: "Firmware Version"
id: firmware_version
icon: mdi:tag
lambda: |-
return {"v1.0.0"};
esp32_ble_server:
services:
- uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a001
advertise: true
characteristics:
- id: battery_voltage_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a101
description: "Batteriespannung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(battery_voltage).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_voltage_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a102
description: "PV Spannung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_voltage).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_current_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a103
description: "PV Strom"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_current).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_power_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a104
description: "PV Leistung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_power).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_temperature_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a105
description: "Reglertemperatur"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_temperature).state;
memcpy(v.data(), &val, sizeof(float));
return v;
# Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
# Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
- id: status_flags_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a106
description: "Statusflags"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
uint8_t flags = 0;
if (id(battery_charging).state) flags |= (1 << 0);
if (id(battery_discharging).state) flags |= (1 << 1);
if (id(pv_active).state) flags |= (1 << 2);
if (id(pv_current_reduction).state) flags |= (1 << 3);
if (id(pv_aes_active).state) flags |= (1 << 4);
v[0] = flags;
return v;
- id: pv_mode_id_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a107
description: "PV Modus-ID"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_mode_id).state;
return v;
- id: pv_battery_status_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a108
description: "PV Batteriestatus (Bitmaske)"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_battery_status_bitmask).state;
return v;
- id: pv_controller_status_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a109
description: "PV Reglerstatus (Bitmaske)"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_controller_status_bitmask).state;
return v;
button:
- platform: restart
name: "ESP Restart"
+265
View File
@@ -0,0 +1,265 @@
# VanAlign Solar - Votronic Solarladeregler über BLE (SIMULATION)
#
# Kopie von esp32_ble_solar.yaml für den Fall, dass gerade kein Votronic-
# Regler zum Anschliessen vorhanden ist. Die `platform: votronic`-Sensoren
# sowie UART/external_components wurden entfernt und durch Template-Sensoren
# ersetzt, die einen plausiblen Tagesverlauf simulieren: PV-Spannung/-Strom
# folgen einem "Sonnenstand" (ein gedachter, ca. 6 Minuten langer Tag), die
# Batteriespannung schwankt gemütlich mit, und die Statusflags/Bitmasken
# leiten sich aus diesen Werten ab. Die BLE-Charakteristiken funktionieren
# dadurch exakt wie im Original - nur eben ohne angeschlossene Hardware.
#
# Name und Friendly Name sind bewusst auf "-sim" abgeändert, damit dieses
# Gerät im BLE-Umfeld nicht mit einem echten VanAlign-Solar-Gerät kollidiert.
#
# Für Debug-Zwecke läuft WLAN mit und die Werte werden zusätzlich auf dem
# eingebauten Webserver (Port 80) angezeigt; WLAN-Zugangsdaten liegen in
# secrets.yaml (lokal anzulegen, ist per .gitignore ausgeschlossen). Alle
# simulierten Werte aktualisieren sich höchstens jede Sekunde, damit sich
# Änderungen beim Debuggen zügig zeigen.
#
# Sobald wieder ein echter Votronic-Regler verfügbar ist, einfach
# esp32_ble_solar.yaml weiterverwenden - diese Datei ist nur zum Testen der
# App/BLE-Anbindung.
esphome:
name: vanalign-solar-sim
friendly_name: "VanAlign Solar (Sim)"
esp32:
board: esp32-s3-devkitc-1
flash_size: 16MB
framework:
type: esp-idf
# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
psram:
mode: octal
speed: 80MHz
logger:
level: WARN
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
web_server:
port: 80
sensor:
# Simulierter "Sonnenstand": 0 nachts, sanfter Buckel tagsüber, Periode
# ca. 6 Minuten - reicht zum Beobachten eines vollen Auf/Ab in der App.
- platform: template
name: "Sonnenstand (Sim)"
id: sun_level
internal: true
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return std::max(0.0f, sinf(t / 180.0f));
- platform: template
name: "Batteriespannung (Sim)"
id: battery_voltage
unit_of_measurement: "V"
accuracy_decimals: 2
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return 12.6f + 0.8f * id(sun_level).state + 0.05f * sin(t / 20.0f);
- platform: template
name: "PV Spannung (Sim)"
id: pv_voltage
unit_of_measurement: "V"
accuracy_decimals: 2
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return id(sun_level).state > 0.01f ? (19.0f + 1.0f * sin(t / 25.0f)) : 0.0f;
- platform: template
name: "PV Strom (Sim)"
id: pv_current
unit_of_measurement: "A"
accuracy_decimals: 2
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return id(sun_level).state * (6.0f + 1.5f * sin(t / 17.0f));
- platform: template
name: "PV Leistung (Sim)"
id: pv_power
unit_of_measurement: "W"
accuracy_decimals: 1
update_interval: 1s
lambda: |-
return id(pv_voltage).state * id(pv_current).state;
- platform: template
name: "Reglertemperatur (Sim)"
id: pv_temperature
unit_of_measurement: "°C"
accuracy_decimals: 1
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return 22.0f + 10.0f * id(sun_level).state + 1.0f * sin(t / 11.0f);
- platform: template
name: "PV Modus-ID (Sim)"
id: pv_mode_id
update_interval: 1s
lambda: |-
return 3.0f; // fester simulierter Modus (MPPT)
- platform: template
name: "PV Batteriestatus (Bitmaske, Sim)"
id: pv_battery_status_bitmask
update_interval: 1s
lambda: |-
return id(sun_level).state > 0.01f ? 1.0f : 0.0f;
- platform: template
name: "PV Reglerstatus (Bitmaske, Sim)"
id: pv_controller_status_bitmask
update_interval: 1s
lambda: |-
return id(sun_level).state > 0.01f ? 2.0f : 0.0f;
binary_sensor:
- platform: template
name: "Batterie lädt (Sim)"
id: battery_charging
lambda: |-
return id(pv_current).state > 0.2f;
- platform: template
name: "Batterie entlädt (Sim)"
id: battery_discharging
lambda: |-
return id(pv_current).state <= 0.2f;
- platform: template
name: "PV Regler aktiv (Sim)"
id: pv_active
lambda: |-
return id(sun_level).state > 0.01f;
- platform: template
name: "PV Strombegrenzung (Sim)"
id: pv_current_reduction
lambda: |-
float t = millis() / 1000.0f;
return id(pv_active).state && sin(t / 60.0f) > 0.9f;
- platform: template
name: "PV AES aktiv (Sim)"
id: pv_aes_active
lambda: |-
return id(battery_voltage).state > 13.3f;
text_sensor:
- platform: template
name: "Firmware Version"
id: firmware_version
icon: mdi:tag
lambda: |-
return {"v1.0.0-sim"};
esp32_ble_server:
services:
- uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a001
advertise: true
characteristics:
- id: battery_voltage_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a101
description: "Batteriespannung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(battery_voltage).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_voltage_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a102
description: "PV Spannung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_voltage).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_current_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a103
description: "PV Strom"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_current).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_power_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a104
description: "PV Leistung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_power).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_temperature_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a105
description: "Reglertemperatur"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_temperature).state;
memcpy(v.data(), &val, sizeof(float));
return v;
# Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
# Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
- id: status_flags_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a106
description: "Statusflags"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
uint8_t flags = 0;
if (id(battery_charging).state) flags |= (1 << 0);
if (id(battery_discharging).state) flags |= (1 << 1);
if (id(pv_active).state) flags |= (1 << 2);
if (id(pv_current_reduction).state) flags |= (1 << 3);
if (id(pv_aes_active).state) flags |= (1 << 4);
v[0] = flags;
return v;
- id: pv_mode_id_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a107
description: "PV Modus-ID"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_mode_id).state;
return v;
- id: pv_battery_status_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a108
description: "PV Batteriestatus (Bitmaske)"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_battery_status_bitmask).state;
return v;
- id: pv_controller_status_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a109
description: "PV Reglerstatus (Bitmaske)"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_controller_status_bitmask).state;
return v;
button:
- platform: restart
name: "ESP Restart"
+11 -9
View File
@@ -5,24 +5,26 @@ set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
OUT=$(mktemp -d) OUT=$(mktemp -d)
swiftc -O -o "$OUT/tests" \ swiftc -O -o "$OUT/tests" \
CamperMonitor/Bluetooth/AESCounterMode.swift \ VanControl/Bluetooth/AESCounterMode.swift \
CamperMonitor/Bluetooth/BitReader.swift \ VanControl/Bluetooth/BitReader.swift \
CamperMonitor/Bluetooth/VictronAdvertisement.swift \ VanControl/Bluetooth/VictronAdvertisement.swift \
CamperMonitor/Bluetooth/DalyProtocol.swift \ VanControl/Bluetooth/DalyProtocol.swift \
CamperMonitor/Bluetooth/DalyState.swift \ VanControl/Bluetooth/DalyState.swift \
CamperMonitor/Bluetooth/JBDProtocol.swift \ VanControl/Bluetooth/JBDProtocol.swift \
CamperMonitor/Bluetooth/WattCycleProtocol.swift \ VanControl/Bluetooth/WattCycleProtocol.swift \
CamperMonitor/Bluetooth/AlpicoolProtocol.swift \ VanControl/Bluetooth/AlpicoolProtocol.swift \
Shared/Bluetooth/VanAlignProtocol.swift \ Shared/Bluetooth/VanAlignProtocol.swift \
Shared/Models/DeviceSnapshot.swift \ Shared/Models/DeviceSnapshot.swift \
Shared/Models/FridgeSettings.swift \ Shared/Models/FridgeSettings.swift \
Shared/Models/ConfiguredDevice.swift \ Shared/Models/ConfiguredDevice.swift \
Shared/Models/VehicleGraphicStyle.swift \
Shared/VehicleTilt.swift \
Shared/Models/LevelState.swift \ Shared/Models/LevelState.swift \
Shared/Models/Profile.swift \ Shared/Models/Profile.swift \
Shared/Models/AlignmentAssistant.swift \ Shared/Models/AlignmentAssistant.swift \
Shared/Models/SensorOrientation.swift \ Shared/Models/SensorOrientation.swift \
Shared/Models/VictronCodes.swift \ Shared/Models/VictronCodes.swift \
Shared/WatchLink/WatchLink.swift \ Shared/WatchLink/WatchLink.swift \
CamperMonitor/Store/KeychainStore.swift \ VanControl/Store/KeychainStore.swift \
Tests/main.swift Tests/main.swift
"$OUT/tests" "$OUT/tests"