Compare commits
3
Commits
Android
...
6612b07fd5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6612b07fd5 | ||
|
|
32659ff0b9 | ||
|
|
3f37e6f047 |
@@ -0,0 +1,94 @@
|
||||
# Baut die Android-APK und hängt sie an ein Gitea-Release.
|
||||
#
|
||||
# Ausgelöst durch ein Etikett der Form v1.2.3. Voraussetzungen und die
|
||||
# Einrichtung der Geheimnisse stehen in Android/RELEASE.md.
|
||||
name: APK veröffentlichen
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
apk:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Quelltext holen
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Der Versionszähler kommt aus der Anzahl der Commits; dafür wird
|
||||
# der ganze Verlauf gebraucht, nicht nur der letzte Stand.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Java einrichten
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Android SDK einrichten
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Signaturschlüssel bereitlegen
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
# Ohne hinterlegten Schlüssel wird mit dem Debug-Schlüssel signiert.
|
||||
# Der Build sagt das selbst deutlich; hier nur der Hinweis im Protokoll.
|
||||
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||
echo "Kein Schlüssel hinterlegt – es wird mit dem Debug-Schlüssel signiert."
|
||||
exit 0
|
||||
fi
|
||||
echo "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks"
|
||||
echo "KEYSTORE_FILE=$RUNNER_TEMP/release.jks" >> "$GITHUB_ENV"
|
||||
|
||||
- name: APK bauen
|
||||
env:
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
# Die Anzahl der Commits steigt verlässlich und erfüllt damit, was
|
||||
# Android vom Versionszähler verlangt: er darf nie kleiner werden.
|
||||
CODE="$(git rev-list --count HEAD)"
|
||||
echo "Version $VERSION (Zähler $CODE)"
|
||||
cd Android
|
||||
chmod +x gradlew
|
||||
./gradlew :app:assembleRelease \
|
||||
-PversionName="$VERSION" \
|
||||
-PversionCode="$CODE" --no-daemon
|
||||
mv app/build/outputs/apk/release/app-release.apk \
|
||||
"../CamperMonitor-$VERSION.apk"
|
||||
|
||||
- name: An das Release hängen
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases"
|
||||
AUTH="Authorization: token $TOKEN"
|
||||
|
||||
# Wurde das Release schon von Hand angelegt, wird es genommen;
|
||||
# sonst hier erzeugt. Beides muss gehen, sonst scheitert der Ablauf
|
||||
# je nachdem, in welcher Reihenfolge man vorgeht.
|
||||
ID="$(curl -sf -H "$AUTH" "$API/tags/$GITHUB_REF_NAME" \
|
||||
| sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1 || true)"
|
||||
|
||||
if [ -z "$ID" ]; then
|
||||
ID="$(curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \
|
||||
"$API" | sed -n 's/.*"id":\([0-9]*\).*/\1/p' | head -1)"
|
||||
fi
|
||||
|
||||
if [ -z "$ID" ]; then
|
||||
echo "Release konnte weder gefunden noch angelegt werden." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -sf -X POST -H "$AUTH" \
|
||||
-F "attachment=@CamperMonitor-$VERSION.apk" \
|
||||
"$API/$ID/assets?name=CamperMonitor-$VERSION.apk" > /dev/null
|
||||
|
||||
echo "CamperMonitor-$VERSION.apk hängt an Release $GITHUB_REF_NAME."
|
||||
@@ -6,3 +6,8 @@ local.properties
|
||||
.idea/
|
||||
captures/
|
||||
.cxx/
|
||||
|
||||
# Signaturschlüssel und Passwörter gehören nirgends ins Repository.
|
||||
keystore.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# Eine APK veröffentlichen
|
||||
|
||||
## Vorweg: die APK gehört nicht ins Repository
|
||||
|
||||
Ein Release-Anhang in Gitea liegt **neben** dem Repository, nicht darin. Gitea
|
||||
legt ihn in seinem Anhang-Speicher ab; im Git-Verlauf taucht er nicht auf.
|
||||
|
||||
Das ist auch gut so. Eine APK ist eine 11-MB-Binärdatei, die sich bei jedem
|
||||
Build komplett ändert. Läge sie im Repository, wüchse der Verlauf mit jeder
|
||||
Version um 11 MB — und zwar **unwiderruflich**: Git vergisst nichts, ein
|
||||
späteres Löschen bringt den Platz nicht zurück. Nach zehn Versionen wäre das
|
||||
Auschecken des Projekts ein 110-MB-Download, obwohl der Quelltext selbst nur
|
||||
wenige hundert Kilobyte hat.
|
||||
|
||||
Deshalb: bauen, an ein Release hängen, fertig. `.gitignore` sorgt dafür, dass
|
||||
die gebauten Dateien gar nicht erst versehentlich hineinrutschen.
|
||||
|
||||
## Signieren
|
||||
|
||||
Android installiert **keine unsignierte APK** — auch nicht per Sideload. Das
|
||||
hat nichts mit Googles Sperren zu tun, sondern damit, dass das System ohne
|
||||
Signatur nicht feststellen kann, ob ein Update vom selben Absender stammt wie
|
||||
die Erstinstallation.
|
||||
|
||||
Ohne eigenen Schlüssel signiert der Build mit dem Android-Debug-Schlüssel und
|
||||
sagt das beim Bauen deutlich. Das funktioniert, hat aber einen Haken: Dieser
|
||||
Schlüssel wird pro Rechner erzeugt. Baust du die nächste Version auf einem
|
||||
anderen Rechner, hat sie eine andere Signatur, und Android verweigert das
|
||||
Update — es bliebe nur Deinstallieren und neu einrichten.
|
||||
|
||||
### Einen eigenen Schlüssel anlegen
|
||||
|
||||
Einmalig. Das Passwort tippst du dabei selbst.
|
||||
|
||||
```bash
|
||||
keytool -genkeypair -v \
|
||||
-keystore ~/camper-monitor.jks \
|
||||
-alias camper -keyalg RSA -keysize 4096 -validity 10000
|
||||
```
|
||||
|
||||
Danach `Android/keystore.properties` anlegen (die Datei ist in `.gitignore`,
|
||||
sie landet nicht im Repository):
|
||||
|
||||
```properties
|
||||
storeFile=/Users/DEINNAME/camper-monitor.jks
|
||||
storePassword=DEIN_PASSWORT
|
||||
keyAlias=camper
|
||||
keyPassword=DEIN_PASSWORT
|
||||
```
|
||||
|
||||
> **Diesen Schlüssel aufbewahren.** Geht er verloren, lässt sich für alle, die
|
||||
> die App installiert haben, nie wieder ein Update ausliefern. Eine Kopie an
|
||||
> einen zweiten Ort, getrennt vom Rechner.
|
||||
|
||||
## Wenn Gitea die APK nicht annimmt
|
||||
|
||||
Zwei Dinge, die leicht zusammenfallen:
|
||||
|
||||
**Der Quelltext als `.zip` und `.tar.gz`** hängt an *jedem* Gitea-Release
|
||||
automatisch dran. Gitea erzeugt beides aus dem Etikett; es ist nichts, was
|
||||
versehentlich hochgeladen wurde, und es lässt sich auch nicht abschalten. Es
|
||||
stört nur optisch und kostet keinen Speicher — die Archive werden erst beim
|
||||
Anklicken erzeugt.
|
||||
|
||||
**Die APK abgelehnt** wird dagegen aus einem handfesten Grund: Gitea nimmt nur
|
||||
Dateitypen an, die in seiner Liste stehen, und `.apk` steht dort nicht. Die
|
||||
Liste dieser Instanz lässt sich abfragen:
|
||||
|
||||
```bash
|
||||
curl -s https://git.home.fritob.de/api/v1/settings/attachment
|
||||
```
|
||||
|
||||
Kommt dort eine `allowed_types`-Liste ohne `.apk` zurück, ist das die Ursache.
|
||||
Die Grösse ist es nicht — die Grenze liegt bei 100 MiB, die APK bei 11.
|
||||
|
||||
### Der richtige Weg: `.apk` erlauben
|
||||
|
||||
In der `app.ini` des Servers (bei Docker meist
|
||||
`/data/gitea/conf/app.ini`, sonst `/etc/gitea/app.ini`):
|
||||
|
||||
```ini
|
||||
[attachment]
|
||||
ALLOWED_TYPES = .avif,.cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.webp,.xls,.xlsx,.zip,.apk
|
||||
|
||||
[repository.release]
|
||||
ALLOWED_TYPES = .apk,.zip
|
||||
```
|
||||
|
||||
Beide Abschnitte, weil je nach Gitea-Fassung mal der eine, mal der andere für
|
||||
Release-Anhänge zuständig ist. Danach Gitea neu starten.
|
||||
|
||||
Wer es sich einfach machen will, schreibt in beide Schlüssel `*/*` — dann ist
|
||||
alles erlaubt. Auf einer Instanz im eigenen Netz ist das vertretbar; öffentlich
|
||||
wäre es das nicht, weil dann auch beliebige ausführbare Dateien hochladbar sind.
|
||||
|
||||
### Ohne Serverzugriff: die Paket-Registry
|
||||
|
||||
Die nimmt beliebige Dateien an, ganz ohne Typenliste, und ist auf dieser
|
||||
Instanz eingeschaltet. Der Preis: Die Datei landet unter *Packages* statt am
|
||||
Release.
|
||||
|
||||
```bash
|
||||
Android/upload-package.sh 1.0.0
|
||||
```
|
||||
|
||||
Das Skript liest den Zugangsschlüssel aus `~/.gitea-token` und gibt ihn
|
||||
nirgends aus. Ein Token mit dem Recht `write:package` genügt; anzulegen unter
|
||||
*Einstellungen → Anwendungen → Zugriffstoken*.
|
||||
|
||||
## Von Hand veröffentlichen
|
||||
|
||||
```bash
|
||||
source Android/env.sh
|
||||
cd Android
|
||||
./gradlew :app:assembleRelease -PversionName=1.0.0 -PversionCode=1
|
||||
```
|
||||
|
||||
Die APK liegt dann unter
|
||||
`Android/app/build/outputs/apk/release/app-release.apk`.
|
||||
|
||||
In Gitea: **Releases → New Release**, ein Etikett wie `v1.0.0` wählen, und die
|
||||
APK unter *Attachments* hochladen. Fertig — genau das ist das „Wie sage ich
|
||||
Gitea, dass die APK dort hinein soll".
|
||||
|
||||
## Von selbst veröffentlichen
|
||||
|
||||
`.gitea/workflows/release.yml` nimmt das ab: Ein Etikett hochschieben genügt.
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
Der Ablauf baut die APK, legt das Release an, falls es noch nicht besteht, und
|
||||
hängt die Datei an. Die Versionsnummer kommt aus dem Etikett, der
|
||||
Versionszähler aus der Anzahl der Commits — so steigt er verlässlich.
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
1. **Ein Runner.** Actions brauchen in Gitea einen registrierten Runner mit dem
|
||||
Etikett `ubuntu-latest`. Unter *Site Administration → Actions → Runners*
|
||||
nachsehen. Ist keiner da, bleibt der Weg von Hand — der tut es genauso.
|
||||
2. **Actions eingeschaltet**, im Repository unter *Settings → Advanced*.
|
||||
3. **Der Schlüssel als Geheimnis**, falls mit eigenem Schlüssel signiert werden
|
||||
soll. Unter *Settings → Actions → Secrets* anlegen:
|
||||
|
||||
| Name | Inhalt |
|
||||
| ------------------ | ------------------------------------------ |
|
||||
| `KEYSTORE_BASE64` | `base64 -i ~/camper-monitor.jks \| pbcopy` |
|
||||
| `KEYSTORE_PASSWORD`| das Passwort des Schlüsselspeichers |
|
||||
| `KEY_ALIAS` | `camper` |
|
||||
| `KEY_PASSWORD` | das Passwort des Schlüssels |
|
||||
|
||||
Fehlen sie, baut der Ablauf trotzdem — dann eben mit dem Debug-Schlüssel,
|
||||
und die APK heisst entsprechend.
|
||||
@@ -1,9 +1,32 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
/**
|
||||
* Angaben zum Signaturschlüssel.
|
||||
*
|
||||
* Sie stehen in `Android/keystore.properties` - einer Datei, die bewusst nicht
|
||||
* im Repository liegt. Fehlt sie, wird stattdessen aus der Umgebung gelesen;
|
||||
* so kann ein Bauknecht sie aus seinen Geheimnissen setzen, ohne dass ein
|
||||
* Passwort je im Verlauf landet.
|
||||
*/
|
||||
val signing = Properties().apply {
|
||||
val file = rootProject.file("keystore.properties")
|
||||
if (file.exists()) {
|
||||
file.inputStream().use { load(it) }
|
||||
} else {
|
||||
System.getenv("KEYSTORE_FILE")?.let { setProperty("storeFile", it) }
|
||||
System.getenv("KEYSTORE_PASSWORD")?.let { setProperty("storePassword", it) }
|
||||
System.getenv("KEY_ALIAS")?.let { setProperty("keyAlias", it) }
|
||||
System.getenv("KEY_PASSWORD")?.let { setProperty("keyPassword", it) }
|
||||
}
|
||||
}
|
||||
val hasSigningKey = signing.getProperty("storeFile") != null
|
||||
|
||||
android {
|
||||
namespace = "de.fritob.campermonitor"
|
||||
compileSdk = 35
|
||||
@@ -13,13 +36,35 @@ android {
|
||||
// Android 8: älter lohnt nicht, dort fehlt zu viel an Bluetooth LE.
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = (findProperty("versionCode") as String?)?.toInt() ?: 1
|
||||
// Beim Bauen aus einem Etikett heraus überschreibbar:
|
||||
// ./gradlew :app:assembleRelease -PversionName=1.2.0
|
||||
versionName = (findProperty("versionName") as String?) ?: "1.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (hasSigningKey) {
|
||||
create("release") {
|
||||
storeFile = file(signing.getProperty("storeFile"))
|
||||
storePassword = signing.getProperty("storePassword")
|
||||
keyAlias = signing.getProperty("keyAlias")
|
||||
keyPassword = signing.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
// Ohne eigenen Schlüssel wird mit dem Debug-Schlüssel signiert.
|
||||
// Unsigniert wäre die APK schlicht nicht installierbar - Android
|
||||
// weist sie ab, auch beim Sideloading. Der Hinweis unten sagt, was
|
||||
// das für Folgen hat.
|
||||
signingConfig = if (hasSigningKey) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,3 +104,20 @@ dependencies {
|
||||
// Die Ablage der Geräte und Profile nutzt org.json aus Android selbst -
|
||||
// eine weitere Bibliothek lohnt für eine Handvoll Felder nicht.
|
||||
}
|
||||
|
||||
// Sichtbar machen, womit signiert wurde - eine mit dem Debug-Schlüssel
|
||||
// signierte Release-APK sieht sonst aus wie eine richtige.
|
||||
tasks.matching { it.name == "assembleRelease" }.configureEach {
|
||||
doLast {
|
||||
if (!hasSigningKey) {
|
||||
logger.lifecycle(
|
||||
"\n ACHTUNG: mit dem Debug-Schlüssel signiert." +
|
||||
"\n Installierbar, aber an diesen Rechner gebunden: auf einem anderen" +
|
||||
"\n entsteht ein anderer Debug-Schlüssel, und ein Update liesse sich" +
|
||||
"\n nicht mehr über die alte Installation legen." +
|
||||
"\n Für einen eigenen Schlüssel: Android/keystore.properties anlegen." +
|
||||
"\n Anleitung in Android/RELEASE.md.\n"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Lädt die gebaute APK in Gitas Paket-Registry.
|
||||
#
|
||||
# Der Weg über die Registry umgeht die Typenliste für Release-Anhänge, die
|
||||
# `.apk` nicht enthält. Siehe RELEASE.md.
|
||||
#
|
||||
# Android/upload-package.sh 1.0.0
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?Aufruf: upload-package.sh <version>, z. B. 1.0.0}"
|
||||
SERVER="${GITEA_SERVER:-https://git.home.fritob.de}"
|
||||
OWNER="${GITEA_OWNER:-fritob}"
|
||||
TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.gitea-token}"
|
||||
|
||||
APK="$(dirname "$0")/app/build/outputs/apk/release/app-release.apk"
|
||||
[ -f "$APK" ] || {
|
||||
echo "Keine APK gefunden. Erst bauen:" >&2
|
||||
echo " source Android/env.sh && cd Android && ./gradlew :app:assembleRelease -PversionName=$VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -f "$TOKEN_FILE" ] || {
|
||||
echo "Kein Zugangsschlüssel in $TOKEN_FILE." >&2
|
||||
echo "In Gitea unter Einstellungen -> Anwendungen einen mit 'write:package' anlegen," >&2
|
||||
echo "dann: printf %s 'DEIN_TOKEN' > $TOKEN_FILE && chmod 600 $TOKEN_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
NAME="CamperMonitor-$VERSION.apk"
|
||||
URL="$SERVER/api/packages/$OWNER/generic/camper-monitor/$VERSION/$NAME"
|
||||
|
||||
# Der Schlüssel geht nur an curl und steht nirgends in der Ausgabe.
|
||||
CODE="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $(cat "$TOKEN_FILE")" \
|
||||
--upload-file "$APK" "$URL")"
|
||||
|
||||
case "$CODE" in
|
||||
201) echo "Hochgeladen: $URL" ;;
|
||||
409) echo "Version $VERSION liegt dort schon. Erst löschen oder Version erhöhen." >&2; exit 1 ;;
|
||||
401|403) echo "Abgelehnt (HTTP $CODE) - fehlt dem Token das Recht 'write:package'?" >&2; exit 1 ;;
|
||||
*) echo "Fehlgeschlagen, HTTP $CODE" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -6,8 +6,38 @@
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* CamperMonitorWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
AA0000000000000000000028 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AA0000000000000000000009 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = AA0000000000000000000022;
|
||||
remoteInfo = CamperMonitorWatch;
|
||||
};
|
||||
/* 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;
|
||||
};
|
||||
/* 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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -16,6 +46,16 @@
|
||||
path = CamperMonitor;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0000000000000000000017 /* CamperMonitorWatch */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = CamperMonitorWatch;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0000000000000000000018 /* Shared */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -26,6 +66,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000020 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -33,6 +80,9 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0000000000000000000002 /* CamperMonitor */,
|
||||
AA0000000000000000000017 /* CamperMonitorWatch */,
|
||||
AA0000000000000000000018 /* Shared */,
|
||||
AA0000000000000000000031 /* Config */,
|
||||
AA0000000000000000000005 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -41,10 +91,19 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0000000000000000000001 /* CamperMonitor.app */,
|
||||
AA0000000000000000000016 /* CamperMonitorWatch.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0000000000000000000031 /* Config */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0000000000000000000030 /* CamperMonitor-Info.plist */,
|
||||
);
|
||||
path = Config;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -55,17 +114,41 @@
|
||||
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 */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
AA0000000000000000000002 /* CamperMonitor */,
|
||||
AA0000000000000000000017 /* CamperMonitorWatch */,
|
||||
AA0000000000000000000018 /* Shared */,
|
||||
);
|
||||
name = CamperMonitor;
|
||||
productName = CamperMonitor;
|
||||
productReference = AA0000000000000000000001 /* CamperMonitor.app */;
|
||||
name = CamperMonitorWatch;
|
||||
productName = CamperMonitorWatch;
|
||||
productReference = AA0000000000000000000016 /* CamperMonitorWatch.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
@@ -81,6 +164,9 @@
|
||||
AA0000000000000000000006 = {
|
||||
CreatedOnToolsVersion = 26.6;
|
||||
};
|
||||
AA0000000000000000000022 = {
|
||||
CreatedOnToolsVersion = 26.6;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */;
|
||||
@@ -99,6 +185,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
AA0000000000000000000006 /* CamperMonitor */,
|
||||
AA0000000000000000000022 /* CamperMonitorWatch */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -111,6 +198,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000021 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -121,8 +215,24 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000019 /* 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 */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
AA0000000000000000000012 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -190,6 +300,7 @@
|
||||
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
|
||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -220,6 +331,7 @@
|
||||
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
|
||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -239,6 +351,68 @@
|
||||
};
|
||||
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 = "Camper Monitor";
|
||||
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 = 11.6;
|
||||
};
|
||||
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 = "Camper Monitor";
|
||||
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 = 11.6;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -260,6 +434,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "CamperMonitorWatch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
AA0000000000000000000023 /* Debug */,
|
||||
AA0000000000000000000024 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = AA0000000000000000000009 /* Project object */;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme LastUpgradeVersion = "2660" version = "1.7">
|
||||
<BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry buildForTesting = "YES" buildForRunning = "YES" buildForProfiling = "YES" buildForArchiving = "YES" buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA0000000000000000000022"
|
||||
BuildableName = "CamperMonitorWatch.app"
|
||||
BlueprintName = "CamperMonitorWatch"
|
||||
ReferencedContainer = "container:CamperMonitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables></Testables>
|
||||
</TestAction>
|
||||
<LaunchAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA0000000000000000000022"
|
||||
BuildableName = "CamperMonitorWatch.app"
|
||||
BlueprintName = "CamperMonitorWatch"
|
||||
ReferencedContainer = "container:CamperMonitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction buildConfiguration = "Release" shouldUseLaunchSchemeArgsEnv = "YES" savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA0000000000000000000022"
|
||||
BuildableName = "CamperMonitorWatch.app"
|
||||
BlueprintName = "CamperMonitorWatch"
|
||||
ReferencedContainer = "container:CamperMonitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction buildConfiguration = "Debug"></AnalyzeAction>
|
||||
<ArchiveAction buildConfiguration = "Release" revealArchiveInOrganizer = "YES"></ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -49,82 +49,3 @@ enum VanAlignProtocol {
|
||||
return (pitch, roll)
|
||||
}
|
||||
}
|
||||
|
||||
/// Zustand des Neigungsmessers.
|
||||
struct LevelState: Equatable {
|
||||
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
|
||||
/// Bereits auf die Einbaulage des Sensors umgerechnet.
|
||||
var pitch: Double?
|
||||
/// Querneigung: positiv bedeutet, die rechte Seite steht höher.
|
||||
var roll: Double?
|
||||
/// Wie der Sensor selbst meldet – nötig, um die Einbaulage zu bestimmen.
|
||||
var rawPitch: Double?
|
||||
var rawRoll: Double?
|
||||
var pitchOffset: Double?
|
||||
var rollOffset: Double?
|
||||
|
||||
var hasReading: Bool { pitch != nil || roll != nil }
|
||||
|
||||
/// Ob kalibriert wurde – oder nil, wenn das Gerät es nicht verrät.
|
||||
///
|
||||
/// Nicht jede Firmware stellt die Offsets bereit. Fehlen sie, heisst das
|
||||
/// „unbekannt“ und nicht „nicht kalibriert“: eine Warnung, die sich nie
|
||||
/// abstellen lässt, ist schlimmer als keine.
|
||||
var calibrationState: Bool? {
|
||||
guard let pitchOffset, let rollOffset else { return nil }
|
||||
return abs(pitchOffset) > 0.001 || abs(rollOffset) > 0.001
|
||||
}
|
||||
|
||||
/// Nur wahr, wenn es auch belegt ist.
|
||||
var isCalibrated: Bool { calibrationState == true }
|
||||
|
||||
/// Nur wahr, wenn das Gerät ausdrücklich Nulloffsets meldet.
|
||||
var isKnownUncalibrated: Bool { calibrationState == false }
|
||||
|
||||
/// Bis hierhin gilt das Fahrzeug als eben genug.
|
||||
static let levelTolerance = 0.5
|
||||
|
||||
var isLevel: Bool {
|
||||
guard let pitch, let roll else { return false }
|
||||
return abs(pitch) <= Self.levelTolerance && abs(roll) <= Self.levelTolerance
|
||||
}
|
||||
|
||||
/// Die grössere der beiden Abweichungen – das ist die, die man zuerst
|
||||
/// ausgleichen will.
|
||||
var largestDeviation: Double? {
|
||||
switch (pitch, roll) {
|
||||
case let (p?, r?): return max(abs(p), abs(r))
|
||||
case let (p?, nil): return abs(p)
|
||||
case let (nil, r?): return abs(r)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Was zu tun ist, in Worten. Beim Ausrichten schaut man aufs Handy,
|
||||
/// nicht auf Vorzeichen.
|
||||
var instruction: String? {
|
||||
guard let pitch, let roll else { return nil }
|
||||
if isLevel { return "Steht eben" }
|
||||
var parts: [String] = []
|
||||
if abs(pitch) > Self.levelTolerance {
|
||||
parts.append(pitch > 0 ? "Heck steht höher" : "Front steht höher")
|
||||
}
|
||||
if abs(roll) > Self.levelTolerance {
|
||||
parts.append(roll > 0 ? "rechts steht höher" : "links steht höher")
|
||||
}
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
|
||||
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
|
||||
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
|
||||
snapshot.metrics = [
|
||||
Metric("pitch", "Längsneigung", pitch, unit: "°", precision: 1, primary: true),
|
||||
Metric("roll", "Querneigung", roll, unit: "°", precision: 1),
|
||||
]
|
||||
snapshot.state = instruction
|
||||
if isKnownUncalibrated {
|
||||
snapshot.offReasons = ["Nicht kalibriert"]
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ import SwiftUI
|
||||
struct CamperMonitorApp: App {
|
||||
@State private var store: DeviceStore
|
||||
@State private var bluetooth: BluetoothManager
|
||||
@State private var watch: PhoneWatchLink
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
init() {
|
||||
let store = DeviceStore()
|
||||
let bluetooth = BluetoothManager(store: store)
|
||||
_store = State(initialValue: store)
|
||||
_bluetooth = State(initialValue: BluetoothManager(store: store))
|
||||
_bluetooth = State(initialValue: bluetooth)
|
||||
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
@@ -17,14 +20,27 @@ struct CamperMonitorApp: App {
|
||||
DashboardView()
|
||||
.environment(store)
|
||||
.environment(bluetooth)
|
||||
.environment(watch)
|
||||
.task { watch.activate() }
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
// Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt
|
||||
// werden, also Funk sparen und beim Zurückkommen neu starten.
|
||||
//
|
||||
// Ausnahme: Solange jemand auf die Uhr schaut, läuft es weiter. Beim
|
||||
// Rangieren liegt das iPhone in der Halterung mit dunklem Bildschirm
|
||||
// – hörte das Funkgerät dann auf, wäre die Anzeige am Handgelenk
|
||||
// genau dann tot, wenn sie gebraucht wird. Die verbundenen Geräte
|
||||
// (Neigungsmesser, BMS, Kühlbox) liefern im Hintergrund weiter; die
|
||||
// Victron-Werbedaten nicht, dafür müsste ohne Filter gesucht werden.
|
||||
watch.isAppInBackground = phase != .active
|
||||
switch phase {
|
||||
case .active: bluetooth.start()
|
||||
case .background: bluetooth.stop()
|
||||
default: break
|
||||
case .active:
|
||||
bluetooth.start()
|
||||
case .background:
|
||||
if !watch.wantsLiveUpdates { bluetooth.stop() }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
/// betrifft, steht bei diesem Gerät.
|
||||
struct SettingsView: View {
|
||||
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
|
||||
@Environment(PhoneWatchLink.self) private var watch
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -21,6 +22,16 @@ struct SettingsView: View {
|
||||
+ "Meldet ein Gerät einen Fehler, werden die Angaben ohnehin "
|
||||
+ "eingeblendet, auch wenn das hier ausgeschaltet ist.")
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("Apple Watch", value: watch.statusText)
|
||||
} footer: {
|
||||
Text("Die Uhr zeigt die Werte dieses iPhones und steuert die "
|
||||
+ "Kühlbox darüber – sie funkt nicht selbst zu den Geräten. "
|
||||
+ "Dafür muss diese App laufen; im Hintergrund liefern "
|
||||
+ "Neigungsmesser, BMS und Kühlbox weiter, die "
|
||||
+ "Victron-Werbedaten erst wieder im Vordergrund.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Einstellungen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import WatchConnectivity
|
||||
|
||||
/// Die Gegenstelle zur Apple Watch auf dem iPhone.
|
||||
///
|
||||
/// Das iPhone bleibt das Funkgerät (warum, steht bei `WatchLink`) und reicht
|
||||
/// den fertigen Stand weiter. Zwei Wege, je nachdem, ob die Uhr gerade
|
||||
/// hinschaut:
|
||||
///
|
||||
/// * **Anwendungskontext** – ein Datensatz, den watchOS aufhebt und auch dann
|
||||
/// zustellt, wenn die App auf der Uhr gerade nicht läuft. Damit steht beim
|
||||
/// Aufwecken sofort etwas da statt eines leeren Bildschirms.
|
||||
/// * **Nachricht** – nur solange die Uhr erreichbar ist und gemeldet hat, dass
|
||||
/// sie hinschaut. Dafür im halben Sekundentakt, was beim Ausrichten zählt.
|
||||
///
|
||||
/// Ohne gekoppelte Uhr läuft hier gar nichts: kein Timer, keine Sitzung.
|
||||
@Observable
|
||||
final class PhoneWatchLink: NSObject {
|
||||
|
||||
/// Ob die Uhr gerade Werte sehen will. Solange das gilt, hält die App das
|
||||
/// Bluetooth auch im Hintergrund am Leben.
|
||||
private(set) var wantsLiveUpdates = false
|
||||
/// Nur für die Anzeige in den Einstellungen.
|
||||
private(set) var statusText = "Keine Uhr gekoppelt"
|
||||
|
||||
private let store: DeviceStore
|
||||
private let bluetooth: BluetoothManager
|
||||
|
||||
private var session: WCSession?
|
||||
private var timer: Timer?
|
||||
/// Bis wann der Wunsch der Uhr nach schnellen Werten gilt.
|
||||
private var liveUntil = Date.distantPast
|
||||
private var lastSentContent: WatchPayload?
|
||||
private var lastContextSent = Date.distantPast
|
||||
|
||||
/// Setzt die App, damit beim Ablauf des Wunsches im Hintergrund das Funken
|
||||
/// wieder eingestellt wird.
|
||||
var isAppInBackground = false
|
||||
|
||||
init(store: DeviceStore, bluetooth: BluetoothManager) {
|
||||
self.store = store
|
||||
self.bluetooth = bluetooth
|
||||
super.init()
|
||||
}
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else {
|
||||
statusText = "Dieses Gerät kann keine Uhr koppeln"
|
||||
return
|
||||
}
|
||||
let session = WCSession.default
|
||||
session.delegate = self
|
||||
self.session = session
|
||||
session.activate()
|
||||
}
|
||||
|
||||
// MARK: - Takt
|
||||
|
||||
private func updateTimer() {
|
||||
let wanted = session?.activationState == .activated && session?.isPaired == true
|
||||
if wanted && timer == nil {
|
||||
// Der schnelle Takt ist der Grundtakt; ob wirklich gesendet wird,
|
||||
// entscheidet `tick` – so muss der Timer nie umgestellt werden.
|
||||
let timer = Timer(timeInterval: WatchLink.liveInterval, repeats: true) { [weak self] _ in
|
||||
self?.tick()
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.timer = timer
|
||||
} else if !wanted {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
let live = Date() < liveUntil
|
||||
if live != wantsLiveUpdates {
|
||||
wantsLiveUpdates = live
|
||||
// Der Wunsch ist gerade abgelaufen und niemand schaut mehr hin:
|
||||
// dann darf das Funkgerät im Hintergrund auch wieder ruhen.
|
||||
if !live && isAppInBackground { bluetooth.stop() }
|
||||
}
|
||||
|
||||
// Den Stand erst zusammenstellen, wenn er auch rausgeht: im Ruhetakt
|
||||
// ist das nur jeder vierte Aufruf, und zusammengestellt wird auf dem
|
||||
// Hauptthread.
|
||||
let isContextDue = Date().timeIntervalSince(lastContextSent) >= WatchLink.idleInterval
|
||||
guard let session, live || isContextDue else { return }
|
||||
guard let payload = buildPayload() else { return }
|
||||
|
||||
if live && session.isReachable {
|
||||
send(payload, over: session)
|
||||
return
|
||||
}
|
||||
|
||||
guard isContextDue,
|
||||
lastSentContent?.hasSameContent(as: payload) != true,
|
||||
session.isPaired, session.isWatchAppInstalled else { return }
|
||||
do {
|
||||
try session.updateApplicationContext(payload.message())
|
||||
lastSentContent = payload
|
||||
lastContextSent = Date()
|
||||
} catch {
|
||||
statusText = "Uhr: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Nachrichten werden ohne Antwort verschickt: Ein verlorener Datensatz ist
|
||||
/// in einer halben Sekunde ohnehin überholt, ein Wiederholungsversuch käme
|
||||
/// zu spät und stünde nur dem nächsten im Weg.
|
||||
private func send(_ payload: WatchPayload, over session: WCSession) {
|
||||
guard let message = try? payload.message() else { return }
|
||||
session.sendMessage(message, replyHandler: nil, errorHandler: nil)
|
||||
lastSentContent = payload
|
||||
}
|
||||
|
||||
/// Nach Änderungen an Geräten oder Profilen aufrufen, damit die Uhr nicht
|
||||
/// bis zum nächsten Takt einen überholten Fahrzeugnamen zeigt.
|
||||
func sendNow() {
|
||||
lastSentContent = nil
|
||||
lastContextSent = .distantPast
|
||||
tick()
|
||||
}
|
||||
|
||||
// MARK: - Stand zusammenstellen
|
||||
|
||||
private func buildPayload() -> WatchPayload? {
|
||||
guard let profile = store.activeProfile else { return nil }
|
||||
let devices = store.activeDevices.map { device in
|
||||
WatchDevice(id: device.id,
|
||||
name: device.name,
|
||||
role: device.role,
|
||||
link: bluetooth.linkStates[device.id] ?? .searching,
|
||||
snapshot: bluetooth.snapshots[device.id],
|
||||
level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil,
|
||||
fridge: device.role == .fridge
|
||||
? bluetooth.fridgeStates[device.id].map(WatchFridge.init) : nil)
|
||||
}
|
||||
return WatchPayload(generatedAt: Date(),
|
||||
profile: profile,
|
||||
isRadioReady: bluetooth.isBluetoothReady,
|
||||
radioStatus: bluetooth.bluetoothStatusText,
|
||||
devices: devices)
|
||||
}
|
||||
|
||||
// MARK: - Befehle von der Uhr
|
||||
|
||||
private func handle(_ command: WatchCommand) {
|
||||
if command.wantsLiveUpdates {
|
||||
liveUntil = Date().addingTimeInterval(WatchLink.liveLease)
|
||||
wantsLiveUpdates = true
|
||||
// Die Uhr kann das iPhone aus dem Hintergrund wecken. Dann steht
|
||||
// das Funkgerät und muss erst wieder anlaufen, sonst geht der
|
||||
// Stellbefehl ins Leere.
|
||||
if isAppInBackground { bluetooth.start() }
|
||||
}
|
||||
|
||||
switch command {
|
||||
case .hello:
|
||||
break
|
||||
case .fridgePower(let device, let on):
|
||||
bluetooth.setFridgePower(on, for: device)
|
||||
case .fridgeEco(let device, let eco):
|
||||
bluetooth.setFridgeEco(eco, for: device)
|
||||
case .fridgeLock(let device, let locked):
|
||||
bluetooth.setFridgeLock(locked, for: device)
|
||||
case .fridgeTarget(let device, let zone, let value):
|
||||
bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device)
|
||||
case .calibrateLevel(let device):
|
||||
bluetooth.calibrateLevel(for: device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
extension PhoneWatchLink: WCSessionDelegate {
|
||||
|
||||
func session(_ session: WCSession,
|
||||
activationDidCompleteWith state: WCSessionActivationState,
|
||||
error: Error?) {
|
||||
DispatchQueue.main.async {
|
||||
if let error {
|
||||
self.statusText = "Uhr: \(error.localizedDescription)"
|
||||
} else {
|
||||
self.updateStatus(session)
|
||||
}
|
||||
self.updateTimer()
|
||||
self.sendNow()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateStatus(_ session: WCSession) {
|
||||
if !session.isPaired {
|
||||
statusText = "Keine Uhr gekoppelt"
|
||||
} else if !session.isWatchAppInstalled {
|
||||
statusText = "Uhr gekoppelt, App dort nicht installiert"
|
||||
} else {
|
||||
statusText = "Uhr bereit"
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
guard let command = WatchCommand.decode(from: message) else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.handle(command)
|
||||
// Auf einen Stellbefehl gleich den frischen Stand hinterherschicken.
|
||||
// Der zeigt zwar noch den alten Wert – die Box antwortet erst –,
|
||||
// aber die Uhr weiss dadurch sofort, dass der Befehl ankam.
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mit Antwort, wenn die Uhr auf eine wartet: das ist der Weg, auf dem sie
|
||||
/// beim Öffnen ohne Verzögerung an einen Stand kommt.
|
||||
func session(_ session: WCSession,
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
if let command = WatchCommand.decode(from: message) { self.handle(command) }
|
||||
replyHandler((try? self.buildPayload()?.message()) ?? [:])
|
||||
}
|
||||
}
|
||||
|
||||
func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
DispatchQueue.main.async { self.sendNow() }
|
||||
}
|
||||
|
||||
/// Die Uhr wurde gekoppelt, gewechselt oder die App dort installiert.
|
||||
func sessionWatchStateDidChange(_ session: WCSession) {
|
||||
DispatchQueue.main.async {
|
||||
self.updateStatus(session)
|
||||
self.updateTimer()
|
||||
self.sendNow()
|
||||
}
|
||||
}
|
||||
|
||||
// Beim Wechsel auf eine andere Uhr muss die Sitzung neu aktiviert werden.
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
func sessionDidDeactivate(_ session: WCSession) {
|
||||
session.activate()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Umformen
|
||||
|
||||
extension WatchFridge {
|
||||
/// Aus dem Zustand der Box wird nur übernommen, was die Uhr anzeigt oder
|
||||
/// stellt – der Rohdatensatz bleibt auf dem iPhone.
|
||||
init(_ state: AlpicoolState) {
|
||||
let range = state.targetRange
|
||||
self.init(isPoweredOn: state.isPoweredOn,
|
||||
isEco: state.isEco,
|
||||
isLocked: state.isLocked,
|
||||
isDualZone: state.isDualZone,
|
||||
unitSymbol: state.unitSymbol,
|
||||
leftTarget: state.leftTarget,
|
||||
leftCurrent: state.leftCurrent,
|
||||
rightTarget: state.isDualZone ? state.rightTarget : nil,
|
||||
rightCurrent: state.isDualZone ? state.rightCurrent : nil,
|
||||
minTarget: range.lowerBound,
|
||||
maxTarget: range.upperBound,
|
||||
batteryVolts: state.batteryVolts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.400", "green" : "0.620", "red" : "0.110" } }, "idiom" : "universal" } ],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "watchos",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||
@@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Camper Monitor am Handgelenk.
|
||||
///
|
||||
/// Die Uhr ist Anzeige und Fernbedienung; gefunkt wird über das iPhone. Was
|
||||
/// hier steht, kam von dort – deshalb steht auf jedem Bildschirm auch, wie alt
|
||||
/// es ist.
|
||||
@main
|
||||
struct CamperWatchApp: App {
|
||||
@State private var link = PhoneLink()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchRootView()
|
||||
.environment(link)
|
||||
.task { link.activate() }
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
// Der schnelle Takt kostet auf beiden Seiten Strom und lohnt nur,
|
||||
// solange jemand hinschaut. Genau das sagt die Bildschirmphase aus:
|
||||
// im Vordergrund schaut jemand hin, sonst nicht.
|
||||
link.setLive(phase == .active)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import WatchConnectivity
|
||||
|
||||
/// Die Gegenstelle zum iPhone auf der Uhr.
|
||||
///
|
||||
/// Hält den zuletzt empfangenen Stand und schickt Stellbefehle zurück. Gefunkt
|
||||
/// wird nur zum iPhone – zu den Geräten im Fahrzeug nie, siehe `WatchLink`.
|
||||
///
|
||||
/// Zwei Quellen laufen zusammen:
|
||||
///
|
||||
/// * Der **Anwendungskontext** liegt schon da, wenn die App startet. Deshalb
|
||||
/// steht sofort etwas auf dem Bildschirm, auch wenn das iPhone in der Tasche
|
||||
/// steckt und gerade nicht antwortet – mit Altersangabe, versteht sich.
|
||||
/// * **Nachrichten** kommen im halben Sekundentakt, solange wir gemeldet haben,
|
||||
/// dass jemand hinschaut. Das Melden wird regelmässig erneuert: Schläft die
|
||||
/// Uhr ein, bleibt die Erneuerung aus und das iPhone hört von selbst auf.
|
||||
@Observable
|
||||
final class PhoneLink: NSObject {
|
||||
|
||||
private(set) var payload: WatchPayload?
|
||||
/// Wann der Stand hier ankam – nicht, wann er entstand. Beides zusammen
|
||||
/// unterscheidet „iPhone weg“ von „Gerät im Fahrzeug antwortet nicht“.
|
||||
private(set) var receivedAt: Date?
|
||||
private(set) var isReachable = false
|
||||
private(set) var isActivated = false
|
||||
|
||||
private var session: WCSession?
|
||||
/// Erneuert die Meldung „hier schaut jemand hin“ – im Vorführbetrieb
|
||||
/// stattdessen der Taktgeber der erfundenen Werte.
|
||||
private var timer: Timer?
|
||||
private var isLive = false
|
||||
|
||||
/// Ohne iPhone lässt sich auf der Uhr sonst nichts vorführen.
|
||||
private let isDemo = ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
|
||||
|
||||
func activate() {
|
||||
if isDemo {
|
||||
startDemo()
|
||||
return
|
||||
}
|
||||
guard WCSession.isSupported() else { return }
|
||||
let session = WCSession.default
|
||||
session.delegate = self
|
||||
self.session = session
|
||||
session.activate()
|
||||
}
|
||||
|
||||
/// Meldet dem iPhone, ob gerade jemand hinschaut. Beim Ausrichten zählt
|
||||
/// jeder halbe Sekundentakt, beim Nachsehen des Ladezustands nicht.
|
||||
func setLive(_ live: Bool) {
|
||||
guard !isDemo else { return }
|
||||
isLive = live
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
guard live else {
|
||||
send(.hello(live: false))
|
||||
return
|
||||
}
|
||||
send(.hello(live: true))
|
||||
// Vor Ablauf der Frist erneuern, damit zwischendurch keine Lücke
|
||||
// entsteht. Bleibt die Erneuerung aus, endet der schnelle Takt von
|
||||
// selbst – niemand muss ihn abbestellen.
|
||||
let renew = Timer(timeInterval: WatchLink.liveLease / 3, repeats: true) { [weak self] _ in
|
||||
self?.send(.hello(live: true))
|
||||
}
|
||||
RunLoop.main.add(renew, forMode: .common)
|
||||
timer = renew
|
||||
}
|
||||
|
||||
/// Schickt einen Befehl und übernimmt die Antwort, falls eine kommt.
|
||||
///
|
||||
/// Die Antwort ist der Stand *vor* der Wirkung – die Kühlbox antwortet dem
|
||||
/// iPhone erst ein paar Zehntel später. Sie zeigt also nicht, dass der
|
||||
/// Befehl gewirkt hat, sondern nur, dass er angekommen ist. Der gestellte
|
||||
/// Wert erscheint, wenn das Gerät ihn zurückmeldet, so wie am iPhone auch.
|
||||
func send(_ command: WatchCommand) {
|
||||
guard !isDemo, let session, session.activationState == .activated,
|
||||
let message = try? command.message() else { return }
|
||||
guard session.isReachable else { return }
|
||||
session.sendMessage(message, replyHandler: { [weak self] reply in
|
||||
guard let payload = WatchPayload.decode(from: reply) else { return }
|
||||
DispatchQueue.main.async { self?.accept(payload) }
|
||||
}, errorHandler: nil)
|
||||
}
|
||||
|
||||
private func accept(_ payload: WatchPayload) {
|
||||
// Ein Nachzügler darf einen neueren Stand nicht überschreiben.
|
||||
if let current = self.payload, current.generatedAt > payload.generatedAt { return }
|
||||
self.payload = payload
|
||||
receivedAt = Date()
|
||||
}
|
||||
|
||||
// MARK: - Vorführbetrieb
|
||||
|
||||
private func startDemo() {
|
||||
payload = WatchDemo.payload(at: 0)
|
||||
receivedAt = Date()
|
||||
isReachable = true
|
||||
isActivated = true
|
||||
var step = 0.0
|
||||
let tick = Timer(timeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
step += 0.5
|
||||
self?.payload = WatchDemo.payload(at: step)
|
||||
self?.receivedAt = Date()
|
||||
}
|
||||
RunLoop.main.add(tick, forMode: .common)
|
||||
timer = tick
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
extension PhoneLink: WCSessionDelegate {
|
||||
|
||||
func session(_ session: WCSession,
|
||||
activationDidCompleteWith state: WCSessionActivationState,
|
||||
error: Error?) {
|
||||
DispatchQueue.main.async {
|
||||
self.isActivated = state == .activated
|
||||
self.isReachable = session.isReachable
|
||||
// Was schon dalag, gilt sofort – sonst bliebe der Bildschirm leer,
|
||||
// bis das iPhone das nächste Mal von sich aus sendet.
|
||||
if let payload = WatchPayload.decode(from: session.receivedApplicationContext) {
|
||||
self.accept(payload)
|
||||
}
|
||||
if self.isLive { self.send(.hello(live: true)) }
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
guard let payload = WatchPayload.decode(from: message) else { return }
|
||||
DispatchQueue.main.async { self.accept(payload) }
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveApplicationContext context: [String: Any]) {
|
||||
guard let payload = WatchPayload.decode(from: context) else { return }
|
||||
DispatchQueue.main.async { self.accept(payload) }
|
||||
}
|
||||
|
||||
func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
DispatchQueue.main.async {
|
||||
self.isReachable = session.isReachable
|
||||
if session.isReachable, self.isLive { self.send(.hello(live: true)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import SwiftUI
|
||||
import WatchKit
|
||||
|
||||
/// Der Ausrichtungs-Assistent am Handgelenk – der eigentliche Grund für die
|
||||
/// Uhr: Beim Rangieren hat man das iPhone nicht in der Hand, aber den Arm am
|
||||
/// Lenkrad. Ein Blick nach unten genügt, und die Rückmeldung „steht eben“ kommt
|
||||
/// zusätzlich als Vibration, ganz ohne Blick.
|
||||
///
|
||||
/// Gerechnet wird mit demselben `AlignmentAssistant` wie am iPhone: kein
|
||||
/// Ortsbezug, nur der zeitliche Verlauf der Neigung. Was besser und was
|
||||
/// schlechter wird, steckt vollständig darin.
|
||||
struct WatchAlignView: View {
|
||||
let deviceID: UUID
|
||||
|
||||
@Environment(PhoneLink.self) private var link
|
||||
@State private var assistant = AlignmentAssistant()
|
||||
/// Damit die Vibration einmal kommt und nicht im Sekundentakt.
|
||||
@State private var hasAnnouncedTarget = false
|
||||
|
||||
private var state: LevelState? {
|
||||
link.payload?.devices.first { $0.id == deviceID }?.level
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
if let current = assistant.current {
|
||||
Text(String(format: "%.1f°", current.deviation))
|
||||
.font(.system(size: 40, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(assistant.hasReachedTarget ? .green : .primary)
|
||||
.contentTransition(.numericText())
|
||||
} else {
|
||||
ProgressView()
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
Label(assistant.trend.text, systemImage: assistant.trend.symbol)
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(trendColor)
|
||||
|
||||
Text(assistant.advice)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let state {
|
||||
HStack(spacing: 12) {
|
||||
WatchBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(width: 56, height: 56)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(format: "Längs %.1f°", state.pitch ?? 0))
|
||||
Text(String(format: "Quer %.1f°", state.roll ?? 0))
|
||||
}
|
||||
.font(.caption2)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
Button("Von vorn messen", systemImage: "arrow.counterclockwise") {
|
||||
assistant.reset()
|
||||
hasAnnouncedTarget = false
|
||||
}
|
||||
.font(.caption2)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.navigationTitle("Ausrichten")
|
||||
.onChange(of: reading, initial: true) { _, reading in
|
||||
guard let reading else { return }
|
||||
assistant.add(pitch: reading.pitch, roll: reading.roll)
|
||||
announceIfLevel()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Wertepaar als Auslöser: nur wenn sich wirklich eine Neigung ändert,
|
||||
/// wird gerechnet – nicht bei jedem Datensatz des iPhones.
|
||||
private var reading: Reading? {
|
||||
guard let state, let pitch = state.pitch, let roll = state.roll else { return nil }
|
||||
return Reading(pitch: pitch, roll: roll)
|
||||
}
|
||||
|
||||
private struct Reading: Equatable {
|
||||
let pitch: Double
|
||||
let roll: Double
|
||||
}
|
||||
|
||||
private var trendColor: Color {
|
||||
switch assistant.trend {
|
||||
case .improving: return .green
|
||||
case .worsening: return .orange
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Vibration ist der Punkt: Sie sagt „anhalten“, ohne dass jemand
|
||||
/// hinschauen muss. Erneut gemeldet wird erst, wenn das Fahrzeug die
|
||||
/// Toleranz zwischendurch wieder verlassen hat.
|
||||
private func announceIfLevel() {
|
||||
if assistant.hasReachedTarget {
|
||||
guard !hasAnnouncedTarget else { return }
|
||||
hasAnnouncedTarget = true
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
} else {
|
||||
hasAnnouncedTarget = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Libelle für die Uhr: eine Blase, die dorthin wandert, wo das Fahrzeug
|
||||
/// **höher** steht – wie bei einer echten Wasserwaage.
|
||||
///
|
||||
/// Bewusst schlichter als am iPhone: Auf 40 mm Bildschirm ist der innere Ring
|
||||
/// (die Toleranz) die einzige Hilfslinie, die sich noch ablesen lässt.
|
||||
struct WatchBubble: View {
|
||||
let pitch: Double?
|
||||
let roll: Double?
|
||||
/// Bis zu welcher Neigung die Anzeige ausschlägt.
|
||||
var range: Double = 4
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let side = min(geometry.size.width, geometry.size.height)
|
||||
let radius = side / 2
|
||||
let bubble = side * 0.22
|
||||
let travel = radius - bubble / 2 - 2
|
||||
let tolerance = max(LevelState.levelTolerance / range * travel, bubble * 0.55)
|
||||
|
||||
ZStack {
|
||||
Circle().fill(Color.gray.opacity(0.25))
|
||||
Circle().strokeBorder(Color.gray.opacity(0.5), lineWidth: 1)
|
||||
Circle()
|
||||
.strokeBorder(isLevel ? Color.green : Color.gray.opacity(0.6),
|
||||
lineWidth: isLevel ? 2 : 1)
|
||||
.frame(width: tolerance * 2, height: tolerance * 2)
|
||||
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: bubble, height: bubble)
|
||||
// Positiver Pitch heisst: das Heck steht höher, die Blase
|
||||
// wandert nach oben – in der Ansicht also nach hinten.
|
||||
.offset(x: clamped(roll) / range * travel,
|
||||
y: -clamped(pitch) / range * travel)
|
||||
.animation(.spring(duration: 0.3), value: pitch)
|
||||
.animation(.spring(duration: 0.3), value: roll)
|
||||
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
|
||||
}
|
||||
.frame(width: side, height: side)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.accessibilityLabel(accessibilityText)
|
||||
}
|
||||
|
||||
/// Hält die Blase im Ring, auch wenn das Fahrzeug stark schräg steht.
|
||||
private func clamped(_ value: Double?) -> Double {
|
||||
guard let value else { return 0 }
|
||||
return min(max(value, -range), range)
|
||||
}
|
||||
|
||||
private var deviation: Double? {
|
||||
switch (pitch, roll) {
|
||||
case let (p?, r?): return max(abs(p), abs(r))
|
||||
case let (p?, nil): return abs(p)
|
||||
case let (nil, r?): return abs(r)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var isLevel: Bool { (deviation ?? .infinity) <= LevelState.levelTolerance }
|
||||
|
||||
/// Grün nur, wenn es wirklich eben ist – die Akzentfarbe der App ist selbst
|
||||
/// grün, sonst sähe schief aus wie eben.
|
||||
private var color: Color {
|
||||
guard let deviation else { return .gray }
|
||||
if deviation <= LevelState.levelTolerance { return .green }
|
||||
return deviation <= 2 ? .orange : .red
|
||||
}
|
||||
|
||||
private var accessibilityText: String {
|
||||
guard let pitch, let roll else { return "Keine Messwerte" }
|
||||
return String(format: "Längsneigung %.1f Grad, Querneigung %.1f Grad", pitch, roll)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Alle Werte eines Geräts. Am iPhone stehen hier zusätzlich Verlauf und
|
||||
/// Diagnose – auf der Uhr wäre beides unlesbar und ist deshalb weggelassen.
|
||||
struct WatchDeviceDetailView: View {
|
||||
let deviceID: UUID
|
||||
|
||||
@Environment(PhoneLink.self) private var link
|
||||
|
||||
private var device: WatchDevice? {
|
||||
link.payload?.devices.first { $0.id == deviceID }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if let device {
|
||||
if let fridge = device.fridge {
|
||||
WatchFridgeControls(deviceID: device.id, fridge: fridge)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(device.snapshot?.metrics.filter { $0.value != nil } ?? []) { metric in
|
||||
LabeledContent(metric.label) {
|
||||
Text(metric.formattedWithUnit)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.footnote)
|
||||
}
|
||||
} header: {
|
||||
Text("Messwerte")
|
||||
}
|
||||
|
||||
Section {
|
||||
if let fault = device.snapshot?.fault {
|
||||
Label(fault, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
if let state = device.snapshot?.state {
|
||||
Text(state).font(.caption2)
|
||||
}
|
||||
ForEach(device.snapshot?.offReasons ?? [], id: \.self) { reason in
|
||||
Text(reason)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Label(device.link.label, systemImage: "antenna.radiowaves.left.and.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("Gerät ist nicht mehr im Fahrzeugprofil.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle(device?.name ?? "Gerät")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Kühlbox von der Uhr aus stellen – das einzige Gerät, das sich stellen
|
||||
/// lässt.
|
||||
///
|
||||
/// Wie am iPhone folgen die Schalter dem Gerät, nicht der Vermutung: Getippt
|
||||
/// wird ein Befehl, angezeigt wird, was die Box zurückmeldet. Über die Uhr
|
||||
/// dauert das einen Wimpernschlag länger – der Weg geht über das iPhone –,
|
||||
/// dafür kann kein Schalter etwas behaupten, das die Box nie bestätigt hat.
|
||||
struct WatchFridgeControls: View {
|
||||
let deviceID: UUID
|
||||
let fridge: WatchFridge
|
||||
|
||||
@Environment(PhoneLink.self) private var link
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Toggle("Ein", isOn: Binding(
|
||||
get: { fridge.isPoweredOn },
|
||||
set: { link.send(.fridgePower(device: deviceID, on: $0)) }
|
||||
))
|
||||
|
||||
Toggle("Eco", isOn: Binding(
|
||||
get: { fridge.isEco },
|
||||
set: { link.send(.fridgeEco(device: deviceID, eco: $0)) }
|
||||
))
|
||||
.disabled(!fridge.isPoweredOn)
|
||||
|
||||
target(zone: .left,
|
||||
title: fridge.isDualZone ? "Soll links" : "Soll",
|
||||
value: fridge.leftTarget)
|
||||
|
||||
if fridge.isDualZone {
|
||||
target(zone: .right, title: "Soll rechts", value: fridge.rightTarget)
|
||||
}
|
||||
} header: {
|
||||
Text("Kühlbox")
|
||||
} footer: {
|
||||
if !link.isReachable {
|
||||
Text("iPhone gerade nicht erreichbar – Stellbefehle gehen erst "
|
||||
+ "wieder durch, wenn es antwortet.")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func target(zone: WatchFridgeZone, title: String, value: Int?) -> some View {
|
||||
Stepper(value: Binding(
|
||||
get: { value ?? fridge.targetRange.lowerBound },
|
||||
set: { link.send(.fridgeTarget(device: deviceID, zone: zone, value: $0)) }
|
||||
), in: fridge.targetRange) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value.map { "\($0) \(fridge.unitSymbol)" } ?? "–")
|
||||
.font(.headline)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
.disabled(!fridge.isPoweredOn || value == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Nivellierung auf der Uhr: Libelle, Zahlen, und was zu tun ist.
|
||||
struct WatchLevelView: View {
|
||||
/// Nur für den Einstieg – angezeigt wird immer der Stand aus dem letzten
|
||||
/// Datensatz, sonst bliebe die Ansicht auf dem Wert vom Öffnen stehen.
|
||||
let device: WatchDevice
|
||||
|
||||
@Environment(PhoneLink.self) private var link
|
||||
@State private var isCalibrating = false
|
||||
|
||||
private var current: WatchDevice { link.payload?.levelDevice ?? device }
|
||||
private var state: LevelState { current.level ?? LevelState() }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 10) {
|
||||
WatchBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(maxWidth: 110)
|
||||
|
||||
HStack(spacing: 14) {
|
||||
angle("Längs", state.pitch)
|
||||
angle("Quer", state.roll)
|
||||
}
|
||||
|
||||
Text(state.instruction ?? "Keine Werte")
|
||||
.font(.footnote.weight(.medium))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(state.isLevel ? .green : .primary)
|
||||
|
||||
if let wedges = wedgeText {
|
||||
Text(wedges)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
WatchAlignView(deviceID: current.id)
|
||||
} label: {
|
||||
Label("Ausrichten", systemImage: "car.side.rear.and.collision.and.car.side.front")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Kalibrieren", systemImage: "scope") { isCalibrating = true }
|
||||
.font(.footnote)
|
||||
|
||||
if state.isKnownUncalibrated {
|
||||
Text("Noch nicht kalibriert – die Anzeige zeigt die Lage des "
|
||||
+ "Sensors, nicht die des Fahrzeugs.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.navigationTitle("Nivellierung")
|
||||
.confirmationDialog("Auf aktuelle Lage kalibrieren?",
|
||||
isPresented: $isCalibrating, titleVisibility: .visible) {
|
||||
Button("Kalibrieren") { link.send(.calibrateLevel(device: current.id)) }
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Nur sinnvoll, wenn das Fahrzeug jetzt eben steht.")
|
||||
}
|
||||
}
|
||||
|
||||
private func angle(_ title: String, _ value: Double?) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
Text(value.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.font(.title3.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie hoch die Auffahrkeile sein müssen – reine Geometrie, und nur zu
|
||||
/// haben, wenn im Fahrzeugprofil Spurweite und Radstand stehen.
|
||||
private var wedgeText: String? {
|
||||
guard let profile = link.payload?.profile else { return nil }
|
||||
var parts: [String] = []
|
||||
if let roll = state.roll, let width = profile.trackWidth,
|
||||
let wedge = LevelingWedge.across(roll: roll, trackWidth: width) {
|
||||
parts.append(String(format: "%.0f cm %@", wedge.heightInCentimetres, wedge.side.text))
|
||||
}
|
||||
if let pitch = state.pitch, let base = profile.wheelbase,
|
||||
let wedge = LevelingWedge.along(pitch: pitch, wheelbase: base) {
|
||||
parts.append(String(format: "%.0f cm %@", wedge.heightInCentimetres, wedge.side.text))
|
||||
}
|
||||
return parts.isEmpty ? nil : "Keile: " + parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Der Einstieg: das Wichtigste untereinander, in der Reihenfolge, in der man
|
||||
/// im Wohnmobil danach schaut.
|
||||
struct WatchRootView: View {
|
||||
@Environment(PhoneLink.self) private var link
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let payload = link.payload {
|
||||
content(for: payload)
|
||||
} else {
|
||||
waiting
|
||||
}
|
||||
}
|
||||
.navigationTitle(link.payload?.profile.name ?? "Camper")
|
||||
}
|
||||
}
|
||||
|
||||
private func content(for payload: WatchPayload) -> some View {
|
||||
List {
|
||||
if let level = payload.levelDevice {
|
||||
Section {
|
||||
NavigationLink {
|
||||
WatchLevelView(device: level)
|
||||
} label: {
|
||||
LevelSummaryRow(state: level.level, link: level.link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(payload.devices.filter { $0.role != .leveling }) { device in
|
||||
NavigationLink {
|
||||
WatchDeviceDetailView(deviceID: device.id)
|
||||
} label: {
|
||||
DeviceRow(device: device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
FreshnessNote(payload: payload, receivedAt: link.receivedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var waiting: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "iphone.gen3.radiowaves.left.and.right")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
Text("Warte auf das iPhone")
|
||||
.font(.headline)
|
||||
Text("Die Werte kommen vom iPhone. Dort muss Camper Monitor "
|
||||
+ "geöffnet sein.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Gerätezeile: Name klein, Hauptwert gross – auf dem kleinen Bildschirm
|
||||
/// zählt nur der eine Wert, wegen dem man den Arm hebt.
|
||||
struct DeviceRow: View {
|
||||
let device: WatchDevice
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: device.role.symbol)
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(device.name)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
if let metric = device.snapshot?.primaryMetric, metric.value != nil {
|
||||
Text(metric.formattedWithUnit)
|
||||
.font(.title3.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(device.isLive ? .primary : .secondary)
|
||||
} else {
|
||||
Text(device.link.label)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Circle()
|
||||
.fill(device.statusColor)
|
||||
.frame(width: 7, height: 7)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Nivellierung bekommt eine eigene, grössere Zeile: sie ist der Grund,
|
||||
/// aus dem man die Uhr im Fahrzeug überhaupt anschaut.
|
||||
struct LevelSummaryRow: View {
|
||||
let state: LevelState?
|
||||
let link: DeviceLinkState
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
WatchBubble(pitch: state?.pitch, roll: state?.roll)
|
||||
.frame(width: 44, height: 44)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Nivellierung")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
// Ohne Messwerte steht hier, woran es liegt, statt eines
|
||||
// nichtssagenden Strichs.
|
||||
Text(state?.instruction ?? link.label)
|
||||
.font(.footnote.weight(.medium))
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie alt der Stand ist – auf der Uhr die wichtigste Nebenangabe. Alles hier
|
||||
/// ist weitergereicht; eine abgerissene Strecke zum iPhone sieht sonst genauso
|
||||
/// aus wie ein Fahrzeug, an dem sich nichts tut.
|
||||
struct FreshnessNote: View {
|
||||
let payload: WatchPayload
|
||||
let receivedAt: Date?
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 1)) { context in
|
||||
let age = context.date.timeIntervalSince(receivedAt ?? payload.generatedAt)
|
||||
Label {
|
||||
Text(text(age: age))
|
||||
.font(.caption2)
|
||||
} icon: {
|
||||
Image(systemName: age > 10 ? "exclamationmark.triangle.fill" : "iphone.gen3")
|
||||
}
|
||||
.foregroundStyle(age > 10 ? .orange : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func text(age: TimeInterval) -> String {
|
||||
if !payload.isRadioReady { return payload.radioStatus }
|
||||
if age < 3 { return "Stand: eben" }
|
||||
if age < 90 { return String(format: "Stand: vor %.0f s", age) }
|
||||
return String(format: "Stand: vor %.0f min – iPhone erreichbar?", age / 60)
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchDevice {
|
||||
/// Grün heisst: der Wert ist frisch und kommt vom Gerät selbst.
|
||||
var statusColor: Color {
|
||||
switch link {
|
||||
case .live: return (snapshot?.isStale ?? true) ? .orange : .green
|
||||
case .needsKey: return .orange
|
||||
case .failed: return .red
|
||||
default: return .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
|
||||
/// Erfundene Werte für den Simulator, damit sich die Ansichten ohne iPhone und
|
||||
/// ohne Fahrzeug prüfen lassen. Eingeschaltet über `CAMPER_DEMO=1`:
|
||||
///
|
||||
/// xcrun simctl launch --terminate-running-process booted \
|
||||
/// de.fritob.CamperMonitor.watchkitapp
|
||||
/// # mit SIMCTL_CHILD_CAMPER_DEMO=1 davor
|
||||
///
|
||||
/// Im normalen Betrieb wird hiervon nichts ausgeführt.
|
||||
enum WatchDemo {
|
||||
|
||||
private static let profile = Profile(name: "Kastenwagen", symbol: "box.truck",
|
||||
trackWidth: 1.85, wheelbase: 3.50)
|
||||
|
||||
private static let solarID = UUID(uuidString: "00000000-0000-0000-0000-000000000050")!
|
||||
private static let batteryID = UUID(uuidString: "00000000-0000-0000-0000-0000000000A0")!
|
||||
private static let fridgeID = UUID(uuidString: "00000000-0000-0000-0000-0000000000F0")!
|
||||
private static let levelID = UUID(uuidString: "00000000-0000-0000-0000-0000000000E0")!
|
||||
|
||||
/// `seconds` läuft weiter, damit sich die Neigung bewegt – sonst zeigt der
|
||||
/// Ausrichtungs-Assistent nie einen Trend.
|
||||
static func payload(at seconds: Double) -> WatchPayload {
|
||||
let now = Date()
|
||||
let swing = sin(seconds / 8)
|
||||
|
||||
var level = LevelState()
|
||||
level.pitch = 1.4 * swing + 0.3
|
||||
level.roll = -0.9 * cos(seconds / 11)
|
||||
level.pitchOffset = 0.4
|
||||
level.rollOffset = -0.2
|
||||
|
||||
var solar = DeviceSnapshot(deviceID: solarID, timestamp: now, rssi: -58)
|
||||
solar.state = "Konstantspannung (Absorption)"
|
||||
solar.metrics = [
|
||||
Metric("pv_power", "PV-Leistung", 284 + 40 * swing, unit: "W", precision: 0, primary: true),
|
||||
Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2),
|
||||
Metric("battery_current", "Ladestrom", 18.6, unit: "A", precision: 1),
|
||||
]
|
||||
|
||||
var battery = DeviceSnapshot(deviceID: batteryID, timestamp: now, rssi: -64)
|
||||
battery.state = "Lädt"
|
||||
battery.metrics = [
|
||||
Metric("soc", "Ladezustand", 78.4, unit: "%", precision: 1, primary: true),
|
||||
Metric("voltage", "Spannung", 13.66, unit: "V", precision: 2),
|
||||
Metric("current", "Strom", 18.6, unit: "A", precision: 1),
|
||||
Metric("capacity", "Restkapazität", 156.8, unit: "Ah", precision: 1),
|
||||
]
|
||||
|
||||
var fridge = DeviceSnapshot(deviceID: fridgeID, timestamp: now, rssi: -66)
|
||||
fridge.state = "Kompressor läuft"
|
||||
fridge.metrics = [
|
||||
Metric("temperature", "Innentemperatur", 6, unit: "°C", precision: 0, primary: true),
|
||||
Metric("target", "Solltemperatur", 4, unit: "°C", precision: 0),
|
||||
Metric("battery_voltage", "Bordspannung", 12.7, unit: "V", precision: 1),
|
||||
]
|
||||
|
||||
return WatchPayload(
|
||||
generatedAt: now,
|
||||
profile: profile,
|
||||
isRadioReady: true,
|
||||
radioStatus: "Demo-Modus",
|
||||
devices: [
|
||||
WatchDevice(id: levelID, name: "Nivellierung", role: .leveling,
|
||||
link: .live, snapshot: level.snapshot(deviceID: levelID, rssi: -70),
|
||||
level: level, fridge: nil),
|
||||
WatchDevice(id: batteryID, name: "Bulltron 200 Ah", role: .bms,
|
||||
link: .live, snapshot: battery, level: nil, fridge: nil),
|
||||
WatchDevice(id: solarID, name: "Solar Dach", role: .solarCharger,
|
||||
link: .live, snapshot: solar, level: nil, fridge: nil),
|
||||
WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge,
|
||||
link: .live, snapshot: fridge, level: nil,
|
||||
fridge: WatchFridge(isPoweredOn: true, isEco: true, isLocked: false,
|
||||
isDualZone: false, unitSymbol: "°C",
|
||||
leftTarget: 4, leftCurrent: 6,
|
||||
rightTarget: nil, rightCurrent: nil,
|
||||
minTarget: -20, maxTarget: 20,
|
||||
batteryVolts: 12.7)),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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>
|
||||
<!-- Alles Übrige erzeugt Xcode aus den INFOPLIST_KEY_-Einstellungen des
|
||||
Targets. Hier steht nur, was sich dort nicht ausdrücken lässt.
|
||||
|
||||
Der Hintergrundbetrieb ist für die Apple Watch da: Beim Rangieren liegt
|
||||
das iPhone in der Halterung mit dunklem Bildschirm, und die Anzeige am
|
||||
Handgelenk wäre sonst genau dann tot, wenn sie gebraucht wird.
|
||||
Verbundene Geräte (Neigungsmesser, BMS, Kühlbox) liefern damit weiter.
|
||||
Die Victron-Werbedaten nicht: dafür müsste ohne Dienstfilter gesucht
|
||||
werden, und das lässt iOS im Hintergrund grundsätzlich nicht zu. -->
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -12,6 +12,11 @@ iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest:
|
||||
|
||||
Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt.
|
||||
|
||||
Dazu gehört eine **Apple-Watch-App**: Übersicht, Nivellierung samt
|
||||
Ausrichtungs-Assistent mit Vibration und die Steuerung der Kühlbox am
|
||||
Handgelenk. Sie funkt nicht selbst, sondern hängt am iPhone – warum, steht
|
||||
unter [Apple Watch](#apple-watch).
|
||||
|
||||
Die Firmware des Neigungsmessers liegt mit im Projekt, siehe
|
||||
[firmware/vanalign](firmware/vanalign/README.md).
|
||||
|
||||
@@ -25,8 +30,21 @@ 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.
|
||||
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
|
||||
muss die watchOS-Plattform in Xcode installiert sein – sonst bricht schon das
|
||||
Übersetzen des Symbolkatalogs ab:
|
||||
|
||||
```bash
|
||||
xcodebuild -downloadPlatform watchOS
|
||||
```
|
||||
|
||||
Auf die Uhr kommt die App danach von selbst: Sie steckt in der iPhone-App und
|
||||
taucht in der Watch-App des iPhones unter *Verfügbare Apps* auf.
|
||||
|
||||
Wichtig: **Der Simulator hat kein Bluetooth.** Die App startet dort, findet aber
|
||||
nie ein Gerät. Zum Testen muss sie auf ein echtes iPhone.
|
||||
|
||||
@@ -191,6 +209,57 @@ damit exakt: 2,0° Querneigung bei 2,00 m Spurweite ergeben 7,0 cm unter die
|
||||
tieferstehende Seite. Die Maße stehen im Fahrzeugprofil, erreichbar über das
|
||||
ⓘ in der Fahrzeugliste.
|
||||
|
||||
## Apple Watch
|
||||
|
||||
Die Uhr zeigt dasselbe wie das Dashboard, nur auf das eingedampft, wofür man
|
||||
den Arm hebt:
|
||||
|
||||
* **Übersicht** – je Gerät der Hauptwert und ein Punkt für den Zustand der
|
||||
Verbindung. Ganz unten steht, wie alt der Stand ist.
|
||||
* **Nivellierung** – Libelle, beide Winkel, was zu tun ist, und die nötige
|
||||
Keilhöhe, falls im Fahrzeugprofil Spurweite und Radstand stehen.
|
||||
* **Ausrichten** – derselbe Assistent wie am iPhone: Tendenz, Rat und der
|
||||
Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das
|
||||
Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn
|
||||
gegenüber dem iPhone – beim Rangieren schaut niemand aufs Display, aber die
|
||||
Vibration am Handgelenk kommt an.
|
||||
* **Kühlbox** – ein/aus, Eco oder Max und die Solltemperatur, über die Krone
|
||||
gestellt.
|
||||
|
||||
### Warum die Uhr nicht selbst funkt
|
||||
|
||||
watchOS könnte Bluetooth, und trotzdem geht alles über das iPhone. Zwei Gründe,
|
||||
beide hart:
|
||||
|
||||
* BMS, Kühlbox und Neigungsmesser lassen jeweils nur **eine** Verbindung zu.
|
||||
Eine mitlesende Uhr würde dem iPhone die Verbindung wegnehmen, statt sie zu
|
||||
ergänzen.
|
||||
* Die Victron-Schlüssel liegen in der Keychain des iPhones. Sie auf die Uhr zu
|
||||
kopieren hiesse, sie ein zweites Mal aufzubewahren, ohne dass der zweite Ort
|
||||
irgendetwas brächte.
|
||||
|
||||
Das iPhone bleibt also das Funkgerät und reicht fertige Messwerte weiter. Was
|
||||
die Uhr stellt, stellt in Wahrheit das iPhone – und angezeigt wird auch dort
|
||||
nur, was das Gerät zurückmeldet, nicht der Tastendruck.
|
||||
|
||||
### Was das für den Betrieb heisst
|
||||
|
||||
Auf dem iPhone muss Camper Monitor laufen. Solange die Uhr meldet, dass jemand
|
||||
hinschaut, hält die App das Funkgerät auch im Hintergrund am Leben – sonst wäre
|
||||
die Anzeige am Handgelenk beim Rangieren tot, denn dann liegt das iPhone mit
|
||||
dunklem Bildschirm in der Halterung. Die verbundenen Geräte liefern dabei
|
||||
weiter; die Victron-Werbedaten nicht, weil iOS im Hintergrund kein ungefiltertes
|
||||
Suchen erlaubt.
|
||||
|
||||
Gesendet wird in zwei Takten: ein halber Sekundentakt, solange die Watch-App im
|
||||
Vordergrund ist, sonst alle zwei Sekunden und nur bei Änderungen. Der schnelle
|
||||
Takt läuft über eine Frist, die die Uhr regelmässig erneuert – schläft sie ein,
|
||||
hört das iPhone von selbst wieder auf.
|
||||
|
||||
Was die Uhr zeigt, ist immer Weitergereichtes. Deshalb steht auf jedem
|
||||
Bildschirm das Alter des Standes: Eine abgerissene Strecke zum iPhone sähe sonst
|
||||
genauso aus wie ein Fahrzeug, an dem sich nichts tut.
|
||||
|
||||
## Diagnose
|
||||
|
||||
Im Alltag zeigt die App nur Messwerte und Bedienelemente. Die technischen
|
||||
@@ -218,6 +287,10 @@ ohne Bluetooth prüfen lassen. In Xcode unter *Product → Scheme → Edit Schem
|
||||
Run → Arguments* die Umgebungsvariable `CAMPER_DEMO` auf `1` setzen. Er greift
|
||||
nur in Debug-Builds.
|
||||
|
||||
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.
|
||||
|
||||
## Protokolle prüfen
|
||||
|
||||
```bash
|
||||
@@ -235,14 +308,19 @@ firmware/vanalign/ Firmware des Neigungsmessers (ESPHome)
|
||||
├── esp32_ble.yaml Neigungsmessung und Bluetooth-Schnittstelle
|
||||
└── experimente/ Nicht für den Betrieb nötig
|
||||
|
||||
CamperMonitor/
|
||||
Shared/ In iPhone- und Watch-App übersetzt
|
||||
├── Models/
|
||||
│ ├── Profile.swift Fahrzeug samt Maßen
|
||||
│ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilberechnung
|
||||
│ ├── SensorOrientation.swift Einbaulage und ihre Erkennung
|
||||
│ ├── 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
|
||||
└── WatchLink/
|
||||
└── WatchLink.swift Datensatz und Befehle zwischen iPhone und Uhr
|
||||
|
||||
CamperMonitor/
|
||||
├── Bluetooth/
|
||||
│ ├── BluetoothManager.swift Zentraler Scan, Verbindungen, Verlauf
|
||||
│ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten
|
||||
@@ -259,6 +337,8 @@ CamperMonitor/
|
||||
├── Store/
|
||||
│ ├── DeviceStore.swift Geräteliste, Persistenz
|
||||
│ └── KeychainStore.swift Victron-Schlüssel
|
||||
├── Watch/
|
||||
│ └── PhoneWatchLink.swift Sendet den Stand, nimmt Befehle der Uhr an
|
||||
└── Views/
|
||||
├── DashboardView.swift Kachelübersicht
|
||||
├── DeviceCard.swift Eine Kachel
|
||||
@@ -271,13 +351,34 @@ CamperMonitor/
|
||||
├── SettingsView.swift App-Einstellungen
|
||||
├── SensorSetupView.swift Assistent für die Einbaulage
|
||||
└── AddDeviceView.swift Scannen und Einrichten
|
||||
|
||||
CamperMonitorWatch/ watchOS-App
|
||||
├── CamperWatchApp.swift Einstieg, meldet dem iPhone das Hinschauen
|
||||
├── PhoneLink.swift Gegenstelle zum iPhone
|
||||
├── WatchDemo.swift Erfundene Werte für den Simulator
|
||||
└── Views/
|
||||
├── WatchRootView.swift Übersicht und Altersangabe
|
||||
├── WatchBubble.swift Libelle für den kleinen Bildschirm
|
||||
├── WatchLevelView.swift Neigung, Keilhöhe, Kalibrierung
|
||||
├── WatchAlignView.swift Ausrichten mit Vibration
|
||||
├── WatchDeviceDetailView.swift Alle Werte eines Geräts
|
||||
└── WatchFridgeControls.swift Kühlbox stellen
|
||||
|
||||
Config/
|
||||
└── CamperMonitor-Info.plist Nur der Hintergrundbetrieb für die Uhr
|
||||
```
|
||||
|
||||
## Bekannte Grenzen
|
||||
|
||||
* **Kein Hintergrundbetrieb.** iOS erlaubt das ungefilterte Scannen nach
|
||||
Advertisements nur im Vordergrund. Die App pausiert, sobald sie in den
|
||||
Hintergrund geht, und nimmt beim Zurückkommen wieder auf.
|
||||
* **Im Hintergrund nur die verbundenen Geräte.** iOS erlaubt das ungefilterte
|
||||
Scannen nach Advertisements nur im Vordergrund; die Victron-Werte stehen im
|
||||
Hintergrund also still. Verbundene Geräte – Neigungsmesser, BMS, Kühlbox –
|
||||
laufen weiter, aber nur solange die Uhr meldet, dass jemand hinschaut. Ohne
|
||||
Uhr pausiert die App im Hintergrund vollständig und nimmt beim Zurückkommen
|
||||
wieder auf.
|
||||
* **Die Uhr braucht das iPhone.** Sie zeigt nur, was von dort kommt. Steckt das
|
||||
iPhone ausser Reichweite, steht auf der Uhr, wie alt der letzte Stand ist –
|
||||
aber kein neuer.
|
||||
* **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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine einzelne Messgrösse in einer bereits formatierten Form.
|
||||
struct Metric: Identifiable, Hashable, Sendable {
|
||||
struct Metric: Identifiable, Hashable, Codable, Sendable {
|
||||
let key: String
|
||||
let label: String
|
||||
let value: Double?
|
||||
@@ -39,7 +39,7 @@ struct Metric: Identifiable, Hashable, Sendable {
|
||||
}
|
||||
|
||||
/// Der komplette, zuletzt empfangene Zustand eines Geräts.
|
||||
struct DeviceSnapshot: Identifiable, Sendable {
|
||||
struct DeviceSnapshot: Identifiable, Codable, Equatable, Sendable {
|
||||
var id: UUID { deviceID }
|
||||
var deviceID: UUID
|
||||
var timestamp: Date
|
||||
@@ -58,7 +58,7 @@ struct DeviceSnapshot: Identifiable, Sendable {
|
||||
/// Feste Angaben des Geräts, etwa Modell oder Seriennummer.
|
||||
var info: [InfoItem] = []
|
||||
|
||||
struct InfoItem: Identifiable, Hashable, Sendable {
|
||||
struct InfoItem: Identifiable, Hashable, Codable, Sendable {
|
||||
let label: String
|
||||
let value: String
|
||||
var id: String { label }
|
||||
@@ -76,7 +76,7 @@ struct DeviceSnapshot: Identifiable, Sendable {
|
||||
}
|
||||
|
||||
/// Verbindungszustand für die UI.
|
||||
enum DeviceLinkState: Equatable, Sendable {
|
||||
enum DeviceLinkState: Equatable, Codable, Sendable {
|
||||
case idle
|
||||
case searching
|
||||
case connecting
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
/// Zustand des Neigungsmessers.
|
||||
struct LevelState: Equatable, Codable, Sendable {
|
||||
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
|
||||
/// Bereits auf die Einbaulage des Sensors umgerechnet.
|
||||
var pitch: Double?
|
||||
/// Querneigung: positiv bedeutet, die rechte Seite steht höher.
|
||||
var roll: Double?
|
||||
/// Wie der Sensor selbst meldet – nötig, um die Einbaulage zu bestimmen.
|
||||
var rawPitch: Double?
|
||||
var rawRoll: Double?
|
||||
var pitchOffset: Double?
|
||||
var rollOffset: Double?
|
||||
|
||||
var hasReading: Bool { pitch != nil || roll != nil }
|
||||
|
||||
/// Ob kalibriert wurde – oder nil, wenn das Gerät es nicht verrät.
|
||||
///
|
||||
/// Nicht jede Firmware stellt die Offsets bereit. Fehlen sie, heisst das
|
||||
/// „unbekannt“ und nicht „nicht kalibriert“: eine Warnung, die sich nie
|
||||
/// abstellen lässt, ist schlimmer als keine.
|
||||
var calibrationState: Bool? {
|
||||
guard let pitchOffset, let rollOffset else { return nil }
|
||||
return abs(pitchOffset) > 0.001 || abs(rollOffset) > 0.001
|
||||
}
|
||||
|
||||
/// Nur wahr, wenn es auch belegt ist.
|
||||
var isCalibrated: Bool { calibrationState == true }
|
||||
|
||||
/// Nur wahr, wenn das Gerät ausdrücklich Nulloffsets meldet.
|
||||
var isKnownUncalibrated: Bool { calibrationState == false }
|
||||
|
||||
/// Bis hierhin gilt das Fahrzeug als eben genug.
|
||||
static let levelTolerance = 0.5
|
||||
|
||||
var isLevel: Bool {
|
||||
guard let pitch, let roll else { return false }
|
||||
return abs(pitch) <= Self.levelTolerance && abs(roll) <= Self.levelTolerance
|
||||
}
|
||||
|
||||
/// Die grössere der beiden Abweichungen – das ist die, die man zuerst
|
||||
/// ausgleichen will.
|
||||
var largestDeviation: Double? {
|
||||
switch (pitch, roll) {
|
||||
case let (p?, r?): return max(abs(p), abs(r))
|
||||
case let (p?, nil): return abs(p)
|
||||
case let (nil, r?): return abs(r)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Was zu tun ist, in Worten. Beim Ausrichten schaut man aufs Handy,
|
||||
/// nicht auf Vorzeichen.
|
||||
var instruction: String? {
|
||||
guard let pitch, let roll else { return nil }
|
||||
if isLevel { return "Steht eben" }
|
||||
var parts: [String] = []
|
||||
if abs(pitch) > Self.levelTolerance {
|
||||
parts.append(pitch > 0 ? "Heck steht höher" : "Front steht höher")
|
||||
}
|
||||
if abs(roll) > Self.levelTolerance {
|
||||
parts.append(roll > 0 ? "rechts steht höher" : "links steht höher")
|
||||
}
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
|
||||
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
|
||||
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
|
||||
snapshot.metrics = [
|
||||
Metric("pitch", "Längsneigung", pitch, unit: "°", precision: 1, primary: true),
|
||||
Metric("roll", "Querneigung", roll, unit: "°", precision: 1),
|
||||
]
|
||||
snapshot.state = instruction
|
||||
if isKnownUncalibrated {
|
||||
snapshot.offReasons = ["Nicht kalibriert"]
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import Foundation
|
||||
|
||||
/// Was zwischen iPhone und Uhr hin- und hergeht.
|
||||
///
|
||||
/// Die Uhr funkt **nicht** selbst zu den Geräten im Fahrzeug, obwohl watchOS
|
||||
/// das könnte. Zwei Gründe sprechen dagegen, und beide sind hart:
|
||||
///
|
||||
/// * BMS, Kühlbox und Neigungsmesser lassen jeweils nur **eine** Verbindung zu.
|
||||
/// Eine mitlesende Uhr würde dem iPhone die Verbindung wegnehmen, statt sie
|
||||
/// zu ergänzen.
|
||||
/// * Die Victron-Schlüssel liegen in der Keychain des iPhones. Die Uhr hat ihre
|
||||
/// eigene; die Schlüssel dorthin zu kopieren hiesse, sie ein zweites Mal
|
||||
/// aufzubewahren, ohne dass ein zweiter Aufbewahrungsort etwas brächte.
|
||||
///
|
||||
/// Das iPhone bleibt also das Funkgerät und schickt fertige Messwerte weiter,
|
||||
/// die Uhr ist Anzeige und Fernbedienung.
|
||||
enum WatchLink {
|
||||
|
||||
/// Schlüssel im WatchConnectivity-Wörterbuch. Übertragen wird jeweils ein
|
||||
/// JSON-`Data`, nicht ein aufgedröseltes Wörterbuch: die Typen unten sind
|
||||
/// damit die einzige Stelle, an der das Format steht.
|
||||
static let payloadKey = "payload"
|
||||
static let commandKey = "command"
|
||||
|
||||
/// Wie lange ein „ich schaue gerade hin“ der Uhr gilt, ohne erneuert zu
|
||||
/// werden. Bricht die Verbindung ab oder wandert die App auf der Uhr in den
|
||||
/// Hintergrund, hört das iPhone von selbst wieder auf, im Sekundentakt zu
|
||||
/// senden.
|
||||
static let liveLease: TimeInterval = 12
|
||||
|
||||
/// Sendetakt, solange die Uhr hinschaut. Der Neigungsmesser wird zweimal je
|
||||
/// Sekunde abgefragt – schneller zu senden brächte nichts.
|
||||
static let liveInterval: TimeInterval = 0.5
|
||||
|
||||
/// Sendetakt sonst. Geht als Anwendungskontext raus, den watchOS auch dann
|
||||
/// noch zustellt, wenn die App auf der Uhr gerade nicht läuft.
|
||||
static let idleInterval: TimeInterval = 2
|
||||
}
|
||||
|
||||
/// Der gesamte Stand eines Fahrzeugs, so wie ihn die Uhr anzeigt.
|
||||
struct WatchPayload: Codable, Equatable, Sendable {
|
||||
/// Steigt, wenn sich das Format ändert. Eine Uhr mit älterer App bekommt
|
||||
/// sonst Werte, die sie falsch versteht – lieber sagt sie „App aktualisieren“.
|
||||
static let currentVersion = 1
|
||||
|
||||
var version = WatchPayload.currentVersion
|
||||
var generatedAt: Date
|
||||
/// Das am iPhone gewählte Fahrzeug.
|
||||
var profile: Profile
|
||||
/// Ob das iPhone gerade überhaupt funken kann.
|
||||
var isRadioReady: Bool
|
||||
/// Klartext dazu, falls nicht.
|
||||
var radioStatus: String
|
||||
var devices: [WatchDevice]
|
||||
|
||||
/// Nach der Rolle gesucht, nicht nach vorhandenen Werten: Ein
|
||||
/// Neigungsmesser, der gerade nicht antwortet, gehört trotzdem an seinen
|
||||
/// Platz – sonst rutschte er beim Verbindungsabriss in die Geräteliste.
|
||||
var levelDevice: WatchDevice? { devices.first { $0.role == .leveling } }
|
||||
var fridgeDevice: WatchDevice? { devices.first { $0.role == .fridge } }
|
||||
|
||||
/// Ob sich inhaltlich nichts geändert hat. Der Zeitstempel zählt dabei
|
||||
/// nicht mit: sonst gälte jeder Datensatz als neu und die Uhr bekäme rund
|
||||
/// um die Uhr Funkverkehr, auch wenn das Fahrzeug still steht.
|
||||
func hasSameContent(as other: WatchPayload) -> Bool {
|
||||
var mine = self, theirs = other
|
||||
mine.generatedAt = .distantPast
|
||||
theirs.generatedAt = .distantPast
|
||||
return mine == theirs
|
||||
}
|
||||
|
||||
/// Wie alt der Stand ist. Auf der Uhr die wichtigste Angabe überhaupt:
|
||||
/// alles hier ist Weitergereichtes, und eine abgerissene Strecke zum
|
||||
/// iPhone sieht sonst aus wie ein stillstehendes Fahrzeug.
|
||||
func age(now: Date = Date()) -> TimeInterval { now.timeIntervalSince(generatedAt) }
|
||||
}
|
||||
|
||||
/// Ein Gerät mitsamt seinem letzten Messwertsatz.
|
||||
struct WatchDevice: Codable, Equatable, Identifiable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var role: DeviceRole
|
||||
var link: DeviceLinkState
|
||||
var snapshot: DeviceSnapshot?
|
||||
/// Nur beim Neigungsmesser belegt.
|
||||
var level: LevelState?
|
||||
/// Nur bei der Kühlbox belegt.
|
||||
var fridge: WatchFridge?
|
||||
|
||||
var isLive: Bool { link == .live && !(snapshot?.isStale ?? true) }
|
||||
}
|
||||
|
||||
/// Zustand und Grenzen der Kühlbox, so weit die Uhr sie braucht.
|
||||
///
|
||||
/// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit
|
||||
/// drin, den auf die Uhr zu schicken nichts brächte.
|
||||
struct WatchFridge: Codable, Equatable, Sendable {
|
||||
var isPoweredOn: Bool
|
||||
var isEco: Bool
|
||||
var isLocked: Bool
|
||||
var isDualZone: Bool
|
||||
var unitSymbol: String
|
||||
var leftTarget: Int?
|
||||
var leftCurrent: Int?
|
||||
var rightTarget: Int?
|
||||
var rightCurrent: Int?
|
||||
var minTarget: Int
|
||||
var maxTarget: Int
|
||||
var batteryVolts: Double?
|
||||
|
||||
var targetRange: ClosedRange<Int> {
|
||||
minTarget < maxTarget ? minTarget...maxTarget : -30...20
|
||||
}
|
||||
}
|
||||
|
||||
/// Was die Uhr das iPhone tun lässt.
|
||||
///
|
||||
/// Gestellt wird nur, was auch am iPhone gestellt werden kann; die Uhr bekommt
|
||||
/// keine eigenen Fähigkeiten. Das Ergebnis liest sie wie jeder andere Wert aus
|
||||
/// dem nächsten Datensatz – bestätigt wird also das Gerät, nicht der Tastendruck.
|
||||
enum WatchCommand: Codable, Equatable, Sendable {
|
||||
/// Die Uhr schaut gerade hin und möchte den schnellen Takt.
|
||||
case hello(live: Bool)
|
||||
case fridgePower(device: UUID, on: Bool)
|
||||
case fridgeEco(device: UUID, eco: Bool)
|
||||
case fridgeLock(device: UUID, locked: Bool)
|
||||
case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int)
|
||||
case calibrateLevel(device: UUID)
|
||||
|
||||
/// Ob der Befehl den schnellen Sendetakt verlängert.
|
||||
var wantsLiveUpdates: Bool {
|
||||
if case .hello(let live) = self { return live }
|
||||
// Wer stellt, schaut auch hin.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Spiegelt `AlpicoolState.Zone`, ohne das Protokoll auf die Uhr zu ziehen.
|
||||
enum WatchFridgeZone: String, Codable, Equatable, Sendable {
|
||||
case left, right
|
||||
}
|
||||
|
||||
// MARK: - Verpacken
|
||||
|
||||
extension WatchPayload {
|
||||
func encoded() throws -> Data { try WatchCoder.encoder.encode(self) }
|
||||
|
||||
/// Aus einem WatchConnectivity-Wörterbuch zurücklesen. Fremde oder zu neue
|
||||
/// Datensätze ergeben nil statt eines halb gefüllten Zustands.
|
||||
static func decode(from message: [String: Any]) -> WatchPayload? {
|
||||
guard let data = message[WatchLink.payloadKey] as? Data,
|
||||
let payload = try? WatchCoder.decoder.decode(WatchPayload.self, from: data),
|
||||
payload.version == WatchPayload.currentVersion else { return nil }
|
||||
return payload
|
||||
}
|
||||
|
||||
func message() throws -> [String: Any] { [WatchLink.payloadKey: try encoded()] }
|
||||
}
|
||||
|
||||
extension WatchCommand {
|
||||
func message() throws -> [String: Any] {
|
||||
[WatchLink.commandKey: try WatchCoder.encoder.encode(self)]
|
||||
}
|
||||
|
||||
static func decode(from message: [String: Any]) -> WatchCommand? {
|
||||
guard let data = message[WatchLink.commandKey] as? Data else { return nil }
|
||||
return try? WatchCoder.decoder.decode(WatchCommand.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Feste Einstellungen für beide Richtungen. Ohne das würde ein Datum auf der
|
||||
/// einen Seite anders geschrieben als auf der anderen gelesen.
|
||||
enum WatchCoder {
|
||||
static let encoder: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .millisecondsSince1970
|
||||
return encoder
|
||||
}()
|
||||
|
||||
static let decoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||
return decoder
|
||||
}()
|
||||
}
|
||||
@@ -949,5 +949,142 @@ let missing = SensorOrientation.identity.apply(pitch: nil, roll: 2)
|
||||
checkEqual("fehlende Werte bleiben leer", missing.pitch == nil, true)
|
||||
checkEqual("vorhandene Werte kommen durch", missing.roll, 2)
|
||||
|
||||
// MARK: 13 – Strecke zur Apple Watch
|
||||
|
||||
print("\nApple Watch – Datensatz und Befehle")
|
||||
|
||||
let watchProfile = Profile(name: "Kastenwagen", symbol: "box.truck",
|
||||
trackWidth: 1.85, wheelbase: 3.50)
|
||||
let watchLevelID = UUID()
|
||||
let watchFridgeID = UUID()
|
||||
let watchBoosterID = UUID()
|
||||
|
||||
var watchLevel = LevelState()
|
||||
watchLevel.pitch = 1.8
|
||||
watchLevel.roll = -0.9
|
||||
watchLevel.pitchOffset = 0.4
|
||||
watchLevel.rollOffset = -0.2
|
||||
|
||||
// Feste Zeitstempel, damit der Vergleich unten wirklich den Inhalt prüft: über
|
||||
// die Strecke bleibt die Millisekunde stehen, ein `Date()` wäre feiner.
|
||||
let watchStamp = Date(timeIntervalSince1970: 1_699_999_999)
|
||||
|
||||
var watchLevelSnapshot = watchLevel.snapshot(deviceID: watchLevelID, rssi: -70)
|
||||
watchLevelSnapshot.timestamp = watchStamp
|
||||
|
||||
var boosterSnapshot = DeviceSnapshot(deviceID: watchBoosterID, timestamp: watchStamp, rssi: -71)
|
||||
boosterSnapshot.state = "Aus"
|
||||
boosterSnapshot.offReasons = ["Keine Eingangsspannung"]
|
||||
boosterSnapshot.metrics = [
|
||||
Metric("output_voltage", "Ausgang", 14.09, unit: "V", precision: 2, primary: true),
|
||||
Metric("input_voltage", "Eingang", 12.42, unit: "V", precision: 2),
|
||||
]
|
||||
|
||||
let watchFridge = WatchFridge(isPoweredOn: true, isEco: true, isLocked: false,
|
||||
isDualZone: false, unitSymbol: "°C",
|
||||
leftTarget: 4, leftCurrent: 6,
|
||||
rightTarget: nil, rightCurrent: nil,
|
||||
minTarget: -20, maxTarget: 20, batteryVolts: 12.7)
|
||||
|
||||
let watchPayload = WatchPayload(
|
||||
generatedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
profile: watchProfile,
|
||||
isRadioReady: true,
|
||||
radioStatus: "Bereit",
|
||||
devices: [
|
||||
WatchDevice(id: watchLevelID, name: "Nivellierung", role: .leveling,
|
||||
link: .live, snapshot: watchLevelSnapshot,
|
||||
level: watchLevel, fridge: nil),
|
||||
WatchDevice(id: watchBoosterID, name: "Ladebooster", role: .chargeBooster,
|
||||
link: .failed("Nicht gefunden"), snapshot: boosterSnapshot,
|
||||
level: nil, fridge: nil),
|
||||
WatchDevice(id: watchFridgeID, name: "Kühlbox", role: .fridge,
|
||||
link: .live, snapshot: nil, level: nil, fridge: watchFridge),
|
||||
])
|
||||
|
||||
// Der ganze Weg: verpacken, als WatchConnectivity-Wörterbuch lesen, vergleichen.
|
||||
if let message = try? watchPayload.message(),
|
||||
let restored = WatchPayload.decode(from: message) {
|
||||
checkEqual("Datensatz kommt unverändert an", restored, watchPayload)
|
||||
checkEqual("Zeitstempel überlebt das Verpacken",
|
||||
restored.generatedAt.timeIntervalSince1970, 1_700_000_000)
|
||||
checkEqual("Fehlerklartext bleibt am Gerät",
|
||||
restored.devices[1].link, .failed("Nicht gefunden"))
|
||||
checkEqual("Neigung wird gefunden", restored.levelDevice?.id, watchLevelID)
|
||||
checkEqual("Kühlbox wird gefunden", restored.fridgeDevice?.id, watchFridgeID)
|
||||
checkEqual("Sollwert der Box überlebt", restored.fridgeDevice?.fridge?.leftTarget, 4)
|
||||
checkEqual("Fahrzeugmasse kommen mit", restored.profile.trackWidth, 1.85)
|
||||
} else {
|
||||
check("Datensatz lässt sich verpacken und wieder lesen", false)
|
||||
}
|
||||
|
||||
// Eine Uhr mit älterer App darf einen neueren Datensatz nicht halb verstehen.
|
||||
var futurePayload = watchPayload
|
||||
futurePayload.version = WatchPayload.currentVersion + 1
|
||||
if let message = try? futurePayload.message() {
|
||||
checkEqual("fremde Formatversion wird abgelehnt",
|
||||
WatchPayload.decode(from: message) == nil, true)
|
||||
} else {
|
||||
check("fremde Formatversion lässt sich verpacken", false)
|
||||
}
|
||||
|
||||
// Zeitpunkte reisen als Millisekunden seit 1970 in einer Fliesskommazahl. Auf
|
||||
// die Millisekunde genau kommen sie zurück, aufs letzte Bit nicht – deshalb
|
||||
// vergleicht der Test oben feste Zeitstempel und nicht ein `Date()`.
|
||||
var preciseSnapshot = boosterSnapshot
|
||||
preciseSnapshot.timestamp = Date(timeIntervalSince1970: 1_699_999_999.123_456)
|
||||
var precisePayload = watchPayload
|
||||
precisePayload.devices[1].snapshot = preciseSnapshot
|
||||
if let message = try? precisePayload.message(),
|
||||
let restored = WatchPayload.decode(from: message)?.devices[1].snapshot?.timestamp {
|
||||
checkEqual("feiner Zeitstempel kommt auf die Millisekunde genau zurück",
|
||||
abs(restored.timeIntervalSince1970 - 1_699_999_999.123_456) < 0.001, true)
|
||||
} else {
|
||||
check("Datensatz mit feinem Zeitstempel lässt sich verpacken", false)
|
||||
}
|
||||
|
||||
checkEqual("leeres Wörterbuch ergibt keinen Datensatz",
|
||||
WatchPayload.decode(from: [:]) == nil, true)
|
||||
checkEqual("Unsinn im Nutzdatenfeld ergibt keinen Datensatz",
|
||||
WatchPayload.decode(from: [WatchLink.payloadKey: Data([0x00, 0x01])]) == nil, true)
|
||||
|
||||
// Der Vergleich entscheidet, ob überhaupt gefunkt wird: der Zeitstempel allein
|
||||
// darf keinen Funkverkehr auslösen, ein geänderter Messwert schon.
|
||||
var laterPayload = watchPayload
|
||||
laterPayload.generatedAt = watchPayload.generatedAt.addingTimeInterval(30)
|
||||
checkEqual("gleicher Inhalt, neuer Zeitstempel gilt als unverändert",
|
||||
watchPayload.hasSameContent(as: laterPayload), true)
|
||||
|
||||
var changedPayload = laterPayload
|
||||
changedPayload.devices[2].fridge?.leftTarget = 5
|
||||
checkEqual("geänderter Sollwert gilt als Änderung",
|
||||
watchPayload.hasSameContent(as: changedPayload), false)
|
||||
|
||||
// Befehle in die Gegenrichtung.
|
||||
let commands: [WatchCommand] = [
|
||||
.hello(live: true),
|
||||
.hello(live: false),
|
||||
.fridgePower(device: watchFridgeID, on: false),
|
||||
.fridgeEco(device: watchFridgeID, eco: true),
|
||||
.fridgeLock(device: watchFridgeID, locked: true),
|
||||
.fridgeTarget(device: watchFridgeID, zone: .right, value: -3),
|
||||
.calibrateLevel(device: watchLevelID),
|
||||
]
|
||||
for command in commands {
|
||||
guard let message = try? command.message(),
|
||||
let restored = WatchCommand.decode(from: message) else {
|
||||
check("Befehl \(command) lässt sich verpacken", false)
|
||||
continue
|
||||
}
|
||||
checkEqual("Befehl kommt unverändert an", restored, command)
|
||||
}
|
||||
|
||||
checkEqual("Anmeldung ohne Hinschauen verlängert den schnellen Takt nicht",
|
||||
WatchCommand.hello(live: false).wantsLiveUpdates, false)
|
||||
checkEqual("wer stellt, schaut auch hin",
|
||||
WatchCommand.fridgePower(device: watchFridgeID, on: true).wantsLiveUpdates, true)
|
||||
checkEqual("ein Datensatz ist kein Befehl",
|
||||
WatchCommand.decode(from: (try? watchPayload.message()) ?? [:]) == nil, true)
|
||||
|
||||
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
|
||||
exit(failures == 0 ? 0 : 1)
|
||||
|
||||
+8
-6
@@ -14,12 +14,14 @@ swiftc -O -o "$OUT/tests" \
|
||||
CamperMonitor/Bluetooth/WattCycleProtocol.swift \
|
||||
CamperMonitor/Bluetooth/AlpicoolProtocol.swift \
|
||||
CamperMonitor/Bluetooth/VanAlignProtocol.swift \
|
||||
CamperMonitor/Models/DeviceSnapshot.swift \
|
||||
CamperMonitor/Models/ConfiguredDevice.swift \
|
||||
CamperMonitor/Models/Profile.swift \
|
||||
CamperMonitor/Models/AlignmentAssistant.swift \
|
||||
CamperMonitor/Models/SensorOrientation.swift \
|
||||
CamperMonitor/Models/VictronCodes.swift \
|
||||
Shared/Models/DeviceSnapshot.swift \
|
||||
Shared/Models/ConfiguredDevice.swift \
|
||||
Shared/Models/LevelState.swift \
|
||||
Shared/Models/Profile.swift \
|
||||
Shared/Models/AlignmentAssistant.swift \
|
||||
Shared/Models/SensorOrientation.swift \
|
||||
Shared/Models/VictronCodes.swift \
|
||||
Shared/WatchLink/WatchLink.swift \
|
||||
CamperMonitor/Store/KeychainStore.swift \
|
||||
Tests/main.swift
|
||||
"$OUT/tests"
|
||||
|
||||
Reference in New Issue
Block a user