24 Commits
Author SHA1 Message Date
fototeddyandClaude Sonnet 5 141e321a6f Watch-Target: MARKETING_VERSION-Komma auf Punkt korrigieren
Beim Setzen der Versionsnummer in Xcode ist bei den beiden
VanControlWatch-Konfigurationen "0,1" statt "0.1" hereingerutscht,
vermutlich durch die deutsche Locale beim Zahlen-Eingabefeld. App Store
Connect erwartet ein Punkt-Format; mit dem Komma wäre das beim
Archivieren/Hochladen vermutlich angeeckt.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Build (Debug + Release) grün.

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:08:28 +02:00
fototeddyandClaude Sonnet 5 e9b9c5bcd5 Solar-Integration und dev_watch-Geräte zusammenführen
solar-integration (aus dem separaten VanAligneiOS-Repo) und dev_watch
haben unabhängige Git-Historien, decken aber überlappende und sich
ergänzende Funktionen ab. Übernommen aus solar-integration: Votronic-
Solar-ESP-Anbindung samt Geräterolle, die Live-Activity/Widget-Extension
fürs Sperrbildschirm/Dynamic-Island/CarPlay, das Querformat-Layout für
Libelle/Fahrzeug-Ansicht und Ausrichtungs-Assistent, sowie die
mehreren Fahrzeuggrafik-Stile (Vanster/California). Beibehalten aus
dev_watch: alle zusätzlichen Geräteprotokolle (Daly-/JBD-BMS, Alpicool-
Kühlbox, WattCycle, Victron), die dort zwischenzeitlich entstanden.

Die Xcode-Projektdatei wurde von Hand um die neue Widget-Extension samt
SharedActivity-Gruppe erweitert (Datei-synchronisierte Gruppen, kein
App-Group-Entitlement nötig). Build für App, Watch und Widget-Extension
geprüft (Debug und Release).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:00:14 +02:00
BiasFandClaude Opus 5 812874baef Kühlbox-Kachel: "Soll" dazuschreiben, Betriebsart gross, Hinweis nach vorn
Auf der Kachel stand eine nackte Zahl, und das ist genau die Zahl, die
man für die Innentemperatur hält. Jetzt steht "Soll" darüber – bei zwei
Zonen "Soll links", mit "Soll rechts" gleich daneben statt in der
Nebenwertzeile.

Die Betriebsart bekommt ein eigenes Schild in Titelgrösse, rechts auf
Höhe des Sollwerts: Eco oder Max in der Akzentfarbe hinterlegt, Aus grau.
Ob die Box überhaupt läuft, ist die zweite Frage nach dem Sollwert, und
Platz ist auf der Kachel reichlich.

In der Fusszeile steht der Hinweis aufs Verbinden jetzt vorn: "Verbindet
erst beim Öffnen · zuletzt gestellt vor 12 Minuten". Er erklärt, warum
hier kein Messwert steht – das ist die Frage, die sich zuerst stellt, das
Alter kommt danach.

Im Vorführbetrieb steht die Kühlbox ebenfalls auf "zuletzt gestellt"
statt live. Sonst zeigt er ein Verhalten, das es nicht mehr gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:21:09 +02:00
BiasFandClaude Opus 5 5fec4a55dd Kühlbox nur verbinden, während man sie ansieht
Jede Verbindung meldet sich am Display der Box an – sie piept, und wer
gerade davorsteht, wird gestört. Dauerhaft verbunden zu sein ist dort
also nicht unsichtbar wie beim BMS, sondern lästig.

Die Kühlbox wird deshalb nur noch verbunden, solange ihre Ansicht offen
ist: am iPhone über die Detailansicht, an der Uhr über einen eigenen
Befehl, den sie beim Öffnen und Schliessen schickt. Ein Stellbefehl geht
weiterhin immer durch – ist die Box nicht verbunden, wird er gemerkt und
löst den Verbindungsaufbau aus.

Abgeriegelt sind alle drei Wege, über die bisher verbunden wurde: beim
Start, im Takt des Wiederverbindens, und – das war das eigentliche Loch –
sobald das Gerät in den Werbedaten auftaucht. Da die App dauerhaft
scannt, hätte allein dieser Weg die Box weiter angefunkt. Dass sie in
Reichweite ist, ist kein Grund, sie anzufassen.

Auf der Übersicht steht dafür der zuletzt gestellte Stand statt der
Messwerte: Sollwert, Ein/Aus, Eco oder Max, dazu wann er gestellt wurde.
Das bleibt richtig, auch wenn es von gestern ist – ein Sollwert ändert
sich nur, wenn jemand ihn ändert. Eine Innentemperatur von gestern sähe
dagegen aus wie eine von jetzt, und man würde ihr glauben. Genau das
sichern die neuen Prüfungen ab: Hauptwert ist der Sollwert, gemessene
Temperatur und Bordspannung kommen nicht vor.

Der Stand liegt in den Einstellungen (FridgeSettings) mit einem
Zeitstempel, der „zuletzt geändert" bedeutet und nicht „zuletzt gesehen" –
sonst behauptete die Kachel Frische, wo sich nichts getan hat. Die Uhr
bekommt dasselbe Bild: WatchFridge trägt jetzt ein Kennzeichen isLive.

BMS und Neigungsmesser bleiben dauerhaft verbunden. Sie stören nicht, und
ihre Werte will man laufend sehen.

Nicht nachgezogen ist die Android-App – dort verbindet der
BluetoothManager weiterhin dauerhaft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:00:10 +02:00
BiasFandClaude Opus 5 8aa8658b5c Uhr: Nivellierung auf drei Seiten, Assistent passt ohne Scrollen
Alles auf einem Bildschirm hiess scrollen, und auf dieser Grösse ist das
der falsche Handgriff: Einen Wisch trifft man beim Rangieren auch ohne
hinzusehen, eine Scrollposition nicht. Was gerade zählt, hängt ausserdem
vom Arbeitsschritt ab.

Jetzt drei Seiten zum Wischen:

* Neigung – Fahrzeugansicht oder Libelle, was zu tun ist, Herkunft der
  Werte
* Keile – die Höhe je Rad. Fehlen Spurweite und Radstand, steht das dort,
  statt dass die Seite leer wirkt.
* Ausrichten – der Assistent, vorher hinter einem Knopf

Der Assistent ist dabei enger gesetzt: kleinere Zahl, zwei Zeilen Rat mit
Skalierung, kleinere Libelle, und kein ScrollView mehr. Beim Rangieren
schaut man kurz hin, da darf nichts ausserhalb des Bildes liegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:59:52 +02:00
BiasFandClaude Opus 5 82df240ebc Komplikation: andere Bundle-Kennung, die alte war bei Apple vergeben
Beim Registrieren lehnte Apple `…watchkitapp.complication` ab: „cannot be
registered to your development team because it is not available." Das
heisst dort immer, die Kennung gehört bereits jemandem – vergebene
Kennungen gibt Apple nie wieder frei, auch nicht nach dem Löschen.

Jetzt `…watchkitapp.levelwidget`. An der Erweiterung hing noch nichts,
das kostet also nichts. Der Anfang muss die Kennung der Watch-App
bleiben, das verlangt iOS für App-Erweiterungen; frei wählbar ist nur
der letzte Teil.

Geprüft: Bau läuft durch, die Erweiterung trägt die neue Kennung und den
Erweiterungspunkt com.apple.widgetkit-extension. Auf der Uhr liegt sie
samt Watch-App und startet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:34:35 +02:00
BiasFandClaude Opus 5 0e05ca22d7 Komplikation fürs Zifferblatt, und die App heisst jetzt VanControl Pro
Zwei Dinge in einem Commit, weil sie sich dieselben Dateien teilen:
project.pbxproj und WatchRootView.swift tragen beides.

**Komplikation.** Neues Target CamperMonitorComplication, auf watchOS
sind Komplikationen WidgetKit-Widgets. Es wird in die Watch-App
eingebettet (PlugIns) und bietet die vier Zubehör-Familien an. Ein Tipp
öffnet die App direkt auf der Nivellierung statt in der Geräteliste:
Die Komplikation hängt eine Adresse an, die Watch-App führt dafür jetzt
einen Navigationsweg, den sie von aussen setzen kann. Die Adresse steht
in Shared/WatchDeepLink.swift, weil beide Seiten sie brauchen – ein
Tippfehler auf einer Seite bliebe sonst stumm.

Bewusst ohne Messwert: Eine Komplikation läuft in einem eigenen Prozess
und käme an die Werte nur über eine App-Gruppe, und die verlangt eine
bezahlte Mitgliedschaft. Ein Wert, der beim Blick aufs Zifferblatt
Stunden alt sein kann, wäre ohnehin schlimmer als keiner – man glaubt
ihm. Die Komplikation tut deshalb genau eine Sache, und die zuverlässig.

**Rebranding.** Der sichtbare Name ist überall VanControl Pro:
CFBundleDisplayName beider Apple-Targets, Android-Label, die Texte in
beiden Apps, beide READMEs und der Name der APK im Gitea-Ablauf.

Die Kennungen bleiben, wie sie sind – Bundle-IDs, Android-Paket,
Target-, Ordner- und Schemanamen. Eine geänderte Bundle-ID ist für iOS
eine neue App: Geräte, Fahrzeugprofile und Victron-Schlüssel wären weg,
dazu neue Profile und eine neu zu koppelnde Watch-App. Sichtbar ist
davon nichts, also gibt es dafür auch keinen Grund.

Geprüft: alle drei Apple-Targets bauen, die Erweiterung liegt im
Watch-Bundle unter PlugIns, die Anzeigenamen stimmen im gebauten
Bundle, Tests grün. Die neue Bundle-ID der Erweiterung braucht beim
ersten Lauf aus Xcode ein Profil, das Xcode selbst anlegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:10:54 +02:00
BiasFandClaude Opus 5 f0df433421 Kalibrierung und Einbaulage gehören in den Sensor, nicht in die Apps
Die Kalibrier-Nullpunkte lagen schon immer im ESP und wurden dort auch
abgezogen – nur verriet die Firmware sie niemandem. Die Einbaulage lag
dagegen in jeder App einzeln, und damit stand sie bei drei Endgeräten
dreimal da, im Zweifel dreimal verschieden. Sie beschreibt aber den
Einbau und nicht das Telefon.

Zwei neue Charakteristiken:

    ...3426  Nullpunkte, zwei Floats, nur lesen
    ...3428  Einbaulage, acht Byte, lesen und schreiben

        0      Version, 1 = gültig gesetzt, 0 = nie geschrieben
        1      Längsachse: 0 = Pitch des Sensors, 1 = Roll
        2      längs umgekehrt (0/1)
        3      quer umgekehrt (0/1)
        4..7   Verdrehung um die Hochachse, float32, Grad

Das Versionsbyte löst die Umstellung: Steht dort 0, hat nie jemand
geschrieben – dann gilt weiter, was die App örtlich gespeichert hat, und
beim nächsten Bestimmen wandert sie hinauf. Mit älterer Firmware ohne die
Charakteristik passiert schlicht nichts.

Angewandt wird die Lage weiterhin in den Apps. Der Sensor verwahrt sie
nur: Meldete er die Winkel bereits umgerechnet, rechnete jede ältere App
die Korrektur ein zweites Mal ein.

Beim Verbinden liest jede App die Lage und übernimmt sie; nach dem
Einbaulage-Assistenten schreibt sie sie hinauf. Die Uhr braucht das
iPhone dafür nicht mehr – sie holt sie sich selbst. Die Android-App
bekommt dabei auch die Verdrehung, die ihrer Fassung bisher ganz fehlte:
Modell, Erkennung und Persistenz nachgezogen.

Geprüft: Firmware übersetzt, im erzeugten Code stehen beide
Charakteristiken mit den richtigen Rechten und der Schreib-Handler;
iPhone- und Watch-Ziel bauen; sieben neue Prüfungen in run-tests.sh
nageln das Byte-Format fest, samt Rundlauf und der Bedeutung von
Version 0. Nicht geprüft ist die Android-Fassung: Auf dieser Maschine
liegt nur Java 8, Gradle verlangt mindestens 11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:50:23 +02:00
BiasF 0761706d98 Revert "Neigungsmesser: BLE-Wert bei jeder Messung setzen statt einmal beim Start"
This reverts commit cee9129f66.
2026-08-31 20:29:32 +02:00
BiasFandClaude Opus 5 a9a44b49d6 Einbaulage: Verdrehung um die Hochachse messen – und Geräte dabei nicht verlieren
Sitzt der Sensor schräg statt längs im Fahrzeug, verteilt sich eine reine
Querneigung auf beide Achsen: Das Fahrzeug kippt zur Seite, und die
Längsanzeige kippt sichtbar mit – bei 20° Verdrehung mit gut einem
Drittel des Werts. Achsentausch und Vorzeichen halfen dagegen nicht, die
springen in 90°-Schritten.

SensorOrientation bekommt deshalb "twist", einen stufenlosen Winkel. Der
Einbaulage-Assistent misst ihn ohne zusätzlichen Handgriff mit: Beim
Kippen der Front nach unten dürfte sich nur die Längsneigung ändern;
wandert die Querneigung mit, ist das der gesuchte Winkel. Unter zwei Grad
gilt es als Wackeln der Hand. Angezeigt wird er in der Zusammenfassung
("um 20° verdreht"), herausgerechnet bei jeder Anzeige.

Beim Kalibrieren liesse sich das grundsätzlich nicht ermitteln: Es misst
eine einzige Lage und zieht sie als Nullpunkt ab. Eine Drehung um die
Hochachse steckt darin nicht – eben sieht in jeder Verdrehung gleich aus.

Das neue Feld hat beim Aufspielen sämtliche eingerichteten Geräte
gekostet, und der Grund gehört hierher: Swifts erzeugte Codable-Umsetzung
verlangt beim Lesen jedes Feld, Standardwerte im Code zählen nicht.
Gespeicherte Einbaulagen hatten kein "twist", also scheiterte das Lesen
der Einbaulage, damit des Geräts, damit der ganzen Liste – und das
nächste Speichern schrieb die leere Liste über den Bestand.

Zwei Vorkehrungen dagegen:

* SensorOrientation liest jetzt von Hand und nachsichtig, mit
  decodeIfPresent und Rückfallwerten, wie Profile und ConfiguredDevice es
  längst tun. Jedes künftige Feld gehört dort hinein.
* DeviceStore liest die Liste zweistufig: scheitert sie als Ganzes, wird
  jedes Gerät einzeln versucht und behalten, was lesbar ist. Die
  Rohdaten wandern zusätzlich in einen eigenen Schlüssel, statt beim
  nächsten Speichern überschrieben zu werden.

Beides in run-tests.sh abgesichert: die Erkennung und Rückrechnung der
Verdrehung samt Gegenprobe ohne Korrektur, und das Lesen gespeicherter
Geräte ohne "twist", ganz ohne Einbaulage und mit unbrauchbarer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:40:45 +02:00
BiasFandClaude Opus 5 4936a767a3 Uhr: Verbindungsversuche bremsen, Messwerte drosseln, Bilder verkleinern
Die Uhr hatte Hänger und wurde von watchOS wegen Speicherverbrauch
abgeschossen. Ursache war eine Schleife ohne Bremse: Ist der Sensor
gerade nicht zu haben, folgte auf jeden Verbindungsversuch sofort der
Abbruch, auf den Abbruch sofort die nächste Suche – so schnell, wie das
Funkmodul es zulässt. Jeder Durchlauf schiebt Arbeit auf den
Hauptthread; die Uhr war dann mit Verbindungsaufbau beschäftigt statt
mit Anzeigen.

Drei Änderungen:

* Nach einem Fehlschlag wird gewartet, beginnend bei einer Sekunde und
  verdoppelnd bis acht. Kommen wieder Werte, beginnt die Wartezeit von
  vorn.
* Fundmeldungen, die nach dem Abschalten der Suche noch nachkommen,
  werden verworfen. Sie lösten je einen weiteren Verbindungsversuch aus.
* Messwerte gehen höchstens fünfmal je Sekunde an die Oberfläche. Der
  Sensor meldet zehnmal je Sekunde und für zwei Achsen getrennt, das
  waren bis zu zwanzig Neuzeichnungen pro Sekunde.

Dazu die Fahrzeugbilder der Watch-App von iPhone-Auflösung (925x600 und
600x600) auf Bildschirmgrösse gebracht. Auf einem 40-mm-Display wurden
sie in voller Grösse entpackt, ohne dass davon etwas zu sehen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:40:25 +02:00
BiasFandClaude Opus 5 cee9129f66 Neigungsmesser: BLE-Wert bei jeder Messung setzen statt einmal beim Start
Seit dem Neubau mit ESPHome 2026.6.5 lieferte der Sensor bei jeder Abfrage
denselben Wert. Der Grund steht im erzeugten Code: Ein getemplatetes
"value:" an einer Charakteristik wird von der neuen Fassung in eine
Aktion übersetzt, die genau einmal in setup() abläuft.

    void setup() {
      ...
      esp32_ble_server_..._setvalueaction_id->play();    // Pitch
      esp32_ble_server_..._setvalueaction_id_2->play();  // Roll

Danach stand in der Charakteristik für immer der Wert vom Start – beim
Hochfahren ist das NAN oder die erste Messung. Die Apps lasen also
gewissenhaft eine Zahl, die sich nie mehr änderte; in der Einbaulage-
Bestimmung sah das aus, als merke sich die App sofort einen Wert.

Jetzt setzt jeder Sensor seinen Wert selbst, über on_value. Das
getemplatete "value:" an den Charakteristiken entfällt – ESPHome lehnt
beides nebeneinander ohnehin ab. Im erzeugten Code steht set_value
dadurch nicht mehr in setup(), sondern an den Auslösern der beiden
Sensoren.

Notify bleibt bewusst aus: Die Apps fragen im Takt ab, das funktioniert,
und eine frühere Fassung mit notify liess das Gerät verstummen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:40:10 +02:00
BiasFandClaude Opus 5 396e9ed004 Auffahrkeile: Höhe je Rad statt getrennt für quer und längs
Bisher standen dort zwei Angaben nebeneinander, etwa "rechts 8 cm" und
"vorne 4 cm". Das liest sich wie zwei Aufgaben, ist aber eine: Unter
"rechts" liegen zwei Räder, unter "vorne" auch, und eines davon ist
dasselbe. Ausführen lässt sich das so nicht.

Gerechnet wird jetzt über alle vier Aufstandspunkte auf einmal. Aus
Spurweite, Radstand und den beiden Winkeln ergeben sich deren Höhen;
angehoben wird auf das höchststehende Rad, das liegen bleibt. Aus dem
Beispiel oben werden damit 12 cm vorne rechts, 8 cm hinten rechts, 4 cm
vorne links und nichts hinten links – dieselbe Geometrie, nur richtig
zusammengesetzt.

Angezeigt wird das als Draufsicht auf das Fahrzeug (WheelLiftPlan), auf
iPhone und Uhr dieselbe Ansicht, auf der Uhr kompakt. LevelingWedge
entfällt.

Abgesichert in run-tests.sh: reine Querneigung, reine Längsneigung,
beides zusammen (16,1 / 9,2 / 7,0 / 0 cm), gespiegelte Vorzeichen und
die Fälle ohne Fahrzeugmasse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 19:06:11 +02:00
BiasFandClaude Opus 5 e47ef25188 Uhr: Neigungsmesser direkt, Fahrzeugansicht, kein Kalibrieren
Die Uhr funkt den Neigungsmesser jetzt selbst an, statt die Werte vom
iPhone weiterreichen zu lassen. Bei diesem einen Gerät geht das, und nur
bei ihm: Es bewirbt seinen Dienst, ist also ohne Einrichtung auffindbar,
und es ist unverschlüsselt – es gibt keinen Schlüssel, der auf der Uhr
ein zweites Mal lagern müsste. Damit steht die Nivellierung am Handgelenk
ohne geöffnetes iPhone, und genau dafür hebt man beim Rangieren den Arm.

Batterie, Solar und Kühlbox bleiben beim Weg über das iPhone: Sie lassen
nur eine Verbindung zu, und die Victron-Schlüssel liegen in dessen
Keychain. Die Einbaulage des Sensors reist einmal mit dem Datensatz
herüber und bleibt auf der Uhr gespeichert – ohne sie stünden längs und
quer je nach Einbau vertauscht.

LevelSession und VanAlignProtocol wandern nach Shared/Bluetooth; beide
sind reines CoreBluetooth und laufen auf watchOS unverändert. Die
Fahrzeugansicht gibt es jetzt auch auf der Uhr, umschaltbar zur Libelle;
Überhöhung und Farbregeln stehen in Shared/VehicleTilt.swift, damit
iPhone und Uhr nicht auseinanderlaufen.

Kalibrieren fällt auf der Uhr weg, samt Befehl. Das gehört einmalig ans
iPhone mit ebenem Fahrzeug; ein Knopf dafür am Handgelenk wäre vor allem
eine Gelegenheit, die Nullage aus Versehen zu verstellen.

Zwei Fehler dabei behoben:

* Beim Start meldete die Uhr dem iPhone nie, dass jemand hinschaut –
  onChange(of: scenePhase) feuert beim ersten Erscheinen nicht. Der
  schnelle Sendetakt blieb aus, bis die App einmal im Hintergrund war.
* Ein einmal direkt gemessener Wert hatte für immer Vorrang. Blieb der
  Sensor stehen, ohne die Verbindung zu trennen, klebte die Anzeige
  daran. Jetzt gilt er drei Sekunden als frisch, danach übernimmt der
  Stand des iPhones – mit Alter und Grund daneben.

Ausserdem WATCHOS_DEPLOYMENT_TARGET auf 10.0 (Xcode hatte 11.6 gesetzt)
und die Bluetooth-Begründung für das Watch-Target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:51:38 +02:00
BiasFandClaude Opus 5 6612b07fd5 Apple Watch: Übersicht, Ausrichten mit Vibration, Kühlbox am Handgelenk
Neues Target CamperMonitorWatch, eingebettet in die iPhone-App. Die Uhr
zeigt je Gerät den Hauptwert, die Nivellierung mit Libelle und Keilhöhen,
den Ausrichtungs-Assistenten und stellt die Kühlbox.

Die Uhr funkt nicht selbst, obwohl watchOS das könnte. BMS, Kühlbox und
Neigungsmesser lassen jeweils nur eine Verbindung zu – eine mitlesende Uhr
würde dem iPhone die Verbindung wegnehmen, statt sie zu ergänzen. Und die
Victron-Schlüssel liegen in der Keychain des iPhones; sie ein zweites Mal
auf der Uhr aufzubewahren brächte nichts. Das iPhone bleibt also das
Funkgerät und reicht fertige Messwerte über WatchConnectivity weiter.

Gesendet wird in zwei Takten: ein halber Sekundentakt als Nachricht,
solange die Watch-App im Vordergrund ist und das über eine Frist meldet,
sonst alle zwei Sekunden als Anwendungskontext und nur bei Änderungen.
Läuft die Frist ab, hört das iPhone von selbst wieder auf.

Solange die Uhr hinschaut, hält die App das Funkgerät auch im Hintergrund
am Leben (UIBackgroundModes in Config/CamperMonitor-Info.plist) – beim
Rangieren liegt das iPhone sonst mit dunklem Bildschirm in der Halterung
und die Anzeige am Handgelenk wäre genau dann tot. Die verbundenen Geräte
liefern dabei weiter, die Victron-Werbedaten nicht: ungefiltertes Suchen
lässt iOS im Hintergrund nicht zu.

Der plattformneutrale Modellcode wandert nach Shared/ und wird in beide
Targets übersetzt; LevelState ist dafür aus VanAlignProtocol.swift
herausgelöst. Datensatz und Befehle stehen in Shared/WatchLink und sind
in run-tests.sh abgesichert: Rundlauf, Schutz gegen fremde Formatversion,
Vergleich ohne Zeitstempel und die Auflösung der Zeitangaben.

Geprüft sind der Bau beider Targets samt Einbetten und der Testlauf; auf
echter Hardware noch nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:20:16 +02:00
114 changed files with 7360 additions and 991 deletions
+4 -4
View File
@@ -59,7 +59,7 @@ jobs:
-PversionName="$VERSION" \
-PversionCode="$CODE" --no-daemon
mv app/build/outputs/apk/release/app-release.apk \
"../CamperMonitor-$VERSION.apk"
"../VanControlPro-$VERSION.apk"
- name: An das Release hängen
env:
@@ -88,7 +88,7 @@ jobs:
fi
curl -sf -X POST -H "$AUTH" \
-F "attachment=@CamperMonitor-$VERSION.apk" \
"$API/$ID/assets?name=CamperMonitor-$VERSION.apk" > /dev/null
-F "attachment=@VanControlPro-$VERSION.apk" \
"$API/$ID/assets?name=VanControlPro-$VERSION.apk" > /dev/null
echo "CamperMonitor-$VERSION.apk hängt an Release $GITHUB_REF_NAME."
echo "VanControlPro-$VERSION.apk hängt an Release $GITHUB_REF_NAME."
@@ -372,6 +372,11 @@ class BluetoothManager(
onUpdate = { record(it) },
onStateChange = { handleLinkState(device, it) },
onLevelState = { state -> publish { levelStates[device.id] = state } },
onDeviceOrientation = { orientation ->
// Im Gerät steht, wie der Sensor eingebaut ist für alle
// Clients dasselbe. Also übernehmen statt überschreiben.
adoptOrientation(orientation, device.id)
},
)
session.orientation = device.sensorOrientation
levelSessions[device.address] = session
@@ -642,8 +647,32 @@ class BluetoothManager(
// MARK: - Neigungsmesser
/**
* Nach einer Änderung der Einbaulage aufrufen.
*
* Die Lage wandert zusätzlich ins Gerät. Dort gehört sie hin: Sie
* beschreibt den Einbau, und iPhone wie Uhr finden sie dann vor, ohne dass
* jemand sie ein zweites Mal bestimmen muss.
*/
fun updateSensorOrientation(device: ConfiguredDevice) {
handler.post { levelSessions[device.address]?.orientation = device.sensorOrientation }
handler.post {
val session = levelSessions[device.address] ?: return@post
session.orientation = device.sensorOrientation
session.storeOrientation(device.sensorOrientation)
}
}
/**
* Übernimmt die Einbaulage, die das Gerät meldet.
*
* Steht dort noch nichts, meldet das Gerät nichts dann bleibt es bei der
* örtlich gespeicherten Fassung, und die wird beim nächsten Bestimmen
* hinaufgeschrieben.
*/
private fun adoptOrientation(orientation: SensorOrientation, deviceID: UUID) {
val device = store.devices.firstOrNull { it.id == deviceID } ?: return
if (device.sensorOrientation == orientation) return
store.update(device.copy(sensorOrientation = orientation))
}
fun calibrateLevel(deviceID: UUID) {
@@ -30,6 +30,12 @@ class LevelSession(
private val onUpdate: (DeviceSnapshot) -> Unit,
private val onStateChange: (DeviceLinkState) -> Unit,
private val onLevelState: (LevelState) -> Unit,
/**
* Meldet die Einbaulage, die im Gerät steht. Sie gilt vor der örtlich
* gespeicherten: dort steht, wie der Sensor eingebaut ist, und das ist für
* alle Clients dasselbe.
*/
private val onDeviceOrientation: (SensorOrientation) -> Unit = {},
) {
private companion object {
val SERVICE: UUID = UUID.fromString(VanAlignProtocol.SERVICE_UUID)
@@ -37,6 +43,7 @@ class LevelSession(
val ROLL: UUID = UUID.fromString(VanAlignProtocol.ROLL_UUID)
val OFFSETS: UUID = UUID.fromString(VanAlignProtocol.OFFSETS_UUID)
val CALIBRATE: UUID = UUID.fromString(VanAlignProtocol.CALIBRATE_UUID)
val ORIENTATION: UUID = UUID.fromString(VanAlignProtocol.ORIENTATION_UUID)
val CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
const val POLL_INTERVAL_MS = 1_000L
}
@@ -53,6 +60,7 @@ class LevelSession(
private var rollCharacteristic: BluetoothGattCharacteristic? = null
private var offsetsCharacteristic: BluetoothGattCharacteristic? = null
private var calibrateCharacteristic: BluetoothGattCharacteristic? = null
private var orientationCharacteristic: BluetoothGattCharacteristic? = null
/** Ob die Firmware die Werte von sich aus meldet. */
private var needsPolling = true
@@ -84,6 +92,7 @@ class LevelSession(
rollCharacteristic = null
offsetsCharacteristic = null
calibrateCharacteristic = null
orientationCharacteristic = null
}
/** Setzt die aktuelle Lage als neue Null. */
@@ -107,6 +116,22 @@ class LevelSession(
handler.postDelayed({ readOffsets() }, 400)
}
/**
* Schreibt die Einbaulage ins Gerät, damit alle Clients dieselbe sehen.
*
* Ältere Firmware hat die Charakteristik nicht; dann bleibt es bei der
* örtlich gespeicherten Fassung, und es passiert schlicht nichts.
*/
fun storeOrientation(value: SensorOrientation) {
val characteristic = orientationCharacteristic ?: return
val g = gatt ?: return
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
characteristic.value = VanAlignProtocol.encoded(value)
g.writeCharacteristic(characteristic)
// Zurücklesen, damit gilt, was wirklich im Gerät steht.
handler.postDelayed({ gatt?.readCharacteristic(characteristic) }, 300)
}
private fun readOffsets() {
val characteristic = offsetsCharacteristic ?: return
gatt?.readCharacteristic(characteristic)
@@ -169,6 +194,7 @@ class LevelSession(
rollCharacteristic = service.getCharacteristic(ROLL)
offsetsCharacteristic = service.getCharacteristic(OFFSETS)
calibrateCharacteristic = service.getCharacteristic(CALIBRATE)
orientationCharacteristic = service.getCharacteristic(ORIENTATION)
val canNotify = pitchCharacteristic?.let {
it.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
@@ -267,6 +293,26 @@ class LevelSession(
state = state.copy(pitchOffset = pitch, rollOffset = roll)
}
publish()
// Android lässt nur eine Abfrage gleichzeitig zu, deshalb der
// Reihe nach: erst die Offsets, dann die Einbaulage, dann der
// Takt. Fehlt die Charakteristik ältere Firmware , geht es
// sofort weiter.
val characteristic = orientationCharacteristic
if (characteristic != null) {
gatt?.readCharacteristic(characteristic)
} else {
startPollingIfNeeded()
}
}
ORIENTATION -> {
// Was im Gerät steht, gilt. Steht dort nichts (Version 0),
// bleibt es bei der örtlichen Fassung, und der Aufrufer
// schreibt sie hinauf.
VanAlignProtocol.orientation(value)?.let { stored ->
if (stored != orientation) orientation = stored
onDeviceOrientation(stored)
}
startPollingIfNeeded()
}
}
@@ -206,6 +206,7 @@ class DeviceStore(context: Context) {
put("orientationSource", device.sensorOrientation.longitudinalSource.name)
put("invertLongitudinal", device.sensorOrientation.invertLongitudinal)
put("invertLateral", device.sensorOrientation.invertLateral)
put("twist", device.sensorOrientation.twist)
}
private fun deviceFrom(json: JSONObject): ConfiguredDevice? {
@@ -227,6 +228,9 @@ class DeviceStore(context: Context) {
}.getOrDefault(SensorOrientation.Source.PITCH),
invertLongitudinal = json.optBoolean("invertLongitudinal", false),
invertLateral = json.optBoolean("invertLateral", false),
// Fehlt der Wert, ist er null so überleben Einrichtungen aus
// der Zeit vor der Verdrehung.
twist = json.optDouble("twist", 0.0),
),
)
}
@@ -89,7 +89,7 @@ fun WithBluetoothPermission(content: @Composable () -> Unit) {
if (asked) {
Text(
"Die Freigabe wurde abgelehnt. Sie lässt sich in den " +
"Android-Einstellungen unter „Apps → Camper Monitor" +
"Android-Einstellungen unter „Apps → VanControl Pro" +
"Berechtigungen“ nachholen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
@@ -65,7 +65,7 @@ fun SettingsScreen(store: DeviceStore, onBack: () -> Unit) {
)
Text(
"Camper Monitor liest Victron-Geräte über ihr Advertisement mit " +
"VanControl Pro liest Victron-Geräte über ihr Advertisement mit " +
"(„Instant Readout“) und spricht Batterie, Kühlbox und " +
"Neigungsmesser direkt über Bluetooth an.",
style = MaterialTheme.typography.bodySmall,
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Camper Monitor</string>
<string name="app_name">VanControl Pro</string>
</resources>
@@ -1,6 +1,9 @@
package de.fritob.campermonitor.protocol
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
/**
* Wie der Neigungsmesser im Fahrzeug sitzt.
@@ -21,18 +24,42 @@ data class SensorOrientation(
val longitudinalSource: Source = Source.PITCH,
val invertLongitudinal: Boolean = false,
val invertLateral: Boolean = false,
/**
* Verdrehung des Sensors um die Hochachse, in Grad der Rest, den der
* Achsentausch nicht abdeckt.
*
* Sitzt der Sensor schräg im Fahrzeug, verteilt sich eine reine
* Querneigung auf beide Sensorachsen: Das Fahrzeug kippt zur Seite, die
* Anzeige meldet zusätzlich Längsneigung. Achsentausch und Vorzeichen
* helfen dagegen nicht, die springen in 90°-Schritten.
*/
val twist: Double = 0.0,
) {
/** Welche Achse des Sensors die Längsneigung des Fahrzeugs liefert. */
enum class Source { PITCH, ROLL }
val isIdentity: Boolean get() = this == IDENTITY
/** Rechnet Sensorwerte in Fahrzeugwerte um. */
/**
* Rechnet Sensorwerte in Fahrzeugwerte um.
*
* Zwei Schritte, in dieser Reihenfolge: erst die grobe Zuordnung der
* Achsen samt Vorzeichen, dann die Verdrehung zurückdrehen. Für kleine
* Winkel verhält sich das Wertepaar wie ein Vektor in der Ebene genau
* deshalb lässt sich die Verdrehung überhaupt herausrechnen.
*/
fun apply(pitch: Double?, roll: Double?): Pair<Double?, Double?> {
val longitudinal = if (longitudinalSource == Source.PITCH) pitch else roll
val lateral = if (longitudinalSource == Source.PITCH) roll else pitch
return longitudinal?.let { if (invertLongitudinal) -it else it } to
lateral?.let { if (invertLateral) -it else it }
val mappedLongitudinal = longitudinal?.let { if (invertLongitudinal) -it else it }
val mappedLateral = lateral?.let { if (invertLateral) -it else it }
if (twist == 0.0 || mappedLongitudinal == null || mappedLateral == null) {
return mappedLongitudinal to mappedLateral
}
val angle = Math.toRadians(twist)
return (mappedLongitudinal * cos(angle) + mappedLateral * sin(angle)) to
(-mappedLongitudinal * sin(angle) + mappedLateral * cos(angle))
}
val summary: String
@@ -42,6 +69,7 @@ data class SensorOrientation(
if (longitudinalSource == Source.ROLL) parts.add("Achsen getauscht")
if (invertLongitudinal) parts.add("längs umgekehrt")
if (invertLateral) parts.add("quer umgekehrt")
if (twist != 0.0) parts.add("um %.0f° verdreht".format(twist))
return parts.joinToString(", ")
}
@@ -68,6 +96,9 @@ object OrientationDetection {
/** Soviel deutlicher muss die gewinnende Deutung sein als die andere. */
const val AMBIGUITY_MARGIN = 1.3
/** Ab hier gilt eine Verdrehung als echt und nicht als Wackeln der Hand. */
const val MINIMUM_TWIST = 2.0
data class Reading(val pitch: Double, val roll: Double) {
operator fun minus(other: Reading) = Reading(pitch - other.pitch, roll - other.roll)
}
@@ -131,16 +162,31 @@ object OrientationDetection {
if (abs(longitudinal) < MINIMUM_TILT || abs(lateral) < MINIMUM_TILT) {
return Result.Error(Failure.TOO_LITTLE_MOVEMENT)
}
val coarse = SensorOrientation(
longitudinalSource = source,
// Front nach unten heisst: das Heck steht höher, die
// Längsneigung des Fahrzeugs ist also positiv.
invertLongitudinal = longitudinal < 0,
// Linke Seite nach unten heisst: rechts steht höher, die
// Querneigung ist positiv.
invertLateral = lateral < 0,
)
// Was nach dem Achsentausch noch übrig ist, ist die Verdrehung um die
// Hochachse. Beim Kippen der Front nach unten dürfte sich nur die
// Längsneigung ändern; wandert die Querneigung mit, sitzt der Sensor
// schräg und zwar um genau diesen Winkel.
val (correctedLong, correctedLat) = coarse.apply(nose.pitch, nose.roll)
val residual = if (correctedLong != null && correctedLat != null) {
Math.toDegrees(atan2(correctedLat, correctedLong))
} else {
0.0
}
// Unter zwei Grad ist es Messrauschen. Zwei Kippbewegungen von Hand
// sind nicht genauer, und eine erfundene Verdrehung wäre schlimmer
// als keine.
return Result.Success(
SensorOrientation(
longitudinalSource = source,
// Front nach unten heisst: das Heck steht höher, die
// Längsneigung des Fahrzeugs ist also positiv.
invertLongitudinal = longitudinal < 0,
// Linke Seite nach unten heisst: rechts steht höher, die
// Querneigung ist positiv.
invertLateral = lateral < 0,
)
coarse.copy(twist = if (abs(residual) >= MINIMUM_TWIST) residual else 0.0)
)
}
}
@@ -24,6 +24,9 @@ object VanAlignProtocol {
/** Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage. */
const val CALIBRATE_UUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427"
/** Acht Byte: die Einbaulage, lesbar und schreibbar. Siehe [orientation]. */
const val ORIENTATION_UUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233428"
val calibrateCommand = byteArrayOf(0x01)
val resetCommand = byteArrayOf(0x00)
@@ -50,6 +53,55 @@ object VanAlignProtocol {
return value.toDouble()
}
/**
* Die Einbaulage, wie sie im Gerät liegt.
*
* 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
*
* Sie gehört ins Gerät, weil sie den Einbau beschreibt und nicht das
* Telefon: iPhone, Uhr und Android sollen dieselbe sehen, ohne sie je
* einzeln zu bestimmen. Gerechnet wird trotzdem in den Apps das Gerät
* verwahrt sie nur, sonst rechnete ein älterer Client die Korrektur ein
* zweites Mal ein.
*
* Version 0 heisst „hier stand noch nie etwas" und ergibt null; dann gilt,
* was die App örtlich gespeichert hat, und sie schreibt es hinauf.
*/
fun orientation(data: ByteArray): SensorOrientation? {
if (data.size < 8 || data.u(0) != 1) return null
val twist = angle(data, 4) ?: return null
return SensorOrientation(
longitudinalSource = if (data.u(1) == 1) {
SensorOrientation.Source.ROLL
} else {
SensorOrientation.Source.PITCH
},
invertLongitudinal = data.u(2) != 0,
invertLateral = data.u(3) != 0,
twist = twist,
)
}
/** Dieselben acht Byte in die andere Richtung. */
fun encoded(orientation: SensorOrientation): ByteArray {
val bytes = ByteArray(8)
bytes[0] = 1
bytes[1] = if (orientation.longitudinalSource == SensorOrientation.Source.ROLL) 1 else 0
bytes[2] = if (orientation.invertLongitudinal) 1 else 0
bytes[3] = if (orientation.invertLateral) 1 else 0
val raw = orientation.twist.toFloat().toRawBits()
for (index in 0 until 4) {
bytes[4 + index] = ((raw shr (8 * index)) and 0xFF).toByte()
}
return bytes
}
/** Die beiden gespeicherten Offsets. */
fun offsets(data: ByteArray): Pair<Double, Double>? {
if (data.size < 8) return null
-266
View File
@@ -1,266 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
AA0000000000000000000001 /* CamperMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
AA0000000000000000000002 /* CamperMonitor */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = CamperMonitor;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
AA0000000000000000000003 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
AA0000000000000000000004 = {
isa = PBXGroup;
children = (
AA0000000000000000000002 /* CamperMonitor */,
AA0000000000000000000005 /* Products */,
);
sourceTree = "<group>";
};
AA0000000000000000000005 /* Products */ = {
isa = PBXGroup;
children = (
AA0000000000000000000001 /* CamperMonitor.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
AA0000000000000000000006 /* CamperMonitor */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "CamperMonitor" */;
buildPhases = (
AA0000000000000000000007 /* Sources */,
AA0000000000000000000003 /* Frameworks */,
AA0000000000000000000008 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
AA0000000000000000000002 /* CamperMonitor */,
);
name = CamperMonitor;
productName = CamperMonitor;
productReference = AA0000000000000000000001 /* CamperMonitor.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
AA0000000000000000000009 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2660;
LastUpgradeCheck = 2660;
TargetAttributes = {
AA0000000000000000000006 = {
CreatedOnToolsVersion = 26.6;
};
};
};
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
de,
);
mainGroup = AA0000000000000000000004;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = AA0000000000000000000005 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
AA0000000000000000000006 /* CamperMonitor */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
AA0000000000000000000008 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
AA0000000000000000000007 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
AA0000000000000000000012 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
AA0000000000000000000013 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_VERSION = 5.0;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
AA0000000000000000000014 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = 6AP73NYV5W;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA0000000000000000000015 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = 6AP73NYV5W;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000012 /* Debug */,
AA0000000000000000000013 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "CamperMonitor" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000014 /* Debug */,
AA0000000000000000000015 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = AA0000000000000000000009 /* Project object */;
}
-31
View File
@@ -1,31 +0,0 @@
import SwiftUI
@main
struct CamperMonitorApp: App {
@State private var store: DeviceStore
@State private var bluetooth: BluetoothManager
@Environment(\.scenePhase) private var scenePhase
init() {
let store = DeviceStore()
_store = State(initialValue: store)
_bluetooth = State(initialValue: BluetoothManager(store: store))
}
var body: some Scene {
WindowGroup {
DashboardView()
.environment(store)
.environment(bluetooth)
}
.onChange(of: scenePhase) { _, phase in
// Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt
// werden, also Funk sparen und beim Zurückkommen neu starten.
switch phase {
case .active: bluetooth.start()
case .background: bluetooth.stop()
default: break
}
}
}
}
-137
View File
@@ -1,137 +0,0 @@
import SwiftUI
/// Kachel auf dem Dashboard: Hauptwert gross, darunter die wichtigsten
/// Nebenwerte und der Verbindungszustand.
struct DeviceCard: View {
let device: ConfiguredDevice
let snapshot: DeviceSnapshot?
let linkState: DeviceLinkState
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
if let snapshot, let primary = snapshot.primaryMetric {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(primary.formatted)
.font(.system(size: 44, weight: .semibold, design: .rounded))
.contentTransition(.numericText())
Text(primary.unit)
.font(.title3.weight(.medium))
.foregroundStyle(.secondary)
}
.foregroundStyle(snapshot.isStale ? .secondary : .primary)
secondaryValues(for: snapshot)
} else {
Text(placeholderText)
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 18)
}
footer
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
}
private var header: some View {
HStack(spacing: 8) {
Image(systemName: device.role.symbol)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 1) {
Text(device.name)
.font(.headline)
Text(device.role.title)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true)
}
}
private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
let others = snapshot.metrics
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
.prefix(3)
return HStack(spacing: 16) {
ForEach(Array(others)) { metric in
VStack(alignment: .leading, spacing: 1) {
Text(metric.formattedWithUnit)
.font(.subheadline.weight(.medium))
.monospacedDigit()
Text(metric.label)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
}
@ViewBuilder
private var footer: some View {
if let fault = snapshot?.fault {
Label(fault, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.red)
.lineLimit(2)
} else if let state = snapshot?.state {
// Bei "Aus" ist erst der Grund die eigentliche Information.
let reason = snapshot?.offReasons.first
Text(reason.map { "\(state) · \($0)" } ?? state)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else if case .failed(let message) = linkState {
Text(message)
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(2)
}
}
private var placeholderText: String {
switch linkState {
case .needsKey: return "Verschlüsselungsschlüssel fehlt im Detail eintragen."
case .failed(let message): return message
default: return "Warte auf Daten…"
}
}
}
/// Kleiner Punkt, der Verbindungszustand und Aktualität zusammenfasst.
struct StatusDot: View {
let linkState: DeviceLinkState
let isStale: Bool
var body: some View {
HStack(spacing: 5) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private var color: Color {
switch linkState {
case .live: return isStale ? .orange : .green
case .needsKey: return .orange
case .failed: return .red
default: return .secondary
}
}
private var label: String {
if case .live = linkState, isStale { return "Veraltet" }
return linkState.label
}
}
-116
View File
@@ -1,116 +0,0 @@
import SwiftUI
/// Zeigt die Neigung am Fahrzeug selbst, statt an einer abstrakten Blase.
///
/// Zwei Ansichten, jede um ihre Achse gekippt:
///
/// * **Seitenansicht** für die Längsneigung. Die Front zeigt nach links, das
/// Heck nach rechts.
/// * **Heckansicht** für die Querneigung. Sie teilt die Blickrichtung des
/// Fahrers, links im Bild ist also links am Fahrzeug bei einer
/// Frontansicht wäre es seitenverkehrt.
///
/// In beiden Fällen wird gegen den mathematischen Drehsinn gekippt: Steht das
/// Heck höher, muss die rechte Bildseite nach oben.
struct VehicleTiltView: View {
let pitch: Double?
let roll: Double?
/// Kleine Neigungen sind am Fahrzeug sonst kaum zu erkennen zwei Grad
/// wären ein knappes Grad Bildneigung. Die Überhöhung wird angeschrieben,
/// damit niemand den Winkel für bare Münze nimmt.
static let exaggeration: Double = 3
var body: some View {
VStack(spacing: 20) {
tiltPanel(image: "VehicleSide",
angle: pitch,
title: "Längs",
lowerLabel: "Front",
upperLabel: "Heck",
aspect: 925.0 / 600.0)
tiltPanel(image: "VehicleRear",
angle: roll,
title: "Quer",
lowerLabel: "links",
upperLabel: "rechts",
aspect: 1)
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
Text(String(format: "Neigung %.0f-fach überhöht dargestellt "
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
Self.exaggeration))
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
}
private func tiltPanel(image: String,
angle: Double?,
title: String,
lowerLabel: String,
upperLabel: String,
aspect: Double) -> some View {
VStack(spacing: 8) {
HStack {
Text(title)
.font(.subheadline.weight(.medium))
Spacer()
Text(angle.map { String(format: "%.1f°", $0) } ?? "")
.font(.subheadline.weight(.semibold).monospacedDigit())
.foregroundStyle(colour(for: angle))
}
ZStack {
// Waagerechte als Bezug ohne sie ist eine kleine Neigung
// nicht einzuschätzen.
Rectangle()
.fill(Color.secondary.opacity(0.35))
.frame(height: 1)
Image(image)
.resizable()
.scaledToFit()
.foregroundStyle(colour(for: angle))
.aspectRatio(aspect, contentMode: .fit)
.rotationEffect(.degrees(-(angle ?? 0) * Self.exaggeration))
.animation(.spring(duration: 0.4), value: angle)
.opacity(angle == nil ? 0.3 : 1)
}
.frame(height: 110)
HStack {
Text(lowerLabel)
Spacer()
Text(upperLabel)
}
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private func colour(for angle: Double?) -> Color {
guard let angle else { return .secondary }
if abs(angle) <= LevelState.levelTolerance { return .green }
if abs(angle) <= 2 { return .orange }
return .red
}
}
/// Umschalter zwischen den beiden Darstellungen, gemerkt über Starts hinweg.
enum LevelDisplayStyle: String, CaseIterable, Identifiable {
case bubble
case vehicle
var id: String { rawValue }
var title: String {
switch self {
case .bubble: return "Libelle"
case .vehicle: return "Fahrzeug"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
<?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>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
</dict>
</plist>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
+330 -28
View File
@@ -1,4 +1,4 @@
# Camper Monitor
# VanControl Pro
iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest:
@@ -9,24 +9,59 @@ iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest:
| Batterie-BMS (Bulltron/Daly, WattCycle) | GATT-Verbindung, alle 5 s abgefragt | SoC, Spannung, Strom, Restkapazität, alle Einzelzellspannungen, Zelldifferenz, Temperaturen, Zyklen, MOSFET-Status |
| Kompressor-Kühlbox (Alpicool und Baugleiche) | GATT-Verbindung, lesen **und** stellen | Ist- und Solltemperatur je Zone, Betriebsart, Kompressorstatus, Bordspannung, Batterieanzeige |
| Neigungsmesser (VanAlign Pro) | GATT-Verbindung | Längs- und Querneigung als Libelle, Kalibrierung aus der App |
| Votronic-Solarladeregler (über eigene ESP32-Bridge) | GATT-Verbindung, im Takt abgefragt | PV-Leistung, PV-Spannung/-Strom, Batteriespannung, Reglertemperatur, Lade-/Reglerstatus |
Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt.
Victron- und Votronic-Solarladeregler sind zwei getrennte Geräterollen, absichtlich
nicht zusammengelegt: Der Victron-Regler meldet sich passiv und verschlüsselt im
Advertisement, der Votronic-Regler hängt über eine eigene ESP32-Bridge (siehe
unten) an einer GATT-Verbindung grundverschiedene Übertragungswege für
grundverschiedene Hardware. In Code und Einstellungen tragen beide deshalb
konsequent `victron`/`votronic` im Namen.
Der Neigungsmesser zeigt sich zusätzlich als **Live Activity** auf Sperrbildschirm,
Dynamic Island und seit iOS 26 automatisch im CarPlay-Dashboard, siehe
[Live Activity](#live-activity).
Dazu gehört eine **Apple-Watch-App**: Übersicht, Nivellierung samt
Ausrichtungs-Assistent mit Vibration und die Steuerung der Kühlbox am
Handgelenk. Den Neigungsmesser funkt die Uhr selbst an, alles Übrige kommt
über das iPhone warum diese Aufteilung, steht unter
[Apple Watch](#apple-watch).
Die Firmware des Neigungsmessers liegt mit im Projekt, siehe
[firmware/vanalign](firmware/vanalign/README.md).
[firmware/vanalign](firmware/vanalign/README.md). Der Votronic-Solarregler hängt
an einer zweiten, unabhängigen ESP32-Bridge Firmware und BLE-Dienst dafür
liegen als `firmware/vanalign/esp32_ble_solar.yaml` im selben Ordner.
## Bauen und installieren
```bash
open /Users/fritob/GIT/Camper-Management/CamperMonitor.xcodeproj
open VanControl.xcodeproj
```
Dann in Xcode:
1. Target `CamperMonitor`**Signing & Capabilities** → dein Apple-Team auswählen.
Die Bundle-ID `de.fritob.CamperMonitor` ggf. anpassen, falls sie schon vergeben ist.
1. Bei allen vier Targets `VanControl`, `VanControlWatch`,
`VanControlComplication`, `VanControlLiveActivityExtension` unter
**Signing & Capabilities** dein Apple-Team auswählen. Die Bundle-IDs ggf.
anpassen, falls sie schon vergeben sind; die der Watch-App muss die des
iPhones mit angehängtem `.watchkitapp` bleiben, die der Komplikation
zusätzlich `.levelwidget`.
2. iPhone per Kabel anschließen, oben als Ziel wählen, ⌘R.
Das Schema `VanControl` baut die Watch-App mit und bettet sie ein. Dafür
muss die watchOS-Plattform in Xcode installiert sein sonst bricht schon das
Übersetzen des Symbolkatalogs ab:
```bash
xcodebuild -downloadPlatform watchOS
```
Auf die Uhr kommt die App danach von selbst: Sie steckt in der iPhone-App und
taucht in der Watch-App des iPhones unter *Verfügbare Apps* auf.
Wichtig: **Der Simulator hat kein Bluetooth.** Die App startet dort, findet aber
nie ein Gerät. Zum Testen muss sie auf ein echtes iPhone.
@@ -101,7 +136,7 @@ Protokoll erkannt wurde, steht in der Detailansicht unter **Diagnose**
zusammen mit der letzten Rohantwort.
Nur **eine** App gleichzeitig kann mit dem BMS verbunden sein. Wenn die
Hersteller-App offen ist, bekommt Camper Monitor keine Verbindung.
Hersteller-App offen ist, bekommt VanControl Pro keine Verbindung.
### Kühlbox
@@ -113,6 +148,23 @@ Beim Einrichten Art auf **Kühlbox** stellen. Die App meldet sich beim Verbinden
selbst an; steht **APP** im Display der Box, verlangt sie dabei einen
Tastendruck am Gerät.
**Verbunden wird nur, während die Kühlbox geöffnet ist.** Jede Verbindung
meldet sich an ihrem Display an und stört, wer gerade davorsteht; und ihre Werte
ändern sich langsam. Auf der Übersicht steht deshalb nicht die gemessene
Innentemperatur, sondern der **zuletzt gestellte Stand** Sollwert, Ein/Aus,
Eco oder Max, dazu wann er gestellt wurde. Der bleibt richtig, auch wenn er von
gestern ist: Ein Sollwert ändert sich nur, wenn jemand ihn ändert. Eine
Innentemperatur von gestern sähe dagegen aus wie eine von jetzt.
Sobald du die Box öffnest, verbindet die App sich und zeigt alles live; beim
Verlassen der Ansicht trennt sie wieder. Dasselbe gilt für die Uhr: Auch von
dort wird die Verbindung nur angefordert, solange die Kühlbox-Ansicht offen ist.
Ein Stellbefehl geht ebenfalls immer durch ist die Box gerade nicht verbunden,
wird er gemerkt und geht raus, sobald sie antwortet.
BMS und Neigungsmesser bleiben dagegen dauerhaft verbunden. Sie stören dabei
nicht, und ihre Werte will man laufend sehen.
Neben den Messwerten gibt es hier als einzigem Gerät auch Bedienelemente:
Ein/Aus, Eco oder Max, Solltemperatur je Zone und die Bedienfeldsperre. Alle
Schalter zeigen den Stand, den die Box zurückmeldet nach jedem Stellbefehl
@@ -125,6 +177,21 @@ sie mit, was einen Quervergleich zu Batterie und Solarregler erlaubt.
Protokoll und Feldbelegung stammen aus
[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
Der Neigungsmesser [VanAlign Pro](https://github.com/) ist ein ESP32 mit
@@ -145,10 +212,17 @@ hinweg gemerkt:
In beiden Fällen: grün heißt eben (bis 0,5°), orange bis zwei Grad, darüber
rot. Dazu steht in Worten, welche Seite höher steht.
Die Fahrzeugzeichnungen stammen aus dem Ursprungsprojekt VanAlign Pro. Sie
liegen als Schablonen im Asset-Katalog und werden je nach Abweichung
eingefärbt; die Helligkeit des Originals wurde dafür in Deckkraft übersetzt,
damit Fenster und Konturen beim Einfärben erhalten bleiben.
Die Fahrzeugansicht kennt mehrere Grafikstile (aktuell Vanster und
California), einstellbar je Fahrzeugprofil. Die Zeichnungen stammen aus dem
Ursprungsprojekt VanAlign Pro. Sie liegen als Schablonen im Asset-Katalog und
werden je nach Abweichung eingefärbt; die Helligkeit des Originals wurde
dafür in Deckkraft übersetzt, damit Fenster und Konturen beim Einfärben
erhalten bleiben.
Im Querformat rücken Libelle bzw. Fahrzeugansicht und die übrigen Angaben
nebeneinander statt untereinander gedacht fürs Handy in der Halterung beim
Rangieren, wo man beiläufig hinschaut statt zu scrollen. Das gilt auch für
den Ausrichtungs-Assistenten weiter unten.
**Vor der ersten Nutzung die Einbaulage bestimmen.** Sitzt der Sensor quer,
gedreht oder kopfüber, meldet er längs und quer vertauscht oder mit falschem
@@ -156,6 +230,46 @@ Vorzeichen. Der Assistent unter *Sensor → Einbaulage* klärt das durch zwei
Kippbewegungen: einmal die Front nach unten, einmal die linke Seite. Aus der
Reaktion ergibt sich die Zuordnung geraten wird nichts.
Dabei wird auch eine **Verdrehung um die Hochachse** mitgemessen, also der
Fall, dass der Sensor schräg statt längs im Fahrzeug klebt. Ohne diese
Korrektur verteilt sich eine reine Querneigung auf beide Achsen: Das Fahrzeug
kippt zur Seite, und die Längsanzeige kippt sichtbar mit bei 20° Verdrehung
mit gut einem Drittel des Werts. Der Winkel steht im Ergebnis des Assistenten
(„um 20° verdreht") und wird von da an herausgerechnet.
Beim **Kalibrieren** liesse sich das nicht ermitteln, und zwar grundsätzlich
nicht: Es misst eine einzige Lage und zieht sie als Nullpunkt ab. Eine Drehung
um die Hochachse steckt darin nicht eben sieht in jeder Verdrehung gleich
aus. Dafür braucht es zwei Kippbewegungen in bekannte Richtungen, und genau die
macht der Einbaulage-Assistent.
### Beides liegt im Sensor, nicht in der App
Sowohl die **Kalibrierung** als auch die **Einbaulage** speichert der ESP32
selbst und gibt sie an jeden aus, der fragt. Das ist der Punkt, sobald mehr als
ein Gerät im Spiel ist: iPhone, Apple Watch und Android-App zeigen dasselbe,
und bestimmt werden muss beides nur ein einziges Mal, von welchem Gerät aus
auch immer.
| | wo |
|---|---|
| Nullpunkt der Kalibrierung | im ESP (`pitch_offset`, `roll_offset`), wird dort auch schon abgezogen |
| Einbaulage samt Verdrehung | im ESP, Charakteristik `…3428`, lesbar und schreibbar |
| Spurweite und Radstand | in der jeweiligen App, im Fahrzeugprofil |
Beim Verbinden liest jede App die Einbaulage aus dem Gerät und übernimmt sie.
Wer sie neu bestimmt, schreibt sie hinauf. Steht dort noch nichts ältere
Firmware oder nie bestimmt , gilt weiter, was die App örtlich gespeichert hat;
kaputtgehen kann dabei nichts.
Angewandt wird sie trotzdem in den Apps und nicht im Sensor. Der Sensor
verwahrt sie nur: Würde er die Winkel schon umgerechnet melden, rechnete jede
ältere App die Korrektur ein zweites Mal ein.
Die acht Byte der Charakteristik sind in `VanAlignProtocol` beschrieben und in
`run-tests.sh` byteweise festgenagelt daran hängen drei Apps und die
Firmware.
**Danach kalibrieren:** Fahrzeug eben stellen, dann *Auf
aktuelle Lage kalibrieren*. Ohne das zeigt die Anzeige die Lage des Sensors,
nicht die des Fahrzeugs je nachdem, wie schief er eingebaut ist.
@@ -186,10 +300,112 @@ Das steckt vollständig im zeitlichen Verlauf der Neigung ohne jede Annahme
über das Gelände.
Sind beim Fahrzeug **Spurweite und Radstand** hinterlegt, rechnet der Assistent
zusätzlich die nötige Höhe der Auffahrkeile aus. Das ist reine Geometrie und
damit exakt: 2,0° Querneigung bei 2,00 m Spurweite ergeben 7,0 cm unter die
tieferstehende Seite. Die Maße stehen im Fahrzeugprofil, erreichbar über das
ⓘ in der Fahrzeugliste.
zusätzlich die nötige Höhe der Auffahrkeile aus **je Rad**, als Draufsicht auf
das Fahrzeug. Getrennte Angaben für quer und längs wären irreführend: „rechts
8 cm" und „vorne 4 cm" beschreiben dasselbe Fahrzeug, und unter beiden liegt
teils dasselbe Rad. Es steht auf vier Punkten, also gehören vier Zahlen hin.
Das ist reine Geometrie und damit exakt: 2,0° Querneigung bei 2,00 m Spurweite
ergeben 7,0 cm unter beide Räder der tieferstehenden Seite; kommen 1,5°
Längsneigung bei 3,50 m Radstand dazu, braucht die tiefste Ecke 16,1 cm, ihre
Nachbarn 9,2 und 7,0 cm, und das höchststehende Rad bleibt liegen. Die Maße
stehen im Fahrzeugprofil, erreichbar über das ⓘ in der Fahrzeugliste.
### Live Activity
In der Nivellierungs-Ansicht lässt sich eine Live Activity einschalten, die
die Neigung auch bei gesperrtem Bildschirm zeigt: Sperrbildschirm, Dynamic
Island und seit iOS 26, ohne eigenes CarPlay-Ziel nötig automatisch im
CarPlay-Dashboard. Der Schalter ist deaktiviert, solange keine Messwerte
anliegen, sonst startete die Anzeige gleich mit einem veralteten Stand.
Die Aktualisierung ist bewusst auf höchstens einmal je Sekunde gedrosselt.
Der Neigungsmesser liefert deutlich öfter, und stiesse jede Messung sofort
eine Aktualisierung an, drosselt iOS das von sich aus zunehmend stärker
sichtbar vor allem in CarPlay, dessen Dashboard ohnehin zurückhaltender
aktualisiert als Sperrbildschirm oder Dynamic Island. Innerhalb der
Sperrfrist eingehende Messwerte werden nicht verworfen, sondern der
jeweils neueste für ihr Ende vorgemerkt.
Ein kurzer Verbindungsabbruch beendet die Aktivität nicht sofort erst nach
zwölf Sekunden ohne Verbindung, und meldet sich das Gerät vorher zurück,
läuft sie unverändert weiter. War sie wegen einer längeren Trennung wirklich
beendet, startet sie bei der nächsten Verbindung automatisch neu.
## Apple Watch
Die Uhr zeigt dasselbe wie das Dashboard, nur auf das eingedampft, wofür man
den Arm hebt:
* **Übersicht** je Gerät der Hauptwert und ein Punkt für den Zustand der
Verbindung. Ganz unten steht, wie alt der Stand ist.
* **Nivellierung** drei Seiten zum Wischen, weil auf diesem Bildschirm ein
Wisch besser zu treffen ist als eine Scrollposition:
1. *Neigung* wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und
Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über
Starts hinweg gemerkt. Dazu, was zu tun ist, und woher die Werte kommen.
2. *Keile* die Höhe je Rad als Draufsicht. Fehlen Spurweite und Radstand im
Fahrzeugprofil, steht das dort statt einer leeren Seite.
3. *Ausrichten* der Assistent fürs Rangieren.
Kalibriert wird nur am iPhone: Das gehört einmalig auf ebenen Boden, und ein
Knopf dafür an der Uhr wäre vor allem eine Gelegenheit, die Nullage aus
Versehen zu verstellen.
* **Ausrichten** derselbe Assistent wie am iPhone: Tendenz, Rat und der
Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das
Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn
gegenüber dem iPhone beim Rangieren schaut niemand aufs Display, aber die
Vibration am Handgelenk kommt an.
* **Kühlbox** ein/aus, Eco oder Max und die Solltemperatur, über die Krone
gestellt. Verbunden wird die Box erst beim Öffnen dieser Ansicht, davor steht
dort der zuletzt gestellte Stand.
### Wer mit wem funkt
Die Aufteilung folgt dem, was jedes Gerät hergibt.
**Den Neigungsmesser funkt die Uhr selbst an.** Er bewirbt seinen Dienst, ist
also ohne jede Einrichtung auffindbar, und er ist unverschlüsselt es gibt
keinen Schlüssel, der auf der Uhr ein zweites Mal lagern müsste. Damit steht
die Nivellierung am Handgelenk **ohne iPhone**: kein geöffnetes Telefon, keine
Reichweite dorthin. Genau dafür hebt man beim Rangieren den Arm.
Die Einbaulage des Sensors reist einmal vom iPhone herüber und bleibt auf der
Uhr gespeichert. Ohne sie stünden längs und quer je nach Einbau vertauscht oder
mit falschem Vorzeichen eingestellt wird sie weiterhin nur am iPhone, im
Assistenten dort.
**Alles Übrige kommt über das iPhone.** Zwei Gründe, beide hart:
* BMS und Kühlbox lassen jeweils nur **eine** Verbindung zu. Eine mitlesende
Uhr nähme dem iPhone die Verbindung weg, statt sie zu ergänzen.
* Die Victron-Schlüssel liegen in der Keychain des iPhones. Sie auf die Uhr zu
kopieren hiesse, sie ein zweites Mal aufzubewahren, ohne dass der zweite Ort
irgendetwas brächte.
Was die Uhr stellt, stellt also in Wahrheit das iPhone und angezeigt wird
auch dort nur, was das Gerät zurückmeldet, nicht der Tastendruck.
### Was das für den Betrieb heisst
Für Batterie, Solar und Kühlbox muss VanControl Pro auf dem iPhone laufen.
Solange die Uhr meldet, dass jemand hinschaut, hält die App das Funkgerät auch
im Hintergrund am Leben; die Victron-Werbedaten stehen dabei still, weil iOS im
Hintergrund kein ungefiltertes Suchen erlaubt.
Für die Nivellierung gilt das alles nicht die läuft an der Uhr allein.
Gefunkt wird dort nur im Vordergrund: watchOS lässt eine App im Hintergrund
ohnehin kaum scannen, und beim Ausrichten schaut man auf die Uhr.
Gesendet wird in zwei Takten: ein halber Sekundentakt, solange die Watch-App im
Vordergrund ist, sonst alle zwei Sekunden und nur bei Änderungen. Der schnelle
Takt läuft über eine Frist, die die Uhr regelmässig erneuert schläft sie ein,
hört das iPhone von selbst wieder auf.
Was die Uhr zeigt, ist immer Weitergereichtes. Deshalb steht auf jedem
Bildschirm das Alter des Standes: Eine abgerissene Strecke zum iPhone sähe sonst
genauso aus wie ein Fahrzeug, an dem sich nichts tut.
## Diagnose
@@ -213,10 +429,33 @@ eingerichtet wurden, wandern beim Update automatisch ins erste Profil.
## Ohne Fahrzeug ansehen
Ein Demo-Modus füllt die App mit erfundenen Werten, damit sich die Ansichten
ohne Bluetooth prüfen lassen. In Xcode unter *Product → Scheme → Edit Scheme →
Run → Arguments* die Umgebungsvariable `CAMPER_DEMO` auf `1` setzen. Er greift
nur in Debug-Builds.
Ein Demo-Modus füllt die App mit erfundenen Fahrzeugen und Messwerten, damit
sich die Ansichten ohne Bluetooth prüfen lassen auch ohne die Hardware zur
Hand zu haben. Auf zwei Wegen einzuschalten:
* **Schalter in den Einstellungen** (*Einstellungen → Entwicklung →
Demo-Modus*) in jeder Build-Konfiguration verfügbar, auch in TestFlight-
und App-Store-Builds. Der Weg für jeden lokal installierten Build ohne
Xcode-Verbindung. Wirkt erst nach einem Neustart der App (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
@@ -231,18 +470,36 @@ und die Prüfsummen beider Daly-Dialekte.
## Aufbau
```
firmware/vanalign/ Firmware des Neigungsmessers (ESPHome)
firmware/vanalign/ Firmware der beiden ESP32 im Fahrzeug (ESPHome)
├── esp32_ble.yaml Neigungsmessung und Bluetooth-Schnittstelle
├── esp32_ble_solar.yaml Zweiter ESP32: Votronic-Solarregler über BLE
└── experimente/ Nicht für den Betrieb nötig
CamperMonitor/
Shared/ In iPhone- und Watch-App übersetzt
├── Models/
│ ├── Profile.swift Fahrzeug samt Maßen
│ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilberechnung
│ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilhöhe je Rad
│ ├── SensorOrientation.swift Einbaulage und ihre Erkennung
│ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart
│ ├── DeviceSnapshot.swift Messwerte in Anzeigeform
── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
── LevelState.swift Neigung, Toleranz und Klartext dazu
│ ├── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
│ └── VehicleGraphicStyle.swift Grafikstile der Fahrzeugansicht (Vanster/California)
├── Bluetooth/
│ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung
│ ├── LevelSession.swift Verbindung zum Neigungsmesser (iPhone und Uhr)
│ ├── VotronicSolarESPProtocol.swift Votronic-Solarregler: Charakteristiken, Statusbits
│ └── VotronicSolarESPSession.swift GATT-Verbindung zur Solar-Bridge, im Takt abgefragt
├── VehicleTilt.swift Überhöhung und Farben der Fahrzeugansicht
├── WheelLiftPlan.swift Keilhöhen als Draufsicht auf die vier Räder
└── WatchLink/
└── WatchLink.swift Datensatz und Befehle zwischen iPhone und Uhr
SharedActivity/ In Haupt-App und Live-Activity-Extension übersetzt
├── LevelActivityAttributes.swift Inhalt der Live Activity (ActivityKit)
└── LevelDirectionFormatting.swift "H 1.8°" statt Vorzeichen, für App und Extension gleich
VanControl/
├── Bluetooth/
│ ├── BluetoothManager.swift Zentraler Scan, Verbindungen, Verlauf
│ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten
@@ -253,31 +510,76 @@ CamperMonitor/
│ ├── JBDProtocol.swift JBD/Xiaoxiang, Rahmen und Auswertung
│ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung
│ ├── AlpicoolProtocol.swift Kühlboxen: Auswertung und Stellbefehle
│ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung
│ ├── LevelSession.swift Verbindung zum Neigungsmesser
│ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage
├── LiveActivity/
│ └── LevelActivityManager.swift Startet/aktualisiert/beendet die Live Activity, drosselt auf 1 Hz
├── Store/
│ ├── DeviceStore.swift Geräteliste, Persistenz
│ ├── DemoData.swift Erfundene Werte für den Demo-Modus
│ └── KeychainStore.swift Victron-Schlüssel
├── Watch/
│ └── PhoneWatchLink.swift Sendet den Stand, nimmt Befehle der Uhr an
└── Views/
├── DashboardView.swift Kachelübersicht
├── DeviceCard.swift Eine Kachel
├── DeviceDetailView.swift Alle Werte, Verlauf, Zellspannungen, Diagnose
├── ProfilesView.swift Fahrzeuge anlegen und verwalten
├── FridgeControls.swift Bedienelemente der Kühlbox
├── LevelView.swift Libelle und Kalibrierung
├── LevelView.swift Libelle, Kalibrierung, Live-Activity-Schalter
├── LevelSetupView.swift Einbaulage und Nullpunkt
├── AlignmentAssistantView.swift Ausrichtungs-Assistent fürs Rangieren
├── VehicleTiltView.swift Neigung am Fahrzeug dargestellt
├── VictronKeyView.swift Verschlüsselungsschlüssel eintragen
├── SettingsView.swift App-Einstellungen
├── SensorSetupView.swift Assistent für die Einbaulage
└── AddDeviceView.swift Scannen und Einrichten
VanControlWatch/ watchOS-App
├── CamperWatchApp.swift Einstieg, meldet dem iPhone das Hinschauen
├── PhoneLink.swift Gegenstelle zum iPhone
├── WatchLevelRadio.swift Eigene Bluetooth-Verbindung zum Neigungsmesser
├── WatchDemo.swift Erfundene Werte für den Simulator
└── Views/
├── WatchRootView.swift Übersicht und Altersangabe
├── WatchBubble.swift Libelle für den kleinen Bildschirm
├── WatchVehicleView.swift Fahrzeugansicht auf Uhrgrösse
├── WatchLevelView.swift Die drei Seiten: Neigung, Keile, Ausrichten
├── WatchAlignView.swift Ausrichten mit Vibration
├── WatchDeviceDetailView.swift Alle Werte eines Geräts
└── WatchFridgeControls.swift Kühlbox stellen
VanControlComplication/ Zifferblatt-Komplikation (watchOS)
└── LevelComplication.swift Tippt auf die Nivellierung, ohne eigenen Messwert
VanControlLiveActivity/ Live-Activity-Extension (WidgetKit)
├── VanControlLiveActivityBundle.swift Einstieg der Extension
└── VanControlLiveActivity.swift Ansicht für Sperrbildschirm, Dynamic Island, CarPlay
Config/
├── VanControl-Info.plist Nur der Hintergrundbetrieb für die Uhr
└── VanControlComplication-Info.plist Anzeigename der Komplikation
```
## Bekannte Grenzen
* **Kein Hintergrundbetrieb.** iOS erlaubt das ungefilterte Scannen nach
Advertisements nur im Vordergrund. Die App pausiert, sobald sie in den
Hintergrund geht, und nimmt beim Zurückkommen wieder auf.
* **Im Hintergrund nur die verbundenen Geräte.** iOS erlaubt das ungefilterte
Scannen nach Advertisements nur im Vordergrund; die Victron-Werte stehen im
Hintergrund also still. Verbundene Geräte Neigungsmesser, BMS, Kühlbox
laufen weiter, aber nur solange die Uhr meldet, dass jemand hinschaut. Ohne
Uhr pausiert die App im Hintergrund vollständig und nimmt beim Zurückkommen
wieder auf.
* **Die Uhr braucht das iPhone ausser für die Nivellierung.** Batterie, Solar
und Kühlbox zeigt sie nur, was von dort kommt; ist das iPhone ausser
Reichweite, steht dort, wie alt der letzte Stand ist. Den Neigungsmesser
erreicht sie selbst.
* **Neigungsmesser und Uhr:** Der ESP32 nimmt mehrere Verbindungen an, iPhone
und Uhr können also gleichzeitig mitlesen. Weist er die zweite ab, hilft es,
die iPhone-App zu schliessen dann gehört der Sensor der Uhr.
* **Auf der Uhr muss beim ersten Start Bluetooth erlaubt werden**, sonst bleibt
die Nivellierung dort leer.
* **Die Live Activity aktualisiert höchstens einmal je Sekunde.** Öfter
drosselt iOS lokale Aktualisierungen ohnehin von sich aus, zunehmend
stärker sichtbar vor allem in CarPlay. Siehe [Live Activity](#live-activity).
* **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen
Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind
gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel
@@ -18,6 +18,7 @@ final class LevelSession: NSObject {
private static let rollUUID = CBUUID(string: VanAlignProtocol.rollUUID)
private static let offsetsUUID = CBUUID(string: VanAlignProtocol.offsetsUUID)
private static let calibrateUUID = CBUUID(string: VanAlignProtocol.calibrateUUID)
private static let orientationUUID = CBUUID(string: VanAlignProtocol.orientationUUID)
let deviceID: UUID
private let queue: DispatchQueue
@@ -25,11 +26,16 @@ final class LevelSession: NSObject {
private let onUpdate: (DeviceSnapshot) -> Void
private let onStateChange: (DeviceLinkState) -> Void
private let onLevelState: (LevelState) -> Void
/// Meldet die Einbaulage, die im Gerät steht. Sie gilt vor der örtlich
/// gespeicherten: dort steht, wie der Sensor eingebaut ist, und das ist für
/// alle Clients dasselbe.
private let onDeviceOrientation: ((SensorOrientation) -> Void)?
private var pitchCharacteristic: CBCharacteristic?
private var rollCharacteristic: CBCharacteristic?
private var offsetsCharacteristic: CBCharacteristic?
private var calibrateCharacteristic: CBCharacteristic?
private var orientationCharacteristic: CBCharacteristic?
private var state = LevelState()
/// Aus den Geräteeinstellungen; rechnet Sensor- in Fahrzeugachsen um.
@@ -49,7 +55,9 @@ final class LevelSession: NSObject {
queue: DispatchQueue,
onUpdate: @escaping (DeviceSnapshot) -> Void,
onStateChange: @escaping (DeviceLinkState) -> Void,
onLevelState: @escaping (LevelState) -> Void) {
onLevelState: @escaping (LevelState) -> Void,
onDeviceOrientation: ((SensorOrientation) -> Void)? = nil) {
self.onDeviceOrientation = onDeviceOrientation
self.deviceID = deviceID
self.queue = queue
self.peripheral = peripheral
@@ -89,6 +97,28 @@ final class LevelSession: NSObject {
rollCharacteristic = nil
offsetsCharacteristic = nil
calibrateCharacteristic = nil
orientationCharacteristic = nil
}
// MARK: - Einbaulage im Gerät
/// Schreibt die Einbaulage ins Gerät, damit alle Clients dieselbe sehen.
///
/// Ältere Firmware hat die Charakteristik nicht; dann bleibt es bei der
/// örtlich gespeicherten Fassung, und es passiert schlicht nichts.
func storeOrientation(_ orientation: SensorOrientation) {
guard let characteristic = orientationCharacteristic,
peripheral.state == .connected else { return }
let type: CBCharacteristicWriteType =
characteristic.properties.contains(.write) ? .withResponse : .withoutResponse
peripheral.writeValue(VanAlignProtocol.encoded(orientation),
for: characteristic, type: type)
// Zurücklesen, damit angezeigt wird, was wirklich im Gerät steht.
queue.asyncAfter(deadline: .now() + 0.3) { [weak self] in
guard let self, let characteristic = self.orientationCharacteristic,
self.peripheral.state == .connected else { return }
self.peripheral.readValue(for: characteristic)
}
}
// MARK: - Kalibrieren
@@ -169,7 +199,8 @@ extension LevelSession: CBPeripheralDelegate {
return
}
peripheral.discoverCharacteristics(
[Self.pitchUUID, Self.rollUUID, Self.offsetsUUID, Self.calibrateUUID],
[Self.pitchUUID, Self.rollUUID, Self.offsetsUUID,
Self.calibrateUUID, Self.orientationUUID],
for: service
)
}
@@ -188,6 +219,7 @@ extension LevelSession: CBPeripheralDelegate {
case Self.rollUUID: rollCharacteristic = characteristic
case Self.offsetsUUID: offsetsCharacteristic = characteristic
case Self.calibrateUUID: calibrateCharacteristic = characteristic
case Self.orientationUUID: orientationCharacteristic = characteristic
default: break
}
}
@@ -210,7 +242,8 @@ extension LevelSession: CBPeripheralDelegate {
startPollingIfNeeded()
// Einmal alles lesen, damit sofort etwas dasteht.
for characteristic in [pitchCharacteristic, rollCharacteristic, offsetsCharacteristic] {
for characteristic in [pitchCharacteristic, rollCharacteristic,
offsetsCharacteristic, orientationCharacteristic] {
guard let characteristic, characteristic.properties.contains(.read) else { continue }
peripheral.readValue(for: characteristic)
}
@@ -233,6 +266,16 @@ extension LevelSession: CBPeripheralDelegate {
state.pitchOffset = offsets.pitch
state.rollOffset = offsets.roll
}
case Self.orientationUUID:
// Was im Gerät steht, gilt: es beschreibt den Einbau, nicht das
// Telefon. Steht dort nichts (Version 0), bleibt es bei der
// örtlichen Fassung der Aufrufer schreibt sie dann hinauf.
guard let stored = VanAlignProtocol.orientation(from: value) else { return }
if stored != orientation {
orientation = stored
}
onDeviceOrientation?(stored)
return
default:
return
}
+97
View File
@@ -0,0 +1,97 @@
import Foundation
/// Neigungsmesser VanAlign Pro ein ESP32 mit MPU6050, der Längs- und
/// Querneigung des Fahrzeugs über Bluetooth bereitstellt.
///
/// Anders als die übrigen Geräte gibt es hier kein Rahmenprotokoll: Jede
/// Messgrösse liegt in einer eigenen Charakteristik als 32-Bit-Float.
enum VanAlignProtocol {
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
static let serviceUUID = "2A24B789-7AAB-4535-AF3E-EE76A35CC42D"
static let pitchUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233424"
static let rollUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233425"
/// Zwei Floats: die gespeicherten Kalibrier-Offsets.
static let offsetsUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233426"
/// Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage.
static let calibrateUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427"
/// Acht Byte: die Einbaulage, lesbar und schreibbar. Siehe `orientation`.
static let orientationUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233428"
static let calibrateCommand = Data([0x01])
static let resetCommand = Data([0x00])
/// Liest einen Winkel aus vier Bytes, little-endian.
///
/// Die Firmware legt den Float per `memcpy` ab, und der ESP32 ist
/// little-endian die Reihenfolge steht also fest. Die Web-Oberfläche des
/// Ursprungsprojekts probiert zusätzlich die umgekehrte Reihenfolge, falls
/// die erste unplausibel aussieht. Das ist nicht nur unnötig, sondern
/// schädlich: ein vertauschter Float von 4,25° ergibt gelesen etwa 0,0 und
/// wirkt damit völlig plausibel. Ein Vorzeichen- oder Wertfehler bliebe so
/// unbemerkt.
static func angle(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)
// NAN meldet die Firmware, solange der Sensor nichts liefert.
guard value.isFinite, abs(value) <= 180 else { return nil }
return Double(value)
}
/// Die Einbaulage, wie sie im Gerät liegt.
///
/// 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
///
/// Sie gehört ins Gerät, weil sie den Einbau beschreibt und nicht das
/// Telefon: iPhone, Uhr und Android sollen dieselbe sehen, ohne sie je
/// einzeln zu bestimmen. Gerechnet wird trotzdem in den Apps das Gerät
/// verwahrt sie nur, sonst rechnete ein älterer Client die Korrektur ein
/// zweites Mal ein.
///
/// Version 0 heisst hier stand noch nie etwas und ergibt nil; dann gilt,
/// was die App örtlich gespeichert hat, und sie schreibt es hinauf.
static func orientation(from data: Data) -> SensorOrientation? {
let bytes = [UInt8](data)
guard bytes.count >= 8, bytes[0] == 1 else { return nil }
var orientation = SensorOrientation()
orientation.longitudinalSource = bytes[1] == 1 ? .roll : .pitch
orientation.invertLongitudinal = bytes[2] != 0
orientation.invertLateral = bytes[3] != 0
guard let twist = angle(from: data.dropFirst(4).prefix(4)) else { return nil }
orientation.twist = twist
return orientation
}
/// Dieselben acht Byte in die andere Richtung.
static func encoded(_ orientation: SensorOrientation) -> Data {
var bytes = [UInt8](repeating: 0, count: 8)
bytes[0] = 1
bytes[1] = orientation.longitudinalSource == .roll ? 1 : 0
bytes[2] = orientation.invertLongitudinal ? 1 : 0
bytes[3] = orientation.invertLateral ? 1 : 0
let raw = Float(orientation.twist).bitPattern
for index in 0..<4 {
bytes[4 + index] = UInt8((raw >> UInt32(8 * index)) & 0xFF)
}
return Data(bytes)
}
/// Die beiden gespeicherten Offsets.
static func offsets(from data: Data) -> (pitch: Double, roll: Double)? {
guard data.count >= 8,
let pitch = angle(from: data.prefix(4)),
let roll = angle(from: data.dropFirst(4).prefix(4)) else { return nil }
return (pitch, roll)
}
}
@@ -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()
}
}
@@ -129,55 +129,85 @@ struct AlignmentAssistant {
}
}
/// Wie hoch ein Auffahrkeil sein muss, um eine Neigung auszugleichen.
/// Wie hoch jedes einzelne Rad unterlegt werden muss.
///
/// Rein geometrisch und damit exakt: Höhe = tan(Winkel) × Abstand der Achsen
/// beziehungsweise der Räder.
struct LevelingWedge: Equatable {
/// Wo der Keil hin muss.
let side: Side
/// Höhe in Metern.
let height: Double
/// Der zugrundeliegende Winkel in Grad.
let angle: Double
/// Getrennte Angaben wie rechts 8 cm" und vorne 4 cm" beschreiben dasselbe
/// Fahrzeug und lassen sich nicht getrennt ausführen: Unter rechts" liegen
/// zwei Räder, unter vorne" auch, und zwei davon sind dieselben. Gefragt ist
/// deshalb die Höhe **je Rad**.
///
/// Das ist reine Geometrie und damit exakt. Das Fahrzeug steht auf vier
/// Punkten; deren Höhen ergeben sich aus Spurweite, Radstand und den beiden
/// Winkeln. Angehoben wird auf die Höhe des höchsten Rades das bleibt
/// liegen, die anderen bekommen die Differenz.
struct LevelingLift: Equatable {
enum Side: Equatable {
case front, rear, left, right
enum Wheel: CaseIterable, Hashable {
case frontLeft, frontRight, rearLeft, rearRight
var text: String {
switch self {
case .front: return "vorne"
case .rear: return "hinten"
case .left: return "links"
case .right: return "rechts"
case .frontLeft: return "vorne links"
case .frontRight: return "vorne rechts"
case .rearLeft: return "hinten links"
case .rearRight: return "hinten rechts"
}
}
}
var heightInCentimetres: Double { height * 100 }
/// Höhen in Metern, je Rad. Das höchste Rad steht auf 0.
let frontLeft: Double
let frontRight: Double
let rearLeft: Double
let rearRight: Double
/// Quer: die tieferliegende Seite muss angehoben werden. Positiver Roll
/// heisst, dass rechts höher steht der Keil gehört also nach links.
static func across(roll: Double, trackWidth: Double) -> LevelingWedge? {
wedge(angle: roll, distance: trackWidth,
whenPositive: .left, whenNegative: .right)
func height(_ wheel: Wheel) -> Double {
switch wheel {
case .frontLeft: return frontLeft
case .frontRight: return frontRight
case .rearLeft: return rearLeft
case .rearRight: return rearRight
}
}
/// Längs: positiver Pitch heisst, dass das Heck höher steht der Keil
/// gehört unter die Vorderräder.
static func along(pitch: Double, wheelbase: Double) -> LevelingWedge? {
wedge(angle: pitch, distance: wheelbase,
whenPositive: .front, whenNegative: .rear)
func centimetres(_ wheel: Wheel) -> Double { height(wheel) * 100 }
/// Das am tiefsten stehende Rad dort liegt die grösste Höhe.
var deepest: Wheel {
Wheel.allCases.max { height($0) < height($1) } ?? .frontLeft
}
private static func wedge(angle: Double,
distance: Double,
whenPositive: Side,
whenNegative: Side) -> LevelingWedge? {
guard distance > 0, abs(angle) > LevelState.levelTolerance else { return nil }
let height = tan(abs(angle) * .pi / 180) * distance
return LevelingWedge(side: angle > 0 ? whenPositive : whenNegative,
height: height,
angle: abs(angle))
var maximumCentimetres: Double { centimetres(deepest) }
/// Unter einem Zentimeter lohnt kein Keil; so genau steht kein Fahrzeug,
/// und so genau misst der Sensor auch nicht.
var isNegligible: Bool { maximumCentimetres < 1 }
/// `pitch` positiv heisst, das Heck steht höher; `roll` positiv, die rechte
/// Seite steht höher. Ohne Fahrzeugmasse gibt es nichts zu rechnen.
static func compute(pitch: Double,
roll: Double,
trackWidth: Double,
wheelbase: Double) -> LevelingLift? {
guard trackWidth > 0, wheelbase > 0 else { return nil }
// Wie viel höher die rechte Seite und wie viel höher das Heck steht.
// Vorzeichen inbegriffen: tan(-2°) ist negativ, dann steht links höher.
let rightRise = tan(roll * .pi / 180) * trackWidth
let rearRise = tan(pitch * .pi / 180) * wheelbase
// Höhen der vier Aufstandspunkte, bezogen auf vorne links.
let heights: [Wheel: Double] = [
.frontLeft: 0,
.frontRight: rightRise,
.rearLeft: rearRise,
.rearRight: rightRise + rearRise,
]
let top = heights.values.max() ?? 0
return LevelingLift(frontLeft: top - heights[.frontLeft]!,
frontRight: top - heights[.frontRight]!,
rearLeft: top - heights[.rearLeft]!,
rearRight: top - heights[.rearRight]!)
}
}
@@ -4,41 +4,66 @@ import Foundation
/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
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 bms
case fridge
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 title: String {
switch self {
case .chargeBooster: return "Ladebooster"
case .solarCharger: return "Solarladeregler"
case .batteryMonitor: return "Batteriemonitor"
case .bms: return "Batterie / BMS"
case .fridge: return "Kühlbox"
case .leveling: return "Nivellierung"
case .chargeBooster: return "Ladebooster"
case .victronSolarCharger: return "Solarladeregler"
case .batteryMonitor: return "Batteriemonitor"
case .bms: return "Batterie / BMS"
case .fridge: return "Kühlbox"
case .leveling: return "Nivellierung"
case .votronicSolar: return "VotronicSolarESP"
}
}
var symbol: String {
switch self {
case .chargeBooster: return "bolt.car"
case .solarCharger: return "sun.max"
case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
case .bms: return "battery.100percent.bolt"
case .fridge: return "refrigerator"
case .leveling: return "level"
case .chargeBooster: return "bolt.car"
case .victronSolarCharger: return "sun.max"
case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
case .bms: return "battery.100percent.bolt"
case .fridge: return "refrigerator"
case .leveling: return "level"
case .votronicSolar: return "sun.max"
}
}
/// Ob nur verbunden wird, während die Ansicht des Geräts offen ist.
///
/// Die Kühlbox meldet jede Verbindung an ihrem Display an und verlangt je
/// nach Modell sogar einen Tastendruck; dauerhaft verbunden zu sein ist
/// dort also nicht unsichtbar, sondern lästig. Ihre Werte ändern sich
/// ausserdem langsam, und was auf der Übersicht zählt der Sollwert
/// ändert sich überhaupt nur, wenn jemand ihn ändert.
///
/// BMS und Neigungsmesser bleiben dagegen verbunden: Sie stören nicht, und
/// ihre Werte will man laufend sehen.
var connectsOnDemand: Bool { self == .fridge }
/// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS
/// braucht eine echte GATT-Verbindung.
var transport: DeviceTransport {
switch self {
case .bms, .fridge, .leveling: return .connect
case .bms, .fridge, .leveling, .votronicSolar: return .connect
default: return .advertisement
}
}
@@ -86,6 +111,8 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
var fridgeZoneMode: FridgeZoneMode = .automatic
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
var sensorOrientation = SensorOrientation.identity
/// Nur für den Neigungsmesser: welche Fahrzeuggrafik die Fahrzeug-Ansicht zeigt.
var vehicleGraphicStyle = VehicleGraphicStyle.vanster
init(id: UUID = UUID(),
name: String,
@@ -116,5 +143,7 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
?? .automatic
sensorOrientation = try container.decodeIfPresent(SensorOrientation.self,
forKey: .sensorOrientation) ?? .identity
vehicleGraphicStyle = try container.decodeIfPresent(VehicleGraphicStyle.self,
forKey: .vehicleGraphicStyle) ?? .vanster
}
}
@@ -1,7 +1,7 @@
import Foundation
/// Eine einzelne Messgrösse in einer bereits formatierten Form.
struct Metric: Identifiable, Hashable, Sendable {
struct Metric: Identifiable, Hashable, Codable, Sendable {
let key: String
let label: String
let value: Double?
@@ -39,7 +39,7 @@ struct Metric: Identifiable, Hashable, Sendable {
}
/// Der komplette, zuletzt empfangene Zustand eines Geräts.
struct DeviceSnapshot: Identifiable, Sendable {
struct DeviceSnapshot: Identifiable, Codable, Equatable, Sendable {
var id: UUID { deviceID }
var deviceID: UUID
var timestamp: Date
@@ -58,7 +58,7 @@ struct DeviceSnapshot: Identifiable, Sendable {
/// Feste Angaben des Geräts, etwa Modell oder Seriennummer.
var info: [InfoItem] = []
struct InfoItem: Identifiable, Hashable, Sendable {
struct InfoItem: Identifiable, Hashable, Codable, Sendable {
let label: String
let value: String
var id: String { label }
@@ -76,7 +76,7 @@ struct DeviceSnapshot: Identifiable, Sendable {
}
/// Verbindungszustand für die UI.
enum DeviceLinkState: Equatable, Sendable {
enum DeviceLinkState: Equatable, Codable, Sendable {
case idle
case searching
case connecting
+47
View File
@@ -0,0 +1,47 @@
import Foundation
/// Der zuletzt bekannte Stand einer Kühlbox ohne Messwerte.
///
/// Die Box wird nur noch verbunden, während man ihre Ansicht offen hat: Jede
/// Verbindung meldet sich an ihrem Display an und stört, wer sie gerade
/// bedient. Auf der Übersicht steht deshalb, was zuletzt **eingestellt** war.
///
/// Messwerte gehören bewusst nicht dazu. Eine Innentemperatur von vorgestern
/// sähe aus wie eine von jetzt, und man würde ihr glauben eine Solltemperatur
/// dagegen ändert sich nur, wenn jemand sie ändert.
struct FridgeSettings: Codable, Equatable, Sendable {
var isPoweredOn: Bool
var isEco: Bool
var isLocked: Bool
var isDualZone: Bool
var usesFahrenheit: Bool
var leftTarget: Int?
var rightTarget: Int?
/// Wann dieser Stand von der Box kam.
var updated: Date
var unitSymbol: String { usesFahrenheit ? "°F" : "°C" }
/// In Worten, wie es auf der Kachel steht.
var stateText: String {
guard isPoweredOn else { return "Aus" }
return isEco ? "Eco" : "Max"
}
/// Was die Übersicht zeigt: die Sollwerte, sonst nichts.
func snapshot(deviceID: UUID) -> DeviceSnapshot {
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: updated, rssi: nil)
var metrics = [
Metric("target_left", isDualZone ? "Soll links" : "Solltemperatur",
leftTarget.map(Double.init), unit: unitSymbol, precision: 0, primary: true),
]
if isDualZone {
metrics.append(Metric("target_right", "Soll rechts",
rightTarget.map(Double.init), unit: unitSymbol, precision: 0))
}
snapshot.metrics = metrics
snapshot.state = stateText
if isLocked { snapshot.offReasons = ["Bedienfeld gesperrt"] }
return snapshot
}
}
@@ -1,57 +1,7 @@
import Foundation
/// Neigungsmesser VanAlign Pro ein ESP32 mit MPU6050, der Längs- und
/// Querneigung des Fahrzeugs über Bluetooth bereitstellt.
///
/// Anders als die übrigen Geräte gibt es hier kein Rahmenprotokoll: Jede
/// Messgrösse liegt in einer eigenen Charakteristik als 32-Bit-Float.
enum VanAlignProtocol {
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
static let serviceUUID = "2A24B789-7AAB-4535-AF3E-EE76A35CC42D"
static let pitchUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233424"
static let rollUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233425"
/// Zwei Floats: die gespeicherten Kalibrier-Offsets.
static let offsetsUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233426"
/// Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage.
static let calibrateUUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427"
static let calibrateCommand = Data([0x01])
static let resetCommand = Data([0x00])
/// Liest einen Winkel aus vier Bytes, little-endian.
///
/// Die Firmware legt den Float per `memcpy` ab, und der ESP32 ist
/// little-endian die Reihenfolge steht also fest. Die Web-Oberfläche des
/// Ursprungsprojekts probiert zusätzlich die umgekehrte Reihenfolge, falls
/// die erste unplausibel aussieht. Das ist nicht nur unnötig, sondern
/// schädlich: ein vertauschter Float von 4,25° ergibt gelesen etwa 0,0 und
/// wirkt damit völlig plausibel. Ein Vorzeichen- oder Wertfehler bliebe so
/// unbemerkt.
static func angle(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)
// NAN meldet die Firmware, solange der Sensor nichts liefert.
guard value.isFinite, abs(value) <= 180 else { return nil }
return Double(value)
}
/// Die beiden gespeicherten Offsets.
static func offsets(from data: Data) -> (pitch: Double, roll: Double)? {
guard data.count >= 8,
let pitch = angle(from: data.prefix(4)),
let roll = angle(from: data.dropFirst(4).prefix(4)) else { return nil }
return (pitch, roll)
}
}
/// Zustand des Neigungsmessers.
struct LevelState: Equatable {
struct LevelState: Equatable, Codable, Sendable {
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
/// Bereits auf die Einbaulage des Sensors umgerechnet.
var pitch: Double?
@@ -15,7 +15,7 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
init(id: UUID = UUID(),
name: String,
symbol: String = "box.truck",
symbol: String = "suv.side",
trackWidth: Double? = nil,
wheelbase: Double? = nil) {
self.id = id
@@ -47,7 +47,6 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft ein nicht
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
static let symbols = [
"box.truck", "truck.pickup.side", "bus", "bus.doubledecker",
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
"car", "car.side", "suv.side", "truck.pickup.side", "box.truck", "bus",
]
}
@@ -23,16 +23,62 @@ struct SensorOrientation: Codable, Equatable, Hashable, Sendable {
var invertLongitudinal = false
var invertLateral = false
/// Verdrehung des Sensors um die Hochachse, in Grad der Rest, den der
/// Achsentausch nicht abdeckt.
///
/// Sitzt der Sensor schräg im Fahrzeug, verteilt sich eine reine
/// Querneigung auf beide Sensorachsen: Das Fahrzeug kippt zur Seite, die
/// Anzeige meldet zusätzlich Längsneigung. Achsentausch und Vorzeichen
/// helfen dagegen nicht, die springen in 90°-Schritten.
///
/// Der Wert wird beim Bestimmen der Einbaulage mitgemessen und kostet
/// keinen zusätzlichen Handgriff. Beim **Kalibrieren** liesse er sich
/// nicht ermitteln: Das misst eine einzige Lage und zieht sie als Nullpunkt
/// ab eine Drehung um die Hochachse ist darin nicht enthalten, denn eben
/// sieht in jeder Verdrehung gleich aus.
var twist: Double = 0
static let identity = SensorOrientation()
init() {}
/// Von Hand geschrieben, nicht von Swift erzeugt und das ist der Punkt.
///
/// Die erzeugte Fassung verlangt beim Lesen jedes Feld; Standardwerte im
/// Code zählen dabei nicht. Eine Einbaulage, die vor `twist` gespeichert
/// wurde, liesse sich damit nicht mehr lesen, das Gerät dazu ebenso wenig,
/// und die Geräteliste bliebe leer. Jedes neue Feld gehört deshalb hier
/// hinein, mit `decodeIfPresent` und einem Rückfallwert.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
longitudinalSource = try container.decodeIfPresent(Source.self,
forKey: .longitudinalSource) ?? .pitch
invertLongitudinal = try container.decodeIfPresent(Bool.self,
forKey: .invertLongitudinal) ?? false
invertLateral = try container.decodeIfPresent(Bool.self, forKey: .invertLateral) ?? false
twist = try container.decodeIfPresent(Double.self, forKey: .twist) ?? 0
}
var isIdentity: Bool { self == .identity }
/// Rechnet Sensorwerte in Fahrzeugwerte um.
///
/// Zwei Schritte, in dieser Reihenfolge: erst die grobe Zuordnung der
/// Achsen samt Vorzeichen, dann die Verdrehung zurückdrehen. Für kleine
/// Winkel verhält sich das Wertepaar wie ein Vektor in der Ebene genau
/// deshalb lässt sich die Verdrehung überhaupt herausrechnen.
func apply(pitch: Double?, roll: Double?) -> (pitch: Double?, roll: Double?) {
let longitudinal = longitudinalSource == .pitch ? pitch : roll
let lateral = longitudinalSource == .pitch ? roll : pitch
return (longitudinal.map { invertLongitudinal ? -$0 : $0 },
lateral.map { invertLateral ? -$0 : $0 })
let mappedLongitudinal = longitudinal.map { invertLongitudinal ? -$0 : $0 }
let mappedLateral = lateral.map { invertLateral ? -$0 : $0 }
guard twist != 0, let long = mappedLongitudinal, let lat = mappedLateral else {
return (mappedLongitudinal, mappedLateral)
}
let angle = twist * .pi / 180
return (long * cos(angle) + lat * sin(angle),
-long * sin(angle) + lat * cos(angle))
}
var summary: String {
@@ -41,6 +87,7 @@ struct SensorOrientation: Codable, Equatable, Hashable, Sendable {
if longitudinalSource == .roll { parts.append("Achsen getauscht") }
if invertLongitudinal { parts.append("längs umgekehrt") }
if invertLateral { parts.append("quer umgekehrt") }
if twist != 0 { parts.append(String(format: "um %.0f° verdreht", twist)) }
return parts.joined(separator: ", ")
}
}
@@ -59,6 +106,9 @@ enum OrientationDetection {
/// Soviel deutlicher muss die gewinnende Deutung sein als die andere.
static let ambiguityMargin = 1.3
/// Ab hier gilt eine Verdrehung als echt und nicht als Wackeln der Hand.
static let minimumTwist = 2.0
struct Reading: Equatable {
let pitch: Double
let roll: Double
@@ -130,6 +180,24 @@ enum OrientationDetection {
// Linke Seite nach unten heisst: rechts steht höher, die Querneigung
// ist positiv.
orientation.invertLateral = lateral < 0
// Was nach dem Achsentausch noch übrig ist, ist die Verdrehung um die
// Hochachse. Beim Kippen der Front nach unten dürfte sich nur die
// Längsneigung ändern; wandert die Querneigung mit, sitzt der Sensor
// schräg und zwar um genau diesen Winkel.
//
// Gemessen wird an der Frontbewegung, nicht an der Seitenbewegung: Die
// Front lässt sich am Fahrzeug genauer treffen, und beide Schritte
// gemeinsam auszuwerten brächte hier nichts, weil eine Verdrehung auf
// beide gleich wirkt.
let corrected = orientation.apply(pitch: nose.pitch, roll: nose.roll)
if let long = corrected.pitch, let lat = corrected.roll {
let residual = atan2(lat, long) * 180 / .pi
// Unter zwei Grad ist es Messrauschen. Zwei Kippbewegungen von Hand
// sind nicht genauer, und eine erfundene Verdrehung wäre schlimmer
// als keine.
orientation.twist = abs(residual) >= minimumTwist ? residual : 0
}
return .success(orientation)
}
}
+52
View File
@@ -0,0 +1,52 @@
import Foundation
/// Welche Silhouette die Fahrzeug-Ansicht des Neigungsmessers zeigt.
///
/// Jeder Fall ist ein zusammengehöriges Bilderset aus Seiten- und
/// Heckansicht in Assets.xcassets, beide als Vorlage ("template")
/// exportiert, damit `foregroundStyle` sie einfärben kann. Im Picker wird
/// nur die Seitenansicht gezeigt.
enum VehicleGraphicStyle: String, CaseIterable, Identifiable, Codable, Sendable {
case vanster
case california
var id: String { rawValue }
var title: String {
switch self {
case .vanster: return "Vanster"
case .california: return "California"
}
}
var sideImageName: String {
switch self {
case .vanster: return "VansterSide"
case .california: return "CaliforniaSide"
}
}
/// Für California liegt noch keine eigene Heckansicht vor, deshalb
/// vorerst die von Vanster.
var rearImageName: String {
switch self {
case .vanster, .california: return "VansterRear"
}
}
/// Seitenverhältnis der jeweiligen Bilder ohne das würde eine Grafik
/// mit anderen Proportionen in der gemeinsamen Bildhülle verzerrt oder
/// ungewollt verkleinert erscheinen.
var sideAspect: Double {
switch self {
case .vanster: return VehicleTilt.sideAspect
case .california: return 1
}
}
var rearAspect: Double {
switch self {
case .vanster, .california: return VehicleTilt.rearAspect
}
}
}
+48
View File
@@ -0,0 +1,48 @@
import Foundation
/// Zustand des Solarladereglers.
struct VotronicSolarESPState: Equatable, Codable, Sendable {
var batteryVoltage: Double?
var pvVoltage: Double?
var pvCurrent: Double?
var pvPower: Double?
var controllerTemperature: Double?
var isBatteryCharging: Bool?
var isBatteryDischarging: Bool?
var isControllerActive: Bool?
var isCurrentLimited: Bool?
var hasReading: Bool {
batteryVoltage != nil || pvVoltage != nil || pvCurrent != nil || pvPower != nil
}
/// Kurzer Klartext, wie bei den übrigen Geräten als "Zustand" angezeigt.
var stateText: String? {
guard isControllerActive != nil else { return nil }
if isBatteryCharging == true { return "Lädt" }
if isControllerActive == true { return "Aktiv" }
return "Inaktiv"
}
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
snapshot.metrics = [
Metric("pv_power", "Solarleistung", pvPower, unit: "W", precision: 0, primary: true),
Metric("pv_voltage", "PV-Spannung", pvVoltage, unit: "V", precision: 1),
Metric("pv_current", "PV-Strom", pvCurrent, unit: "A", precision: 1),
Metric("battery_voltage", "Batteriespannung", batteryVoltage, unit: "V", precision: 2),
]
if let controllerTemperature {
snapshot.metrics.append(
Metric("controller_temperature", "Reglertemperatur", controllerTemperature,
unit: "°C", precision: 0)
)
}
snapshot.state = stateText
if isCurrentLimited == true {
snapshot.offReasons = ["PV-Strombegrenzung aktiv"]
}
return snapshot
}
}
+26
View File
@@ -0,0 +1,26 @@
import SwiftUI
/// Was sich die Fahrzeugansichten auf iPhone und Uhr teilen.
///
/// Beide zeichnen dieselben Schablonen, nur in anderer Grösse die Regeln,
/// nach denen gekippt und eingefärbt wird, dürfen deshalb nicht zweimal
/// dastehen und auseinanderlaufen.
enum VehicleTilt {
/// Kleine Neigungen sind am Fahrzeug sonst kaum zu erkennen zwei Grad
/// wären ein knappes Grad Bildneigung. Die Überhöhung wird angeschrieben,
/// damit niemand den Bildwinkel für den echten nimmt.
static let exaggeration: Double = 3
/// Seitenansicht: Front links, Heck rechts.
static let sideAspect = 925.0 / 600.0
/// Heckansicht, quadratisch.
static let rearAspect = 1.0
/// Grün heisst eben, orange bis zwei Grad, darüber rot.
static func colour(for angle: Double?) -> Color {
guard let angle else { return .secondary }
if abs(angle) <= LevelState.levelTolerance { return .green }
return abs(angle) <= 2 ? .orange : .red
}
}
+19
View File
@@ -0,0 +1,19 @@
import Foundation
/// Die Adressen, über die etwas in der Watch-App geöffnet wird.
///
/// Sie stehen hier, weil beide Seiten sie brauchen und sie sonst zweimal
/// dastünden: Die Komplikation hängt sie an ihre Anzeige, die App wertet sie
/// beim Öffnen aus. Ein Tippfehler auf einer Seite wäre stumm der Tipp aufs
/// Zifferblatt öffnete dann einfach die Geräteliste.
enum WatchDeepLink {
private static let scheme = "campermonitor"
/// Führt direkt auf die Nivellierung.
static let level = URL(string: "\(scheme)://nivellierung")!
/// Ob diese Adresse zur Nivellierung führt.
static func isLevel(_ url: URL) -> Bool {
url.scheme == scheme && (url.host() == "nivellierung")
}
}
+223
View File
@@ -0,0 +1,223 @@
import Foundation
/// Was zwischen iPhone und Uhr hin- und hergeht.
///
/// Die Aufteilung folgt dem, was jedes Gerät hergibt:
///
/// * **Den Neigungsmesser funkt die Uhr selbst an** (`WatchLevelRadio`). Er
/// bewirbt seinen Dienst, ist also ohne Einrichtung auffindbar, und er ist
/// unverschlüsselt. Damit steht die Nivellierung am Handgelenk auch dann,
/// wenn das iPhone in der Tasche schläft und das ist der Fall, für den man
/// die Uhr überhaupt anschaut.
/// * **Alles andere kommt über das iPhone.** BMS und Kühlbox lassen nur eine
/// Verbindung zu; eine mitlesende Uhr nähme dem iPhone die Verbindung weg,
/// statt sie zu ergänzen. Und die Victron-Schlüssel liegen in der Keychain
/// des iPhones sie auf die Uhr zu kopieren hiesse, sie ein zweites Mal
/// aufzubewahren, ohne dass der zweite Ort etwas brächte.
///
/// Übertragen werden deshalb die Messwerte der übrigen Geräte, dazu Fahrzeug
/// und Einbaulage des Sensors: Beides braucht die Uhr, um aus den Rohwinkeln
/// des Neigungsmessers dasselbe zu machen wie das iPhone.
enum WatchLink {
/// Schlüssel im WatchConnectivity-Wörterbuch. Übertragen wird jeweils ein
/// JSON-`Data`, nicht ein aufgedröseltes Wörterbuch: die Typen unten sind
/// damit die einzige Stelle, an der das Format steht.
static let payloadKey = "payload"
static let commandKey = "command"
/// Wie lange ein ich schaue gerade hin der Uhr gilt, ohne erneuert zu
/// werden. Bricht die Verbindung ab oder wandert die App auf der Uhr in den
/// Hintergrund, hört das iPhone von selbst wieder auf, im Sekundentakt zu
/// senden.
static let liveLease: TimeInterval = 12
/// Sendetakt, solange die Uhr hinschaut. Der Neigungsmesser wird zweimal je
/// Sekunde abgefragt schneller zu senden brächte nichts.
static let liveInterval: TimeInterval = 0.5
/// Sendetakt sonst. Geht als Anwendungskontext raus, den watchOS auch dann
/// noch zustellt, wenn die App auf der Uhr gerade nicht läuft.
static let idleInterval: TimeInterval = 2
}
/// Der gesamte Stand eines Fahrzeugs, so wie ihn die Uhr anzeigt.
struct WatchPayload: Codable, Equatable, Sendable {
/// Steigt, wenn sich das Format ändert. Eine Uhr mit älterer App bekommt
/// sonst Werte, die sie falsch versteht lieber sagt sie App aktualisieren.
static let currentVersion = 1
var version = WatchPayload.currentVersion
var generatedAt: Date
/// Das am iPhone gewählte Fahrzeug.
var profile: Profile
/// Ob das iPhone gerade überhaupt funken kann.
var isRadioReady: Bool
/// Klartext dazu, falls nicht.
var radioStatus: String
var devices: [WatchDevice]
/// Nach der Rolle gesucht, nicht nach vorhandenen Werten: Ein
/// Neigungsmesser, der gerade nicht antwortet, gehört trotzdem an seinen
/// Platz sonst rutschte er beim Verbindungsabriss in die Geräteliste.
var levelDevice: WatchDevice? { devices.first { $0.role == .leveling } }
var fridgeDevice: WatchDevice? { devices.first { $0.role == .fridge } }
/// Ob sich inhaltlich nichts geändert hat. Der Zeitstempel zählt dabei
/// nicht mit: sonst gälte jeder Datensatz als neu und die Uhr bekäme rund
/// um die Uhr Funkverkehr, auch wenn das Fahrzeug still steht.
func hasSameContent(as other: WatchPayload) -> Bool {
var mine = self, theirs = other
mine.generatedAt = .distantPast
theirs.generatedAt = .distantPast
return mine == theirs
}
/// Wie alt der Stand ist. Auf der Uhr die wichtigste Angabe überhaupt:
/// alles hier ist Weitergereichtes, und eine abgerissene Strecke zum
/// iPhone sieht sonst aus wie ein stillstehendes Fahrzeug.
func age(now: Date = Date()) -> TimeInterval { now.timeIntervalSince(generatedAt) }
}
/// Ein Gerät mitsamt seinem letzten Messwertsatz.
struct WatchDevice: Codable, Equatable, Identifiable, Sendable {
var id: UUID
var name: String
var role: DeviceRole
var link: DeviceLinkState
var snapshot: DeviceSnapshot?
/// Nur beim Neigungsmesser belegt.
var level: LevelState?
/// Ebenfalls nur dort: wie der Sensor im Fahrzeug sitzt. Die Uhr funkt
/// den Neigungsmesser selbst an und braucht die Zuordnung deshalb auch
/// eingestellt wird sie weiterhin nur am iPhone.
var orientation: SensorOrientation?
/// Nur bei der Kühlbox belegt.
var fridge: WatchFridge?
var isLive: Bool { link == .live && !(snapshot?.isStale ?? true) }
}
/// Zustand und Grenzen der Kühlbox, so weit die Uhr sie braucht.
///
/// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit
/// drin, den auf die Uhr zu schicken nichts brächte.
struct WatchFridge: Codable, Equatable, Sendable {
/// Ob die Box gerade wirklich verbunden ist.
///
/// Sie wird nur verbunden, während jemand ihre Ansicht offen hat jede
/// Verbindung meldet sich an ihrem Display an. Sonst stehen hier die
/// zuletzt gestellten Werte, und die Messwerte fehlen.
var isLive: Bool
/// Wann der Stand zuletzt von der Box kam.
var updated: Date
var isPoweredOn: Bool
var isEco: Bool
var isLocked: Bool
var isDualZone: Bool
var unitSymbol: String
var leftTarget: Int?
var leftCurrent: Int?
var rightTarget: Int?
var rightCurrent: Int?
var minTarget: Int
var maxTarget: Int
var batteryVolts: Double?
var targetRange: ClosedRange<Int> {
minTarget < maxTarget ? minTarget...maxTarget : -30...20
}
}
/// Was die Uhr das iPhone tun lässt.
///
/// Gestellt wird nur, was auch am iPhone gestellt werden kann; die Uhr bekommt
/// keine eigenen Fähigkeiten. Das Ergebnis liest sie wie jeder andere Wert aus
/// dem nächsten Datensatz bestätigt wird also das Gerät, nicht der Tastendruck.
enum WatchCommand: Codable, Equatable, Sendable {
/// Die Uhr schaut gerade hin und möchte den schnellen Takt.
case hello(live: Bool)
case fridgePower(device: UUID, on: Bool)
case fridgeEco(device: UUID, eco: Bool)
case fridgeLock(device: UUID, locked: Bool)
case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int)
/// Die Kühlbox-Ansicht auf der Uhr wurde geöffnet oder geschlossen. Das
/// iPhone verbindet die Box daraufhin oder gibt sie wieder frei.
case fridgeSession(device: UUID, wanted: Bool)
/// Ob der Befehl den schnellen Sendetakt verlängert.
var wantsLiveUpdates: Bool {
if case .hello(let live) = self { return live }
// Wer stellt, schaut auch hin.
return true
}
}
/// Spiegelt `AlpicoolState.Zone`, ohne das Protokoll auf die Uhr zu ziehen.
enum WatchFridgeZone: String, Codable, Equatable, Sendable {
case left, right
}
// MARK: - Verpacken
extension WatchFridge {
/// Aus dem zuletzt gestellten Stand, ohne Messwerte.
init(_ settings: FridgeSettings) {
self.init(isLive: false,
updated: settings.updated,
isPoweredOn: settings.isPoweredOn,
isEco: settings.isEco,
isLocked: settings.isLocked,
isDualZone: settings.isDualZone,
unitSymbol: settings.unitSymbol,
leftTarget: settings.leftTarget,
leftCurrent: nil,
rightTarget: settings.rightTarget,
rightCurrent: nil,
minTarget: settings.usesFahrenheit ? -22 : -30,
maxTarget: settings.usesFahrenheit ? 68 : 20,
batteryVolts: nil)
}
}
extension WatchPayload {
func encoded() throws -> Data { try WatchCoder.encoder.encode(self) }
/// Aus einem WatchConnectivity-Wörterbuch zurücklesen. Fremde oder zu neue
/// Datensätze ergeben nil statt eines halb gefüllten Zustands.
static func decode(from message: [String: Any]) -> WatchPayload? {
guard let data = message[WatchLink.payloadKey] as? Data,
let payload = try? WatchCoder.decoder.decode(WatchPayload.self, from: data),
payload.version == WatchPayload.currentVersion else { return nil }
return payload
}
func message() throws -> [String: Any] { [WatchLink.payloadKey: try encoded()] }
}
extension WatchCommand {
func message() throws -> [String: Any] {
[WatchLink.commandKey: try WatchCoder.encoder.encode(self)]
}
static func decode(from message: [String: Any]) -> WatchCommand? {
guard let data = message[WatchLink.commandKey] as? Data else { return nil }
return try? WatchCoder.decoder.decode(WatchCommand.self, from: data)
}
}
/// Feste Einstellungen für beide Richtungen. Ohne das würde ein Datum auf der
/// einen Seite anders geschrieben als auf der anderen gelesen.
enum WatchCoder {
static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .millisecondsSince1970
return encoder
}()
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970
return decoder
}()
}
+74
View File
@@ -0,0 +1,74 @@
import SwiftUI
/// Die Keilhöhen als Draufsicht auf das Fahrzeug.
///
/// Vier Zahlen an vier Rädern, statt zweier Angaben rechts" und vorne", die
/// sich nicht getrennt ausführen lassen. Oben ist vorne; links im Bild ist
/// links am Fahrzeug, aus Sicht des Fahrers.
///
/// Das höchststehende Rad bekommt nichts es bleibt liegen, die anderen
/// werden auf seine Höhe gebracht.
struct WheelLiftPlan: View {
let lift: LevelingLift
/// Für die Uhr: kleinere Schrift, engerer Aufbau.
var isCompact = false
var body: some View {
VStack(spacing: isCompact ? 2 : 6) {
Text("vorne")
.font(labelFont)
.foregroundStyle(.secondary)
HStack(spacing: isCompact ? 6 : 12) {
VStack(spacing: isCompact ? 6 : 14) {
badge(.frontLeft)
badge(.rearLeft)
}
body_
VStack(spacing: isCompact ? 6 : 14) {
badge(.frontRight)
badge(.rearRight)
}
}
Text("hinten")
.font(labelFont)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(accessibilityText)
}
/// Der Umriss dazwischen macht aus vier Zahlen ein Fahrzeug.
private var body_: some View {
RoundedRectangle(cornerRadius: isCompact ? 4 : 8)
.strokeBorder(Color.secondary.opacity(0.4), lineWidth: 1)
.frame(width: isCompact ? 26 : 48, height: isCompact ? 44 : 84)
}
private func badge(_ wheel: LevelingLift.Wheel) -> some View {
let value = lift.centimetres(wheel)
let needed = value >= 1
return Text(needed ? String(format: "%.0f", value) : "")
.font(valueFont)
.monospacedDigit()
.foregroundStyle(needed ? .primary : .secondary)
.frame(width: isCompact ? 26 : 42, height: isCompact ? 20 : 30)
.background(needed ? Color.orange.opacity(0.25) : Color.secondary.opacity(0.12),
in: .rect(cornerRadius: isCompact ? 4 : 6))
}
private var labelFont: Font { isCompact ? .system(size: 9) : .caption2 }
private var valueFont: Font {
isCompact ? .system(size: 12, weight: .semibold) : .callout.weight(.semibold)
}
private var accessibilityText: String {
let parts = LevelingLift.Wheel.allCases.compactMap { wheel -> String? in
let value = lift.centimetres(wheel)
guard value >= 1 else { return nil }
return String(format: "%@ %.0f Zentimeter", wheel.text, value)
}
return parts.isEmpty ? "Keine Keile nötig" : "Unterlegen: " + parts.joined(separator: ", ")
}
}
@@ -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))"
}
}
+368 -29
View File
@@ -76,22 +76,22 @@ var advertisement: [UInt8] = [0xE1, 0x02, 0x10, 0x00, 0x4C, 0xA0, 0x01,
UInt8(nonce & 0xFF), UInt8(nonce >> 8), deviceKey[0]]
advertisement += encrypted
let solar = try? VictronAdvertisement.decode(manufacturerData: Data(advertisement),
let victronSolar = try? VictronAdvertisement.decode(manufacturerData: Data(advertisement),
key: deviceKey,
deviceID: UUID(),
rssi: -55)
func value(_ snapshot: DeviceSnapshot?, _ key: String) -> Double? {
snapshot?.metrics.first { $0.key == key }?.value
}
check("Advertisement wird dekodiert", solar != nil)
checkEqual("Zustand als Klartext", solar?.state, "Konstantstrom (Bulk)")
checkEqual("kein Fehler gemeldet", solar?.fault, nil)
checkEqual("Batteriespannung", value(solar, "battery_voltage").map(round2), 13.45)
checkEqual("Ladestrom", value(solar, "battery_current").map { ($0 * 10).rounded() / 10 }, 15.2)
checkEqual("Tagesertrag", value(solar, "yield_today"), 2.34)
checkEqual("PV-Leistung", value(solar, "pv_power"), 210)
checkEqual("Laststrom bleibt leer (NA)", value(solar, "load_current"), nil)
checkEqual("PV-Leistung ist der Hauptwert", solar?.primaryMetric?.key, "pv_power")
check("Advertisement wird dekodiert", victronSolar != nil)
checkEqual("Zustand als Klartext", victronSolar?.state, "Konstantstrom (Bulk)")
checkEqual("kein Fehler gemeldet", victronSolar?.fault, nil)
checkEqual("Batteriespannung", value(victronSolar, "battery_voltage").map(round2), 13.45)
checkEqual("Ladestrom", value(victronSolar, "battery_current").map { ($0 * 10).rounded() / 10 }, 15.2)
checkEqual("Tagesertrag", value(victronSolar, "yield_today"), 2.34)
checkEqual("PV-Leistung", value(victronSolar, "pv_power"), 210)
checkEqual("Laststrom bleibt leer (NA)", value(victronSolar, "load_current"), nil)
checkEqual("PV-Leistung ist der Hauptwert", victronSolar?.primaryMetric?.key, "pv_power")
var wrongKey = deviceKey; wrongKey[0] = 0x00
do {
@@ -698,6 +698,44 @@ checkEqual("falsche Prüfsumme wird verworfen",
checkEqual("angefangenes Paket wird aufgehoben",
AlpicoolProtocol.extractFrames(from: Array(fridgeStream.prefix(10))).frames.count, 0)
// MARK: 9b Kühlbox: der Stand, der die Verbindung überdauert
print("\nKühlbox zuletzt gestellter Stand")
// Derselbe Datensatz wie oben, nur diesmal auf das eingedampft, was ohne
// Verbindung noch gilt.
var boxState = AlpicoolState()
boxState.apply(AlpicoolProtocol.Frame(command: 0x01, payload: [
0x00, 0x01, 0x01, 0x00, 0x04, 0x14, 0xE2, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x06, 0x57, 0x0C, 0x07,
]))
guard let boxSettings = boxState.settings else {
check("Stand lässt sich aus dem Datensatz ableiten", false)
exit(1)
}
checkEqual("eingeschaltet", boxSettings.isPoweredOn, true)
checkEqual("Eco", boxSettings.isEco, true)
checkEqual("Solltemperatur", boxSettings.leftTarget, 4)
checkEqual("Einheit", boxSettings.unitSymbol, "°C")
checkEqual("in Worten", boxSettings.stateText, "Eco")
// Die Übersicht zeigt den Sollwert und ausdrücklich keine Innentemperatur.
let boxSnapshot = boxSettings.snapshot(deviceID: UUID())
checkEqual("Hauptwert ist der Sollwert", boxSnapshot.primaryMetric?.key, "target_left")
checkEqual("Sollwert steht drin", value(boxSnapshot, "target_left"), 4)
checkEqual("keine gemessene Temperatur", boxSnapshot.metrics.contains { $0.key == "temp_left" }, false)
checkEqual("keine Bordspannung", boxSnapshot.metrics.contains { $0.key == "supply_voltage" }, false)
// Ohne Antwort der Box gibt es auch nichts aufzuheben.
checkEqual("leerer Zustand ergibt keinen Stand", AlpicoolState().settings == nil, true)
// Auf der Uhr dasselbe Bild: Sollwerte ja, Messwerte nein.
let watchBox = WatchFridge(boxSettings)
checkEqual("auf der Uhr als nicht live gekennzeichnet", watchBox.isLive, false)
checkEqual("Sollwert kommt mit", watchBox.leftTarget, 4)
checkEqual("Messwert bleibt leer", watchBox.leftCurrent == nil, true)
// MARK: 10 VanAlign Neigungsmesser
print("\nVanAlign-Neigungsmesser")
@@ -826,28 +864,53 @@ ageing.add(pitch: 1, roll: 0, at: start.addingTimeInterval(AlignmentAssistant.me
checkEqual("alte Messwerte fallen heraus", ageing.samples.count, 1)
// MARK: Auffahrkeile
print("\nAuffahrkeile")
// tan(2°) x 2,00 m = 6,99 cm
let acrossWedge = LevelingWedge.across(roll: 2.0, trackWidth: 2.0)
checkEqual("Keilhöhe quer",
acrossWedge.map { ($0.heightInCentimetres * 10).rounded() / 10 }, 7.0)
checkEqual("rechts höher heisst Keil nach links", acrossWedge?.side, .left)
checkEqual("links höher heisst Keil nach rechts",
LevelingWedge.across(roll: -2.0, trackWidth: 2.0)?.side, .right)
print("\nAuffahrkeile Höhe je Rad")
// tan(1,5°) x 3,50 m = 9,16 cm
let alongWedge = LevelingWedge.along(pitch: 1.5, wheelbase: 3.5)
checkEqual("Keilhöhe längs",
alongWedge.map { ($0.heightInCentimetres * 10).rounded() / 10 }, 9.2)
checkEqual("Heck höher heisst Keil nach vorne", alongWedge?.side, .front)
checkEqual("Front höher heisst Keil nach hinten",
LevelingWedge.along(pitch: -1.5, wheelbase: 3.5)?.side, .rear)
// Reine Querneigung: tan(2°) x 2,00 m = 6,99 cm unter beide Räder der
// tieferstehenden Seite. Rechts steht höher, also muss links hoch.
let acrossOnly = LevelingLift.compute(pitch: 0, roll: 2.0, trackWidth: 2.0, wheelbase: 3.5)
checkEqual("quer: vorne links",
acrossOnly.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 7.0)
checkEqual("quer: hinten links",
acrossOnly.map { ($0.centimetres(.rearLeft) * 10).rounded() / 10 }, 7.0)
checkEqual("quer: rechts bleibt liegen",
acrossOnly.map { $0.centimetres(.frontRight) + $0.centimetres(.rearRight) }, 0)
checkEqual("innerhalb der Toleranz kein Keil",
LevelingWedge.across(roll: 0.3, trackWidth: 2.0) == nil, true)
checkEqual("ohne Maß kein Keil",
LevelingWedge.across(roll: 3.0, trackWidth: 0) == nil, true)
// Reine Längsneigung: tan(1,5°) x 3,50 m = 9,16 cm unter beide Vorderräder,
// wenn das Heck höher steht.
let alongOnly = LevelingLift.compute(pitch: 1.5, roll: 0, trackWidth: 2.0, wheelbase: 3.5)
checkEqual("längs: vorne links",
alongOnly.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 9.2)
checkEqual("längs: vorne rechts",
alongOnly.map { ($0.centimetres(.frontRight) * 10).rounded() / 10 }, 9.2)
checkEqual("längs: hinten bleibt liegen",
alongOnly.map { $0.centimetres(.rearLeft) + $0.centimetres(.rearRight) }, 0)
// Beides zusammen: Das tiefste Rad braucht die Summe, das höchste nichts, und
// die beiden dazwischen je einen Anteil. Genau das lässt sich mit getrennten
// Angaben für "rechts" und "vorne" nicht ausdrücken.
let liftBoth = LevelingLift.compute(pitch: 1.5, roll: 2.0, trackWidth: 2.0, wheelbase: 3.5)
checkEqual("kombiniert: tiefstes Rad ist vorne links", liftBoth?.deepest, .frontLeft)
checkEqual("kombiniert: vorne links trägt beide Anteile",
liftBoth.map { ($0.centimetres(.frontLeft) * 10).rounded() / 10 }, 16.1)
checkEqual("kombiniert: vorne rechts nur den Längsanteil",
liftBoth.map { ($0.centimetres(.frontRight) * 10).rounded() / 10 }, 9.2)
checkEqual("kombiniert: hinten links nur den Queranteil",
liftBoth.map { ($0.centimetres(.rearLeft) * 10).rounded() / 10 }, 7.0)
checkEqual("kombiniert: hinten rechts steht am höchsten und bleibt liegen",
liftBoth.map { $0.centimetres(.rearRight) }, 0)
// Vorzeichen: andersherum geneigt, andere Ecke.
let mirrored = LevelingLift.compute(pitch: -1.5, roll: -2.0, trackWidth: 2.0, wheelbase: 3.5)
checkEqual("umgekehrt geneigt: tiefstes Rad ist hinten rechts",
mirrored?.deepest, .rearRight)
checkEqual("fast eben: kein Keil nötig",
LevelingLift.compute(pitch: 0.1, roll: 0.1,
trackWidth: 2.0, wheelbase: 3.5)?.isNegligible, true)
checkEqual("ohne Fahrzeugmasse keine Rechnung",
LevelingLift.compute(pitch: 2, roll: 2, trackWidth: 0, wheelbase: 3.5) == nil, true)
// MARK: 12 Einbaulage des Neigungsmessers
print("\nEinbaulage")
@@ -945,9 +1008,285 @@ let untouched = SensorOrientation.identity.apply(pitch: 1.5, roll: -0.5)
checkEqual("unveränderte Lage lässt längs stehen", untouched.pitch, 1.5)
checkEqual("unveränderte Lage lässt quer stehen", untouched.roll, -0.5)
// Die Einbaulage liegt im Gerät, damit iPhone, Uhr und Android dieselbe sehen.
// Acht Byte, hin und zurück.
print("\nEinbaulage im Gerät")
var stored = SensorOrientation()
stored.longitudinalSource = .roll
stored.invertLongitudinal = true
stored.invertLateral = false
stored.twist = -17.5
let storedBytes = VanAlignProtocol.encoded(stored)
checkEqual("acht Byte lang", storedBytes.count, 8)
checkEqual("Version steht vorne", storedBytes.first, 1)
checkEqual("gelesen kommt dasselbe zurück", VanAlignProtocol.orientation(from: storedBytes), stored)
// Ein Gerät, in dem noch nie etwas stand, meldet Version 0. Das ist kein
// Fehler, sondern heisst "hier gilt, was die App hat" und die schreibt sie
// dann hinauf.
checkEqual("Version 0 gilt als leer",
VanAlignProtocol.orientation(from: Data(repeating: 0, count: 8)) == nil, true)
checkEqual("zu kurze Antwort ergibt nichts",
VanAlignProtocol.orientation(from: Data([1, 0, 0, 0])) == nil, true)
// Die Feldbelegung byteweise festgenagelt: daran hängen drei Apps und die
// Firmware, das darf nicht unbemerkt verrutschen.
var plain = SensorOrientation()
plain.twist = 1
checkEqual("Feldbelegung", [UInt8](VanAlignProtocol.encoded(plain)),
[1, 0, 0, 0, 0x00, 0x00, 0x80, 0x3F]) // 1.0f little endian
var mounted = SensorOrientation()
mounted.longitudinalSource = .roll
mounted.invertLateral = true
checkEqual("Achsentausch und Vorzeichen an ihrem Platz",
[UInt8](VanAlignProtocol.encoded(mounted)).prefix(4).map { $0 }, [1, 1, 0, 1])
// Gespeicherte Einrichtungen müssen ältere Fassungen überleben. Ein neues Feld
// darf das Lesen nicht scheitern lassen sonst ist die Geräteliste leer, und
// das nächste Speichern schreibt diese Leere über den Bestand.
print("\nGespeicherte Geräte lesen")
func decodeDevice(_ json: String) -> ConfiguredDevice? {
try? JSONDecoder().decode(ConfiguredDevice.self, from: Data(json.utf8))
}
let deviceBeforeTwist = """
{"role":"leveling","profileID":"00000000-0000-0000-0000-00000000C001",
"name":"Nivellierung","id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B5",
"advertisedName":"vanalign","fridgeZoneMode":"automatic",
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DA",
"sensorOrientation":{"invertLongitudinal":false,"invertLateral":true,
"longitudinalSource":"roll"}}
"""
let restoredDevice = decodeDevice(deviceBeforeTwist)
checkEqual("Gerät ohne twist lässt sich weiterhin lesen", restoredDevice?.name, "Nivellierung")
checkEqual("die bekannten Felder der Einbaulage bleiben erhalten",
restoredDevice?.sensorOrientation.longitudinalSource, .roll)
checkEqual("das neue Feld fällt auf null zurück", restoredDevice?.sensorOrientation.twist, 0)
let deviceWithoutOrientation = """
{"role":"bms","profileID":"00000000-0000-0000-0000-00000000C001","name":"Bulltron",
"id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B6",
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DB"}
"""
checkEqual("Gerät ganz ohne Einbaulage ebenso",
decodeDevice(deviceWithoutOrientation)?.sensorOrientation, .identity)
checkEqual("eine unbrauchbare Einbaulage bleibt ein Fehler",
decodeDevice("""
{"role":"bms","profileID":"00000000-0000-0000-0000-00000000C001","name":"Kaputt",
"id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B7",
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DC",
"sensorOrientation":{"longitudinalSource":"quer"}}
""") == nil, true)
// Schräg eingebauter Sensor: um 20 Grad um die Hochachse verdreht. Eine reine
// Kippbewegung nach vorne verteilt sich dann auf beide Sensorachsen.
let twistAngle = 20.0
func twisted(_ longitudinal: Double, _ lateral: Double) -> OrientationDetection.Reading {
// Umgekehrter Weg zu SensorOrientation.apply: aus Fahrzeugwerten wird das,
// was ein verdrehter Sensor melden würde.
let a = twistAngle * .pi / 180
return OrientationDetection.Reading(pitch: longitudinal * cos(a) - lateral * sin(a),
roll: longitudinal * sin(a) + lateral * cos(a))
}
let noseTwisted = twisted(10, 0) // Front nach unten, sauber um die Querachse
let sideTwisted = twisted(0, 10) // linke Seite nach unten
if case .success(let skew) = detect(nose: noseTwisted, side: sideTwisted) {
checkEqual("Verdrehung wird erkannt", (skew.twist * 10).rounded() / 10, 20.0)
checkEqual("Achsen bleiben dabei zugeordnet", skew.longitudinalSource, .pitch)
// Die Probe: zurückgerechnet muss aus der Frontbewegung reine Längsneigung
// werden und aus der Seitenbewegung reine Querneigung.
let front = skew.apply(pitch: noseTwisted.pitch, roll: noseTwisted.roll)
checkEqual("verdreht gemessene Frontbewegung wird reine Längsneigung",
front.pitch.map { ($0 * 10).rounded() / 10 }, 10.0)
checkEqual("und hat keine Querneigung mehr",
front.roll.map { abs($0) < 0.001 }, true)
let side = skew.apply(pitch: sideTwisted.pitch, roll: sideTwisted.roll)
checkEqual("verdreht gemessene Seitenbewegung wird reine Querneigung",
side.roll.map { ($0 * 10).rounded() / 10 }, 10.0)
checkEqual("und hat keine Längsneigung mehr",
side.pitch.map { abs($0) < 0.001 }, true)
} else {
check("verdrehter Einbau wird ausgewertet", false)
}
// Genau das, was vorher fehlte: ohne Korrektur schlägt eine reine Querneigung
// auf die Längsanzeige durch bei 20 Grad Verdrehung mit gut einem Drittel.
let uncorrected = SensorOrientation.identity.apply(pitch: sideTwisted.pitch,
roll: sideTwisted.roll)
checkEqual("ohne Korrektur kippt die Längsanzeige mit",
uncorrected.pitch.map { ($0 * 10).rounded() / 10 }, -3.4)
// Ein bisschen Wackeln ist keine Verdrehung.
if case .success(let steady) = detect(nose: Reading(pitch: 10, roll: 0.2),
side: Reading(pitch: 0.2, roll: 10)) {
checkEqual("kleiner Rest gilt als Rauschen, nicht als Verdrehung", steady.twist, 0)
} else {
check("saubere Kippbewegung wird ausgewertet", false)
}
let missing = SensorOrientation.identity.apply(pitch: nil, roll: 2)
checkEqual("fehlende Werte bleiben leer", missing.pitch == nil, true)
checkEqual("vorhandene Werte kommen durch", missing.roll, 2)
// MARK: 13 Strecke zur Apple Watch
print("\nApple Watch Datensatz und Befehle")
let watchProfile = Profile(name: "Kastenwagen", symbol: "box.truck",
trackWidth: 1.85, wheelbase: 3.50)
let watchLevelID = UUID()
let watchFridgeID = UUID()
let watchBoosterID = UUID()
var watchLevel = LevelState()
watchLevel.pitch = 1.8
watchLevel.roll = -0.9
watchLevel.pitchOffset = 0.4
watchLevel.rollOffset = -0.2
// Feste Zeitstempel, damit der Vergleich unten wirklich den Inhalt prüft: über
// die Strecke bleibt die Millisekunde stehen, ein `Date()` wäre feiner.
let watchStamp = Date(timeIntervalSince1970: 1_699_999_999)
var watchLevelSnapshot = watchLevel.snapshot(deviceID: watchLevelID, rssi: -70)
watchLevelSnapshot.timestamp = watchStamp
var boosterSnapshot = DeviceSnapshot(deviceID: watchBoosterID, timestamp: watchStamp, rssi: -71)
boosterSnapshot.state = "Aus"
boosterSnapshot.offReasons = ["Keine Eingangsspannung"]
boosterSnapshot.metrics = [
Metric("output_voltage", "Ausgang", 14.09, unit: "V", precision: 2, primary: true),
Metric("input_voltage", "Eingang", 12.42, unit: "V", precision: 2),
]
// Quer eingebauter Sensor: Die Uhr rechnet damit selbst, also muss die
// Zuordnung mit über die Strecke.
var watchOrientation = SensorOrientation()
watchOrientation.longitudinalSource = .roll
watchOrientation.invertLateral = true
let watchFridge = WatchFridge(isLive: true,
updated: Date(timeIntervalSince1970: 1_699_999_000),
isPoweredOn: true, isEco: true, isLocked: false,
isDualZone: false, unitSymbol: "°C",
leftTarget: 4, leftCurrent: 6,
rightTarget: nil, rightCurrent: nil,
minTarget: -20, maxTarget: 20, batteryVolts: 12.7)
let watchPayload = WatchPayload(
generatedAt: Date(timeIntervalSince1970: 1_700_000_000),
profile: watchProfile,
isRadioReady: true,
radioStatus: "Bereit",
devices: [
WatchDevice(id: watchLevelID, name: "Nivellierung", role: .leveling,
link: .live, snapshot: watchLevelSnapshot,
level: watchLevel, orientation: watchOrientation, fridge: nil),
WatchDevice(id: watchBoosterID, name: "Ladebooster", role: .chargeBooster,
link: .failed("Nicht gefunden"), snapshot: boosterSnapshot,
level: nil, orientation: nil, fridge: nil),
WatchDevice(id: watchFridgeID, name: "Kühlbox", role: .fridge,
link: .live, snapshot: nil, level: nil,
orientation: nil, fridge: watchFridge),
])
// Der ganze Weg: verpacken, als WatchConnectivity-Wörterbuch lesen, vergleichen.
if let message = try? watchPayload.message(),
let restored = WatchPayload.decode(from: message) {
checkEqual("Datensatz kommt unverändert an", restored, watchPayload)
checkEqual("Zeitstempel überlebt das Verpacken",
restored.generatedAt.timeIntervalSince1970, 1_700_000_000)
checkEqual("Fehlerklartext bleibt am Gerät",
restored.devices[1].link, .failed("Nicht gefunden"))
checkEqual("Neigung wird gefunden", restored.levelDevice?.id, watchLevelID)
checkEqual("Kühlbox wird gefunden", restored.fridgeDevice?.id, watchFridgeID)
checkEqual("Sollwert der Box überlebt", restored.fridgeDevice?.fridge?.leftTarget, 4)
checkEqual("Fahrzeugmasse kommen mit", restored.profile.trackWidth, 1.85)
checkEqual("Einbaulage kommt mit die Uhr misst selbst",
restored.levelDevice?.orientation?.longitudinalSource, .roll)
checkEqual("Umkehrung der Querachse ebenfalls",
restored.levelDevice?.orientation?.invertLateral, true)
} else {
check("Datensatz lässt sich verpacken und wieder lesen", false)
}
// Eine Uhr mit älterer App darf einen neueren Datensatz nicht halb verstehen.
var futurePayload = watchPayload
futurePayload.version = WatchPayload.currentVersion + 1
if let message = try? futurePayload.message() {
checkEqual("fremde Formatversion wird abgelehnt",
WatchPayload.decode(from: message) == nil, true)
} else {
check("fremde Formatversion lässt sich verpacken", false)
}
// Zeitpunkte reisen als Millisekunden seit 1970 in einer Fliesskommazahl. Auf
// die Millisekunde genau kommen sie zurück, aufs letzte Bit nicht deshalb
// vergleicht der Test oben feste Zeitstempel und nicht ein `Date()`.
var preciseSnapshot = boosterSnapshot
preciseSnapshot.timestamp = Date(timeIntervalSince1970: 1_699_999_999.123_456)
var precisePayload = watchPayload
precisePayload.devices[1].snapshot = preciseSnapshot
if let message = try? precisePayload.message(),
let restored = WatchPayload.decode(from: message)?.devices[1].snapshot?.timestamp {
checkEqual("feiner Zeitstempel kommt auf die Millisekunde genau zurück",
abs(restored.timeIntervalSince1970 - 1_699_999_999.123_456) < 0.001, true)
} else {
check("Datensatz mit feinem Zeitstempel lässt sich verpacken", false)
}
checkEqual("leeres Wörterbuch ergibt keinen Datensatz",
WatchPayload.decode(from: [:]) == nil, true)
checkEqual("Unsinn im Nutzdatenfeld ergibt keinen Datensatz",
WatchPayload.decode(from: [WatchLink.payloadKey: Data([0x00, 0x01])]) == nil, true)
// Der Vergleich entscheidet, ob überhaupt gefunkt wird: der Zeitstempel allein
// darf keinen Funkverkehr auslösen, ein geänderter Messwert schon.
var laterPayload = watchPayload
laterPayload.generatedAt = watchPayload.generatedAt.addingTimeInterval(30)
checkEqual("gleicher Inhalt, neuer Zeitstempel gilt als unverändert",
watchPayload.hasSameContent(as: laterPayload), true)
var changedPayload = laterPayload
changedPayload.devices[2].fridge?.leftTarget = 5
checkEqual("geänderter Sollwert gilt als Änderung",
watchPayload.hasSameContent(as: changedPayload), false)
// Befehle in die Gegenrichtung.
let commands: [WatchCommand] = [
.hello(live: true),
.hello(live: false),
.fridgePower(device: watchFridgeID, on: false),
.fridgeEco(device: watchFridgeID, eco: true),
.fridgeLock(device: watchFridgeID, locked: true),
.fridgeTarget(device: watchFridgeID, zone: .right, value: -3),
.fridgeSession(device: watchFridgeID, wanted: true),
.fridgeSession(device: watchFridgeID, wanted: false),
]
for command in commands {
guard let message = try? command.message(),
let restored = WatchCommand.decode(from: message) else {
check("Befehl \(command) lässt sich verpacken", false)
continue
}
checkEqual("Befehl kommt unverändert an", restored, command)
}
checkEqual("Anmeldung ohne Hinschauen verlängert den schnellen Takt nicht",
WatchCommand.hello(live: false).wantsLiveUpdates, false)
checkEqual("wer stellt, schaut auch hin",
WatchCommand.fridgePower(device: watchFridgeID, on: true).wantsLiveUpdates, true)
checkEqual("ein Datensatz ist kein Befehl",
WatchCommand.decode(from: (try? watchPayload.message()) ?? [:]) == nil, true)
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
exit(failures == 0 ? 0 : 1)
+8 -2
View File
@@ -13,7 +13,7 @@ Anschließend skaliert es auf 1024×1024 ohne Alphakanal.
```bash
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.
@@ -32,5 +32,11 @@ gefüllte Form, in der die Details erhalten bleiben.
```bash
swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift
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.
+791
View File
@@ -0,0 +1,791 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
AA0000000000000000000027 /* VanControlWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* VanControlWatch.app */; 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 */
/* Begin PBXContainerItemProxy section */
AA0000000000000000000028 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA0000000000000000000009 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AA0000000000000000000022;
remoteInfo = VanControlWatch;
};
AA0000000000000000000043 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA0000000000000000000009 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AA0000000000000000000037;
remoteInfo = VanControlComplication;
};
AA0000000000000000000063 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA0000000000000000000009 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AA0000000000000000000062;
remoteInfo = VanControlLiveActivityExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
AA0000000000000000000026 /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
AA0000000000000000000027 /* VanControlWatch.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000041 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
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";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
AA0000000000000000000001 /* VanControl.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanControl.app; sourceTree = BUILT_PRODUCTS_DIR; };
AA0000000000000000000016 /* VanControlWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanControlWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
AA0000000000000000000030 /* VanControl-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "VanControl-Info.plist"; sourceTree = "<group>"; };
AA0000000000000000000032 /* VanControlComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VanControlComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; };
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 */
/* 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 */
AA0000000000000000000002 /* VanControl */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = VanControl;
sourceTree = "<group>";
};
AA0000000000000000000017 /* VanControlWatch */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = VanControlWatch;
sourceTree = "<group>";
};
AA0000000000000000000018 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Shared;
sourceTree = "<group>";
};
AA0000000000000000000033 /* VanControlComplication */ = {
isa = PBXFileSystemSynchronizedRootGroup;
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>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
AA0000000000000000000003 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
AA0000000000000000000049 /* ActivityKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000020 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000035 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
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 */
/* Begin PBXGroup section */
AA0000000000000000000004 = {
isa = PBXGroup;
children = (
AA0000000000000000000002 /* VanControl */,
AA0000000000000000000017 /* VanControlWatch */,
AA0000000000000000000033 /* VanControlComplication */,
AA0000000000000000000018 /* Shared */,
AA0000000000000000000057 /* SharedActivity */,
AA0000000000000000000031 /* Config */,
AA0000000000000000000056 /* VanControlLiveActivity */,
AA0000000000000000000059 /* Frameworks */,
AA0000000000000000000005 /* Products */,
);
sourceTree = "<group>";
};
AA0000000000000000000005 /* Products */ = {
isa = PBXGroup;
children = (
AA0000000000000000000001 /* VanControl.app */,
AA0000000000000000000016 /* VanControlWatch.app */,
AA0000000000000000000032 /* VanControlComplication.appex */,
AA0000000000000000000054 /* VanControlLiveActivityExtension.appex */,
);
name = Products;
sourceTree = "<group>";
};
AA0000000000000000000031 /* Config */ = {
isa = PBXGroup;
children = (
AA0000000000000000000030 /* VanControl-Info.plist */,
AA0000000000000000000045 /* VanControlComplication-Info.plist */,
);
path = Config;
sourceTree = "<group>";
};
AA0000000000000000000059 /* Frameworks */ = {
isa = PBXGroup;
children = (
AA0000000000000000000051 /* WidgetKit.framework */,
AA0000000000000000000052 /* SwiftUI.framework */,
AA0000000000000000000053 /* ActivityKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
AA0000000000000000000006 /* VanControl */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "VanControl" */;
buildPhases = (
AA0000000000000000000007 /* Sources */,
AA0000000000000000000003 /* Frameworks */,
AA0000000000000000000008 /* Resources */,
AA0000000000000000000026 /* Embed Watch Content */,
AA0000000000000000000065 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
AA0000000000000000000029 /* PBXTargetDependency */,
AA0000000000000000000064 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
AA0000000000000000000002 /* VanControl */,
AA0000000000000000000018 /* Shared */,
AA0000000000000000000057 /* SharedActivity */,
);
name = VanControl;
productName = VanControl;
productReference = AA0000000000000000000001 /* VanControl.app */;
productType = "com.apple.product-type.application";
};
AA0000000000000000000022 /* VanControlWatch */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "VanControlWatch" */;
buildPhases = (
AA0000000000000000000019 /* Sources */,
AA0000000000000000000020 /* Frameworks */,
AA0000000000000000000021 /* Resources */,
AA0000000000000000000041 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
AA0000000000000000000044 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
AA0000000000000000000017 /* VanControlWatch */,
AA0000000000000000000018 /* Shared */,
);
name = VanControlWatch;
productName = VanControlWatch;
productReference = AA0000000000000000000016 /* VanControlWatch.app */;
productType = "com.apple.product-type.application";
};
AA0000000000000000000037 /* VanControlComplication */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "VanControlComplication" */;
buildPhases = (
AA0000000000000000000034 /* Sources */,
AA0000000000000000000035 /* Frameworks */,
AA0000000000000000000036 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
AA0000000000000000000018 /* Shared */,
AA0000000000000000000033 /* VanControlComplication */,
);
name = VanControlComplication;
productName = VanControlComplication;
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";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
AA0000000000000000000009 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2660;
LastUpgradeCheck = 2660;
TargetAttributes = {
AA0000000000000000000006 = {
CreatedOnToolsVersion = 26.6;
};
AA0000000000000000000022 = {
CreatedOnToolsVersion = 26.6;
};
AA0000000000000000000037 = {
CreatedOnToolsVersion = 26.6;
};
AA0000000000000000000062 = {
CreatedOnToolsVersion = 26.6;
};
};
};
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "VanControl" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
de,
);
mainGroup = AA0000000000000000000004;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = AA0000000000000000000005 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
AA0000000000000000000006 /* VanControl */,
AA0000000000000000000022 /* VanControlWatch */,
AA0000000000000000000037 /* VanControlComplication */,
AA0000000000000000000062 /* VanControlLiveActivityExtension */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
AA0000000000000000000008 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000021 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000036 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000061 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
AA0000000000000000000007 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000019 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000034 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000060 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
AA0000000000000000000029 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
platformFilter = ios;
target = AA0000000000000000000022 /* VanControlWatch */;
targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */;
};
AA0000000000000000000044 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = AA0000000000000000000037 /* VanControlComplication */;
targetProxy = AA0000000000000000000043 /* PBXContainerItemProxy */;
};
AA0000000000000000000064 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = AA0000000000000000000062 /* VanControlLiveActivityExtension */;
targetProxy = AA0000000000000000000063 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
AA0000000000000000000012 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
AA0000000000000000000013 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_VERSION = 5.0;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
AA0000000000000000000014 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/VanControl-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
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_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA0000000000000000000015 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/VanControl-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
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_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
AA0000000000000000000023 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.s0.fototeddy.VanControl;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
};
name = Debug;
};
AA0000000000000000000024 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V5C6Q86XJR;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.s0.fototeddy.VanControl;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
};
name = Release;
};
AA0000000000000000000038 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V5C6Q86XJR;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/VanControlComplication-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.watchkitapp.levelwidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
};
name = Debug;
};
AA0000000000000000000039 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = V5C6Q86XJR;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/VanControlComplication-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControl.watchkitapp.levelwidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
};
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 */
/* Begin XCConfigurationList section */
AA0000000000000000000010 /* Build configuration list for PBXProject "VanControl" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000012 /* Debug */,
AA0000000000000000000013 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "VanControl" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000014 /* Debug */,
AA0000000000000000000015 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "VanControlWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000023 /* Debug */,
AA0000000000000000000024 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "VanControlComplication" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000038 /* Debug */,
AA0000000000000000000039 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA0000000000000000000068 /* Build configuration list for PBXNativeTarget "VanControlLiveActivityExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000066 /* Debug */,
AA0000000000000000000067 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = AA0000000000000000000009 /* Project object */;
}
@@ -6,9 +6,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000006"
BuildableName = "CamperMonitor.app"
BlueprintName = "CamperMonitor"
ReferencedContainer = "container:CamperMonitor.xcodeproj">
BuildableName = "VanControl.app"
BlueprintName = "VanControl"
ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
@@ -21,9 +21,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000006"
BuildableName = "CamperMonitor.app"
BlueprintName = "CamperMonitor"
ReferencedContainer = "container:CamperMonitor.xcodeproj">
BuildableName = "VanControl.app"
BlueprintName = "VanControl"
ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
@@ -32,9 +32,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000006"
BuildableName = "CamperMonitor.app"
BlueprintName = "CamperMonitor"
ReferencedContainer = "container:CamperMonitor.xcodeproj">
BuildableName = "VanControl.app"
BlueprintName = "VanControl"
ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "VanControlWatch.app"
BlueprintName = "VanControlWatch"
ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "VanControlWatch.app"
BlueprintName = "VanControlWatch"
ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "VanControlWatch.app"
BlueprintName = "VanControlWatch"
ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

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

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

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

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

@@ -232,6 +232,19 @@ struct AlpicoolState: Equatable {
}
}
/// Der Stand, der die Verbindung überdauert ohne Messwerte.
var settings: FridgeSettings? {
guard hasStatus else { return nil }
return FridgeSettings(isPoweredOn: isPoweredOn,
isEco: isEco,
isLocked: isLocked,
isDualZone: isDualZone,
usesFahrenheit: usesFahrenheit,
leftTarget: leftTarget,
rightTarget: isDualZone ? rightTarget : nil,
updated: Date())
}
/// Die Bytes, die ein Stellbefehl ändert.
///
/// Messwerte gehören nicht dazu: Temperatur und Spannung schwanken
@@ -17,6 +17,8 @@ struct Discovery: Identifiable, Hashable {
var looksLikeSupported: Bool
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
var isLevelSensor = false
/// Der Solarladeregler bewirbt seinen Dienst ebenso.
var isVotronicSolarESPSensor = false
var isVictron: Bool { victronRecordType != nil }
@@ -30,6 +32,7 @@ struct Discovery: Identifiable, Hashable {
return "Victron · " + Self.victronRecordName(type)
}
if isLevelSensor { return "VanAlign Neigungsmesser" }
if isVotronicSolarESPSensor { return "VotronicSolarESP" }
if looksLikeSupported { return "Sieht nach BMS oder Kühlbox aus" }
return "Bluetooth-Gerät"
}
@@ -149,9 +152,15 @@ final class BluetoothManager: NSObject {
private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
private(set) var levelStates: [UUID: LevelState] = [:]
private(set) var votronicSolarESPStates: [UUID: VotronicSolarESPState] = [:]
private(set) var isBluetoothReady = false
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.
var isDiscovering = false {
didSet {
@@ -162,7 +171,7 @@ final class BluetoothManager: NSObject {
// 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?
/// Momentaufnahme der eingerichteten Geräte, damit die Funk-Queue nicht in
@@ -178,8 +187,14 @@ final class BluetoothManager: NSObject {
}
private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID
/// Geräte, die nur verbunden werden, solange ihre Ansicht offen ist.
/// Schlüssel ist die Gerätekennung, gezählt wird nicht eine Ansicht ist
/// zu einer Zeit offen.
private var requestedSessions: Set<UUID> = []
private var bmsSessions: [UUID: BMSSession] = [:]
private var levelSessions: [UUID: LevelSession] = [:]
private var votronicSolarESPSessions: [UUID: VotronicSolarESPSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
private var reconnectTimer: DispatchSourceTimer?
@@ -257,6 +272,13 @@ final class BluetoothManager: NSObject {
for snapshot in DemoData.snapshots() {
snapshots[snapshot.deviceID] = snapshot
linkStates[snapshot.deviceID] = .live
// Die Kühlbox wird nur beim Öffnen verbunden im Vorführbetrieb
// steht sie deshalb wie im Alltag auf zuletzt gestellt.
if snapshot.deviceID == DemoData.fridge.id,
let settings = DemoData.fridgeState.settings {
linkStates[snapshot.deviceID] = .idle
snapshots[snapshot.deviceID] = settings.snapshot(deviceID: snapshot.deviceID)
}
if let value = snapshot.primaryMetric?.value {
history[snapshot.deviceID] = DemoData.history(for: snapshot.deviceID, around: value)
}
@@ -297,6 +319,15 @@ final class BluetoothManager: NSObject {
bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
fridgeStates = fridgeStates.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
// ihren zuletzt gestellten Stand.
for device in store.activeDevices where device.role.connectsOnDemand {
guard linkStates[device.id] != .live else { continue }
linkStates[device.id] = .idle
showLastKnownFridgeSettings(for: device.id)
}
queue.async { self.applyConfiguration(devices) }
}
@@ -309,9 +340,64 @@ final class BluetoothManager: NSObject {
}
}
// MARK: - Verbindung auf Anforderung (vom Hauptthread aufgerufen)
/// Verbindet ein Gerät, das sonst unverbunden bleibt solange seine
/// Ansicht offen ist.
///
/// Für die Kühlbox: Jede Verbindung meldet sich an ihrem Display an, manche
/// Modelle verlangen dabei sogar einen Tastendruck. Dauerhaft verbunden zu
/// sein ist dort also nicht unsichtbar, sondern lästig.
func beginSession(for device: ConfiguredDevice) {
guard device.role.connectsOnDemand else { return }
let deviceID = device.id
queue.async {
self.requestedSessions.insert(deviceID)
guard let entry = self.managed.values.first(where: { $0.id == deviceID }) else { return }
// Der Wunsch des Benutzers hebt die Wartesperre auf.
self.clearBackOff(entry.peripheralID)
self.connectIfNeeded(entry)
}
}
/// Gibt das Gerät wieder frei. Der zuletzt gemeldete Stand bleibt in der
/// Übersicht stehen, damit dort nicht plötzlich eine Lücke klafft.
func endSession(for device: ConfiguredDevice) {
guard device.role.connectsOnDemand else { return }
let deviceID = device.id
queue.async {
self.requestedSessions.remove(deviceID)
guard let entry = self.managed.values.first(where: { $0.id == deviceID }) else { return }
self.bmsSessions[entry.peripheralID]?.stop()
self.bmsSessions[entry.peripheralID] = nil
self.disconnect(entry.peripheralID)
self.publish {
self.linkStates[deviceID] = .idle
self.showLastKnownFridgeSettings(for: deviceID)
}
}
}
/// Setzt an die Stelle der Live-Werte den zuletzt gestellten Stand.
///
/// Nur auf dem Hauptthread aufrufen: greift auf den Speicher zu.
private func showLastKnownFridgeSettings(for deviceID: UUID) {
guard let settings = store.lastFridgeSettings(for: deviceID) else {
snapshots[deviceID] = nil
return
}
snapshots[deviceID] = settings.snapshot(deviceID: deviceID)
// Der Zustand bleibt stehen, damit sich die Box beim nächsten Öffnen
// sofort bedienen lässt die Schalter zeigen dann den letzten Stand.
}
// MARK: - Kühlbox steuern (vom Hauptthread aufgerufen)
func updateFridgeZoneMode(for device: ConfiguredDevice) {
guard !isDemo else {
applyDemoFridgeChange(for: device.id) { $0.zoneMode = device.fridgeZoneMode }
return
}
let deviceID = device.id, mode = device.fridgeZoneMode
queue.async {
guard let session = self.session(for: deviceID) else { return }
@@ -322,21 +408,51 @@ final class BluetoothManager: NSObject {
}
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) }
}
func setFridgePower(_ on: Bool, for deviceID: UUID) {
guard !isDemo else {
applyDemoFridgeChange(for: deviceID) { $0.isPoweredOn = on }
return
}
sendFridgeSettings(for: deviceID) { $0.settingsCommand(poweredOn: on) }
}
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) }
}
func setFridgeLock(_ locked: Bool, for deviceID: UUID) {
guard !isDemo else {
applyDemoFridgeChange(for: deviceID) { $0.isLocked = locked }
return
}
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
/// und zwar auf der Funk-Queue, wo dieser Zustand lebt.
///
@@ -381,14 +497,31 @@ final class BluetoothManager: NSObject {
}
/// Nach einer Änderung der Einbaulage aufrufen.
///
/// Die Lage wandert zusätzlich ins Gerät. Dort gehört sie hin: Sie
/// beschreibt den Einbau, und Uhr wie Android-App finden sie dann vor,
/// ohne dass jemand sie ein zweites Mal bestimmen muss.
func updateSensorOrientation(for device: ConfiguredDevice) {
let deviceID = device.id, orientation = device.sensorOrientation
queue.async {
guard let session = self.levelSession(for: deviceID) else { return }
session.orientation = orientation
session.storeOrientation(orientation)
}
}
/// Übernimmt die Einbaulage, die das Gerät meldet.
///
/// Steht dort noch nichts, meldet das Gerät nichts dann bleibt es bei der
/// örtlich gespeicherten Fassung, und die wird beim nächsten Bestimmen
/// hinaufgeschrieben.
private func adoptOrientation(_ orientation: SensorOrientation, for deviceID: UUID) {
guard var device = store.devices.first(where: { $0.id == deviceID }),
device.sensorOrientation != orientation else { return }
device.sensorOrientation = orientation
store.update(device)
}
/// Setzt die aktuelle Lage des Fahrzeugs als neue Null.
func calibrateLevel(for deviceID: UUID) {
queue.async { self.levelSession(for: deviceID)?.calibrate() }
@@ -414,8 +547,10 @@ final class BluetoothManager: NSObject {
private func applyConfiguration(_ devices: [ManagedDevice]) {
managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) })
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören.
let wanted = Set(devices.filter { $0.transport == .connect }.map(\.peripheralID))
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören und zu
// denen, die nur auf Anforderung verbunden werden und gerade niemand
// anschaut.
let wanted = Set(devices.filter { shouldStayConnected($0) }.map(\.peripheralID))
for (peripheralID, session) in bmsSessions where !wanted.contains(peripheralID) {
session.stop()
bmsSessions[peripheralID] = nil
@@ -426,6 +561,11 @@ final class BluetoothManager: NSObject {
levelSessions[peripheralID] = nil
disconnect(peripheralID)
}
for (peripheralID, session) in votronicSolarESPSessions where !wanted.contains(peripheralID) {
session.stop()
votronicSolarESPSessions[peripheralID] = nil
disconnect(peripheralID)
}
// Einstellungen an bestehende Sitzungen weiterreichen.
for device in devices {
bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode
@@ -471,9 +611,11 @@ final class BluetoothManager: NSObject {
discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
for (_, session) in bmsSessions { session.stop() }
for (_, session) in levelSessions { session.stop() }
for (_, session) in votronicSolarESPSessions { session.stop() }
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
bmsSessions.removeAll()
levelSessions.removeAll()
votronicSolarESPSessions.removeAll()
connectedPeripherals.removeAll()
connectedSince.removeAll()
pendingControls.removeAll()
@@ -492,11 +634,22 @@ final class BluetoothManager: NSObject {
private func connectManagedPeripherals() {
guard central?.state == .poweredOn else { return }
for device in managed.values where device.transport == .connect {
for device in managed.values where shouldStayConnected(device) {
connectIfNeeded(device)
}
}
/// Ob dieses Gerät von sich aus verbunden gehalten wird.
///
/// Geräte auf Anforderung die Kühlbox nur dann, wenn ihre Ansicht
/// gerade offen ist oder ein Stellbefehl darauf wartet.
private func shouldStayConnected(_ device: ManagedDevice) -> Bool {
guard device.transport == .connect else { return false }
guard device.role.connectsOnDemand else { return true }
return requestedSessions.contains(device.id)
|| pendingControls[device.peripheralID]?.isEmpty == false
}
private func connectIfNeeded(_ device: ManagedDevice) {
if let existing = connectedPeripherals[device.peripheralID],
existing.state == .connected || existing.state == .connecting {
@@ -654,7 +807,8 @@ final class BluetoothManager: NSObject {
}
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
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)
pendingDiscoveries[peripheral.identifier] = entry
}
@@ -719,8 +873,11 @@ extension BluetoothManager: CBCentralManagerDelegate {
handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi)
case .connect:
// Das Gerät wurde gesehen falls die Verbindung fehlt, jetzt aufbauen.
if connectedPeripherals[identifier] == nil {
// Das Gerät wurde gesehen falls die Verbindung fehlt, jetzt
// aufbauen. Ausser bei Geräten auf Anforderung: Die Kühlbox piept
// bei jeder Verbindung, und dass sie in Reichweite ist, ist kein
// Grund, sie anzufassen.
if connectedPeripherals[identifier] == nil, shouldStayConnected(device) {
connectIfNeeded(device)
}
}
@@ -738,10 +895,28 @@ extension BluetoothManager: CBCentralManagerDelegate {
queue: queue,
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
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
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
// Im Gerät steht, wie der Sensor eingebaut ist für alle
// Clients dasselbe. Also übernehmen statt überschreiben.
self?.publish { self?.adoptOrientation(orientation, for: device.id) }
}
)
session.orientation = device.sensorOrientation
@@ -750,6 +925,27 @@ extension BluetoothManager: CBCentralManagerDelegate {
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(
deviceID: device.id,
peripheral: peripheral,
@@ -764,7 +960,14 @@ extension BluetoothManager: CBCentralManagerDelegate {
)
session.onFridgeState = { [weak self] state in
self?.fridgeStateCache[peripheral.identifier] = state
self?.publish { self?.fridgeStates[device.id] = state }
self?.publish {
self?.fridgeStates[device.id] = state
// Für die Übersicht aufheben: Sie zeigt den Stand auch dann,
// wenn längst niemand mehr verbunden ist.
if let settings = state.settings {
self?.store.setLastFridgeSettings(settings, for: device.id)
}
}
}
session.fridgeZoneMode = device.fridgeZoneMode
bmsSessions[peripheral.identifier] = session
@@ -790,13 +993,29 @@ extension BluetoothManager: CBCentralManagerDelegate {
bmsSessions[peripheral.identifier] = nil
levelSessions[peripheral.identifier]?.handleDisconnect()
levelSessions[peripheral.identifier] = nil
votronicSolarESPSessions[peripheral.identifier]?.handleDisconnect()
votronicSolarESPSessions[peripheral.identifier] = nil
connectedPeripherals[peripheral.identifier] = nil
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
.map { Date().timeIntervalSince($0) } ?? 0
if let device = managed[peripheral.identifier] {
publish { self.linkStates[device.id] = .searching }
if let device = managed[peripheral.identifier], !shouldStayConnected(device) {
// Auf Anforderung verbunden und niemand schaut mehr hin: nicht
// wieder anklopfen, sondern den letzten Stand stehenlassen.
let deviceID = device.id
publish {
self.linkStates[deviceID] = .idle
self.showLastKnownFridgeSettings(for: deviceID)
}
} else if let device = managed[peripheral.identifier] {
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 {
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
// 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
/// 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 \
/// booted de.fritob.CamperMonitor
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
/// * Schalter in den Einstellungen (`SettingsView`) in jeder
/// Build-Konfiguration verfügbar, auch in TestFlight- und
/// App-Store-Builds. Der Weg für jeden lokal installierten Build ohne
/// Xcode-Verbindung. Wirkt erst nach einem Neustart der App, weil
/// `DeviceStore` und `BluetoothManager` den Stand nur beim Start lesen.
/// * Umgebungsvariable `CAMPER_DEMO=1` beim Start nur in Debug-Builds,
/// praktisch im Simulator:
///
/// 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 {
/// Schlüssel für den Einstellungen-Schalter, siehe `SettingsView`.
static let enabledKey = "demoModeEnabled"
static var isEnabled: Bool {
#if DEBUG
return ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
#else
return false
if ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1" { return true }
#endif
return UserDefaults.standard.bool(forKey: enabledKey)
}
static let mainProfile = Profile(id: Profile.defaultID, name: "Kastenwagen",
@@ -34,9 +42,9 @@ enum DemoData {
name: "Ladebooster", role: .chargeBooster, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000B1")!)
static let solar = ConfiguredDevice(
static let victronSolar = ConfiguredDevice(
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")!)
static let battery = ConfiguredDevice(
@@ -44,9 +52,9 @@ enum DemoData {
name: "Bulltron 200 Ah", role: .bms, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000A1")!)
static let caravanSolar = ConfiguredDevice(
static let caravanVictronSolar = ConfiguredDevice(
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")!)
static let fridge = ConfiguredDevice(
@@ -59,8 +67,13 @@ enum DemoData {
name: "Nivellierung", role: .leveling, profileID: Profile.defaultID,
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] {
[solar, booster, battery, fridge, level, caravanSolar]
[victronSolar, booster, battery, fridge, level, votronicSolar, caravanVictronSolar]
}
/// Leicht schräg stehend, damit die Libelle etwas zu zeigen hat.
@@ -83,10 +96,24 @@ enum DemoData {
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] {
var solarSnapshot = DeviceSnapshot(deviceID: solar.id, timestamp: Date(), rssi: -58)
solarSnapshot.state = "Konstantspannung (Absorption)"
solarSnapshot.metrics = [
var victronSolarSnapshot = DeviceSnapshot(deviceID: victronSolar.id, timestamp: Date(), rssi: -58)
victronSolarSnapshot.state = "Konstantspannung (Absorption)"
victronSolarSnapshot.metrics = [
Metric("pv_power", "PV-Leistung", 284, unit: "W", precision: 0, primary: true),
Metric("battery_power", "Ladeleistung", 262, unit: "W", precision: 0),
Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2),
@@ -120,7 +147,7 @@ enum DemoData {
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.metrics = [
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),
]
return [solarSnapshot, boosterSnapshot, batterySnapshot,
return [victronSolarSnapshot, boosterSnapshot, batterySnapshot,
fridgeState.snapshot(deviceID: fridge.id, rssi: -66),
levelState.snapshot(deviceID: level.id, rssi: -70),
votronicSolarESPState.snapshot(deviceID: votronicSolar.id, rssi: -58),
caravanSnapshot]
}
@@ -9,6 +9,9 @@ final class DeviceStore {
private static let devicesKey = "configuredDevices"
private static let profilesKey = "profiles"
private static let activeProfileKey = "activeProfileID"
/// Hierhin wird gerettet, was sich nicht lesen liess siehe `loadDevices`.
private static let unreadableDevicesKey = "configuredDevices.unreadable"
private static let fridgeSettingsKey = "lastFridgeSettings"
/// Alle Geräte über alle Profile hinweg.
private(set) var devices: [ConfiguredDevice] = []
@@ -16,6 +19,11 @@ final class DeviceStore {
private(set) var activeProfileID: UUID = Profile.defaultID
/// Ob beim Start Geräte im Speicher lagen, die sich nicht lesen liessen.
/// Ihre Rohdaten sind aufgehoben, statt beim nächsten Speichern
/// überschrieben zu werden.
private(set) var hasUnreadableDevices = false
/// Zwischenspeicher, damit nicht bei jedem Advertisement die Keychain
/// befragt wird das passiert bis zu mehrmals pro Sekunde.
@ObservationIgnored private var keyCache: [UUID: [UInt8]] = [:]
@@ -137,6 +145,36 @@ final class DeviceStore {
return bytes
}
// MARK: - Zuletzt bekannter Stand der Kühlbox
/// Die Kühlbox wird nur verbunden, während ihre Ansicht offen ist. Damit
/// die Übersicht trotzdem etwas zeigt, wird der zuletzt gemeldete Stand
/// aufgehoben die Einstellungen, nicht die Messwerte.
func lastFridgeSettings(for deviceID: UUID) -> FridgeSettings? {
allFridgeSettings()[deviceID.uuidString]
}
/// Der Zeitstempel bedeutet zuletzt **geändert**, nicht zuletzt
/// gesehen: Sonst wanderte er im Sekundentakt und die Übersicht behauptete
/// Frische, wo sich nichts getan hat.
func setLastFridgeSettings(_ settings: FridgeSettings, for deviceID: UUID) {
var all = allFridgeSettings()
if var existing = all[deviceID.uuidString] {
existing.updated = settings.updated
guard existing != settings else { return }
}
all[deviceID.uuidString] = settings
guard let data = try? JSONEncoder().encode(all) else { return }
UserDefaults.standard.set(data, forKey: Self.fridgeSettingsKey)
}
private func allFridgeSettings() -> [String: FridgeSettings] {
guard let data = UserDefaults.standard.data(forKey: Self.fridgeSettingsKey),
let decoded = try? JSONDecoder().decode([String: FridgeSettings].self, from: data)
else { return [:] }
return decoded
}
// MARK: - Persistenz
private func load() {
@@ -151,9 +189,8 @@ final class DeviceStore {
profiles = [.initial]
}
if let data = defaults.data(forKey: Self.devicesKey),
let decoded = try? JSONDecoder().decode([ConfiguredDevice].self, from: data) {
devices = decoded
if let data = defaults.data(forKey: Self.devicesKey) {
devices = loadDevices(from: data)
}
if let raw = defaults.string(forKey: Self.activeProfileKey),
@@ -171,7 +208,48 @@ final class DeviceStore {
}
}
/// Liest die Geräteliste so verlustarm wie möglich.
///
/// Der einfache Weg die ganze Liste auf einmal scheitert vollständig,
/// sobald ein einziges Gerät nicht lesbar ist: etwa weil eine neuere
/// Fassung der App ein Feld hinzugefügt hat, das die ältere nicht kennt,
/// oder umgekehrt. Die Liste wäre dann leer, und das nächste Speichern
/// schriebe diese Leere über den Bestand. Genau so gehen Einrichtungen
/// verloren, ohne dass jemand etwas löscht.
///
/// Deshalb zweistufig: erst die ganze Liste, und wenn das misslingt, jedes
/// Gerät für sich. Was dabei übrig bleibt, wird behalten; die Rohdaten
/// wandern zusätzlich in einen eigenen Schlüssel, damit sich der Bestand
/// später noch untersuchen lässt.
private func loadDevices(from data: Data) -> [ConfiguredDevice] {
let decoder = JSONDecoder()
if let decoded = try? decoder.decode([ConfiguredDevice].self, from: data) {
return decoded
}
let salvaged = (try? decoder.decode([Salvage].self, from: data))?
.compactMap(\.device) ?? []
UserDefaults.standard.set(data, forKey: Self.unreadableDevicesKey)
hasUnreadableDevices = true
return salvaged
}
/// Hülle, die ein einzelnes unlesbares Gerät verschluckt, statt die ganze
/// Liste scheitern zu lassen.
private struct Salvage: Decodable {
let device: ConfiguredDevice?
init(from decoder: Decoder) throws {
device = try? ConfiguredDevice(from: decoder)
}
}
/// 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() {
guard !DemoData.isEnabled else { return }
let defaults = UserDefaults.standard
if let data = try? JSONEncoder().encode(devices) {
defaults.set(data, forKey: Self.devicesKey)
@@ -5,7 +5,7 @@ import Security
/// UserDefaults, deshalb Keychain.
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) {
let account = deviceID.uuidString
+56
View File
@@ -0,0 +1,56 @@
import SwiftUI
@main
struct VanControlApp: App {
@State private var store: DeviceStore
@State private var bluetooth: BluetoothManager
@State private var watch: PhoneWatchLink
@State private var levelActivity: LevelActivityManager
@Environment(\.scenePhase) private var scenePhase
init() {
let store = DeviceStore()
let bluetooth = BluetoothManager(store: store)
let levelActivity = LevelActivityManager()
bluetooth.activityManager = levelActivity
_store = State(initialValue: store)
_bluetooth = State(initialValue: bluetooth)
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
_levelActivity = State(initialValue: levelActivity)
}
var body: some Scene {
WindowGroup {
DashboardView()
.environment(store)
.environment(bluetooth)
.environment(watch)
.environment(levelActivity)
.task { watch.activate() }
}
.onChange(of: scenePhase) { _, phase in
// Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt
// werden, also Funk sparen und beim Zurückkommen neu starten.
//
// Ausnahme: Solange jemand auf die Uhr schaut, läuft es weiter. Beim
// Rangieren liegt das iPhone in der Halterung mit dunklem Bildschirm
// hörte das Funkgerät dann auf, wäre die Anzeige am Handgelenk
// genau dann tot, wenn sie gebraucht wird. Die verbundenen Geräte
// (Neigungsmesser, BMS, Kühlbox) liefern im Hintergrund weiter; die
// Victron-Werbedaten nicht, dafür müsste ohne Filter gesucht werden.
watch.isAppInBackground = phase != .active
switch phase {
case .active:
bluetooth.start()
case .background:
// 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:
break
}
}
}
}
@@ -127,7 +127,7 @@ private struct ConfigureDeviceView: View {
@Environment(\.dismiss) private var dismiss
@State private var name: String = ""
@State private var role: DeviceRole = .solarCharger
@State private var role: DeviceRole = .victronSolarCharger
@State private var key: String = ""
private var needsKey: Bool { role.transport == .advertisement }
@@ -188,13 +188,15 @@ private struct ConfigureDeviceView: View {
private func prefill() {
if let recordType = discovery.victronRecordType {
switch VictronAdvertisement.RecordType(rawValue: recordType) {
case .solarCharger: role = .solarCharger
case .solarCharger: role = .victronSolarCharger
case .dcdcConverter, .orionXS: role = .chargeBooster
case .batteryMonitor: role = .batteryMonitor
default: role = .solarCharger
default: role = .victronSolarCharger
}
} else if discovery.isLevelSensor {
role = .leveling
} else if discovery.isVotronicSolarESPSensor {
role = .votronicSolar
} else if let name = discovery.name?.lowercased(),
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
role = .fridge
@@ -206,6 +208,8 @@ private struct ConfigureDeviceView: View {
// ohnehin darüber unter "Gefunden als".
if discovery.isLevelSensor {
name = "Nivellierung"
} else if discovery.isVotronicSolarESPSensor {
name = role.title
} else if let advertised = discovery.name, advertised.count <= 20,
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
name = advertised
@@ -11,6 +11,7 @@ struct AlignmentAssistantView: View {
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var assistant = AlignmentAssistant()
@State private var didAnnounceTarget = false
@@ -18,48 +19,18 @@ struct AlignmentAssistantView: View {
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 {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.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)
Group {
if isLandscape {
landscapeLayout
} else {
portraitLayout
}
.padding(.horizontal)
.frame(maxWidth: .infinity)
}
.background(Color(.systemGroupedBackground))
.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
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
/// Hinweis sähe das aus, als hinge die App man rangiert dann nach einem
/// Wert, der längst nicht mehr gilt.
@@ -120,40 +175,54 @@ struct AlignmentAssistantView: View {
.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 {
HStack(spacing: 12) {
Image(systemName: assistant.hasReachedTarget
? "checkmark.circle.fill" : assistant.trend.symbol)
.font(.title)
.font(isLandscape ? .title2 : .title)
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
Text(assistant.advice)
.font(.title3.weight(.medium))
.font(isLandscape ? .subheadline.weight(.medium) : .title3.weight(.medium))
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding()
.padding(isLandscape ? 10 : 16)
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
: Color(.secondarySystemGroupedBackground),
in: .rect(cornerRadius: 16))
}
private var readings: some View {
HStack(spacing: 12) {
reading("Längs", state.pitch)
reading("Quer", state.roll)
reading("Gesamt", assistant.current?.deviation)
HStack(spacing: isLandscape ? 8 : 12) {
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
if !isLandscape {
reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation))
}
}
}
private func reading(_ title: String, _ value: Double?) -> some View {
VStack(spacing: 4) {
Text(value.map { String(format: "%.1f°", $0) } ?? "")
.font(.title2.weight(.semibold).monospacedDigit())
private func reading(_ title: String, _ text: String) -> some View {
VStack(spacing: isLandscape ? 1 : 4) {
Text(text)
.font((isLandscape ? Font.callout : .title2).weight(.semibold).monospacedDigit())
Text(title)
.font(.caption)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.padding(.vertical, isLandscape ? 6 : 12)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
}
@@ -176,42 +245,44 @@ struct AlignmentAssistantView: View {
@ViewBuilder
private var wedgeSection: some View {
let across = profile?.trackWidth.flatMap { width in
state.roll.flatMap { LevelingWedge.across(roll: $0, trackWidth: width) }
}
let along = profile?.wheelbase.flatMap { base in
state.pitch.flatMap { LevelingWedge.along(pitch: $0, wheelbase: base) }
}
// Gerechnet wird über alle vier Räder auf einmal. Getrennte Angaben für
// quer und längs beschreiben dasselbe Fahrzeug und lassen sich nicht
// getrennt ausführen: Unter rechts" liegen zwei Räder, unter vorne"
// auch, und eines davon ist dasselbe.
let lift: LevelingLift? = {
guard let width = profile?.trackWidth, let base = profile?.wheelbase,
let pitch = state.pitch, let roll = state.roll else { return nil }
return LevelingLift.compute(pitch: pitch, roll: roll,
trackWidth: width, wheelbase: base)
}()
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: isLandscape ? 6 : 10) {
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 "
+ "sich beim Fahrzeug hinterlegen.")
.font(.callout)
.font(.caption)
.foregroundStyle(.secondary)
} else if across == nil && along == nil {
} 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 {
Text("Keine Keile nötig.")
.font(.callout)
.foregroundStyle(.secondary)
} else {
ForEach([across, along].compactMap { $0 }, id: \.side) { wedge in
HStack {
Text(wedge.side.text.capitalized)
Spacer()
Text(String(format: "%.0f cm", wedge.heightInCentimetres))
.font(.title3.weight(.semibold).monospacedDigit())
}
}
Text("Höhe unter die tieferstehende Seite, damit das Fahrzeug eben steht.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.padding(isLandscape ? 10 : 16)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
}
@@ -229,5 +300,3 @@ struct AlignmentAssistantView: View {
#endif
}
}
extension LevelingWedge.Side: Hashable {}
+241
View File
@@ -0,0 +1,241 @@
import SwiftUI
/// Kachel auf dem Dashboard: Hauptwert gross, darunter die wichtigsten
/// Nebenwerte und der Verbindungszustand.
struct DeviceCard: View {
let device: ConfiguredDevice
let snapshot: DeviceSnapshot?
let linkState: DeviceLinkState
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
if isShowingLastSettings, let snapshot {
lastSettings(for: snapshot)
} else if let snapshot, snapshot.primaryMetric != nil, device.role == .leveling {
levelReadout(for: snapshot)
} else if let snapshot, let primary = snapshot.primaryMetric {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(primary.formatted)
.font(.system(size: 44, weight: .semibold, design: .rounded))
.contentTransition(.numericText())
Text(primary.unit)
.font(.title3.weight(.medium))
.foregroundStyle(.secondary)
}
.foregroundStyle(snapshot.isStale ? .secondary : .primary)
secondaryValues(for: snapshot)
} else {
Text(placeholderText)
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 18)
}
footer
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
}
private var header: some View {
HStack(spacing: 8) {
Image(systemName: device.role.symbol)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 1) {
Text(device.name)
.font(.headline)
Text(device.role.title)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true)
}
}
/// Ob hier der zuletzt gestellte Stand steht statt Messwerten.
private var isShowingLastSettings: Bool {
device.role.connectsOnDemand && linkState != .live
}
/// Die Kachel der Kühlbox, solange sie nicht verbunden ist.
///
/// Hier steht kein Messwert, sondern was zuletzt eingestellt war also
/// gehört Soll dazu. Ohne das läse man die Zahl als Innentemperatur, und
/// genau dieser Irrtum wäre teuer.
private func lastSettings(for snapshot: DeviceSnapshot) -> some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(snapshot.metrics.count > 1 ? "Soll links" : "Soll")
.font(.caption)
.foregroundStyle(.secondary)
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(snapshot.primaryMetric?.formatted ?? "")
.font(.system(size: 44, weight: .semibold, design: .rounded))
Text(snapshot.primaryMetric?.unit ?? "")
.font(.title3.weight(.medium))
.foregroundStyle(.secondary)
}
}
if let right = snapshot.metrics.first(where: { $0.key == "target_right" }),
right.value != nil {
VStack(alignment: .leading, spacing: 2) {
Text("Soll rechts")
.font(.caption)
.foregroundStyle(.secondary)
Text(right.formattedWithUnit)
.font(.title2.weight(.semibold))
.monospacedDigit()
}
}
Spacer(minLength: 0)
modeBadge(snapshot.state)
}
}
/// Betriebsart als Schild: Auf der Kachel ist Platz, und ob die Box läuft
/// oder aus ist, ist die zweite Frage nach dem Sollwert.
@ViewBuilder
private func modeBadge(_ state: String?) -> some View {
if let state {
let isOff = state == "Aus"
Text(state)
.font(.title3.weight(.semibold))
.foregroundStyle(isOff ? Color.secondary : Color.accentColor)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(isOff ? Color.secondary.opacity(0.15)
: Color.accentColor.opacity(0.15),
in: .rect(cornerRadius: 12))
}
}
/// Für die Nivellierung: Front/Heck und links/rechts nebeneinander, groß,
/// ohne "Längsneigung"/"Querneigung"-Beschriftung die Richtung steht ja
/// schon im vorangestellten Buchstaben.
private func levelReadout(for snapshot: DeviceSnapshot) -> some View {
let pitch = snapshot.metrics.first(where: { $0.key == "pitch" })?.value
let roll = snapshot.metrics.first(where: { $0.key == "roll" })?.value
return HStack(spacing: 24) {
Text(LevelDirectionFormatting.pitchTile(pitch))
Text(LevelDirectionFormatting.rollTile(roll))
}
.font(.system(size: 36, weight: .semibold, design: .rounded))
.contentTransition(.numericText())
.foregroundStyle(snapshot.isStale ? .secondary : .primary)
}
private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
let others = snapshot.metrics
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
.prefix(3)
return HStack(spacing: 16) {
ForEach(Array(others)) { metric in
VStack(alignment: .leading, spacing: 1) {
Text(metric.formattedWithUnit)
.font(.subheadline.weight(.medium))
.monospacedDigit()
Text(metric.label)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 0)
}
}
@ViewBuilder
private var footer: some View {
if isShowingLastSettings, let snapshot {
// Kein Messwert, sondern der zuletzt gestellte Stand. Das gehört
// dazugeschrieben, sonst liest man ihn als aktuelle Temperatur.
Text(lastSetText(snapshot))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else if let fault = snapshot?.fault {
Label(fault, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.red)
.lineLimit(2)
} else if let state = snapshot?.state {
// Bei "Aus" ist erst der Grund die eigentliche Information.
let reason = snapshot?.offReasons.first
Text(reason.map { "\(state) · \($0)" } ?? state)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else if case .failed(let message) = linkState {
Text(message)
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(2)
}
}
/// Was unter einem Gerät steht, das nur beim Öffnen verbunden wird.
///
/// Der Hinweis aufs Verbinden steht vorn: Er erklärt, warum hier kein
/// Messwert steht, und das ist die Frage, die sich zuerst stellt.
private func lastSetText(_ snapshot: DeviceSnapshot) -> String {
"Verbindet erst beim Öffnen · zuletzt gestellt \(relativeUpdate(snapshot.timestamp))"
}
/// vor 3 Minuten statt einer Uhrzeit auf der Kachel zählt das Alter.
private func relativeUpdate(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.locale = Locale(identifier: "de_DE")
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: Date())
}
private var placeholderText: String {
switch linkState {
case .needsKey: return "Verschlüsselungsschlüssel fehlt im Detail eintragen."
case .failed(let message): return message
default:
return device.role.connectsOnDemand
? "Verbindet erst beim Öffnen."
: "Warte auf Daten…"
}
}
}
/// Kleiner Punkt, der Verbindungszustand und Aktualität zusammenfasst.
struct StatusDot: View {
let linkState: DeviceLinkState
let isStale: Bool
var body: some View {
HStack(spacing: 5) {
Circle()
.fill(color)
.frame(width: 8, height: 8)
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private var color: Color {
switch linkState {
case .live: return isStale ? .orange : .green
case .needsKey: return .orange
case .failed: return .red
default: return .secondary
}
}
private var label: String {
if case .live = linkState, isStale { return "Veraltet" }
return linkState.label
}
}
@@ -7,6 +7,7 @@ struct DeviceDetailView: View {
@Environment(DeviceStore.self) private var store
@Environment(BluetoothManager.self) private var bluetooth
@Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var editedName = ""
/// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der
@@ -37,98 +38,126 @@ struct DeviceDetailView: View {
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
/// iPhone im Querformat meldet eine kompakte Höhe das ist das
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
private var isLandscape: Bool { verticalSizeClass == .compact }
/// Nur der Neigungsmesser bekommt die Querformat-Sonderbehandlung
/// (Verbindungsstatus ans Ende, Anzeige auf Bildschirmhöhe) andere
/// Sensoren behalten ihre bisherige Reihenfolge und Grösse.
private var showsCompactLevelLayout: Bool { isLandscape && currentDevice.role == .leveling }
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
var body: some View {
List {
statusSection
if needsKeyAttention { keyPrompt }
// Die Höhe der Libelle/Fahrzeug-Anzeige im Querformat richtet sich
// nach der tatsächlich verfügbaren Bildschirmhöhe, nicht nach einem
// festen Wert deshalb misst ein GeometryReader die Liste von aussen.
GeometryReader { geometry in
List {
// Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort
// sichtbar sein, ohne erst am Verbindungsstatus
// vorbeizuscrollen der rutscht dort ganz ans Ende. Gilt
// nur für den Neigungsmesser, siehe `showsCompactLevelLayout`.
if !showsCompactLevelLayout { statusSection }
if needsKeyAttention { keyPrompt }
if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
FridgeControls(device: currentDevice, state: fridge)
}
if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
FridgeControls(device: currentDevice, state: fridge)
}
if currentDevice.role == .leveling {
LevelControls(device: currentDevice,
state: bluetooth.levelStates[device.id] ?? LevelState())
}
if currentDevice.role == .leveling {
LevelControls(device: currentDevice,
state: bluetooth.levelStates[device.id] ?? LevelState(),
availableHeight: geometry.size.height)
}
if let snapshot, !snapshot.metrics.isEmpty {
Section("Messwerte") {
ForEach(snapshot.metrics) { metric in
LabeledContent(metric.label) {
Text(metric.formattedWithUnit)
.monospacedDigit()
.foregroundStyle(metric.value == nil ? .secondary : .primary)
if let snapshot, !snapshot.metrics.isEmpty {
Section("Messwerte") {
ForEach(snapshot.metrics) { metric in
LabeledContent(metric.label) {
Text(metric.formattedWithUnit)
.monospacedDigit()
.foregroundStyle(metric.value == nil ? .secondary : .primary)
}
}
}
}
}
if samples.count > 1, let primary = snapshot?.primaryMetric {
Section("Verlauf \(primary.label)") {
Chart(samples) { sample in
AreaMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value))
.foregroundStyle(.tint.opacity(0.15))
LineMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value))
.foregroundStyle(.tint)
.interpolationMethod(.monotone)
}
.chartYAxisLabel(primary.unit)
.frame(height: 180)
.padding(.vertical, 8)
}
}
if let snapshot, !snapshot.cellVoltages.isEmpty {
cellSection(snapshot.cellVoltages)
}
if let snapshot, !snapshot.info.isEmpty {
Section("Gerät") {
ForEach(snapshot.info) { item in
LabeledContent(item.label, value: item.value)
if samples.count > 1, let primary = snapshot?.primaryMetric {
Section("Verlauf \(primary.label)") {
Chart(samples) { sample in
AreaMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value))
.foregroundStyle(.tint.opacity(0.15))
LineMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value))
.foregroundStyle(.tint)
.interpolationMethod(.monotone)
}
.chartYAxisLabel(primary.unit)
.frame(height: 180)
.padding(.vertical, 8)
}
}
}
if let snapshot, snapshot.temperatures.count > 1 {
Section("Temperaturen") {
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
LabeledContent("Fühler \(index + 1)") {
Text(String(format: "%.0f °C", value)).monospacedDigit()
if let snapshot, !snapshot.cellVoltages.isEmpty {
cellSection(snapshot.cellVoltages)
}
if let snapshot, !snapshot.info.isEmpty {
Section("Gerät") {
ForEach(snapshot.info) { item in
LabeledContent(item.label, value: item.value)
}
}
}
}
if currentDevice.role.transport == .advertisement {
if showsTechnicalDetails { diagnosticsSection }
} else if showsTechnicalDetails {
bmsDiagnosticsSection
}
if showsCompactLevelLayout { statusSection }
settingsSection
}
.navigationTitle(currentDevice.name)
.navigationBarTitleDisplayMode(.inline)
.onDisappear(perform: saveName)
.onAppear {
editedName = currentDevice.name
keyInput = store.victronKeyText(for: device.id) ?? ""
}
.confirmationDialog("Gerät entfernen?",
isPresented: $showDeleteConfirmation,
titleVisibility: .visible) {
Button("Entfernen", role: .destructive) {
store.remove(device)
bluetooth.refreshConfiguration()
dismiss()
if let snapshot, snapshot.temperatures.count > 1 {
Section("Temperaturen") {
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
LabeledContent("Fühler \(index + 1)") {
Text(String(format: "%.0f °C", value)).monospacedDigit()
}
}
}
}
if currentDevice.role.transport == .advertisement {
if showsTechnicalDetails { diagnosticsSection }
} else if showsTechnicalDetails {
bmsDiagnosticsSection
}
settingsSection
}
.navigationTitle(currentDevice.name)
.navigationBarTitleDisplayMode(.inline)
.onDisappear {
saveName()
// Die Kühlbox wird nur verbunden, solange man sie ansieht jede
// Verbindung meldet sich an ihrem Display an.
bluetooth.endSession(for: currentDevice)
}
.onAppear {
editedName = currentDevice.name
keyInput = store.victronKeyText(for: device.id) ?? ""
bluetooth.beginSession(for: currentDevice)
}
.confirmationDialog("Gerät entfernen?",
isPresented: $showDeleteConfirmation,
titleVisibility: .visible) {
Button("Entfernen", role: .destructive) {
store.remove(device)
bluetooth.refreshConfiguration()
dismiss()
}
} message: {
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
}
} message: {
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
}
}
@@ -25,6 +25,8 @@ struct LevelSetupView: View {
store.devices.first { $0.id == device.id } ?? device
}
private let graphicColumns = [GridItem(.adaptive(minimum: 76), spacing: 12)]
var body: some View {
List {
Section {
@@ -80,6 +82,44 @@ struct LevelSetupView: View {
} footer: {
Text(calibrationHint)
}
Section {
LazyVGrid(columns: graphicColumns, spacing: 12) {
ForEach(VehicleGraphicStyle.allCases) { style in
Button {
var updated = currentDevice
updated.vehicleGraphicStyle = style
store.update(updated)
} label: {
VStack(spacing: 4) {
Image(style.sideImageName)
.resizable()
.scaledToFit()
.padding(10)
.frame(width: 60, height: 60)
.background(currentDevice.vehicleGraphicStyle == style
? Color.accentColor.opacity(0.18)
: Color.secondary.opacity(0.08),
in: .rect(cornerRadius: 12))
.foregroundStyle(currentDevice.vehicleGraphicStyle == style
? Color.accentColor : Color.primary)
Text(style.title)
.font(.caption2)
.foregroundStyle(currentDevice.vehicleGraphicStyle == style
? Color.accentColor : Color.secondary)
}
}
.buttonStyle(.plain)
.accessibilityLabel(style.title)
}
}
.padding(.vertical, 4)
} header: {
Text("Fahrzeuggrafik")
} footer: {
Text("Wird in der Fahrzeug-Darstellung des Neigungsmessers verwendet. "
+ "Weitere Grafiken folgen.")
}
}
.navigationTitle("Neigungsmesser einrichten")
.navigationBarTitleDisplayMode(.inline)
@@ -104,45 +104,76 @@ struct LevelBubble: View {
struct LevelControls: View {
let device: ConfiguredDevice
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(DeviceStore.self) private var store
@Environment(LevelActivityManager.self) private var levelActivity
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var showAssistant = false
@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 {
Section {
VStack(spacing: 16) {
Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
if isLandscape {
HStack(alignment: .center, spacing: 20) {
display
.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)
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)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
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
// der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt.
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 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
/// ohne ihn stimmt die Anzeige nicht.
private var setupSummary: String {
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) {
Text(value.map { String(format: "%.1f°", $0) } ?? "")
Text(text)
.font(.title2.weight(.semibold).monospacedDigit())
Text(title)
.font(.caption)
@@ -145,7 +145,7 @@ private struct ProfileEditView: View {
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var symbol = "box.truck"
@State private var symbol = "suv.side"
@State private var trackWidth = ""
@State private var wheelbase = ""
@@ -4,11 +4,24 @@ import SwiftUI
/// betrifft, steht bei diesem Gerät.
struct SettingsView: View {
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
@AppStorage(DemoData.enabledKey) private var demoModeEnabled = false
@Environment(PhoneWatchLink.self) private var watch
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
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 {
Toggle("Diagnose anzeigen", isOn: $showDiagnostics)
} header: {
@@ -21,6 +34,16 @@ struct SettingsView: View {
+ "Meldet ein Gerät einen Fehler, werden die Angaben ohnehin "
+ "eingeblendet, auch wenn das hier ausgeschaltet ist.")
}
Section {
LabeledContent("Apple Watch", value: watch.statusText)
} footer: {
Text("Die Uhr zeigt die Werte dieses iPhones und steuert die "
+ "Kühlbox darüber sie funkt nicht selbst zu den Geräten. "
+ "Dafür muss diese App laufen; im Hintergrund liefern "
+ "Neigungsmesser, BMS und Kühlbox weiter, die "
+ "Victron-Werbedaten erst wieder im Vordergrund.")
}
}
.navigationTitle("Einstellungen")
.navigationBarTitleDisplayMode(.inline)
+113
View File
@@ -0,0 +1,113 @@
import SwiftUI
/// Zeigt die Neigung am Fahrzeug selbst, statt an einer abstrakten Blase.
///
/// Zwei Ansichten, jede um ihre Achse gekippt:
///
/// * **Seitenansicht** für die Längsneigung. Die Front zeigt nach links, das
/// Heck nach rechts.
/// * **Heckansicht** für die Querneigung. Sie teilt die Blickrichtung des
/// Fahrers, links im Bild ist also links am Fahrzeug bei einer
/// Frontansicht wäre es seitenverkehrt.
///
/// In beiden Fällen wird gegen den mathematischen Drehsinn gekippt: Steht das
/// Heck höher, muss die rechte Bildseite nach oben.
struct VehicleTiltView: View {
let pitch: 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 {
if compact {
HStack(alignment: .top, spacing: 16) {
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
}
} else {
VStack(spacing: 20) {
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)
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
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)
}
}
}
private func tiltPanel(image: String,
angle: Double?,
title: String,
lowerLabel: String,
upperLabel: String,
aspect: Double) -> some View {
VStack(spacing: compact ? 4 : 8) {
HStack {
Text(title)
.font(compact ? .caption2.weight(.medium) : .subheadline.weight(.medium))
Spacer()
Text(angle.map { String(format: "%.1f°", $0) } ?? "")
.font((compact ? Font.caption2 : .subheadline).weight(.semibold).monospacedDigit())
.foregroundStyle(VehicleTilt.colour(for: angle))
}
ZStack {
// Waagerechte als Bezug ohne sie ist eine kleine Neigung
// nicht einzuschätzen.
Rectangle()
.fill(Color.secondary.opacity(0.35))
.frame(height: 1)
Image(image)
.resizable()
.scaledToFit()
.foregroundStyle(VehicleTilt.colour(for: angle))
.aspectRatio(aspect, contentMode: .fit)
.rotationEffect(.degrees(-(angle ?? 0) * VehicleTilt.exaggeration))
.animation(.spring(duration: 0.4), value: angle)
.opacity(angle == nil ? 0.3 : 1)
}
.frame(height: compact ? compactPanelHeight : 110)
HStack {
Text(lowerLabel)
Spacer()
Text(upperLabel)
}
.font(compact ? .system(size: 9) : .caption2)
.foregroundStyle(.secondary)
}
}
}
/// Umschalter zwischen den beiden Darstellungen, gemerkt über Starts hinweg.
enum LevelDisplayStyle: String, CaseIterable, Identifiable {
case bubble
case vehicle
var id: String { rawValue }
var title: String {
switch self {
case .bubble: return "Libelle"
case .vehicle: return "Fahrzeug"
}
}
}
+287
View File
@@ -0,0 +1,287 @@
import Foundation
import Observation
import WatchConnectivity
/// Die Gegenstelle zur Apple Watch auf dem iPhone.
///
/// Das iPhone bleibt das Funkgerät (warum, steht bei `WatchLink`) und reicht
/// den fertigen Stand weiter. Zwei Wege, je nachdem, ob die Uhr gerade
/// hinschaut:
///
/// * **Anwendungskontext** ein Datensatz, den watchOS aufhebt und auch dann
/// zustellt, wenn die App auf der Uhr gerade nicht läuft. Damit steht beim
/// Aufwecken sofort etwas da statt eines leeren Bildschirms.
/// * **Nachricht** nur solange die Uhr erreichbar ist und gemeldet hat, dass
/// sie hinschaut. Dafür im halben Sekundentakt, was beim Ausrichten zählt.
///
/// Ohne gekoppelte Uhr läuft hier gar nichts: kein Timer, keine Sitzung.
@Observable
final class PhoneWatchLink: NSObject {
/// Ob die Uhr gerade Werte sehen will. Solange das gilt, hält die App das
/// Bluetooth auch im Hintergrund am Leben.
private(set) var wantsLiveUpdates = false
/// Nur für die Anzeige in den Einstellungen.
private(set) var statusText = "Keine Uhr gekoppelt"
private let store: DeviceStore
private let bluetooth: BluetoothManager
private var session: WCSession?
private var timer: Timer?
/// Bis wann der Wunsch der Uhr nach schnellen Werten gilt.
private var liveUntil = Date.distantPast
private var lastSentContent: WatchPayload?
private var lastContextSent = Date.distantPast
/// Setzt die App, damit beim Ablauf des Wunsches im Hintergrund das Funken
/// wieder eingestellt wird.
var isAppInBackground = false
init(store: DeviceStore, bluetooth: BluetoothManager) {
self.store = store
self.bluetooth = bluetooth
super.init()
}
func activate() {
guard WCSession.isSupported() else {
statusText = "Dieses Gerät kann keine Uhr koppeln"
return
}
let session = WCSession.default
session.delegate = self
self.session = session
session.activate()
}
// MARK: - Takt
private func updateTimer() {
let wanted = session?.activationState == .activated && session?.isPaired == true
if wanted && timer == nil {
// Der schnelle Takt ist der Grundtakt; ob wirklich gesendet wird,
// entscheidet `tick` so muss der Timer nie umgestellt werden.
let timer = Timer(timeInterval: WatchLink.liveInterval, repeats: true) { [weak self] _ in
self?.tick()
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
} else if !wanted {
timer?.invalidate()
timer = nil
}
}
private func tick() {
let live = Date() < liveUntil
if live != wantsLiveUpdates {
wantsLiveUpdates = live
// Der Wunsch ist gerade abgelaufen und niemand schaut mehr hin:
// dann darf das Funkgerät im Hintergrund auch wieder ruhen.
if !live && isAppInBackground { bluetooth.stop() }
}
// Den Stand erst zusammenstellen, wenn er auch rausgeht: im Ruhetakt
// ist das nur jeder vierte Aufruf, und zusammengestellt wird auf dem
// Hauptthread.
let isContextDue = Date().timeIntervalSince(lastContextSent) >= WatchLink.idleInterval
guard let session, live || isContextDue else { return }
guard let payload = buildPayload() else { return }
if live && session.isReachable {
send(payload, over: session)
return
}
guard isContextDue,
lastSentContent?.hasSameContent(as: payload) != true,
session.isPaired, session.isWatchAppInstalled else { return }
do {
try session.updateApplicationContext(payload.message())
lastSentContent = payload
lastContextSent = Date()
} catch {
statusText = "Uhr: \(error.localizedDescription)"
}
}
/// Nachrichten werden ohne Antwort verschickt: Ein verlorener Datensatz ist
/// in einer halben Sekunde ohnehin überholt, ein Wiederholungsversuch käme
/// zu spät und stünde nur dem nächsten im Weg.
private func send(_ payload: WatchPayload, over session: WCSession) {
guard let message = try? payload.message() else { return }
session.sendMessage(message, replyHandler: nil, errorHandler: nil)
lastSentContent = payload
}
/// Nach Änderungen an Geräten oder Profilen aufrufen, damit die Uhr nicht
/// bis zum nächsten Takt einen überholten Fahrzeugnamen zeigt.
func sendNow() {
lastSentContent = nil
lastContextSent = .distantPast
tick()
}
// MARK: - Stand zusammenstellen
private func buildPayload() -> WatchPayload? {
guard let profile = store.activeProfile else { return nil }
let devices = store.activeDevices.map { device in
WatchDevice(id: device.id,
name: device.name,
role: device.role,
link: bluetooth.linkStates[device.id] ?? .searching,
snapshot: bluetooth.snapshots[device.id],
level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil,
orientation: device.role == .leveling ? device.sensorOrientation : nil,
fridge: device.role == .fridge ? fridgeState(for: device) : nil)
}
return WatchPayload(generatedAt: Date(),
profile: profile,
isRadioReady: bluetooth.isBluetoothReady,
radioStatus: bluetooth.bluetoothStatusText,
devices: devices)
}
/// Live, solange die Box verbunden ist sonst der zuletzt gestellte Stand
/// aus dem Speicher, ohne Messwerte.
private func fridgeState(for device: ConfiguredDevice) -> WatchFridge? {
if linkStateIsLive(device), let state = bluetooth.fridgeStates[device.id] {
return WatchFridge(state)
}
return store.lastFridgeSettings(for: device.id).map(WatchFridge.init)
}
private func linkStateIsLive(_ device: ConfiguredDevice) -> Bool {
bluetooth.linkStates[device.id] == .live
}
// MARK: - Befehle von der Uhr
private func handle(_ command: WatchCommand) {
if command.wantsLiveUpdates {
liveUntil = Date().addingTimeInterval(WatchLink.liveLease)
wantsLiveUpdates = true
// Die Uhr kann das iPhone aus dem Hintergrund wecken. Dann steht
// das Funkgerät und muss erst wieder anlaufen, sonst geht der
// Stellbefehl ins Leere.
if isAppInBackground { bluetooth.start() }
}
switch command {
case .hello:
break
case .fridgePower(let device, let on):
bluetooth.setFridgePower(on, for: device)
case .fridgeEco(let device, let eco):
bluetooth.setFridgeEco(eco, for: device)
case .fridgeLock(let device, let locked):
bluetooth.setFridgeLock(locked, for: device)
case .fridgeTarget(let device, let zone, let value):
bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device)
case .fridgeSession(let deviceID, let wanted):
guard let device = store.devices.first(where: { $0.id == deviceID }) else { break }
if wanted {
bluetooth.beginSession(for: device)
} else {
bluetooth.endSession(for: device)
}
}
}
}
// MARK: - WCSessionDelegate
extension PhoneWatchLink: WCSessionDelegate {
func session(_ session: WCSession,
activationDidCompleteWith state: WCSessionActivationState,
error: Error?) {
DispatchQueue.main.async {
if let error {
self.statusText = "Uhr: \(error.localizedDescription)"
} else {
self.updateStatus(session)
}
self.updateTimer()
self.sendNow()
}
}
private func updateStatus(_ session: WCSession) {
if !session.isPaired {
statusText = "Keine Uhr gekoppelt"
} else if !session.isWatchAppInstalled {
statusText = "Uhr gekoppelt, App dort nicht installiert"
} else {
statusText = "Uhr bereit"
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
guard let command = WatchCommand.decode(from: message) else { return }
DispatchQueue.main.async {
self.handle(command)
// Auf einen Stellbefehl gleich den frischen Stand hinterherschicken.
// Der zeigt zwar noch den alten Wert die Box antwortet erst ,
// aber die Uhr weiss dadurch sofort, dass der Befehl ankam.
self.tick()
}
}
/// Mit Antwort, wenn die Uhr auf eine wartet: das ist der Weg, auf dem sie
/// beim Öffnen ohne Verzögerung an einen Stand kommt.
func session(_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
DispatchQueue.main.async {
if let command = WatchCommand.decode(from: message) { self.handle(command) }
replyHandler((try? self.buildPayload()?.message()) ?? [:])
}
}
func sessionReachabilityDidChange(_ session: WCSession) {
DispatchQueue.main.async { self.sendNow() }
}
/// Die Uhr wurde gekoppelt, gewechselt oder die App dort installiert.
func sessionWatchStateDidChange(_ session: WCSession) {
DispatchQueue.main.async {
self.updateStatus(session)
self.updateTimer()
self.sendNow()
}
}
// Beim Wechsel auf eine andere Uhr muss die Sitzung neu aktiviert werden.
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
}
// MARK: - Umformen
extension WatchFridge {
/// Aus dem Zustand der Box wird nur übernommen, was die Uhr anzeigt oder
/// stellt der Rohdatensatz bleibt auf dem iPhone.
init(_ state: AlpicoolState) {
let range = state.targetRange
self.init(isLive: true,
updated: Date(),
isPoweredOn: state.isPoweredOn,
isEco: state.isEco,
isLocked: state.isLocked,
isDualZone: state.isDualZone,
unitSymbol: state.unitSymbol,
leftTarget: state.leftTarget,
leftCurrent: state.leftCurrent,
rightTarget: state.isDualZone ? state.rightTarget : nil,
rightCurrent: state.isDualZone ? state.rightCurrent : nil,
minTarget: range.lowerBound,
maxTarget: range.upperBound,
batteryVolts: state.batteryVolts)
}
}

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,92 @@
import SwiftUI
import WidgetKit
/// Komplikation fürs Zifferblatt: ein Tipp, und die Nivellierung steht da.
///
/// Bewusst **ohne Messwert**. Eine Komplikation läuft in einem eigenen Prozess
/// und käme an die Werte nur über eine App-Gruppe die verlangt eine bezahlte
/// Entwicklermitgliedschaft. Ein Wert, der beim Blick aufs Zifferblatt
/// womöglich Stunden alt ist, wäre ausserdem schlimmer als keiner: Man würde
/// ihm glauben.
///
/// Die Komplikation tut deshalb genau eine Sache, und die zuverlässig: Sie
/// öffnet die App auf der Nivellierung. Von dort sind die Werte live, weil die
/// Uhr den Sensor selbst anfunkt.
struct LevelComplicationEntry: TimelineEntry {
let date: Date
}
/// Nichts zu planen: Die Anzeige ändert sich nie, also ein einziger Eintrag
/// ohne Nachschub. Das kostet die Uhr auch nichts.
struct LevelComplicationProvider: TimelineProvider {
func placeholder(in context: Context) -> LevelComplicationEntry {
LevelComplicationEntry(date: .now)
}
func getSnapshot(in context: Context,
completion: @escaping (LevelComplicationEntry) -> Void) {
completion(LevelComplicationEntry(date: .now))
}
func getTimeline(in context: Context,
completion: @escaping (Timeline<LevelComplicationEntry>) -> Void) {
completion(Timeline(entries: [LevelComplicationEntry(date: .now)], policy: .never))
}
}
struct LevelComplicationView: View {
@Environment(\.widgetFamily) private var family
var body: some View {
switch family {
case .accessoryInline:
// Eine Zeile Text, mehr lässt diese Familie nicht zu.
Label("Nivellierung", systemImage: "level")
case .accessoryCorner:
Image(systemName: "level")
.font(.title2)
.widgetLabel("Nivellieren")
case .accessoryRectangular:
HStack(spacing: 6) {
Image(systemName: "level")
.font(.title3)
VStack(alignment: .leading, spacing: 0) {
Text("Nivellierung")
.font(.headline)
Text("Antippen zum Ausrichten")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
default:
// Rund auf dem Zifferblatt der knappste Platz, also nur das
// Symbol, gross genug zum Treffen.
ZStack {
AccessoryWidgetBackground()
Image(systemName: "level")
.font(.title2)
}
}
}
}
@main
struct LevelComplication: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "de.fritob.VanControl.complication.level",
provider: LevelComplicationProvider()) { _ in
LevelComplicationView()
// Der Tipp landet nicht irgendwo in der App, sondern auf der
// Nivellierung dafür wertet die App diese Adresse aus.
.widgetURL(WatchDeepLink.level)
.containerBackground(.clear, for: .widget)
}
.configurationDisplayName("Nivellierung")
.description("Öffnet die Nivellierung direkt vom Zifferblatt.")
.supportedFamilies([.accessoryCircular, .accessoryCorner,
.accessoryInline, .accessoryRectangular])
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,288 @@
//
// VanControlLiveActivity.swift
// VanControlLiveActivity
//
// Created by Christopher Helberg on 02.09.26.
//
import ActivityKit
import WidgetKit
import SwiftUI
private struct LevelIcon: View {
let isLevel: Bool
let hasReading: Bool
var body: some View {
Image(systemName: hasReading ? (isLevel ? "checkmark.circle.fill" : "level") : "level")
.foregroundStyle(hasReading ? (isLevel ? .green : .orange) : .secondary)
}
}
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
private let levelTolerance: Double = 0.5
/// Grün bis Toleranz, orange bis 2°, sonst rot dieselbe Skala für Blase
/// und Text, wie in der App (`VehicleTilt.colour`). Hier dupliziert, weil
/// die Extension `Shared/` nicht mitkompiliert (siehe
/// `LevelActivityAttributes.swift`).
private func levelColour(forDeviation deviation: Double?) -> Color {
guard let deviation else { return .white.opacity(0.4) }
if deviation <= levelTolerance { return .green }
if deviation <= 2 { return .orange }
return .red
}
/// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte
/// Dynamic Island dieselbe Optik wie `LevelBubble` in der App, aber ohne
/// deren Abhängigkeit auf `Shared/Models/LevelState.swift`, das in der
/// Extension nicht mitkompiliert wird.
private struct LevelBubbleGlyph: View {
let pitch: Double?
let roll: Double?
let isLevel: Bool
var diameter: CGFloat = 54
/// Bis zu welcher Neigung die Blase ausschlägt, wie in der App-Libelle.
private let range: Double = 6
private var hasReading: Bool { pitch != nil || roll != nil }
private var bubbleColor: Color {
guard let pitch, let roll else { return .white.opacity(0.4) }
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
}
var body: some View {
let radius = diameter / 2
let bubble = diameter * 0.22
let travel = radius - bubble / 2 - 3
let offsetX = clamped(roll) / range * travel
let offsetY = clamped(pitch) / range * travel
let toleranceRadius = max(levelTolerance / range * travel, bubble * 0.55)
ZStack {
Circle()
.fill(Color.white.opacity(0.1))
Circle()
.strokeBorder(Color.white.opacity(0.3), lineWidth: 1)
Circle()
.strokeBorder(isLevel ? Color.green : Color.white.opacity(0.25),
lineWidth: isLevel ? 2 : 1)
.frame(width: toleranceRadius * 2, height: toleranceRadius * 2)
Circle()
.fill(bubbleColor)
.frame(width: bubble, height: bubble)
// Positiver Pitch heisst: Heck steht höher, die Blase wandert
// also nach unten wie bei der echten Wasserwaage in der App.
.offset(x: offsetX, y: offsetY)
.opacity(hasReading ? 1 : 0.3)
}
.frame(width: diameter, height: diameter)
}
private func clamped(_ value: Double?) -> Double {
guard let value else { return 0 }
return min(max(value, -range), range)
}
}
struct VanControlLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: LevelActivityAttributes.self) { context in
LevelActivityContent(attributes: context.attributes, state: context.state)
.activityBackgroundTint(Color(white: 0.08))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
// Die Dynamic Island bleibt bewusst so klein wie möglich Libelle
// und Zahlenwerte gehören auf Sperrbildschirm/CarPlay, hier reicht
// ein Blick auf Icon und, aufgeklappt, die Kurzanweisung.
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
.font(.title3)
}
DynamicIslandExpandedRegion(.bottom) {
Text(context.state.instruction ?? context.attributes.deviceName)
.font(.caption)
.foregroundStyle(context.state.isLevel ? .green : .primary)
.lineLimit(1)
}
} compactLeading: {
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
} compactTrailing: {
EmptyView()
} minimal: {
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
}
.keylineTint(context.state.isLevel ? .green : .orange)
}
.supplementalActivityFamilies([.small])
}
}
/// Wählt je nach Darstellungsgrösse zwischen Sperrbildschirm- und
/// Kompaktansicht.
///
/// Ohne `.small`-Familie fällt CarPlay (wie die Smart Stack der Uhr) auf
/// `compactLeading`/`compactTrailing` der Dynamic Island zurück dort steht
/// nur das generische Symbol und, weil `compactTrailing` leer bleibt, gar
/// kein Text. Erst die `.small`-Familie liefert CarPlay Libelle und Text.
private struct LevelActivityContent: View {
let attributes: LevelActivityAttributes
let state: LevelActivityAttributes.ContentState
@Environment(\.activityFamily) private var activityFamily
var body: some View {
switch activityFamily {
case .small:
CompactLevelView(attributes: attributes, state: state)
default:
LockScreenLevelView(attributes: attributes, state: state)
}
}
}
/// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus
/// ein Blick-Symbol statt eines ganzen Satzes zum Lesen im Vorbeifahren.
///
/// Gezeigt wird nur, welche Seite höher steht: Buchstabe (Längsneigung),
/// Pfeil, Buchstabe (Querneigung) z.B. "H L" für "Heck und links stehen
/// höher". Ein einzelner Pfeil in der Mitte reicht, weil ohnehin nur die
/// höher stehende Seite benannt wird ("höher" ist implizit "nach oben").
private struct CompactLevelView: View {
let attributes: LevelActivityAttributes
let state: LevelActivityAttributes.ContentState
private var hasReading: Bool { state.pitch != nil || state.roll != nil }
/// "H"/"F", nur wenn ausserhalb der Toleranz.
private var pitchLetter: String? {
guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
return pitch >= 0 ? "H" : "F"
}
/// "R"/"L", nur wenn ausserhalb der Toleranz.
private var rollLetter: String? {
guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
return roll >= 0 ? "R" : "L"
}
/// Dieselbe Skala wie die Blase, über die grössere der beiden Neigungen.
private var statusColor: Color {
guard let pitch = state.pitch, let roll = state.roll else { return .white.opacity(0.4) }
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
}
var body: some View {
HStack(spacing: 14) {
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40)
Group {
if !hasReading {
Text("")
} else if state.isLevel {
Image(systemName: "checkmark.circle.fill")
} else {
HStack(spacing: 6) {
if let pitchLetter { Text(pitchLetter) }
Image(systemName: "arrow.up")
if let rollLetter { Text(rollLetter) }
}
}
}
// Skalierbarer Textstil statt fester Punktgrösse: CarPlay-
// Bildschirme unterscheiden sich in Grösse, die exakten Masse
// eines Displays sind aber nicht abfragbar. `minimumScaleFactor`
// lässt die Schrift so gross wie möglich, aber so klein wie
// nötig werden, um in den vom System zugeteilten Platz zu passen.
.font(.title2.weight(.bold))
.minimumScaleFactor(0.5)
.lineLimit(1)
.foregroundStyle(statusColor)
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
/// Sperrbildschirm- und Banner-Ansicht.
private struct LockScreenLevelView: View {
let attributes: LevelActivityAttributes
let state: LevelActivityAttributes.ContentState
/// Längszeile, z.B. "Heck steht höher 1.8°" nur wenn ausserhalb der
/// Toleranz, genau wie bei `LevelState.instruction` in der App.
private var pitchLine: String? {
guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
let label = pitch >= 0 ? "Heck steht höher" : "Front steht höher"
return "\(label) \(LevelDirectionFormatting.magnitude(pitch))"
}
/// Querzeile, z.B. "links steht höher 0.9°".
private var rollLine: String? {
guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
let label = roll >= 0 ? "rechts steht höher" : "links steht höher"
return "\(label) \(LevelDirectionFormatting.magnitude(roll))"
}
var body: some View {
HStack(spacing: 20) {
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 64)
VStack(alignment: .leading, spacing: 6) {
Text(attributes.deviceName)
.font(.caption)
.foregroundStyle(.secondary)
if pitchLine == nil && rollLine == nil {
Text(state.pitch == nil && state.roll == nil ? "Keine Messwerte" : "Steht eben")
.font(.title3.weight(.semibold))
.foregroundStyle(state.isLevel ? .green : .white)
} else {
if let pitchLine {
Text(pitchLine).font(.title3.weight(.semibold))
}
if let rollLine {
Text(rollLine).font(.title3.weight(.semibold))
}
}
}
Spacer(minLength: 0)
}
.padding(16)
.foregroundStyle(.white)
}
}
extension LevelActivityAttributes {
fileprivate static var preview: LevelActivityAttributes {
LevelActivityAttributes(deviceName: "Nivellierung")
}
}
extension LevelActivityAttributes.ContentState {
fileprivate static var level: LevelActivityAttributes.ContentState {
LevelActivityAttributes.ContentState(pitch: 0.1, roll: -0.2, isLevel: true,
instruction: "Steht eben", isCalibrated: true,
updatedAt: .now)
}
fileprivate static var tilted: LevelActivityAttributes.ContentState {
LevelActivityAttributes.ContentState(pitch: 2.4, roll: -1.1, isLevel: false,
instruction: "Heck steht höher, links steht höher",
isCalibrated: true, updatedAt: .now)
}
}
#Preview("Notification", as: .content, using: LevelActivityAttributes.preview) {
VanControlLiveActivity()
} contentStates: {
LevelActivityAttributes.ContentState.level
LevelActivityAttributes.ContentState.tilted
}
@@ -0,0 +1,16 @@
//
// VanControlLiveActivityBundle.swift
// VanControlLiveActivity
//
// Created by Christopher Helberg on 02.09.26.
//
import WidgetKit
import SwiftUI
@main
struct VanControlLiveActivityBundle: WidgetBundle {
var body: some Widget {
VanControlLiveActivity()
}
}
@@ -0,0 +1,4 @@
{
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.400", "green" : "0.620", "red" : "0.110" } }, "idiom" : "universal" } ],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,11 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : { "author" : "xcode", "version" : 1 }
}
@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }
@@ -0,0 +1,10 @@
{
"images" : [
{
"filename" : "VansterRear.png",
"idiom" : "universal"
}
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "template-rendering-intent" : "template" }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,10 @@
{
"images" : [
{
"filename" : "VansterSide.png",
"idiom" : "universal"
}
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "template-rendering-intent" : "template" }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+48
View File
@@ -0,0 +1,48 @@
import SwiftUI
/// VanControl Pro am Handgelenk.
///
/// Zwei Quellen, jede dort, wo sie hingehört:
///
/// * Der **Neigungsmesser** hängt direkt an der Uhr (`WatchLevelRadio`). Die
/// Nivellierung braucht damit kein geöffnetes iPhone und genau dafür hebt
/// man beim Rangieren den Arm.
/// * **Batterie, Solar und Kühlbox** kommen über das iPhone (`PhoneLink`),
/// weil dort die Schlüssel liegen und die Geräte nur eine Verbindung
/// zulassen.
@main
struct CamperWatchApp: App {
@State private var link = PhoneLink()
@State private var radio = WatchLevelRadio()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
WatchRootView()
.environment(link)
.environment(radio)
.task {
link.activate()
radio.start()
// Beim Start meldet `onChange` nichts: Die Bildschirmphase
// steht schon auf aktiv und ändert sich nicht mehr. Ohne
// diese Zeile bliebe der schnelle Takt des iPhones aus, bis
// die App einmal im Hintergrund war.
link.setLive(true)
}
// Die Einbaulage kommt bevorzugt aus dem Sensor selbst. Nur
// falls dort noch nichts steht ältere Firmware, oder nie
// bestimmt , gilt, was das iPhone meldet.
.onChange(of: link.payload?.levelDevice?.orientation) { _, orientation in
guard let orientation, !radio.hasDeviceOrientation else { return }
radio.orientation = orientation
}
}
.onChange(of: scenePhase) { _, phase in
// Beides kostet Strom und lohnt nur, solange jemand hinschaut.
// Genau das sagt die Bildschirmphase aus.
link.setLive(phase == .active)
if phase == .active { radio.start() } else { radio.stop() }
}
}
}
+147
View File
@@ -0,0 +1,147 @@
import Foundation
import Observation
import WatchConnectivity
/// Die Gegenstelle zum iPhone auf der Uhr.
///
/// Hält den zuletzt empfangenen Stand und schickt Stellbefehle zurück. Gefunkt
/// wird nur zum iPhone zu den Geräten im Fahrzeug nie, siehe `WatchLink`.
///
/// Zwei Quellen laufen zusammen:
///
/// * Der **Anwendungskontext** liegt schon da, wenn die App startet. Deshalb
/// steht sofort etwas auf dem Bildschirm, auch wenn das iPhone in der Tasche
/// steckt und gerade nicht antwortet mit Altersangabe, versteht sich.
/// * **Nachrichten** kommen im halben Sekundentakt, solange wir gemeldet haben,
/// dass jemand hinschaut. Das Melden wird regelmässig erneuert: Schläft die
/// Uhr ein, bleibt die Erneuerung aus und das iPhone hört von selbst auf.
@Observable
final class PhoneLink: NSObject {
private(set) var payload: WatchPayload?
/// Wann der Stand hier ankam nicht, wann er entstand. Beides zusammen
/// unterscheidet iPhone weg von Gerät im Fahrzeug antwortet nicht.
private(set) var receivedAt: Date?
private(set) var isReachable = false
private(set) var isActivated = false
private var session: WCSession?
/// Erneuert die Meldung hier schaut jemand hin im Vorführbetrieb
/// stattdessen der Taktgeber der erfundenen Werte.
private var timer: Timer?
private var isLive = false
/// Ohne iPhone lässt sich auf der Uhr sonst nichts vorführen.
private let isDemo = ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
func activate() {
if isDemo {
startDemo()
return
}
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
self.session = session
session.activate()
}
/// Meldet dem iPhone, ob gerade jemand hinschaut. Beim Ausrichten zählt
/// jeder halbe Sekundentakt, beim Nachsehen des Ladezustands nicht.
func setLive(_ live: Bool) {
guard !isDemo else { return }
isLive = live
timer?.invalidate()
timer = nil
guard live else {
send(.hello(live: false))
return
}
send(.hello(live: true))
// Vor Ablauf der Frist erneuern, damit zwischendurch keine Lücke
// entsteht. Bleibt die Erneuerung aus, endet der schnelle Takt von
// selbst niemand muss ihn abbestellen.
let renew = Timer(timeInterval: WatchLink.liveLease / 3, repeats: true) { [weak self] _ in
self?.send(.hello(live: true))
}
RunLoop.main.add(renew, forMode: .common)
timer = renew
}
/// Schickt einen Befehl und übernimmt die Antwort, falls eine kommt.
///
/// Die Antwort ist der Stand *vor* der Wirkung die Kühlbox antwortet dem
/// iPhone erst ein paar Zehntel später. Sie zeigt also nicht, dass der
/// Befehl gewirkt hat, sondern nur, dass er angekommen ist. Der gestellte
/// Wert erscheint, wenn das Gerät ihn zurückmeldet, so wie am iPhone auch.
func send(_ command: WatchCommand) {
guard !isDemo, let session, session.activationState == .activated,
let message = try? command.message() else { return }
guard session.isReachable else { return }
session.sendMessage(message, replyHandler: { [weak self] reply in
guard let payload = WatchPayload.decode(from: reply) else { return }
DispatchQueue.main.async { self?.accept(payload) }
}, errorHandler: nil)
}
private func accept(_ payload: WatchPayload) {
// Ein Nachzügler darf einen neueren Stand nicht überschreiben.
if let current = self.payload, current.generatedAt > payload.generatedAt { return }
self.payload = payload
receivedAt = Date()
}
// MARK: - Vorführbetrieb
private func startDemo() {
payload = WatchDemo.payload(at: 0)
receivedAt = Date()
isReachable = true
isActivated = true
var step = 0.0
let tick = Timer(timeInterval: 0.5, repeats: true) { [weak self] _ in
step += 0.5
self?.payload = WatchDemo.payload(at: step)
self?.receivedAt = Date()
}
RunLoop.main.add(tick, forMode: .common)
timer = tick
}
}
// MARK: - WCSessionDelegate
extension PhoneLink: WCSessionDelegate {
func session(_ session: WCSession,
activationDidCompleteWith state: WCSessionActivationState,
error: Error?) {
DispatchQueue.main.async {
self.isActivated = state == .activated
self.isReachable = session.isReachable
// Was schon dalag, gilt sofort sonst bliebe der Bildschirm leer,
// bis das iPhone das nächste Mal von sich aus sendet.
if let payload = WatchPayload.decode(from: session.receivedApplicationContext) {
self.accept(payload)
}
if self.isLive { self.send(.hello(live: true)) }
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
guard let payload = WatchPayload.decode(from: message) else { return }
DispatchQueue.main.async { self.accept(payload) }
}
func session(_ session: WCSession, didReceiveApplicationContext context: [String: Any]) {
guard let payload = WatchPayload.decode(from: context) else { return }
DispatchQueue.main.async { self.accept(payload) }
}
func sessionReachabilityDidChange(_ session: WCSession) {
DispatchQueue.main.async {
self.isReachable = session.isReachable
if session.isReachable, self.isLive { self.send(.hello(live: true)) }
}
}
}
+115
View File
@@ -0,0 +1,115 @@
import SwiftUI
import WatchKit
/// Der Ausrichtungs-Assistent am Handgelenk der eigentliche Grund für die
/// Uhr: Beim Rangieren hat man das iPhone nicht in der Hand, aber den Arm am
/// Lenkrad. Ein Blick nach unten genügt, und die Rückmeldung steht eben kommt
/// zusätzlich als Vibration, ganz ohne Blick.
///
/// Gerechnet wird mit demselben `AlignmentAssistant` wie am iPhone: kein
/// Ortsbezug, nur der zeitliche Verlauf der Neigung. Was besser und was
/// schlechter wird, steckt vollständig darin.
struct WatchAlignView: View {
@Environment(PhoneLink.self) private var link
@Environment(WatchLevelRadio.self) private var radio
@State private var assistant = AlignmentAssistant()
/// Damit die Vibration einmal kommt und nicht im Sekundentakt.
@State private var hasAnnouncedTarget = false
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
private var state: LevelState? { reading.hasReading ? reading.state : nil }
var body: some View {
// Ohne ScrollView: Beim Rangieren schaut man kurz hin, da darf nichts
// ausserhalb des Bildes liegen. Alles ist deshalb so bemessen, dass es
// auch auf der kleinsten Uhr auf einen Blick passt.
VStack(spacing: 3) {
if let current = assistant.current {
Text(String(format: "%.1f°", current.deviation))
.font(.system(size: 30, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(assistant.hasReachedTarget ? .green : .primary)
.contentTransition(.numericText())
} else {
ProgressView()
.padding(.vertical, 6)
}
Label(assistant.trend.text, systemImage: assistant.trend.symbol)
.font(.caption2.weight(.medium))
.foregroundStyle(trendColor)
Text(assistant.advice)
.font(.system(size: 11))
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.lineLimit(2)
.minimumScaleFactor(0.8)
if let state {
HStack(spacing: 8) {
WatchBubble(pitch: state.pitch, roll: state.roll)
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 1) {
Text(String(format: "Längs %.1f°", state.pitch ?? 0))
Text(String(format: "Quer %.1f°", state.roll ?? 0))
}
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
}
}
Button {
assistant.reset()
hasAnnouncedTarget = false
} label: {
Label("Von vorn", systemImage: "arrow.counterclockwise")
.font(.system(size: 10))
}
.buttonStyle(.borderless)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 2)
.navigationTitle("Ausrichten")
.onChange(of: tilt, initial: true) { _, tilt in
guard let tilt else { return }
assistant.add(pitch: tilt.pitch, roll: tilt.roll)
announceIfLevel()
}
}
/// Ein Wertepaar als Auslöser: gerechnet wird nur, wenn sich die Neigung
/// wirklich ändert nicht bei jeder Aktualisierung der Anzeige.
private var tilt: Tilt? {
guard let state, let pitch = state.pitch, let roll = state.roll else { return nil }
return Tilt(pitch: pitch, roll: roll)
}
private struct Tilt: Equatable {
let pitch: Double
let roll: Double
}
private var trendColor: Color {
switch assistant.trend {
case .improving: return .green
case .worsening: return .orange
default: return .secondary
}
}
/// Die Vibration ist der Punkt: Sie sagt anhalten, ohne dass jemand
/// hinschauen muss. Erneut gemeldet wird erst, wenn das Fahrzeug die
/// Toleranz zwischendurch wieder verlassen hat.
private func announceIfLevel() {
if assistant.hasReachedTarget {
guard !hasAnnouncedTarget else { return }
hasAnnouncedTarget = true
WKInterfaceDevice.current().play(.success)
} else {
hasAnnouncedTarget = false
}
}
}

Some files were not shown because too many files have changed in this diff Show More