Author SHA1 Message Date
fototeddyandClaude Opus 5 ae12d8ede4 CarPlay-Demo: Nivellierung auf dem Fahrzeugdisplay
Eigene Build-Konfiguration "CarPlay" samt Schema, damit der Versuchsbau neben
der TestFlight-App stehen kann: eigener Name, eigene Bundle-ID, persoenliches
Team, manuelles Signing. Debug und Release bleiben unberuehrt.

Wichtig dabei: Die Signing-Einstellungen stehen ausschliesslich in den
Target-Konfigurationen. Steht CODE_SIGN_STYLE auf Projektebene, schreibt der
naechste xcodebuild-Lauf alle uebrigen Konfigurationen auf Manual um und loescht
ihr Team – der TestFlight-Build waere damit hin.

Der CarPlay-Code haengt an `CARPLAY_DEMO` und existiert im regulaeren Build
nicht. Die Szene zeichnet in das gelieferte CPWindow; das CPMapTemplate darueber
bleibt leer, weil es auf dem Stellplatz nichts zu tippen gibt. Die Anzeige
sperrt sich oberhalb von 5 km/h selbst – erzwungen, nicht zugesagt.

Die Groessen sind auf die 400x240 Punkte ausgelegt, die CarPlay liefert, nicht
auf die 800x480 Pixel. In Pixeln gedachte Schriftgroessen sind auf dem
Fahrzeugdisplay doppelt zu gross und schneiden den Text ab.

Bekannt: Unter iOS 26.5 stuerzt Apples CarPlayTemplateUIHost beim Aufbau der
Navigationsleiste ab (`_updateShareButtonVisibility` fragt seinen
destinationSharingDelegate nach `vehicleSupportsDestinationSharing`, den dieser
nicht kennt). Beides ist privates API und von der App aus nicht erreichbar;
unter iOS 18 laeuft es. Szene und Root-Template werden vorher in beiden
Faellen akzeptiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:35:33 +02:00
fototeddyandClaude Opus 5 b1ffbe324a Libelle: Blase wandert zur höheren Seite, einheitlich auf allen Anzeigen
Die drei Libellen widersprachen sich: Die Live Activity liess die Blase bei
positivem Pitch nach unten wandern – mit dem Kommentar "wie bei der echten
Wasserwaage in der App" –, waehrend iPhone und Uhr genau das Gegenteil taten.
Gemeint war immer eine Draufsicht mit Fahrtrichtung oben, in der die Blase zur
hoeheren Seite ausschlaegt: Steht das Heck hoeher, gehoert sie nach hinten,
also nach unten. iPhone und Uhr ziehen jetzt nach.

Die Android-Fassung bleibt vorerst bei der alten Richtung.

Dazu `shortInstructions`: dieselbe Aussage wie `instruction`, aber in kurzen
Zeilen statt einem Satz. Auf schmalen Anzeigen passt "Heck steht hoeher, links
steht hoeher" in keine lesbare Zeile. Beide stehen nebeneinander, damit die
Vorzeichen nicht auseinanderlaufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 18:35:14 +02:00
fototeddyandClaude Sonnet 5 8401f62d91 California-Seitenansicht eng zuschneiden statt in quadratischer Leinwand
Die Vorlage lag auf einer quadratischen 1254×1254-Leinwand, der Van
darin füllte aber nur rund 59% der Höhe – bei deklariertem
sideAspect = 1 (quadratisch) rechnete SwiftUI die Anzeigegrösse für
dieses volle Quadrat, wodurch der Van in der gemeinsamen Bildhülle
deutlich kleiner erschien als Vanster, dessen Vorlage eng auf die
Silhouette zugeschnitten ist.

Bild jetzt auf den Inhalt zugeschnitten (plus kleiner Rand, wie bei
Vanster), sideAspect entsprechend von 1 auf das tatsächliche Verhältnis
der zugeschnittenen Vorlage (1212×790) angepasst. In beiden
Asset-Katalogen ersetzt (iPhone und Watch, siehe vorherigen Commit zur
Watch-Fahrzeuggrafik).

Die Heckansicht war laut Rückmeldung bereits optisch stimmig und blieb
unverändert.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:27:20 +02:00
fototeddyandClaude Sonnet 5 c45cf36920 Fahrzeuggrafik-Stil (Vanster/California) auf die Watch ausweiten
Die Uhr zeigte in der Fahrzeug-Ansicht bislang immer Vanster fest
verdrahtet, unabhängig von der Auswahl am iPhone – das galt schon vor
dieser Session, nicht erst seit dem Solar-Merge.

WatchDevice trägt jetzt vehicleGraphicStyle (nur beim Neigungsmesser
belegt, wie orientation), von PhoneWatchLink beim Aufbau der Nutzlast
gefüllt. WatchVehicleView nimmt den Stil als Parameter statt der fest
verdrahteten Vanster-Bildnamen, WatchLevelView reicht ihn aus dem
zuletzt vom iPhone übertragenen Stand durch. California-Bilder dafür
zusätzlich in den Watch-eigenen Asset-Katalog kopiert (die Uhr hat
einen eigenen, vom iPhone getrennten Katalog).

Ausgewählt wird der Stil weiterhin nur am iPhone (Neigungsmesser
einrichten → Fahrzeuggrafik). Persistenz war dafür schon vorhanden:
vehicleGraphicStyle ist ein normales, codiertes Feld auf
ConfiguredDevice ohne eigene encode(to:)-Überschreibung – wird also
schon beim Speichern mitgeschrieben.

Nebenbei: Xcode hat beim Bearbeiten den Build-Zähler auf 2 gesetzt und
LSApplicationCategoryType (Dienstprogramme) ergänzt – unverändert
übernommen.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:22:32 +02:00
fototeddyandClaude Sonnet 5 47ead9b006 Watch-App-Icon an das neue Haupt-App-Icon angleichen
VanControlWatch hat ein eigenes, vom iPhone-Target unabhängiges
AppIcon.appiconset – das zeigte beim Einbinden von App1/App2 noch das
alte Logo. Jetzt dieselbe Vorlage wie das primäre iPhone-Icon
(App1-Testflight), damit iPhone- und Watch-App auf dem Homescreen
zusammengehörig aussehen.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:14:17 +02:00
fototeddyandClaude Sonnet 5 b23e96615e App1/App2-Icons als primäres und Alternate-Icon einbinden
Vier neue AppIcon-Asset-Kataloge angelegt (App1/App2, jeweils Standard-
und TestFlight-Variante), aus den bereitgestellten 1024×1024-Vorlagen
(bereits randlos, ohne Alphakanal – nur noch skaliert).

Verdrahtet für den aktuellen TestFlight-Build:
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-App1-Testflight (primär)
- ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = AppIcon-App2-Testflight
- ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES, damit auch die
  noch unverdrahteten App1/App2-Sets (für den finalen Store-Build) mit
  ins Bundle kommen, statt vom Compiler als ungenutzt verworfen zu werden

Fürs finale Release genügt es, ASSETCATALOG_COMPILER_APPICON_NAME auf
AppIcon-App1 und die Alternate-Liste auf AppIcon-App2 umzustellen – die
TestFlight-Varianten sollen Betatester optisch von der echten Version
unterscheiden.

Nur das Haupt-App-Target ist betroffen; Watch- und Complication-Icon
bleiben unverändert. Build (App + Watch) und Tests grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 17:14:22 +02:00
fototeddyandClaude Sonnet 5 0b3bf5e815 Neue California-Fahrzeuggrafik einbauen, eigene Heckansicht ergänzt
Seiten- und Heckansicht kamen als weiß gefüllte Vorlage ohne Alphakanal
(Tools/California Side.png, Tools/California Back.png). Dafür ein neues
Werkzeug ergänzt, das den weißen Grund in echte Transparenz übersetzt
(mit weichem statt hartem Schwellwert, um Kanten nicht auszufransen),
danach wie gewohnt durch make-vehicle-art.swift geschickt.

California hatte bisher keine eigene Heckansicht und griff ersatzweise
auf die von Vanster zurück – jetzt eigenes CaliforniaRear-Imageset,
VehicleGraphicStyle.rearImageName entsprechend angepasst. Seiten- und
Heckbild sind quadratisch (1254×1254), passend zu den bestehenden
Seitenverhältnissen (sideAspect/rearAspect = 1) – keine Anpassung dort
nötig.

Build und Tests grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 21:31:42 +02:00
fototeddyandClaude Sonnet 5 8d58ea6e49 Export-Compliance: ITSAppUsesNonExemptEncryption setzen
App Store Connect fragt beim Hochladen nach Verschlüsselung, wenn der
Info.plist-Schlüssel fehlt. Die App entschlüsselt Victron-Advertisements
per AES-128-CTR über CommonCrypto – ein Standardalgorithmus über eine
vom Betriebssystem gestellte Standardbibliothek, nur Nebenzweck der App
und damit nach US-Exportrecht typischerweise ausgenommen. Schlüssel auf
false gesetzt, damit die Abfrage bei künftigen Uploads entfällt.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Build (Debug + Release) grün.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:00:14 +02:00
114 changed files with 4320 additions and 941 deletions
-600
View File
@@ -1,600 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* CamperMonitorWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
AA0000000000000000000042 /* CamperMonitorComplication.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000032 /* CamperMonitorComplication.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
AA0000000000000000000028 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA0000000000000000000009 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AA0000000000000000000022;
remoteInfo = CamperMonitorWatch;
};
AA0000000000000000000043 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA0000000000000000000009 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AA0000000000000000000037;
remoteInfo = CamperMonitorComplication;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
AA0000000000000000000026 /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000041 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
AA0000000000000000000042 /* CamperMonitorComplication.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
AA0000000000000000000001 /* CamperMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; };
AA0000000000000000000016 /* CamperMonitorWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitorWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
AA0000000000000000000030 /* CamperMonitor-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitor-Info.plist"; sourceTree = "<group>"; };
AA0000000000000000000032 /* CamperMonitorComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CamperMonitorComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; };
AA0000000000000000000045 /* CamperMonitorComplication-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitorComplication-Info.plist"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
AA0000000000000000000002 /* CamperMonitor */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = CamperMonitor;
sourceTree = "<group>";
};
AA0000000000000000000017 /* CamperMonitorWatch */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = CamperMonitorWatch;
sourceTree = "<group>";
};
AA0000000000000000000018 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Shared;
sourceTree = "<group>";
};
AA0000000000000000000033 /* CamperMonitorComplication */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = CamperMonitorComplication;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
AA0000000000000000000003 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000020 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000000000000000000035 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
AA0000000000000000000004 = {
isa = PBXGroup;
children = (
AA0000000000000000000002 /* CamperMonitor */,
AA0000000000000000000017 /* CamperMonitorWatch */,
AA0000000000000000000033 /* CamperMonitorComplication */,
AA0000000000000000000018 /* Shared */,
AA0000000000000000000031 /* Config */,
AA0000000000000000000005 /* Products */,
);
sourceTree = "<group>";
};
AA0000000000000000000005 /* Products */ = {
isa = PBXGroup;
children = (
AA0000000000000000000001 /* CamperMonitor.app */,
AA0000000000000000000016 /* CamperMonitorWatch.app */,
AA0000000000000000000032 /* CamperMonitorComplication.appex */,
);
name = Products;
sourceTree = "<group>";
};
AA0000000000000000000031 /* Config */ = {
isa = PBXGroup;
children = (
AA0000000000000000000030 /* CamperMonitor-Info.plist */,
AA0000000000000000000045 /* CamperMonitorComplication-Info.plist */,
);
path = Config;
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 */,
AA0000000000000000000026 /* Embed Watch Content */,
);
buildRules = (
);
dependencies = (
AA0000000000000000000029 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
AA0000000000000000000002 /* CamperMonitor */,
AA0000000000000000000018 /* Shared */,
);
name = CamperMonitor;
productName = CamperMonitor;
productReference = AA0000000000000000000001 /* CamperMonitor.app */;
productType = "com.apple.product-type.application";
};
AA0000000000000000000022 /* CamperMonitorWatch */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "CamperMonitorWatch" */;
buildPhases = (
AA0000000000000000000019 /* Sources */,
AA0000000000000000000020 /* Frameworks */,
AA0000000000000000000021 /* Resources */,
AA0000000000000000000041 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
AA0000000000000000000044 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
AA0000000000000000000017 /* CamperMonitorWatch */,
AA0000000000000000000018 /* Shared */,
);
name = CamperMonitorWatch;
productName = CamperMonitorWatch;
productReference = AA0000000000000000000016 /* CamperMonitorWatch.app */;
productType = "com.apple.product-type.application";
};
AA0000000000000000000037 /* CamperMonitorComplication */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "CamperMonitorComplication" */;
buildPhases = (
AA0000000000000000000034 /* Sources */,
AA0000000000000000000035 /* Frameworks */,
AA0000000000000000000036 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
AA0000000000000000000018 /* Shared */,
AA0000000000000000000033 /* CamperMonitorComplication */,
);
name = CamperMonitorComplication;
productName = CamperMonitorComplication;
productReference = AA0000000000000000000032 /* CamperMonitorComplication.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;
};
};
};
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 */,
AA0000000000000000000022 /* CamperMonitorWatch */,
AA0000000000000000000037 /* CamperMonitorComplication */,
);
};
/* 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;
};
/* 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;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
AA0000000000000000000029 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
platformFilter = ios;
target = AA0000000000000000000022 /* CamperMonitorWatch */;
targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */;
};
AA0000000000000000000044 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = AA0000000000000000000037 /* CamperMonitorComplication */;
targetProxy = AA0000000000000000000043 /* 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 = 6AP73NYV5W;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
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_FILE = "Config/CamperMonitor-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
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;
};
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 = 6AP73NYV5W;
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.fritob.CamperMonitor;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.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 = 6AP73NYV5W;
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.fritob.CamperMonitor;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.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 = 6AP73NYV5W;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.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 = 6AP73NYV5W;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.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;
};
/* 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;
};
AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "CamperMonitorWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000023 /* Debug */,
AA0000000000000000000024 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "CamperMonitorComplication" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000038 /* Debug */,
AA0000000000000000000039 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = AA0000000000000000000009 /* Project object */;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

+31
View File
@@ -0,0 +1,31 @@
<?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>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Damit die Nivellierung auf dem CarPlay-Display nur im Stand angezeigt wird.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Damit die Nivellierung auf dem CarPlay-Display nur im Stand angezeigt wird.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>CarPlay</string>
<key>UISceneDelegateClassName</key>
<string>CarPlaySceneDelegate</string>
</dict>
</array>
</dict>
</dict>
</dict>
</plist>
+8
View File
@@ -0,0 +1,8 @@
<?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>com.apple.developer.carplay-maps</key>
<true/>
</dict>
</plist>
+127 -25
View File
@@ -9,9 +9,21 @@ iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest:
| Batterie-BMS (Bulltron/Daly, WattCycle) | GATT-Verbindung, alle 5 s abgefragt | SoC, Spannung, Strom, Restkapazität, alle Einzelzellspannungen, Zelldifferenz, Temperaturen, Zyklen, MOSFET-Status |
| Kompressor-Kühlbox (Alpicool und Baugleiche) | GATT-Verbindung, lesen **und** stellen | Ist- und Solltemperatur je Zone, Betriebsart, Kompressorstatus, Bordspannung, Batterieanzeige |
| Neigungsmesser (VanAlign Pro) | GATT-Verbindung | Längs- und Querneigung als Libelle, Kalibrierung aus der App |
| Votronic-Solarladeregler (über eigene ESP32-Bridge) | GATT-Verbindung, im Takt abgefragt | PV-Leistung, PV-Spannung/-Strom, Batteriespannung, Reglertemperatur, Lade-/Reglerstatus |
Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt.
Victron- und Votronic-Solarladeregler sind zwei getrennte Geräterollen, absichtlich
nicht zusammengelegt: Der Victron-Regler meldet sich passiv und verschlüsselt im
Advertisement, der Votronic-Regler hängt über eine eigene ESP32-Bridge (siehe
unten) an einer GATT-Verbindung grundverschiedene Übertragungswege für
grundverschiedene Hardware. In Code und Einstellungen tragen beide deshalb
konsequent `victron`/`votronic` im Namen.
Der Neigungsmesser zeigt sich zusätzlich als **Live Activity** auf Sperrbildschirm,
Dynamic Island und seit iOS 26 automatisch im CarPlay-Dashboard, siehe
[Live Activity](#live-activity).
Dazu gehört eine **Apple-Watch-App**: Übersicht, Nivellierung samt
Ausrichtungs-Assistent mit Vibration und die Steuerung der Kühlbox am
Handgelenk. Den Neigungsmesser funkt die Uhr selbst an, alles Übrige kommt
@@ -19,23 +31,27 @@ Handgelenk. Den Neigungsmesser funkt die Uhr selbst an, alles Übrige kommt
[Apple Watch](#apple-watch).
Die Firmware des Neigungsmessers liegt mit im Projekt, siehe
[firmware/vanalign](firmware/vanalign/README.md).
[firmware/vanalign](firmware/vanalign/README.md). Der Votronic-Solarregler hängt
an einer zweiten, unabhängigen ESP32-Bridge Firmware und BLE-Dienst dafür
liegen als `firmware/vanalign/esp32_ble_solar.yaml` im selben Ordner.
## Bauen und installieren
```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.
Dasselbe beim Target `CamperMonitorWatch`; dessen Bundle-ID muss die des
iPhones mit angehängtem `.watchkitapp` bleiben.
1. Bei allen vier Targets `VanControl`, `VanControlWatch`,
`VanControlComplication`, `VanControlLiveActivityExtension` unter
**Signing & Capabilities** dein Apple-Team auswählen. Die Bundle-IDs ggf.
anpassen, falls sie schon vergeben sind; die der Watch-App muss die des
iPhones mit angehängtem `.watchkitapp` bleiben, die der Komplikation
zusätzlich `.levelwidget`.
2. iPhone per Kabel anschließen, oben als Ziel wählen, ⌘R.
Das Schema `CamperMonitor` baut die Watch-App mit und bettet sie ein. Dafür
Das Schema `VanControl` baut die Watch-App mit und bettet sie ein. Dafür
muss die watchOS-Plattform in Xcode installiert sein sonst bricht schon das
Übersetzen des Symbolkatalogs ab:
@@ -161,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
@@ -181,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
@@ -273,6 +311,27 @@ Längsneigung bei 3,50 m Radstand dazu, braucht die tiefste Ecke 16,1 cm, ihre
Nachbarn 9,2 und 7,0 cm, und das höchststehende Rad bleibt liegen. Die Maße
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
@@ -370,14 +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:
Die Watch-App kennt denselben Schalter und braucht dann kein iPhone: Im Schema
`CamperMonitorWatch` dieselbe Variable setzen. Die Neigung wandert dort
langsam hin und her, sonst hätte der Ausrichtungs-Assistent nichts zu zeigen.
* **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
@@ -392,8 +470,9 @@ und die Prüfsummen beider Daly-Dialekte.
## Aufbau
```
firmware/vanalign/ Firmware des Neigungsmessers (ESPHome)
firmware/vanalign/ Firmware der beiden ESP32 im Fahrzeug (ESPHome)
├── esp32_ble.yaml Neigungsmessung und Bluetooth-Schnittstelle
├── esp32_ble_solar.yaml Zweiter ESP32: Votronic-Solarregler über BLE
└── experimente/ Nicht für den Betrieb nötig
Shared/ In iPhone- und Watch-App übersetzt
@@ -404,16 +483,23 @@ Shared/ In iPhone- und Watch-App übersetzt
│ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart
│ ├── DeviceSnapshot.swift Messwerte in Anzeigeform
│ ├── LevelState.swift Neigung, Toleranz und Klartext dazu
── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
│ └── VehicleGraphicStyle.swift Grafikstile der Fahrzeugansicht (Vanster/California)
├── Bluetooth/
│ ├── VanAlignProtocol.swift Neigungsmesser: Winkel und Kalibrierung
── LevelSession.swift Verbindung zum Neigungsmesser (iPhone und Uhr)
── LevelSession.swift Verbindung zum Neigungsmesser (iPhone und Uhr)
│ ├── VotronicSolarESPProtocol.swift Votronic-Solarregler: Charakteristiken, Statusbits
│ └── VotronicSolarESPSession.swift GATT-Verbindung zur Solar-Bridge, im Takt abgefragt
├── VehicleTilt.swift Überhöhung und Farben der Fahrzeugansicht
├── WheelLiftPlan.swift Keilhöhen als Draufsicht auf die vier Räder
└── WatchLink/
└── WatchLink.swift Datensatz und Befehle zwischen iPhone und Uhr
CamperMonitor/
SharedActivity/ In Haupt-App und Live-Activity-Extension übersetzt
├── LevelActivityAttributes.swift Inhalt der Live Activity (ActivityKit)
└── LevelDirectionFormatting.swift "H 1.8°" statt Vorzeichen, für App und Extension gleich
VanControl/
├── Bluetooth/
│ ├── BluetoothManager.swift Zentraler Scan, Verbindungen, Verlauf
│ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten
@@ -425,8 +511,11 @@ CamperMonitor/
│ ├── WattCycleProtocol.swift WattCycle, Freischaltung und Auswertung
│ ├── AlpicoolProtocol.swift Kühlboxen: Auswertung und Stellbefehle
│ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage
├── LiveActivity/
│ └── LevelActivityManager.swift Startet/aktualisiert/beendet die Live Activity, drosselt auf 1 Hz
├── Store/
│ ├── DeviceStore.swift Geräteliste, Persistenz
│ ├── DemoData.swift Erfundene Werte für den Demo-Modus
│ └── KeychainStore.swift Victron-Schlüssel
├── Watch/
│ └── PhoneWatchLink.swift Sendet den Stand, nimmt Befehle der Uhr an
@@ -436,14 +525,16 @@ CamperMonitor/
├── 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
CamperMonitorWatch/ watchOS-App
VanControlWatch/ watchOS-App
├── CamperWatchApp.swift Einstieg, meldet dem iPhone das Hinschauen
├── PhoneLink.swift Gegenstelle zum iPhone
├── WatchLevelRadio.swift Eigene Bluetooth-Verbindung zum Neigungsmesser
@@ -457,8 +548,16 @@ CamperMonitorWatch/ watchOS-App
├── WatchDeviceDetailView.swift Alle Werte eines Geräts
└── WatchFridgeControls.swift Kühlbox stellen
VanControlComplication/ Zifferblatt-Komplikation (watchOS)
└── LevelComplication.swift Tippt auf die Nivellierung, ohne eigenen Messwert
VanControlLiveActivity/ Live-Activity-Extension (WidgetKit)
├── VanControlLiveActivityBundle.swift Einstieg der Extension
└── VanControlLiveActivity.swift Ansicht für Sperrbildschirm, Dynamic Island, CarPlay
Config/
── CamperMonitor-Info.plist Nur der Hintergrundbetrieb für die Uhr
── VanControl-Info.plist Nur der Hintergrundbetrieb für die Uhr
└── VanControlComplication-Info.plist Anzeigename der Komplikation
```
## Bekannte Grenzen
@@ -478,6 +577,9 @@ Config/
die iPhone-App zu schliessen dann gehört der Sensor der Uhr.
* **Auf der Uhr muss beim ersten Start Bluetooth erlaubt werden**, sonst bleibt
die Nivellierung dort leer.
* **Die Live Activity aktualisiert höchstens einmal je Sekunde.** Öfter
drosselt iOS lokale Aktualisierungen ohnehin von sich aus, zunehmend
stärker sichtbar vor allem in CarPlay. Siehe [Live Activity](#live-activity).
* **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen
Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind
gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel
@@ -0,0 +1,55 @@
import Foundation
/// VotronicSolarESP zweiter ESP32 im Fahrzeug, liest einen
/// Votronic-Solarladeregler aus und stellt die Werte über einen eigenen
/// BLE-Dienst bereit. Siehe `firmware/vanalign/esp32_ble_solar.yaml`.
///
/// Wie beim Neigungsmesser: kein Rahmenprotokoll, jede Messgrösse liegt in
/// einer eigenen Charakteristik, alle sind reine Lesewerte, der Client fragt
/// sie im Takt ab.
enum VotronicSolarESPProtocol {
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
static let serviceUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A001"
static let batteryVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A101"
static let pvVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A102"
static let pvCurrentUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A103"
static let pvPowerUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A104"
static let controllerTempUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A105"
/// Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
/// Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
static let statusFlagsUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A106"
/// Liest einen Messwert aus vier Bytes, little-endian wie beim
/// Neigungsmesser legt die Firmware den Float per `memcpy` ab.
static func float(from data: Data) -> Double? {
guard data.count >= 4 else { return nil }
var raw: UInt32 = 0
for (index, byte) in data.prefix(4).enumerated() {
raw |= UInt32(byte) << UInt32(8 * index)
}
let value = Float(bitPattern: raw)
guard value.isFinite else { return nil }
return Double(value)
}
struct StatusFlags {
var isBatteryCharging = false
var isBatteryDischarging = false
var isControllerActive = false
var isCurrentLimited = false
var isAESActive = false
}
static func statusFlags(from data: Data) -> StatusFlags? {
guard let byte = data.first else { return nil }
var flags = StatusFlags()
flags.isBatteryCharging = byte & (1 << 0) != 0
flags.isBatteryDischarging = byte & (1 << 1) != 0
flags.isControllerActive = byte & (1 << 2) != 0
flags.isCurrentLimited = byte & (1 << 3) != 0
flags.isAESActive = byte & (1 << 4) != 0
return flags
}
}
@@ -0,0 +1,160 @@
import CoreBluetooth
import Foundation
/// Hält die Verbindung zum Solarladeregler.
///
/// Einfacher noch als `LevelSession`: die Firmware bietet für keine
/// Charakteristik `notify` an (siehe `esp32_ble_solar.yaml`), es wird also
/// immer im Takt abgefragt statt abonniert.
final class VotronicSolarESPSession: NSObject {
static let serviceUUID = CBUUID(string: VotronicSolarESPProtocol.serviceUUID)
private static let batteryVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.batteryVoltageUUID)
private static let pvVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.pvVoltageUUID)
private static let pvCurrentUUID = CBUUID(string: VotronicSolarESPProtocol.pvCurrentUUID)
private static let pvPowerUUID = CBUUID(string: VotronicSolarESPProtocol.pvPowerUUID)
private static let controllerTempUUID = CBUUID(string: VotronicSolarESPProtocol.controllerTempUUID)
private static let statusFlagsUUID = CBUUID(string: VotronicSolarESPProtocol.statusFlagsUUID)
let deviceID: UUID
private let queue: DispatchQueue
private let peripheral: CBPeripheral
private let onUpdate: (DeviceSnapshot) -> Void
private let onStateChange: (DeviceLinkState) -> Void
private let onVotronicSolarESPState: (VotronicSolarESPState) -> Void
private var characteristics: [CBUUID: CBCharacteristic] = [:]
private var state = VotronicSolarESPState()
private var pollTimer: DispatchSourceTimer?
/// Reicht für einen Solarregler, dessen Werte sich über Sekunden ändern
/// deutlich seltener als beim Ausrichten mit dem Neigungsmesser.
var pollInterval: TimeInterval = 5
init(deviceID: UUID,
peripheral: CBPeripheral,
queue: DispatchQueue,
onUpdate: @escaping (DeviceSnapshot) -> Void,
onStateChange: @escaping (DeviceLinkState) -> Void,
onVotronicSolarESPState: @escaping (VotronicSolarESPState) -> Void) {
self.deviceID = deviceID
self.queue = queue
self.peripheral = peripheral
self.onUpdate = onUpdate
self.onStateChange = onStateChange
self.onVotronicSolarESPState = onVotronicSolarESPState
super.init()
peripheral.delegate = self
}
// MARK: - Lebenszyklus
func start() {
onStateChange(.connecting)
peripheral.discoverServices([Self.serviceUUID])
}
func stop() {
pollTimer?.cancel()
pollTimer = nil
characteristics.removeAll()
}
func handleDisconnect() {
pollTimer?.cancel()
pollTimer = nil
characteristics.removeAll()
}
// MARK: - Abfrage
private func startPolling() {
guard pollTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now(), repeating: pollInterval)
timer.setEventHandler { [weak self] in self?.readAll() }
timer.resume()
pollTimer = timer
}
private func readAll() {
guard peripheral.state == .connected else { return }
for characteristic in characteristics.values where characteristic.properties.contains(.read) {
peripheral.readValue(for: characteristic)
}
}
private func publish() {
guard state.hasReading else { return }
onStateChange(.live)
onVotronicSolarESPState(state)
onUpdate(state.snapshot(deviceID: deviceID, rssi: nil))
}
}
// MARK: - CBPeripheralDelegate
extension VotronicSolarESPSession: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
onStateChange(.failed(error.localizedDescription))
return
}
guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
onStateChange(.failed("Solarladeregler-Dienst nicht gefunden"))
return
}
peripheral.discoverCharacteristics(
[Self.batteryVoltageUUID, Self.pvVoltageUUID, Self.pvCurrentUUID,
Self.pvPowerUUID, Self.controllerTempUUID, Self.statusFlagsUUID],
for: service
)
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
guard error == nil, let found = service.characteristics else {
onStateChange(.failed(error?.localizedDescription ?? "Keine Merkmale gefunden"))
return
}
for characteristic in found {
characteristics[characteristic.uuid] = characteristic
}
guard !characteristics.isEmpty else {
onStateChange(.failed("Solarwerte nicht gefunden"))
return
}
startPolling()
readAll()
}
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard error == nil, let value = characteristic.value else { return }
switch characteristic.uuid {
case Self.batteryVoltageUUID:
state.batteryVoltage = VotronicSolarESPProtocol.float(from: value)
case Self.pvVoltageUUID:
state.pvVoltage = VotronicSolarESPProtocol.float(from: value)
case Self.pvCurrentUUID:
state.pvCurrent = VotronicSolarESPProtocol.float(from: value)
case Self.pvPowerUUID:
state.pvPower = VotronicSolarESPProtocol.float(from: value)
case Self.controllerTempUUID:
state.controllerTemperature = VotronicSolarESPProtocol.float(from: value)
case Self.statusFlagsUUID:
guard let flags = VotronicSolarESPProtocol.statusFlags(from: value) else { return }
state.isBatteryCharging = flags.isBatteryCharging
state.isBatteryDischarging = flags.isBatteryDischarging
state.isControllerActive = flags.isControllerActive
state.isCurrentLimited = flags.isCurrentLimited
default:
return
}
publish()
}
}
+21 -4
View File
@@ -4,33 +4,46 @@ import Foundation
/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
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 .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 .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"
}
}
@@ -50,7 +63,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
/// 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
}
}
@@ -98,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,
@@ -128,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
}
}
+19
View File
@@ -65,6 +65,25 @@ struct LevelState: Equatable, Codable, Sendable {
return parts.joined(separator: ", ")
}
/// Dasselbe wie ``instruction``, aber in kurzen Zeilen statt einem Satz
/// für schmale Anzeigen wie das CarPlay-Display, wo "Heck steht höher,
/// rechts steht höher" nicht in eine lesbare Zeile passt.
///
/// Die Vorzeichen müssen zu ``instruction`` passen; beide stehen deshalb
/// nebeneinander.
var shortInstructions: [String] {
guard let pitch, let roll else { return [] }
if isLevel { return ["Steht eben"] }
var parts: [String] = []
if abs(pitch) > Self.levelTolerance {
parts.append(pitch > 0 ? "Heck höher" : "Front höher")
}
if abs(roll) > Self.levelTolerance {
parts.append(roll > 0 ? "Rechts höher" : "Links höher")
}
return parts
}
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
snapshot.metrics = [
+2 -3
View File
@@ -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",
]
}
+51
View File
@@ -0,0 +1,51 @@
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"
}
}
var rearImageName: String {
switch self {
case .vanster: return "VansterRear"
case .california: return "CaliforniaRear"
}
}
/// 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 1212.0 / 790.0
}
}
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
}
}
+4
View File
@@ -92,6 +92,10 @@ struct WatchDevice: Codable, Equatable, Identifiable, Sendable {
/// den Neigungsmesser selbst an und braucht die Zuordnung deshalb auch
/// eingestellt wird sie weiterhin nur am iPhone.
var orientation: SensorOrientation?
/// Ebenfalls nur beim Neigungsmesser: welche Fahrzeuggrafik die
/// Fahrzeug-Ansicht zeigt. Ausgewählt wird sie nur am iPhone, die Uhr
/// übernimmt nur die Anzeige.
var vehicleGraphicStyle: VehicleGraphicStyle?
/// Nur bei der Kühlbox belegt.
var fridge: WatchFridge?
@@ -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))"
}
}
+14 -13
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 {
@@ -1190,13 +1190,14 @@ let watchPayload = WatchPayload(
devices: [
WatchDevice(id: watchLevelID, name: "Nivellierung", role: .leveling,
link: .live, snapshot: watchLevelSnapshot,
level: watchLevel, orientation: watchOrientation, fridge: nil),
level: watchLevel, orientation: watchOrientation,
vehicleGraphicStyle: .vanster, fridge: nil),
WatchDevice(id: watchBoosterID, name: "Ladebooster", role: .chargeBooster,
link: .failed("Nicht gefunden"), snapshot: boosterSnapshot,
level: nil, orientation: nil, fridge: nil),
level: nil, orientation: nil, vehicleGraphicStyle: nil, fridge: nil),
WatchDevice(id: watchFridgeID, name: "Kühlbox", role: .fridge,
link: .live, snapshot: nil, level: nil,
orientation: nil, fridge: watchFridge),
orientation: nil, vehicleGraphicStyle: nil, fridge: watchFridge),
])
// Der ganze Weg: verpacken, als WatchConnectivity-Wörterbuch lesen, vergleichen.
+25 -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,28 @@ 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 siehe `remove-white-background.swift`.
## remove-white-background.swift
Vorstufe zu `make-vehicle-art.swift`, wenn die Vorlage auf weißem statt
transparentem Grund liegt (kein Alphakanal). Ohne diesen Schritt hielte
`make-vehicle-art` die ganze Fläche für Zeichnung und färbte den Hintergrund
mit ein.
Reines Weiß wird voll durchsichtig, alles andere bleibt stehen; ein weicher
Übergang statt einer harten Schwelle vermeidet gezackte Kanten an
kantengeglätteten Linien.
```bash
swiftc -O -o /tmp/remove-white-background Tools/remove-white-background.swift
swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift
/tmp/remove-white-background seitenansicht.png /tmp/seite-transparent.png
/tmp/make-vehicle-art /tmp/seite-transparent.png VanControl/Assets.xcassets/CaliforniaSide.imageset/CaliforniaSide.png
```
+62
View File
@@ -0,0 +1,62 @@
import AppKit
import CoreGraphics
import Foundation
// Vorlagen kommen manchmal auf weißem statt transparentem Grund. Ohne
// Alphakanal hielte `make-vehicle-art.swift` die ganze Fläche für Zeichnung
// und färbte den Hintergrund mit ein. Dieses Werkzeug macht daraus einen
// echten Alphakanal: reines Weiß wird voll durchsichtig, alles andere bleibt
// stehen. Ein weicher Übergang statt einer harten Schwelle verhindert
// gezackte Kanten an den (leicht kantengeglätteten) Linien.
let arguments = CommandLine.arguments
guard arguments.count >= 3,
let source = NSImage(contentsOfFile: arguments[1]),
let image = source.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
print("Quelle nicht lesbar"); exit(1)
}
let width = image.width, height = image.height
let bytesPerRow = width * 4
var pixels = [UInt8](repeating: 0, count: bytesPerRow * height)
guard let context = CGContext(data: &pixels, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
print("Puffer nicht anlegbar"); exit(1)
}
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
/// Unterhalb davon gilt ein Pixel als reiner Hintergrund, oberhalb als
/// sicher Zeichnung. Dazwischen wird linear zwischen 0 und voller Deckkraft
/// überblendet, damit Kanten nicht ausgefranst wirken.
let whiteFloor = 230.0
let whiteCeiling = 250.0
for index in stride(from: 0, to: pixels.count, by: 4) {
// Quelle ist opak (kein Alphakanal), also stehen hier die Rohfarben.
let r = Double(pixels[index])
let g = Double(pixels[index + 1])
let b = Double(pixels[index + 2])
let whiteness = min(r, g, b)
let t = min(1, max(0, (whiteness - whiteFloor) / (whiteCeiling - whiteFloor)))
let alpha = 1 - t
guard alpha > 0.004 else {
pixels[index] = 0; pixels[index + 1] = 0; pixels[index + 2] = 0; pixels[index + 3] = 0
continue
}
// Premultipliziert ablegen.
pixels[index] = UInt8(r * alpha)
pixels[index + 1] = UInt8(g * alpha)
pixels[index + 2] = UInt8(b * alpha)
pixels[index + 3] = UInt8(alpha * 255)
}
guard let result = context.makeImage() else { print("Bild leer"); exit(1) }
let rep = NSBitmapImageRep(cgImage: result)
guard let png = rep.representation(using: .png, properties: [:]) else {
print("PNG nicht erzeugbar"); exit(1)
}
try png.write(to: URL(fileURLWithPath: arguments[2]))
print("\(arguments[2]): fertig")
+975
View File
@@ -0,0 +1,975 @@
// !$*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_ALTERNATE_APPICON_NAMES = "AppIcon-App2-Testflight";
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-App1-Testflight";
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
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_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
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_ALTERNATE_APPICON_NAMES = "AppIcon-App2-Testflight";
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-App1-Testflight";
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
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_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
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 = 3;
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 = 3;
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 = 2;
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 = 2;
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 = 2;
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 = 2;
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;
};
AA0000000000000000000090 /* CarPlay */ = {
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 CARPLAY_DEMO $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = CarPlay;
};
AA0000000000000000000091 /* CarPlay */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon-App2-Testflight";
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-App1-Testflight";
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Config/VanControlCarPlay.entitlements;
"CODE_SIGN_IDENTITY[sdk=iphonesimulator*]" = "-";
"CODE_SIGN_IDENTITY[sdk=watchsimulator*]" = "-";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = P8J6283TT5;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/VanControlCarPlay-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "VanControl CarPlay";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO;
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.VanControlCarPlay;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG CARPLAY_DEMO $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = CarPlay;
};
AA0000000000000000000092 /* CarPlay */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
"CODE_SIGN_IDENTITY[sdk=iphonesimulator*]" = "-";
"CODE_SIGN_IDENTITY[sdk=watchsimulator*]" = "-";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 3;
DEVELOPMENT_TEAM = P8J6283TT5;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "VanControl CarPlay";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.s0.fototeddy.VanControlCarPlay;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanControlCarPlay.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
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 = CarPlay;
};
AA0000000000000000000093 /* CarPlay */ = {
isa = XCBuildConfiguration;
buildSettings = {
"CODE_SIGN_IDENTITY[sdk=iphonesimulator*]" = "-";
"CODE_SIGN_IDENTITY[sdk=watchsimulator*]" = "-";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = P8J6283TT5;
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.VanControlCarPlay.watchkitapp.levelwidget;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
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 = CarPlay;
};
AA0000000000000000000094 /* CarPlay */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
"CODE_SIGN_IDENTITY[sdk=iphonesimulator*]" = "-";
"CODE_SIGN_IDENTITY[sdk=watchsimulator*]" = "-";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = P8J6283TT5;
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.VanControlCarPlay.VanControlLiveActivity;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
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 = CarPlay;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
AA0000000000000000000010 /* Build configuration list for PBXProject "VanControl" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000012 /* Debug */,
AA0000000000000000000090 /* CarPlay */,
AA0000000000000000000013 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "VanControl" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000014 /* Debug */,
AA0000000000000000000091 /* CarPlay */,
AA0000000000000000000015 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "VanControlWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000023 /* Debug */,
AA0000000000000000000092 /* CarPlay */,
AA0000000000000000000024 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "VanControlComplication" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000038 /* Debug */,
AA0000000000000000000093 /* CarPlay */,
AA0000000000000000000039 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA0000000000000000000068 /* Build configuration list for PBXNativeTarget "VanControlLiveActivityExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000066 /* Debug */,
AA0000000000000000000094 /* CarPlay */,
AA0000000000000000000067 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = AA0000000000000000000009 /* Project object */;
}
@@ -0,0 +1,90 @@
<?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 = "AA0000000000000000000006"
BuildableName = "VanControl.app"
BlueprintName = "VanControl"
ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "CarPlay"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "CarPlay"
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 = "AA0000000000000000000006"
BuildableName = "VanControl.app"
BlueprintName = "VanControl"
ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "CARPLAY_DEMO_PITCH"
value = "1.8"
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable
key = "CARPLAY_DEMO_ROLL"
value = "-0.9"
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "CarPlay"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000006"
BuildableName = "VanControl.app"
BlueprintName = "VanControl"
ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "CarPlay">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "CarPlay"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -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>
@@ -15,9 +15,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "CamperMonitorWatch.app"
BlueprintName = "CamperMonitorWatch"
ReferencedContainer = "container:CamperMonitor.xcodeproj">
BuildableName = "VanControlWatch.app"
BlueprintName = "VanControlWatch"
ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
@@ -45,9 +45,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "CamperMonitorWatch.app"
BlueprintName = "CamperMonitorWatch"
ReferencedContainer = "container:CamperMonitor.xcodeproj">
BuildableName = "VanControlWatch.app"
BlueprintName = "VanControlWatch"
ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
@@ -62,9 +62,9 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "CamperMonitorWatch.app"
BlueprintName = "CamperMonitorWatch"
ReferencedContainer = "container:CamperMonitor.xcodeproj">
BuildableName = "VanControlWatch.app"
BlueprintName = "VanControlWatch"
ReferencedContainer = "container:VanControl.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,11 @@
{
"images" : [
{
"filename" : "AppIcon-App1-Testflight-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

@@ -0,0 +1,11 @@
{
"images" : [
{
"filename" : "AppIcon-App1-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,11 @@
{
"images" : [
{
"filename" : "AppIcon-App2-Testflight-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,11 @@
{
"images" : [
{
"filename" : "AppIcon-App2-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : { "author" : "xcode", "version" : 1 }
}

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

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

After

Width:  |  Height:  |  Size: 311 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" : "VehicleSide.png",
"filename" : "VansterRear.png",
"idiom" : "universal"
}
],

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

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

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

@@ -17,6 +17,8 @@ struct Discovery: Identifiable, Hashable {
var looksLikeSupported: Bool
/// 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
@@ -185,6 +194,7 @@ final class BluetoothManager: NSObject {
private var bmsSessions: [UUID: BMSSession] = [:]
private var levelSessions: [UUID: LevelSession] = [:]
private var votronicSolarESPSessions: [UUID: VotronicSolarESPSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
private var reconnectTimer: DispatchSourceTimer?
@@ -309,6 +319,7 @@ 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.
@@ -383,6 +394,10 @@ final class BluetoothManager: NSObject {
// 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 }
@@ -393,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.
///
@@ -516,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
@@ -561,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()
@@ -755,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
}
@@ -842,10 +895,23 @@ 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
@@ -859,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,
@@ -906,6 +993,8 @@ 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)
@@ -920,7 +1009,13 @@ extension BluetoothManager: CBCentralManagerDelegate {
self.showLastKnownFridgeSettings(for: deviceID)
}
} else if let device = managed[peripheral.identifier] {
publish { self.linkStates[device.id] = .searching }
let deviceName = store.devices.first { $0.id == device.id }?.name ?? ""
publish {
self.linkStates[device.id] = .searching
// Erst nach einer Gnadenfrist wirklich beenden sonst
// flackerte die Live Activity bei jedem kurzen Funkloch.
self.activityManager?.handleDisconnect(deviceID: device.id, deviceName: deviceName)
}
if lifetime >= stableConnection {
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
@@ -0,0 +1,91 @@
#if CARPLAY_DEMO
import SwiftUI
/// Die Libelle auf dem Fahrzeugdisplay.
///
/// Quer geteilt: links die Blase, rechts in Worten, was zu tun ist. Beim
/// Rangieren wird abwechselnd auf Display und Umfeld geschaut deshalb steht
/// die Aussage in einem Satz da, statt sich aus zwei Zahlen ergeben zu müssen.
///
/// Die Groessen sind auf die 400 × 240 **Punkte** ausgelegt, die CarPlay
/// liefert nicht auf die 800 × 480 Pixel. Das ist die Falle: In Pixeln
/// gedachte Schriftgroessen sind auf dem Fahrzeugdisplay doppelt zu gross und
/// schneiden den Text ab.
struct CarPlayLevelScreen: View {
@Environment(CarPlayLevelSource.self) private var source
private var state: LevelState {
LevelState(pitch: source.pitch, roll: source.roll)
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if CarPlaySpeedGate.shared.isStationary {
levelling
} else {
driving
}
}
.onAppear { CarPlaySpeedGate.shared.start() }
.onDisappear { CarPlaySpeedGate.shared.stop() }
}
private var levelling: some View {
HStack(spacing: 18) {
LevelBubble(pitch: source.pitch, roll: source.roll)
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
let lines = state.shortInstructions
ForEach(lines.isEmpty ? ["Keine Verbindung"] : lines, id: \.self) { line in
Text(line)
.font(.system(size: 20, weight: .semibold, design: .rounded))
.foregroundStyle(state.isLevel ? .green : .white)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
HStack(spacing: 16) {
tile("Längs", LevelDirectionFormatting.pitchTile(source.pitch))
tile("Quer", LevelDirectionFormatting.rollTile(source.roll))
}
if source.isSimulated {
// Ohne diesen Hinweis wäre ein Screenshot aus dem Simulator
// eine Behauptung über Messwerte, die es nicht gab.
Text("Simulierte Werte")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.orange)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(16)
}
private var driving: some View {
VStack(spacing: 10) {
Image(systemName: "car.fill")
.font(.system(size: 30))
Text("Nivellierung nur im Stand")
.font(.system(size: 20, weight: .semibold, design: .rounded))
}
.foregroundStyle(.white.opacity(0.7))
}
private func tile(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
Text(value)
.font(.system(size: 22, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.white)
}
}
}
#endif
+105
View File
@@ -0,0 +1,105 @@
#if CARPLAY_DEMO
import Foundation
import Observation
/// Woher die CarPlay-Libelle ihre Werte bekommt.
///
/// Im Simulator gibt es kein Bluetooth und damit keinen Neigungsmesser. Für
/// die Demo läuft deshalb ein simulierter Verlauf: ein Fahrzeug, das langsam
/// auf die Keile rollt, gerade wird und wieder abrutscht. Das reicht, um die
/// Anzeige zu beurteilen und für Screenshots, die zeigen, was die Funktion
/// tut.
///
/// Für gezielte Aufnahmen lassen sich feste Werte über die Umgebung setzen:
/// `CARPLAY_DEMO_PITCH` und `CARPLAY_DEMO_ROLL` (in Grad). Dann steht die
/// Blase still und der Screenshot ist reproduzierbar.
///
/// Am Fahrzeug speist stattdessen der `BluetoothManager` über ``apply(_:)``
/// ein; die Demo-Schleife läuft dann nicht.
@Observable
final class CarPlayLevelSource {
static let shared = CarPlayLevelSource()
private(set) var pitch: Double?
private(set) var roll: Double?
/// Ob die Werte simuliert sind. Die Anzeige weist darauf hin ein
/// Screenshot ohne diesen Hinweis wäre gegenüber Apple nicht ehrlich.
private(set) var isSimulated = false
@ObservationIgnored private var timer: Timer?
@ObservationIgnored private var phase: Double = 0
@ObservationIgnored private lazy var isSequenced =
ProcessInfo.processInfo.environment["CARPLAY_DEMO_SEQUENCE"] != nil
private init() {}
/// Zehn Aktualisierungen pro Sekunde schnell genug, dass die Blase der
/// Bewegung des Fahrzeugs folgt statt ihr nachzulaufen.
private static let updateInterval: TimeInterval = 0.1
func start() {
guard timer == nil else { return }
let environment = ProcessInfo.processInfo.environment
if let fixedPitch = environment["CARPLAY_DEMO_PITCH"].flatMap(Double.init),
let fixedRoll = environment["CARPLAY_DEMO_ROLL"].flatMap(Double.init) {
pitch = fixedPitch
roll = fixedRoll
isSimulated = true
return
}
isSimulated = true
let timer = Timer.scheduledTimer(withTimeInterval: Self.updateInterval, repeats: true) { [weak self] _ in
self?.step()
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
func stop() {
timer?.invalidate()
timer = nil
}
/// Echte Messwerte vom Neigungsmesser. Sobald die kommen, ist nichts mehr
/// simuliert.
func apply(_ state: LevelState) {
stop()
isSimulated = false
pitch = state.pitch
roll = state.roll
}
/// Feste Zustände im Wechsel, je sieben Sekunden für Screenshots, die
/// beide Fälle zuverlässig zeigen statt auf den richtigen Moment einer
/// Schwingung zu warten.
private static let sequence: [(pitch: Double, roll: Double)] = [
(1.8, -0.9), // deutlich schief
(0.2, -0.1), // ausgerichtet
]
/// Ein Fahrzeug, das sich über gut zwanzig Sekunden einpendelt und wieder
/// abweicht. Zwei unterschiedlich schnelle Schwingungen, damit Längs- und
/// Querneigung nicht im Gleichtakt laufen das sähe mechanisch aus.
private func step() {
phase += Self.updateInterval
if isSequenced {
// Dritte Phase: die Sperre waehrend der Fahrt.
let step = Int(phase / 7) % (Self.sequence.count + 1)
if step == Self.sequence.count {
CarPlaySpeedGate.shared.setDemoDriving(true)
} else {
CarPlaySpeedGate.shared.setDemoDriving(false)
(pitch, roll) = Self.sequence[step]
}
return
}
pitch = 3.2 * sin(phase / 3.4)
roll = 2.1 * sin(phase / 2.1 + 0.7)
}
}
#endif
@@ -0,0 +1,40 @@
import SwiftUI
/// Zeigt die CarPlay-Ansicht im iPhone-Simulator, in den Maßen, die CarPlay
/// tatsächlich liefert (400 × 240 Punkte, also 800 × 480 Pixel).
///
/// Nur zum Beurteilen des Layouts, solange der CarPlay-Simulator selbst nicht
/// zur Verfügung steht. Ohne `CARPLAY_DEMO_PREVIEW` in der Umgebung passiert
/// nichts, und ohne das `CARPLAY_DEMO`-Flag existiert der Code gar nicht.
extension View {
@ViewBuilder
func carPlayPreviewOverlay() -> some View {
#if CARPLAY_DEMO
if ProcessInfo.processInfo.environment["CARPLAY_DEMO_PREVIEW"] != nil {
CarPlayPreviewHost()
} else {
self
}
#else
self
#endif
}
}
#if CARPLAY_DEMO
struct CarPlayPreviewHost: View {
var body: some View {
// Ohne Beiwerk und exakt zentriert: so laesst sich der Screenshot
// verlustfrei auf den CarPlay-Bereich zuschneiden.
VStack {
CarPlayLevelScreen()
.environment(CarPlayLevelSource.shared)
.frame(width: 400, height: 240)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
.ignoresSafeArea()
.onAppear { CarPlayLevelSource.shared.start() }
}
}
#endif
@@ -0,0 +1,96 @@
#if CARPLAY_DEMO
import CarPlay
import OSLog
import SwiftUI
/// Einstiegspunkt der CarPlay-Szene.
///
/// Mit dem Navigations-Entitlement liefert CarPlay ein eigenes `CPWindow`, in
/// das frei gezeichnet werden darf anders als bei den Template-Kategorien,
/// die nur vorgegebene Bausteine erlauben und höchstens alle zehn Sekunden
/// aktualisieren. Genau deshalb hängt hier die Libelle drin: Beim Rangieren
/// muss sie der Bewegung des Fahrzeugs folgen, nicht ihr hinterherlaufen.
///
/// Das `CPMapTemplate` darüber ist nur die Bedienleiste. Es bleibt bewusst
/// leer: Auf dem Stellplatz gibt es nichts zu tippen, es gibt nur etwas
/// abzulesen.
@objc(CarPlaySceneDelegate)
final class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPMapTemplateDelegate {
private var interfaceController: CPInterfaceController?
/// Ohne eigenes Logging ist beim Debuggen nicht zu unterscheiden, ob die
/// Szene gar nicht verbindet oder ob CarPlay das Template ablehnt.
private static let log = Logger(subsystem: "de.s0.fototeddy.VanControl", category: "CarPlay")
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController,
to window: CPWindow
) {
Self.log.notice("didConnect: Szene verbunden, Fenster \(NSCoder.string(for: window.bounds.size), privacy: .public)")
self.interfaceController = interfaceController
let root = CarPlayLevelScreen()
.environment(CarPlayLevelSource.shared)
let host = UIHostingController(rootView: root)
host.view.backgroundColor = .black
window.rootViewController = host
window.makeKeyAndVisible()
CarPlayLevelSource.shared.start()
// Bewusst nackt konfiguriert: iOS 26 laesst den CarPlayTemplateUIHost
// beim Aufbau der Navigationsleiste ueber den Share-Button stolpern
// (`_updateShareButtonVisibility` -> unrecognized selector), sobald die
// Leiste erzwungen wird. Ohne Buttons und mit der Standard-Automatik
// gibt es nichts zu konfigurieren und auf dem Stellplatz gibt es
// ohnehin nichts zu tippen.
let map = CPMapTemplate()
map.mapButtons = []
map.leadingNavigationBarButtons = []
map.trailingNavigationBarButtons = []
map.hidesButtonsWithNavigationBar = true
map.mapDelegate = self
interfaceController.setRootTemplate(map, animated: false) { success, error in
if let error {
Self.log.error("setRootTemplate abgelehnt: \(error.localizedDescription, privacy: .public)")
} else {
Self.log.notice("setRootTemplate ok: \(success, privacy: .public)")
}
}
}
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didDisconnectInterfaceController interfaceController: CPInterfaceController,
from window: CPWindow
) {
Self.log.notice("didDisconnect: Szene getrennt")
self.interfaceController = nil
window.rootViewController = nil
CarPlayLevelSource.shared.stop()
}
// MARK: - Zielteilung
// CarPlay bietet seit iOS 26 an, ein Navigationsziel an das Fahrzeug zu
// übergeben. Wir haben keine Ziele aber das Protokoll vollständig zu
// bedienen ist Teil davon, eine korrekte Navigations-App zu sein. Der
// Verdacht dabei: Apples Template-Host löst seinen Sharing-Delegate nur
// dann sauber auf, wenn die App diese Methoden anbietet.
@available(iOS 26.4, *)
func mapTemplate(_ mapTemplate: CPMapTemplate, willShareDestinationFor trip: CPTrip) {
Self.log.notice("willShareDestination die App teilt keine Ziele")
}
@available(iOS 26.4, *)
func mapTemplate(_ mapTemplate: CPMapTemplate, didShareDestinationFor trip: CPTrip) {
Self.log.notice("didShareDestination")
}
@available(iOS 26.4, *)
func mapTemplate(_ mapTemplate: CPMapTemplate, didFailToShareDestinationFor trip: CPTrip, error: Error) {
Self.log.error("didFailToShareDestination: \(error.localizedDescription, privacy: .public)")
}
}
#endif
+77
View File
@@ -0,0 +1,77 @@
#if CARPLAY_DEMO
import CoreLocation
import Observation
/// Sperrt die Libelle, sobald das Fahrzeug schneller als Schrittgeschwindigkeit
/// fährt.
///
/// Ausrichten passiert im Stand oder im Zentimeterbereich davor. Eine Anzeige,
/// die während der Fahrt etwas zu lesen gäbe, wäre Ablenkung ohne Nutzen also
/// gibt es sie dort nicht. Die Grenze ist bewusst technisch erzwungen und nicht
/// bloss eine Zusage im Text.
@Observable
final class CarPlaySpeedGate: NSObject, CLLocationManagerDelegate {
static let shared = CarPlaySpeedGate()
/// Fünf km/h: schneller als Rangieren, langsamer als jede Verkehrssituation,
/// in der jemand aufs Display sehen dürfte.
static let limit: CLLocationSpeed = 5.0 / 3.6
private(set) var isStationary = true
private(set) var speed: CLLocationSpeed?
@ObservationIgnored private let manager = CLLocationManager()
private override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
}
func start() {
// Fuer Screenshots laesst sich die Geschwindigkeit vorgeben (in km/h);
// eine simulierte GPS-Fahrt nur fuer ein Standbild waere unverhaeltnis-
// maessig. Ohne die Variable zaehlt ausschliesslich der echte Sensor.
if let forced = ProcessInfo.processInfo.environment["CARPLAY_DEMO_SPEED"].flatMap(Double.init) {
speed = forced / 3.6
isStationary = forced / 3.6 <= Self.limit
return
}
manager.requestWhenInUseAuthorization()
manager.startUpdatingLocation()
}
/// Nur für die Demo-Sequenz: erzwingt den Fahrzustand, damit sich die
/// Sperre zeigen lässt, ohne eine GPS-Fahrt zu simulieren.
func setDemoDriving(_ driving: Bool) {
speed = driving ? 30 / 3.6 : 0
isStationary = !driving
}
func stop() {
manager.stopUpdatingLocation()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
// CoreLocation meldet im Stand eine negative Geschwindigkeit: der Wert
// ist dann schlicht unbekannt. Das als "fährt" zu werten, würde die
// Funktion genau dort abschalten, wo sie gebraucht wird auf dem
// Stellplatz, oft unter Bäumen mit schlechtem Empfang.
guard location.speed >= 0 else {
speed = nil
isStationary = true
return
}
speed = location.speed
isStationary = location.speed <= Self.limit
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
speed = nil
isStationary = true
}
}
#endif
@@ -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:
///
/// * Schalter in den Einstellungen (`SettingsView`) in jeder
/// Build-Konfiguration verfügbar, auch in TestFlight- und
/// App-Store-Builds. Der Weg für jeden lokal installierten Build ohne
/// Xcode-Verbindung. Wirkt erst nach einem Neustart der App, weil
/// `DeviceStore` und `BluetoothManager` den Stand nur beim Start lesen.
/// * Umgebungsvariable `CAMPER_DEMO=1` beim Start nur in Debug-Builds,
/// praktisch im Simulator:
///
/// xcrun simctl launch --terminate-running-process \
/// booted de.fritob.CamperMonitor
/// booted de.fritob.VanControl
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
///
/// Im normalen Betrieb wird hiervon nichts ausgeführt.
enum DemoData {
/// Schlüssel für den Einstellungen-Schalter, siehe `SettingsView`.
static let enabledKey = "demoModeEnabled"
static var isEnabled: Bool {
#if DEBUG
return ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
#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]
}
@@ -244,7 +244,12 @@ final class DeviceStore {
}
}
/// Im Demo-Modus wird nichts geschrieben sonst überlebten die erfundenen
/// Fahrzeuge und Geräte das Ausschalten des Demo-Modus, weil z.B. das
/// Ändern der Fahrzeuggrafik oder der Einbaulage ganz normal `save()`
/// aufruft.
private func save() {
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
@@ -1,18 +1,22 @@
import SwiftUI
@main
struct CamperMonitorApp: App {
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 {
@@ -21,7 +25,9 @@ struct CamperMonitorApp: App {
.environment(store)
.environment(bluetooth)
.environment(watch)
.environment(levelActivity)
.task { watch.activate() }
.carPlayPreviewOverlay()
}
.onChange(of: scenePhase) { _, phase in
// Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt
@@ -38,7 +44,11 @@ struct CamperMonitorApp: App {
case .active:
bluetooth.start()
case .background:
if !watch.wantsLiveUpdates { bluetooth.stop() }
// Läuft gerade eine Live Activity, muss der Neigungsmesser
// auch mit dunklem Bildschirm weiter Werte liefern sonst
// friert die Anzeige auf Sperrbildschirm und CarPlay beim
// ersten Wegdrücken der App ein.
if !watch.wantsLiveUpdates && !levelActivity.isActive { bluetooth.stop() }
default:
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,49 +19,19 @@ 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)
Group {
if isLandscape {
landscapeLayout
} else {
portraitLayout
}
}
.pickerStyle(.segmented)
.padding(.top, 8)
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: 320)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll)
}
if !isLive { disconnectedBanner }
adviceBanner
readings
if let best = assistant.best, let gain = assistant.improvementAtBest,
let seconds = assistant.timeSinceBest {
bestPointCard(best: best, gain: gain, seconds: seconds)
}
wedgeSection
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
assistant.reset()
didAnnounceTarget = false
}
.buttonStyle(.bordered)
.padding(.bottom, 24)
}
.padding(.horizontal)
.frame(maxWidth: .infinity)
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Ausrichtungs-Assistent")
.navigationBarTitleDisplayMode(.inline)
@@ -95,10 +66,94 @@ struct AlignmentAssistantView: View {
}
}
// MARK: - Layouts
private var portraitLayout: some View {
ScrollView {
VStack(spacing: 24) {
picker.padding(.top, 8)
display
if !isLive { disconnectedBanner }
adviceBanner
readings
if let best = assistant.best, let gain = assistant.improvementAtBest,
let seconds = assistant.timeSinceBest {
bestPointCard(best: best, gain: gain, seconds: seconds)
}
wedgeSection
resetButton
.padding(.bottom, 24)
}
.padding(.horizontal)
.frame(maxWidth: .infinity)
}
}
/// Anzeige links, alles zum Rangieren Nötige rechts ohne Scrollen, denn
/// im Querformat schaut man beiläufig hin, nicht in Ruhe. Die
/// Bestpunkt-Karte und der ausführliche Verbindungs-Hinweis bleiben dafür
/// dem Hochformat vorbehalten; die Keilhöhen bleiben in jedem Fall sichtbar.
private var landscapeLayout: some View {
HStack(alignment: .top, spacing: 16) {
VStack(spacing: 8) {
picker
display
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
VStack(spacing: 8) {
if !isLive { compactDisconnectedBanner }
adviceBanner
readings
wedgeSection
resetButton
}
.frame(maxWidth: .infinity)
}
.padding(12)
}
// MARK: - Bausteine
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))
}
@@ -187,23 +256,25 @@ struct AlignmentAssistantView: View {
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 {
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 let lift, !lift.isNegligible {
WheelLiftPlan(lift: lift)
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)
@@ -211,7 +282,7 @@ struct AlignmentAssistantView: View {
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.padding(isLandscape ? 10 : 16)
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
}
@@ -229,4 +300,3 @@ struct AlignmentAssistantView: View {
#endif
}
}
@@ -13,6 +13,8 @@ struct DeviceCard: View {
if isShowingLastSettings, let snapshot {
lastSettings(for: snapshot)
} else if let snapshot, snapshot.primaryMetric != nil, device.role == .leveling {
levelReadout(for: snapshot)
} else if let snapshot, let primary = snapshot.primaryMetric {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(primary.formatted)
@@ -115,6 +117,21 @@ struct DeviceCard: View {
}
}
/// Für die Nivellierung: Front/Heck und links/rechts nebeneinander, groß,
/// ohne "Längsneigung"/"Querneigung"-Beschriftung die Richtung steht ja
/// schon im vorangestellten Buchstaben.
private func levelReadout(for snapshot: DeviceSnapshot) -> some View {
let pitch = snapshot.metrics.first(where: { $0.key == "pitch" })?.value
let roll = snapshot.metrics.first(where: { $0.key == "roll" })?.value
return HStack(spacing: 24) {
Text(LevelDirectionFormatting.pitchTile(pitch))
Text(LevelDirectionFormatting.rollTile(roll))
}
.font(.system(size: 36, weight: .semibold, design: .rounded))
.contentTransition(.numericText())
.foregroundStyle(snapshot.isStale ? .secondary : .primary)
}
private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
let others = snapshot.metrics
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
@@ -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,11 +38,29 @@ 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 {
// 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 {
statusSection
// Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort
// sichtbar sein, ohne erst am Verbindungsstatus
// vorbeizuscrollen der rutscht dort ganz ans Ende. Gilt
// nur für den Neigungsmesser, siehe `showsCompactLevelLayout`.
if !showsCompactLevelLayout { statusSection }
if needsKeyAttention { keyPrompt }
if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
@@ -50,7 +69,8 @@ struct DeviceDetailView: View {
if currentDevice.role == .leveling {
LevelControls(device: currentDevice,
state: bluetooth.levelStates[device.id] ?? LevelState())
state: bluetooth.levelStates[device.id] ?? LevelState(),
availableHeight: geometry.size.height)
}
if let snapshot, !snapshot.metrics.isEmpty {
@@ -94,6 +114,8 @@ struct DeviceDetailView: View {
}
}
if showsCompactLevelLayout { statusSection }
if let snapshot, snapshot.temperatures.count > 1 {
Section("Temperaturen") {
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
@@ -137,6 +159,7 @@ struct DeviceDetailView: View {
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
}
}
}
// MARK: - Abschnitte
@@ -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)
@@ -75,9 +75,11 @@ struct LevelBubble: View {
Circle()
.fill(bubbleColor)
.frame(width: bubble, height: bubble)
// Positiver Pitch heisst: das Heck steht höher, die Blase
// wandert also nach oben in der Ansicht nach hinten.
.offset(x: offsetX, y: -offsetY)
// Draufsicht mit Fahrtrichtung oben: Positiver Pitch heisst,
// das Heck steht höher die Blase wandert nach hinten, also
// nach unten. Wie bei einer echten Wasserwaage, die zur
// höheren Seite ausschlägt.
.offset(x: offsetX, y: offsetY)
.animation(.spring(duration: 0.35), value: offsetX)
.animation(.spring(duration: 0.35), value: offsetY)
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
@@ -104,29 +106,59 @@ 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)
}
}
.pickerStyle(.segmented)
if isLandscape {
HStack(alignment: .center, spacing: 20) {
display
.frame(maxWidth: .infinity)
switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxHeight: 220)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll)
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,
@@ -137,13 +169,14 @@ struct LevelControls: View {
}
HStack(spacing: 24) {
reading("Längs", state.pitch)
reading("Quer", state.roll)
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
}
Section {
Button {
@@ -177,6 +210,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 +250,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,12 +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: {
@@ -15,22 +15,29 @@ import SwiftUI
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: "VehicleSide",
angle: pitch,
title: "Längs",
lowerLabel: "Front",
upperLabel: "Heck",
aspect: VehicleTilt.sideAspect)
tiltPanel(image: "VehicleRear",
angle: roll,
title: "Quer",
lowerLabel: "links",
upperLabel: "rechts",
aspect: VehicleTilt.rearAspect)
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 "
@@ -42,6 +49,7 @@ struct VehicleTiltView: View {
.frame(maxWidth: .infinity)
}
}
}
private func tiltPanel(image: String,
angle: Double?,
@@ -49,13 +57,13 @@ struct VehicleTiltView: View {
lowerLabel: String,
upperLabel: String,
aspect: Double) -> some View {
VStack(spacing: 8) {
VStack(spacing: compact ? 4 : 8) {
HStack {
Text(title)
.font(.subheadline.weight(.medium))
.font(compact ? .caption2.weight(.medium) : .subheadline.weight(.medium))
Spacer()
Text(angle.map { String(format: "%.1f°", $0) } ?? "")
.font(.subheadline.weight(.semibold).monospacedDigit())
.font((compact ? Font.caption2 : .subheadline).weight(.semibold).monospacedDigit())
.foregroundStyle(VehicleTilt.colour(for: angle))
}
@@ -75,14 +83,14 @@ struct VehicleTiltView: View {
.animation(.spring(duration: 0.4), value: angle)
.opacity(angle == nil ? 0.3 : 1)
}
.frame(height: 110)
.frame(height: compact ? compactPanelHeight : 110)
HStack {
Text(lowerLabel)
Spacer()
Text(upperLabel)
}
.font(.caption2)
.font(compact ? .system(size: 9) : .caption2)
.foregroundStyle(.secondary)
}
}
@@ -135,6 +135,7 @@ final class PhoneWatchLink: NSObject {
snapshot: bluetooth.snapshots[device.id],
level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil,
orientation: device.role == .leveling ? device.sensorOrientation : nil,
vehicleGraphicStyle: device.role == .leveling ? device.vehicleGraphicStyle : nil,
fridge: device.role == .fridge ? fridgeState(for: device) : nil)
}
return WatchPayload(generatedAt: Date(),

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

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

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

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

After

Width:  |  Height:  |  Size: 311 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" : "VehicleSide.png",
"filename" : "VansterRear.png",
"idiom" : "universal"
}
],

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

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

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

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