Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
141e321a6f | ||
|
|
35f4e6fbcb | ||
|
|
8e5ee6927e | ||
|
|
eed8f33e30 | ||
|
|
b956324234 | ||
|
|
ed026ce580 | ||
|
|
59076076e8 | ||
|
|
003222bf30 | ||
|
|
303a9735d0 | ||
|
|
83ea85f3b8 | ||
|
|
e9b9c5bcd5 |
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,13 @@
|
|||||||
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_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 +516,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 +533,13 @@
|
|||||||
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_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 +548,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 +564,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 +596,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 +626,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 +655,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 +679,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 +749,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 +758,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 +767,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 +776,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 |
|
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:
|
||||||
///
|
///
|
||||||
/// xcrun simctl launch --terminate-running-process \
|
/// * Schalter in den Einstellungen (`SettingsView`) – in jeder
|
||||||
/// booted de.fritob.CamperMonitor
|
/// Build-Konfiguration verfügbar, auch in TestFlight- und
|
||||||
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
|
/// 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:
|
||||||
///
|
///
|
||||||
/// Im normalen Betrieb wird hiervon nichts ausgeführt.
|
/// xcrun simctl launch --terminate-running-process \
|
||||||
|
/// booted de.fritob.VanControl
|
||||||
|
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
|
||||||
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,48 +19,18 @@ 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")
|
||||||
@@ -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)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else if let lift, !lift.isNegligible {
|
|
||||||
WheelLiftPlan(lift: lift)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
|
|
||||||
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
|
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
} else if let lift, !lift.isNegligible {
|
||||||
|
WheelLiftPlan(lift: lift, isCompact: isLandscape)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
if !isLandscape {
|
||||||
|
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
|
||||||
|
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
|
||||||
|
.font(.caption)
|
||||||
|
.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,104 +38,126 @@ 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 {
|
||||||
List {
|
// Die Höhe der Libelle/Fahrzeug-Anzeige im Querformat richtet sich
|
||||||
statusSection
|
// nach der tatsächlich verfügbaren Bildschirmhöhe, nicht nach einem
|
||||||
if needsKeyAttention { keyPrompt }
|
// festen Wert – deshalb misst ein GeometryReader die Liste von aussen.
|
||||||
|
GeometryReader { geometry in
|
||||||
|
List {
|
||||||
|
// Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort
|
||||||
|
// sichtbar sein, ohne erst am Verbindungsstatus
|
||||||
|
// vorbeizuscrollen – der rutscht dort ganz ans Ende. Gilt
|
||||||
|
// nur für den Neigungsmesser, siehe `showsCompactLevelLayout`.
|
||||||
|
if !showsCompactLevelLayout { statusSection }
|
||||||
|
if needsKeyAttention { keyPrompt }
|
||||||
|
|
||||||
if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
|
if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
|
||||||
FridgeControls(device: currentDevice, state: fridge)
|
FridgeControls(device: currentDevice, state: fridge)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
Section("Messwerte") {
|
Section("Messwerte") {
|
||||||
ForEach(snapshot.metrics) { metric in
|
ForEach(snapshot.metrics) { metric in
|
||||||
LabeledContent(metric.label) {
|
LabeledContent(metric.label) {
|
||||||
Text(metric.formattedWithUnit)
|
Text(metric.formattedWithUnit)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(metric.value == nil ? .secondary : .primary)
|
.foregroundStyle(metric.value == nil ? .secondary : .primary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if samples.count > 1, let primary = snapshot?.primaryMetric {
|
if samples.count > 1, let primary = snapshot?.primaryMetric {
|
||||||
Section("Verlauf – \(primary.label)") {
|
Section("Verlauf – \(primary.label)") {
|
||||||
Chart(samples) { sample in
|
Chart(samples) { sample in
|
||||||
AreaMark(x: .value("Zeit", sample.time),
|
AreaMark(x: .value("Zeit", sample.time),
|
||||||
y: .value(primary.label, sample.value))
|
y: .value(primary.label, sample.value))
|
||||||
.foregroundStyle(.tint.opacity(0.15))
|
.foregroundStyle(.tint.opacity(0.15))
|
||||||
LineMark(x: .value("Zeit", sample.time),
|
LineMark(x: .value("Zeit", sample.time),
|
||||||
y: .value(primary.label, sample.value))
|
y: .value(primary.label, sample.value))
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
.interpolationMethod(.monotone)
|
.interpolationMethod(.monotone)
|
||||||
}
|
}
|
||||||
.chartYAxisLabel(primary.unit)
|
.chartYAxisLabel(primary.unit)
|
||||||
.frame(height: 180)
|
.frame(height: 180)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let snapshot, !snapshot.cellVoltages.isEmpty {
|
|
||||||
cellSection(snapshot.cellVoltages)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let snapshot, !snapshot.info.isEmpty {
|
|
||||||
Section("Gerät") {
|
|
||||||
ForEach(snapshot.info) { item in
|
|
||||||
LabeledContent(item.label, value: item.value)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let snapshot, snapshot.temperatures.count > 1 {
|
if let snapshot, !snapshot.cellVoltages.isEmpty {
|
||||||
Section("Temperaturen") {
|
cellSection(snapshot.cellVoltages)
|
||||||
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
}
|
||||||
LabeledContent("Fühler \(index + 1)") {
|
|
||||||
Text(String(format: "%.0f °C", value)).monospacedDigit()
|
if let snapshot, !snapshot.info.isEmpty {
|
||||||
|
Section("Gerät") {
|
||||||
|
ForEach(snapshot.info) { item in
|
||||||
|
LabeledContent(item.label, value: item.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if currentDevice.role.transport == .advertisement {
|
if showsCompactLevelLayout { statusSection }
|
||||||
if showsTechnicalDetails { diagnosticsSection }
|
|
||||||
} else if showsTechnicalDetails {
|
|
||||||
bmsDiagnosticsSection
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsSection
|
if let snapshot, snapshot.temperatures.count > 1 {
|
||||||
}
|
Section("Temperaturen") {
|
||||||
.navigationTitle(currentDevice.name)
|
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
LabeledContent("Fühler \(index + 1)") {
|
||||||
.onDisappear {
|
Text(String(format: "%.0f °C", value)).monospacedDigit()
|
||||||
saveName()
|
}
|
||||||
// Die Kühlbox wird nur verbunden, solange man sie ansieht – jede
|
}
|
||||||
// Verbindung meldet sich an ihrem Display an.
|
}
|
||||||
bluetooth.endSession(for: currentDevice)
|
}
|
||||||
}
|
|
||||||
.onAppear {
|
if currentDevice.role.transport == .advertisement {
|
||||||
editedName = currentDevice.name
|
if showsTechnicalDetails { diagnosticsSection }
|
||||||
keyInput = store.victronKeyText(for: device.id) ?? ""
|
} else if showsTechnicalDetails {
|
||||||
bluetooth.beginSession(for: currentDevice)
|
bmsDiagnosticsSection
|
||||||
}
|
}
|
||||||
.confirmationDialog("Gerät entfernen?",
|
|
||||||
isPresented: $showDeleteConfirmation,
|
settingsSection
|
||||||
titleVisibility: .visible) {
|
}
|
||||||
Button("Entfernen", role: .destructive) {
|
.navigationTitle(currentDevice.name)
|
||||||
store.remove(device)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
bluetooth.refreshConfiguration()
|
.onDisappear {
|
||||||
dismiss()
|
saveName()
|
||||||
|
// Die Kühlbox wird nur verbunden, solange man sie ansieht – jede
|
||||||
|
// Verbindung meldet sich an ihrem Display an.
|
||||||
|
bluetooth.endSession(for: currentDevice)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
editedName = currentDevice.name
|
||||||
|
keyInput = store.victronKeyText(for: device.id) ?? ""
|
||||||
|
bluetooth.beginSession(for: currentDevice)
|
||||||
|
}
|
||||||
|
.confirmationDialog("Gerät entfernen?",
|
||||||
|
isPresented: $showDeleteConfirmation,
|
||||||
|
titleVisibility: .visible) {
|
||||||
|
Button("Entfernen", role: .destructive) {
|
||||||
|
store.remove(device)
|
||||||
|
bluetooth.refreshConfiguration()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
|
||||||
}
|
}
|
||||||
} message: {
|
|
||||||
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,45 +104,76 @@ 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)
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
picker
|
||||||
|
if let instruction = state.instruction {
|
||||||
|
Label(instruction,
|
||||||
|
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||||
|
.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 {
|
||||||
|
Label(instruction,
|
||||||
|
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 24) {
|
||||||
|
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||||||
|
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 8)
|
||||||
switch displayStyle {
|
|
||||||
case .bubble:
|
|
||||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
|
||||||
.frame(maxHeight: 220)
|
|
||||||
case .vehicle:
|
|
||||||
VehicleTiltView(pitch: state.pitch, roll: state.roll)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let instruction = state.instruction {
|
|
||||||
Label(instruction,
|
|
||||||
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 24) {
|
|
||||||
reading("Längs", state.pitch)
|
|
||||||
reading("Quer", state.roll)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -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,31 +15,39 @@ 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 {
|
||||||
VStack(spacing: 20) {
|
if compact {
|
||||||
tiltPanel(image: "VehicleSide",
|
HStack(alignment: .top, spacing: 16) {
|
||||||
angle: pitch,
|
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
|
||||||
title: "Längs",
|
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
|
||||||
lowerLabel: "Front",
|
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
|
||||||
upperLabel: "Heck",
|
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
|
||||||
aspect: VehicleTilt.sideAspect)
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
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)
|
||||||
|
|
||||||
tiltPanel(image: "VehicleRear",
|
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
|
||||||
angle: roll,
|
Text(String(format: "Neigung %.0f-fach überhöht dargestellt – "
|
||||||
title: "Quer",
|
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
|
||||||
lowerLabel: "links",
|
VehicleTilt.exaggeration))
|
||||||
upperLabel: "rechts",
|
.font(.caption2)
|
||||||
aspect: VehicleTilt.rearAspect)
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
|
.frame(maxWidth: .infinity)
|
||||||
Text(String(format: "Neigung %.0f-fach überhöht dargestellt – "
|
}
|
||||||
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
|
|
||||||
VehicleTilt.exaggeration))
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
@@ -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"};
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
|||||||