5 Commits
Author SHA1 Message Date
BiasFandClaude Opus 5 812874baef Kühlbox-Kachel: "Soll" dazuschreiben, Betriebsart gross, Hinweis nach vorn
Auf der Kachel stand eine nackte Zahl, und das ist genau die Zahl, die
man für die Innentemperatur hält. Jetzt steht "Soll" darüber – bei zwei
Zonen "Soll links", mit "Soll rechts" gleich daneben statt in der
Nebenwertzeile.

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

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

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

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

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

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

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

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

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

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

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

Jetzt drei Seiten zum Wischen:

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:10:54 +02:00
28 changed files with 902 additions and 110 deletions
+4 -4
View File
@@ -59,7 +59,7 @@ jobs:
-PversionName="$VERSION" \ -PversionName="$VERSION" \
-PversionCode="$CODE" --no-daemon -PversionCode="$CODE" --no-daemon
mv app/build/outputs/apk/release/app-release.apk \ mv app/build/outputs/apk/release/app-release.apk \
"../CamperMonitor-$VERSION.apk" "../VanControlPro-$VERSION.apk"
- name: An das Release hängen - name: An das Release hängen
env: env:
@@ -88,7 +88,7 @@ jobs:
fi fi
curl -sf -X POST -H "$AUTH" \ curl -sf -X POST -H "$AUTH" \
-F "attachment=@CamperMonitor-$VERSION.apk" \ -F "attachment=@VanControlPro-$VERSION.apk" \
"$API/$ID/assets?name=CamperMonitor-$VERSION.apk" > /dev/null "$API/$ID/assets?name=VanControlPro-$VERSION.apk" > /dev/null
echo "CamperMonitor-$VERSION.apk hängt an Release $GITHUB_REF_NAME." echo "VanControlPro-$VERSION.apk hängt an Release $GITHUB_REF_NAME."
@@ -89,7 +89,7 @@ fun WithBluetoothPermission(content: @Composable () -> Unit) {
if (asked) { if (asked) {
Text( Text(
"Die Freigabe wurde abgelehnt. Sie lässt sich in den " + "Die Freigabe wurde abgelehnt. Sie lässt sich in den " +
"Android-Einstellungen unter „Apps → Camper Monitor" + "Android-Einstellungen unter „Apps → VanControl Pro" +
"Berechtigungen“ nachholen.", "Berechtigungen“ nachholen.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
@@ -65,7 +65,7 @@ fun SettingsScreen(store: DeviceStore, onBack: () -> Unit) {
) )
Text( Text(
"Camper Monitor liest Victron-Geräte über ihr Advertisement mit " + "VanControl Pro liest Victron-Geräte über ihr Advertisement mit " +
"(„Instant Readout“) und spricht Batterie, Kühlbox und " + "(„Instant Readout“) und spricht Batterie, Kühlbox und " +
"Neigungsmesser direkt über Bluetooth an.", "Neigungsmesser direkt über Bluetooth an.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Camper Monitor</string> <string name="app_name">VanControl Pro</string>
</resources> </resources>
+153 -4
View File
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* CamperMonitorWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* CamperMonitorWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
AA0000000000000000000042 /* CamperMonitorComplication.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000032 /* CamperMonitorComplication.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -18,6 +19,13 @@
remoteGlobalIDString = AA0000000000000000000022; remoteGlobalIDString = AA0000000000000000000022;
remoteInfo = CamperMonitorWatch; remoteInfo = CamperMonitorWatch;
}; };
AA0000000000000000000043 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA0000000000000000000009 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AA0000000000000000000037;
remoteInfo = CamperMonitorComplication;
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
@@ -32,12 +40,25 @@
name = "Embed Watch Content"; name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AA0000000000000000000041 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
AA0000000000000000000042 /* CamperMonitorComplication.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
AA0000000000000000000001 /* CamperMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; AA0000000000000000000001 /* 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; }; 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>"; }; AA0000000000000000000030 /* CamperMonitor-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitor-Info.plist"; sourceTree = "<group>"; };
AA0000000000000000000032 /* CamperMonitorComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CamperMonitorComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; };
AA0000000000000000000045 /* CamperMonitorComplication-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitorComplication-Info.plist"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -56,6 +77,11 @@
path = Shared; path = Shared;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
AA0000000000000000000033 /* CamperMonitorComplication */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = CamperMonitorComplication;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -73,6 +99,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AA0000000000000000000035 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -81,6 +114,7 @@
children = ( children = (
AA0000000000000000000002 /* CamperMonitor */, AA0000000000000000000002 /* CamperMonitor */,
AA0000000000000000000017 /* CamperMonitorWatch */, AA0000000000000000000017 /* CamperMonitorWatch */,
AA0000000000000000000033 /* CamperMonitorComplication */,
AA0000000000000000000018 /* Shared */, AA0000000000000000000018 /* Shared */,
AA0000000000000000000031 /* Config */, AA0000000000000000000031 /* Config */,
AA0000000000000000000005 /* Products */, AA0000000000000000000005 /* Products */,
@@ -92,6 +126,7 @@
children = ( children = (
AA0000000000000000000001 /* CamperMonitor.app */, AA0000000000000000000001 /* CamperMonitor.app */,
AA0000000000000000000016 /* CamperMonitorWatch.app */, AA0000000000000000000016 /* CamperMonitorWatch.app */,
AA0000000000000000000032 /* CamperMonitorComplication.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -100,6 +135,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AA0000000000000000000030 /* CamperMonitor-Info.plist */, AA0000000000000000000030 /* CamperMonitor-Info.plist */,
AA0000000000000000000045 /* CamperMonitorComplication-Info.plist */,
); );
path = Config; path = Config;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -137,10 +173,12 @@
AA0000000000000000000019 /* Sources */, AA0000000000000000000019 /* Sources */,
AA0000000000000000000020 /* Frameworks */, AA0000000000000000000020 /* Frameworks */,
AA0000000000000000000021 /* Resources */, AA0000000000000000000021 /* Resources */,
AA0000000000000000000041 /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
AA0000000000000000000044 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
AA0000000000000000000017 /* CamperMonitorWatch */, AA0000000000000000000017 /* CamperMonitorWatch */,
@@ -151,6 +189,27 @@
productReference = AA0000000000000000000016 /* CamperMonitorWatch.app */; productReference = AA0000000000000000000016 /* CamperMonitorWatch.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
AA0000000000000000000037 /* CamperMonitorComplication */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "CamperMonitorComplication" */;
buildPhases = (
AA0000000000000000000034 /* Sources */,
AA0000000000000000000035 /* Frameworks */,
AA0000000000000000000036 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
AA0000000000000000000018 /* Shared */,
AA0000000000000000000033 /* CamperMonitorComplication */,
);
name = CamperMonitorComplication;
productName = CamperMonitorComplication;
productReference = AA0000000000000000000032 /* CamperMonitorComplication.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -167,6 +226,9 @@
AA0000000000000000000022 = { AA0000000000000000000022 = {
CreatedOnToolsVersion = 26.6; CreatedOnToolsVersion = 26.6;
}; };
AA0000000000000000000037 = {
CreatedOnToolsVersion = 26.6;
};
}; };
}; };
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */; buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */;
@@ -186,6 +248,7 @@
targets = ( targets = (
AA0000000000000000000006 /* CamperMonitor */, AA0000000000000000000006 /* CamperMonitor */,
AA0000000000000000000022 /* CamperMonitorWatch */, AA0000000000000000000022 /* CamperMonitorWatch */,
AA0000000000000000000037 /* CamperMonitorComplication */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -205,6 +268,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AA0000000000000000000036 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -222,6 +292,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AA0000000000000000000034 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
@@ -231,6 +308,11 @@
target = AA0000000000000000000022 /* CamperMonitorWatch */; target = AA0000000000000000000022 /* CamperMonitorWatch */;
targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */; targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */;
}; };
AA0000000000000000000044 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = AA0000000000000000000037 /* CamperMonitorComplication */;
targetProxy = AA0000000000000000000043 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@@ -301,7 +383,7 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist"; INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -332,7 +414,7 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist"; INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -361,7 +443,7 @@
DEVELOPMENT_TEAM = 6AP73NYV5W; DEVELOPMENT_TEAM = 6AP73NYV5W;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -393,7 +475,7 @@
DEVELOPMENT_TEAM = 6AP73NYV5W; DEVELOPMENT_TEAM = 6AP73NYV5W;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; INFOPLIST_KEY_CFBundleDisplayName = "VanControl Pro";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone."; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@@ -415,6 +497,64 @@
}; };
name = Release; name = Release;
}; };
AA0000000000000000000038 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6AP73NYV5W;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp.levelwidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
};
name = Debug;
};
AA0000000000000000000039 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6AP73NYV5W;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp.levelwidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
};
name = Release;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -445,6 +585,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "CamperMonitorComplication" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000038 /* Debug */,
AA0000000000000000000039 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = AA0000000000000000000009 /* Project object */; rootObject = AA0000000000000000000009 /* Project object */;
@@ -232,6 +232,19 @@ struct AlpicoolState: Equatable {
} }
} }
/// Der Stand, der die Verbindung überdauert ohne Messwerte.
var settings: FridgeSettings? {
guard hasStatus else { return nil }
return FridgeSettings(isPoweredOn: isPoweredOn,
isEco: isEco,
isLocked: isLocked,
isDualZone: isDualZone,
usesFahrenheit: usesFahrenheit,
leftTarget: leftTarget,
rightTarget: isDualZone ? rightTarget : nil,
updated: Date())
}
/// Die Bytes, die ein Stellbefehl ändert. /// Die Bytes, die ein Stellbefehl ändert.
/// ///
/// Messwerte gehören nicht dazu: Temperatur und Spannung schwanken /// Messwerte gehören nicht dazu: Temperatur und Spannung schwanken
+109 -7
View File
@@ -178,6 +178,11 @@ final class BluetoothManager: NSObject {
} }
private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID
/// Geräte, die nur verbunden werden, solange ihre Ansicht offen ist.
/// Schlüssel ist die Gerätekennung, gezählt wird nicht eine Ansicht ist
/// zu einer Zeit offen.
private var requestedSessions: Set<UUID> = []
private var bmsSessions: [UUID: BMSSession] = [:] private var bmsSessions: [UUID: BMSSession] = [:]
private var levelSessions: [UUID: LevelSession] = [:] private var levelSessions: [UUID: LevelSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:] private var connectedPeripherals: [UUID: CBPeripheral] = [:]
@@ -257,6 +262,13 @@ final class BluetoothManager: NSObject {
for snapshot in DemoData.snapshots() { for snapshot in DemoData.snapshots() {
snapshots[snapshot.deviceID] = snapshot snapshots[snapshot.deviceID] = snapshot
linkStates[snapshot.deviceID] = .live linkStates[snapshot.deviceID] = .live
// Die Kühlbox wird nur beim Öffnen verbunden im Vorführbetrieb
// steht sie deshalb wie im Alltag auf zuletzt gestellt.
if snapshot.deviceID == DemoData.fridge.id,
let settings = DemoData.fridgeState.settings {
linkStates[snapshot.deviceID] = .idle
snapshots[snapshot.deviceID] = settings.snapshot(deviceID: snapshot.deviceID)
}
if let value = snapshot.primaryMetric?.value { if let value = snapshot.primaryMetric?.value {
history[snapshot.deviceID] = DemoData.history(for: snapshot.deviceID, around: value) history[snapshot.deviceID] = DemoData.history(for: snapshot.deviceID, around: value)
} }
@@ -298,6 +310,14 @@ final class BluetoothManager: NSObject {
fridgeStates = fridgeStates.filter { known.contains($0.key) } fridgeStates = fridgeStates.filter { known.contains($0.key) }
levelStates = levelStates.filter { known.contains($0.key) } levelStates = levelStates.filter { known.contains($0.key) }
// Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin
// ihren zuletzt gestellten Stand.
for device in store.activeDevices where device.role.connectsOnDemand {
guard linkStates[device.id] != .live else { continue }
linkStates[device.id] = .idle
showLastKnownFridgeSettings(for: device.id)
}
queue.async { self.applyConfiguration(devices) } queue.async { self.applyConfiguration(devices) }
} }
@@ -309,6 +329,57 @@ final class BluetoothManager: NSObject {
} }
} }
// MARK: - Verbindung auf Anforderung (vom Hauptthread aufgerufen)
/// Verbindet ein Gerät, das sonst unverbunden bleibt solange seine
/// Ansicht offen ist.
///
/// Für die Kühlbox: Jede Verbindung meldet sich an ihrem Display an, manche
/// Modelle verlangen dabei sogar einen Tastendruck. Dauerhaft verbunden zu
/// sein ist dort also nicht unsichtbar, sondern lästig.
func beginSession(for device: ConfiguredDevice) {
guard device.role.connectsOnDemand else { return }
let deviceID = device.id
queue.async {
self.requestedSessions.insert(deviceID)
guard let entry = self.managed.values.first(where: { $0.id == deviceID }) else { return }
// Der Wunsch des Benutzers hebt die Wartesperre auf.
self.clearBackOff(entry.peripheralID)
self.connectIfNeeded(entry)
}
}
/// Gibt das Gerät wieder frei. Der zuletzt gemeldete Stand bleibt in der
/// Übersicht stehen, damit dort nicht plötzlich eine Lücke klafft.
func endSession(for device: ConfiguredDevice) {
guard device.role.connectsOnDemand else { return }
let deviceID = device.id
queue.async {
self.requestedSessions.remove(deviceID)
guard let entry = self.managed.values.first(where: { $0.id == deviceID }) else { return }
self.bmsSessions[entry.peripheralID]?.stop()
self.bmsSessions[entry.peripheralID] = nil
self.disconnect(entry.peripheralID)
self.publish {
self.linkStates[deviceID] = .idle
self.showLastKnownFridgeSettings(for: deviceID)
}
}
}
/// Setzt an die Stelle der Live-Werte den zuletzt gestellten Stand.
///
/// Nur auf dem Hauptthread aufrufen: greift auf den Speicher zu.
private func showLastKnownFridgeSettings(for deviceID: UUID) {
guard let settings = store.lastFridgeSettings(for: deviceID) else {
snapshots[deviceID] = nil
return
}
snapshots[deviceID] = settings.snapshot(deviceID: deviceID)
// Der Zustand bleibt stehen, damit sich die Box beim nächsten Öffnen
// sofort bedienen lässt die Schalter zeigen dann den letzten Stand.
}
// MARK: - Kühlbox steuern (vom Hauptthread aufgerufen) // MARK: - Kühlbox steuern (vom Hauptthread aufgerufen)
func updateFridgeZoneMode(for device: ConfiguredDevice) { func updateFridgeZoneMode(for device: ConfiguredDevice) {
@@ -431,8 +502,10 @@ final class BluetoothManager: NSObject {
private func applyConfiguration(_ devices: [ManagedDevice]) { private func applyConfiguration(_ devices: [ManagedDevice]) {
managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) }) managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) })
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören. // Verbindungen zu Geräten lösen, die nicht mehr dazugehören und zu
let wanted = Set(devices.filter { $0.transport == .connect }.map(\.peripheralID)) // denen, die nur auf Anforderung verbunden werden und gerade niemand
// anschaut.
let wanted = Set(devices.filter { shouldStayConnected($0) }.map(\.peripheralID))
for (peripheralID, session) in bmsSessions where !wanted.contains(peripheralID) { for (peripheralID, session) in bmsSessions where !wanted.contains(peripheralID) {
session.stop() session.stop()
bmsSessions[peripheralID] = nil bmsSessions[peripheralID] = nil
@@ -509,11 +582,22 @@ final class BluetoothManager: NSObject {
private func connectManagedPeripherals() { private func connectManagedPeripherals() {
guard central?.state == .poweredOn else { return } guard central?.state == .poweredOn else { return }
for device in managed.values where device.transport == .connect { for device in managed.values where shouldStayConnected(device) {
connectIfNeeded(device) connectIfNeeded(device)
} }
} }
/// Ob dieses Gerät von sich aus verbunden gehalten wird.
///
/// Geräte auf Anforderung die Kühlbox nur dann, wenn ihre Ansicht
/// gerade offen ist oder ein Stellbefehl darauf wartet.
private func shouldStayConnected(_ device: ManagedDevice) -> Bool {
guard device.transport == .connect else { return false }
guard device.role.connectsOnDemand else { return true }
return requestedSessions.contains(device.id)
|| pendingControls[device.peripheralID]?.isEmpty == false
}
private func connectIfNeeded(_ device: ManagedDevice) { private func connectIfNeeded(_ device: ManagedDevice) {
if let existing = connectedPeripherals[device.peripheralID], if let existing = connectedPeripherals[device.peripheralID],
existing.state == .connected || existing.state == .connecting { existing.state == .connected || existing.state == .connecting {
@@ -736,8 +820,11 @@ extension BluetoothManager: CBCentralManagerDelegate {
handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi) handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi)
case .connect: case .connect:
// Das Gerät wurde gesehen falls die Verbindung fehlt, jetzt aufbauen. // Das Gerät wurde gesehen falls die Verbindung fehlt, jetzt
if connectedPeripherals[identifier] == nil { // aufbauen. Ausser bei Geräten auf Anforderung: Die Kühlbox piept
// bei jeder Verbindung, und dass sie in Reichweite ist, ist kein
// Grund, sie anzufassen.
if connectedPeripherals[identifier] == nil, shouldStayConnected(device) {
connectIfNeeded(device) connectIfNeeded(device)
} }
} }
@@ -786,7 +873,14 @@ extension BluetoothManager: CBCentralManagerDelegate {
) )
session.onFridgeState = { [weak self] state in session.onFridgeState = { [weak self] state in
self?.fridgeStateCache[peripheral.identifier] = state self?.fridgeStateCache[peripheral.identifier] = state
self?.publish { self?.fridgeStates[device.id] = state } self?.publish {
self?.fridgeStates[device.id] = state
// Für die Übersicht aufheben: Sie zeigt den Stand auch dann,
// wenn längst niemand mehr verbunden ist.
if let settings = state.settings {
self?.store.setLastFridgeSettings(settings, for: device.id)
}
}
} }
session.fridgeZoneMode = device.fridgeZoneMode session.fridgeZoneMode = device.fridgeZoneMode
bmsSessions[peripheral.identifier] = session bmsSessions[peripheral.identifier] = session
@@ -817,7 +911,15 @@ extension BluetoothManager: CBCentralManagerDelegate {
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier) let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
.map { Date().timeIntervalSince($0) } ?? 0 .map { Date().timeIntervalSince($0) } ?? 0
if let device = managed[peripheral.identifier] { if let device = managed[peripheral.identifier], !shouldStayConnected(device) {
// Auf Anforderung verbunden und niemand schaut mehr hin: nicht
// wieder anklopfen, sondern den letzten Stand stehenlassen.
let deviceID = device.id
publish {
self.linkStates[deviceID] = .idle
self.showLastKnownFridgeSettings(for: deviceID)
}
} else if let device = managed[peripheral.identifier] {
publish { self.linkStates[device.id] = .searching } publish { self.linkStates[device.id] = .searching }
if lifetime >= stableConnection { if lifetime >= stableConnection {
// Die Verbindung stand und ist weggefallen - im Fahrzeug der // Die Verbindung stand und ist weggefallen - im Fahrzeug der
+31
View File
@@ -11,6 +11,7 @@ final class DeviceStore {
private static let activeProfileKey = "activeProfileID" private static let activeProfileKey = "activeProfileID"
/// Hierhin wird gerettet, was sich nicht lesen liess siehe `loadDevices`. /// Hierhin wird gerettet, was sich nicht lesen liess siehe `loadDevices`.
private static let unreadableDevicesKey = "configuredDevices.unreadable" private static let unreadableDevicesKey = "configuredDevices.unreadable"
private static let fridgeSettingsKey = "lastFridgeSettings"
/// Alle Geräte über alle Profile hinweg. /// Alle Geräte über alle Profile hinweg.
private(set) var devices: [ConfiguredDevice] = [] private(set) var devices: [ConfiguredDevice] = []
@@ -144,6 +145,36 @@ final class DeviceStore {
return bytes return bytes
} }
// MARK: - Zuletzt bekannter Stand der Kühlbox
/// Die Kühlbox wird nur verbunden, während ihre Ansicht offen ist. Damit
/// die Übersicht trotzdem etwas zeigt, wird der zuletzt gemeldete Stand
/// aufgehoben die Einstellungen, nicht die Messwerte.
func lastFridgeSettings(for deviceID: UUID) -> FridgeSettings? {
allFridgeSettings()[deviceID.uuidString]
}
/// Der Zeitstempel bedeutet zuletzt **geändert**, nicht zuletzt
/// gesehen: Sonst wanderte er im Sekundentakt und die Übersicht behauptete
/// Frische, wo sich nichts getan hat.
func setLastFridgeSettings(_ settings: FridgeSettings, for deviceID: UUID) {
var all = allFridgeSettings()
if var existing = all[deviceID.uuidString] {
existing.updated = settings.updated
guard existing != settings else { return }
}
all[deviceID.uuidString] = settings
guard let data = try? JSONEncoder().encode(all) else { return }
UserDefaults.standard.set(data, forKey: Self.fridgeSettingsKey)
}
private func allFridgeSettings() -> [String: FridgeSettings] {
guard let data = UserDefaults.standard.data(forKey: Self.fridgeSettingsKey),
let decoded = try? JSONDecoder().decode([String: FridgeSettings].self, from: data)
else { return [:] }
return decoded
}
// MARK: - Persistenz // MARK: - Persistenz
private func load() { private func load() {
+90 -3
View File
@@ -11,7 +11,9 @@ struct DeviceCard: View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
header header
if let snapshot, let primary = snapshot.primaryMetric { if isShowingLastSettings, let snapshot {
lastSettings(for: snapshot)
} else if let snapshot, let primary = snapshot.primaryMetric {
HStack(alignment: .firstTextBaseline, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(primary.formatted) Text(primary.formatted)
.font(.system(size: 44, weight: .semibold, design: .rounded)) .font(.system(size: 44, weight: .semibold, design: .rounded))
@@ -54,6 +56,65 @@ struct DeviceCard: View {
} }
} }
/// Ob hier der zuletzt gestellte Stand steht statt Messwerten.
private var isShowingLastSettings: Bool {
device.role.connectsOnDemand && linkState != .live
}
/// Die Kachel der Kühlbox, solange sie nicht verbunden ist.
///
/// Hier steht kein Messwert, sondern was zuletzt eingestellt war also
/// gehört Soll dazu. Ohne das läse man die Zahl als Innentemperatur, und
/// genau dieser Irrtum wäre teuer.
private func lastSettings(for snapshot: DeviceSnapshot) -> some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text(snapshot.metrics.count > 1 ? "Soll links" : "Soll")
.font(.caption)
.foregroundStyle(.secondary)
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(snapshot.primaryMetric?.formatted ?? "")
.font(.system(size: 44, weight: .semibold, design: .rounded))
Text(snapshot.primaryMetric?.unit ?? "")
.font(.title3.weight(.medium))
.foregroundStyle(.secondary)
}
}
if let right = snapshot.metrics.first(where: { $0.key == "target_right" }),
right.value != nil {
VStack(alignment: .leading, spacing: 2) {
Text("Soll rechts")
.font(.caption)
.foregroundStyle(.secondary)
Text(right.formattedWithUnit)
.font(.title2.weight(.semibold))
.monospacedDigit()
}
}
Spacer(minLength: 0)
modeBadge(snapshot.state)
}
}
/// Betriebsart als Schild: Auf der Kachel ist Platz, und ob die Box läuft
/// oder aus ist, ist die zweite Frage nach dem Sollwert.
@ViewBuilder
private func modeBadge(_ state: String?) -> some View {
if let state {
let isOff = state == "Aus"
Text(state)
.font(.title3.weight(.semibold))
.foregroundStyle(isOff ? Color.secondary : Color.accentColor)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(isOff ? Color.secondary.opacity(0.15)
: Color.accentColor.opacity(0.15),
in: .rect(cornerRadius: 12))
}
}
private func secondaryValues(for snapshot: DeviceSnapshot) -> some View { private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
let others = snapshot.metrics let others = snapshot.metrics
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil } .filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
@@ -76,7 +137,14 @@ struct DeviceCard: View {
@ViewBuilder @ViewBuilder
private var footer: some View { private var footer: some View {
if let fault = snapshot?.fault { if isShowingLastSettings, let snapshot {
// Kein Messwert, sondern der zuletzt gestellte Stand. Das gehört
// dazugeschrieben, sonst liest man ihn als aktuelle Temperatur.
Text(lastSetText(snapshot))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else if let fault = snapshot?.fault {
Label(fault, systemImage: "exclamationmark.triangle.fill") Label(fault, systemImage: "exclamationmark.triangle.fill")
.font(.caption) .font(.caption)
.foregroundStyle(.red) .foregroundStyle(.red)
@@ -96,11 +164,30 @@ struct DeviceCard: View {
} }
} }
/// Was unter einem Gerät steht, das nur beim Öffnen verbunden wird.
///
/// Der Hinweis aufs Verbinden steht vorn: Er erklärt, warum hier kein
/// Messwert steht, und das ist die Frage, die sich zuerst stellt.
private func lastSetText(_ snapshot: DeviceSnapshot) -> String {
"Verbindet erst beim Öffnen · zuletzt gestellt \(relativeUpdate(snapshot.timestamp))"
}
/// vor 3 Minuten statt einer Uhrzeit auf der Kachel zählt das Alter.
private func relativeUpdate(_ date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.locale = Locale(identifier: "de_DE")
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: Date())
}
private var placeholderText: String { private var placeholderText: String {
switch linkState { switch linkState {
case .needsKey: return "Verschlüsselungsschlüssel fehlt im Detail eintragen." case .needsKey: return "Verschlüsselungsschlüssel fehlt im Detail eintragen."
case .failed(let message): return message case .failed(let message): return message
default: return "Warte auf Daten…" default:
return device.role.connectsOnDemand
? "Verbindet erst beim Öffnen."
: "Warte auf Daten…"
} }
} }
} }
+7 -1
View File
@@ -114,10 +114,16 @@ struct DeviceDetailView: View {
} }
.navigationTitle(currentDevice.name) .navigationTitle(currentDevice.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onDisappear(perform: saveName) .onDisappear {
saveName()
// Die Kühlbox wird nur verbunden, solange man sie ansieht jede
// Verbindung meldet sich an ihrem Display an.
bluetooth.endSession(for: currentDevice)
}
.onAppear { .onAppear {
editedName = currentDevice.name editedName = currentDevice.name
keyInput = store.victronKeyText(for: device.id) ?? "" keyInput = store.victronKeyText(for: device.id) ?? ""
bluetooth.beginSession(for: currentDevice)
} }
.confirmationDialog("Gerät entfernen?", .confirmationDialog("Gerät entfernen?",
isPresented: $showDeleteConfirmation, isPresented: $showDeleteConfirmation,
+24 -3
View File
@@ -135,8 +135,7 @@ final class PhoneWatchLink: NSObject {
snapshot: bluetooth.snapshots[device.id], snapshot: bluetooth.snapshots[device.id],
level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil, level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil,
orientation: device.role == .leveling ? device.sensorOrientation : nil, orientation: device.role == .leveling ? device.sensorOrientation : nil,
fridge: device.role == .fridge fridge: device.role == .fridge ? fridgeState(for: device) : nil)
? bluetooth.fridgeStates[device.id].map(WatchFridge.init) : nil)
} }
return WatchPayload(generatedAt: Date(), return WatchPayload(generatedAt: Date(),
profile: profile, profile: profile,
@@ -145,6 +144,19 @@ final class PhoneWatchLink: NSObject {
devices: devices) devices: devices)
} }
/// Live, solange die Box verbunden ist sonst der zuletzt gestellte Stand
/// aus dem Speicher, ohne Messwerte.
private func fridgeState(for device: ConfiguredDevice) -> WatchFridge? {
if linkStateIsLive(device), let state = bluetooth.fridgeStates[device.id] {
return WatchFridge(state)
}
return store.lastFridgeSettings(for: device.id).map(WatchFridge.init)
}
private func linkStateIsLive(_ device: ConfiguredDevice) -> Bool {
bluetooth.linkStates[device.id] == .live
}
// MARK: - Befehle von der Uhr // MARK: - Befehle von der Uhr
private func handle(_ command: WatchCommand) { private func handle(_ command: WatchCommand) {
@@ -168,6 +180,13 @@ final class PhoneWatchLink: NSObject {
bluetooth.setFridgeLock(locked, for: device) bluetooth.setFridgeLock(locked, for: device)
case .fridgeTarget(let device, let zone, let value): case .fridgeTarget(let device, let zone, let value):
bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device) bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device)
case .fridgeSession(let deviceID, let wanted):
guard let device = store.devices.first(where: { $0.id == deviceID }) else { break }
if wanted {
bluetooth.beginSession(for: device)
} else {
bluetooth.endSession(for: device)
}
} }
} }
} }
@@ -250,7 +269,9 @@ extension WatchFridge {
/// stellt der Rohdatensatz bleibt auf dem iPhone. /// stellt der Rohdatensatz bleibt auf dem iPhone.
init(_ state: AlpicoolState) { init(_ state: AlpicoolState) {
let range = state.targetRange let range = state.targetRange
self.init(isPoweredOn: state.isPoweredOn, self.init(isLive: true,
updated: Date(),
isPoweredOn: state.isPoweredOn,
isEco: state.isEco, isEco: state.isEco,
isLocked: state.isLocked, isLocked: state.isLocked,
isDualZone: state.isDualZone, isDualZone: state.isDualZone,
@@ -0,0 +1,92 @@
import SwiftUI
import WidgetKit
/// Komplikation fürs Zifferblatt: ein Tipp, und die Nivellierung steht da.
///
/// Bewusst **ohne Messwert**. Eine Komplikation läuft in einem eigenen Prozess
/// und käme an die Werte nur über eine App-Gruppe die verlangt eine bezahlte
/// Entwicklermitgliedschaft. Ein Wert, der beim Blick aufs Zifferblatt
/// womöglich Stunden alt ist, wäre ausserdem schlimmer als keiner: Man würde
/// ihm glauben.
///
/// Die Komplikation tut deshalb genau eine Sache, und die zuverlässig: Sie
/// öffnet die App auf der Nivellierung. Von dort sind die Werte live, weil die
/// Uhr den Sensor selbst anfunkt.
struct LevelComplicationEntry: TimelineEntry {
let date: Date
}
/// Nichts zu planen: Die Anzeige ändert sich nie, also ein einziger Eintrag
/// ohne Nachschub. Das kostet die Uhr auch nichts.
struct LevelComplicationProvider: TimelineProvider {
func placeholder(in context: Context) -> LevelComplicationEntry {
LevelComplicationEntry(date: .now)
}
func getSnapshot(in context: Context,
completion: @escaping (LevelComplicationEntry) -> Void) {
completion(LevelComplicationEntry(date: .now))
}
func getTimeline(in context: Context,
completion: @escaping (Timeline<LevelComplicationEntry>) -> Void) {
completion(Timeline(entries: [LevelComplicationEntry(date: .now)], policy: .never))
}
}
struct LevelComplicationView: View {
@Environment(\.widgetFamily) private var family
var body: some View {
switch family {
case .accessoryInline:
// Eine Zeile Text, mehr lässt diese Familie nicht zu.
Label("Nivellierung", systemImage: "level")
case .accessoryCorner:
Image(systemName: "level")
.font(.title2)
.widgetLabel("Nivellieren")
case .accessoryRectangular:
HStack(spacing: 6) {
Image(systemName: "level")
.font(.title3)
VStack(alignment: .leading, spacing: 0) {
Text("Nivellierung")
.font(.headline)
Text("Antippen zum Ausrichten")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
default:
// Rund auf dem Zifferblatt der knappste Platz, also nur das
// Symbol, gross genug zum Treffen.
ZStack {
AccessoryWidgetBackground()
Image(systemName: "level")
.font(.title2)
}
}
}
}
@main
struct LevelComplication: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "de.fritob.CamperMonitor.complication.level",
provider: LevelComplicationProvider()) { _ in
LevelComplicationView()
// Der Tipp landet nicht irgendwo in der App, sondern auf der
// Nivellierung dafür wertet die App diese Adresse aus.
.widgetURL(WatchDeepLink.level)
.containerBackground(.clear, for: .widget)
}
.configurationDisplayName("Nivellierung")
.description("Öffnet die Nivellierung direkt vom Zifferblatt.")
.supportedFamilies([.accessoryCircular, .accessoryCorner,
.accessoryInline, .accessoryRectangular])
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
/// Camper Monitor am Handgelenk. /// VanControl Pro am Handgelenk.
/// ///
/// Zwei Quellen, jede dort, wo sie hingehört: /// Zwei Quellen, jede dort, wo sie hingehört:
/// ///
+22 -16
View File
@@ -20,52 +20,58 @@ struct WatchAlignView: View {
private var state: LevelState? { reading.hasReading ? reading.state : nil } private var state: LevelState? { reading.hasReading ? reading.state : nil }
var body: some View { var body: some View {
ScrollView { // Ohne ScrollView: Beim Rangieren schaut man kurz hin, da darf nichts
VStack(spacing: 8) { // ausserhalb des Bildes liegen. Alles ist deshalb so bemessen, dass es
// auch auf der kleinsten Uhr auf einen Blick passt.
VStack(spacing: 3) {
if let current = assistant.current { if let current = assistant.current {
Text(String(format: "%.1f°", current.deviation)) Text(String(format: "%.1f°", current.deviation))
.font(.system(size: 40, weight: .semibold, design: .rounded)) .font(.system(size: 30, weight: .semibold, design: .rounded))
.monospacedDigit() .monospacedDigit()
.foregroundStyle(assistant.hasReachedTarget ? .green : .primary) .foregroundStyle(assistant.hasReachedTarget ? .green : .primary)
.contentTransition(.numericText()) .contentTransition(.numericText())
} else { } else {
ProgressView() ProgressView()
.padding(.vertical, 12) .padding(.vertical, 6)
} }
Label(assistant.trend.text, systemImage: assistant.trend.symbol) Label(assistant.trend.text, systemImage: assistant.trend.symbol)
.font(.footnote.weight(.medium)) .font(.caption2.weight(.medium))
.foregroundStyle(trendColor) .foregroundStyle(trendColor)
Text(assistant.advice) Text(assistant.advice)
.font(.caption) .font(.system(size: 11))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2)
.minimumScaleFactor(0.8)
if let state { if let state {
HStack(spacing: 12) { HStack(spacing: 8) {
WatchBubble(pitch: state.pitch, roll: state.roll) WatchBubble(pitch: state.pitch, roll: state.roll)
.frame(width: 56, height: 56) .frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 1) {
Text(String(format: "Längs %.1f°", state.pitch ?? 0)) Text(String(format: "Längs %.1f°", state.pitch ?? 0))
Text(String(format: "Quer %.1f°", state.roll ?? 0)) Text(String(format: "Quer %.1f°", state.roll ?? 0))
} }
.font(.caption2) .font(.system(size: 11))
.monospacedDigit() .monospacedDigit()
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.padding(.top, 2)
} }
Button("Von vorn messen", systemImage: "arrow.counterclockwise") { Button {
assistant.reset() assistant.reset()
hasAnnouncedTarget = false hasAnnouncedTarget = false
} label: {
Label("Von vorn", systemImage: "arrow.counterclockwise")
.font(.system(size: 10))
} }
.font(.caption2) .buttonStyle(.borderless)
.padding(.top, 4) .foregroundStyle(.secondary)
}
.padding(.horizontal, 4)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 2)
.navigationTitle("Ausrichten") .navigationTitle("Ausrichten")
.onChange(of: tilt, initial: true) { _, tilt in .onChange(of: tilt, initial: true) { _, tilt in
guard let tilt else { return } guard let tilt else { return }
@@ -55,5 +55,15 @@ struct WatchDeviceDetailView: View {
} }
} }
.navigationTitle(device?.name ?? "Gerät") .navigationTitle(device?.name ?? "Gerät")
// Die Kühlbox wird nur verbunden, solange jemand sie ansieht. Das gilt
// auch von hier aus das iPhone verbindet auf Zuruf.
.onAppear {
guard device?.fridge != nil else { return }
link.send(.fridgeSession(device: deviceID, wanted: true))
}
.onDisappear {
guard device?.fridge != nil else { return }
link.send(.fridgeSession(device: deviceID, wanted: false))
}
} }
} }
@@ -40,6 +40,12 @@ struct WatchFridgeControls: View {
Text("iPhone gerade nicht erreichbar Stellbefehle gehen erst " Text("iPhone gerade nicht erreichbar Stellbefehle gehen erst "
+ "wieder durch, wenn es antwortet.") + "wieder durch, wenn es antwortet.")
.font(.caption2) .font(.caption2)
} else if !fridge.isLive {
// Kein Messwert, sondern der zuletzt gestellte Stand: Die Box
// wird erst verbunden, während man diese Ansicht offen hat.
Text("Zuletzt gestellter Stand. Die Box verbindet sich gerade "
+ "die Werte kommen gleich.")
.font(.caption2)
} }
} }
} }
+76 -16
View File
@@ -1,16 +1,45 @@
import SwiftUI import SwiftUI
/// Nivellierung auf der Uhr. /// Nivellierung auf der Uhr, aufgeteilt auf drei Seiten zum Wischen.
///
/// Auf einem Bildschirm dieser Grösse ist Wischen besser als Scrollen: Beim
/// Rangieren trifft man einen Wisch auch ohne hinzusehen, eine Scrollposition
/// dagegen nicht und was gerade wichtig ist, hängt vom Arbeitsschritt ab.
///
/// 1. **Neigung** wie das Fahrzeug steht, als Fahrzeugansicht oder Libelle.
/// 2. **Keile** wie hoch jedes Rad unterlegt werden muss.
/// 3. **Ausrichten** der Assistent fürs Rangieren, mit Vibration.
/// ///
/// Die Werte kommen bevorzugt **direkt vom Sensor** (`WatchLevelRadio`); nur /// Die Werte kommen bevorzugt **direkt vom Sensor** (`WatchLevelRadio`); nur
/// wenn der gerade nicht erreichbar ist, tritt der zuletzt über das iPhone /// wenn der gerade nicht erreichbar ist, tritt der zuletzt über das iPhone
/// gemeldete Stand an seine Stelle. Woher sie stammen, steht unten ein alter /// gemeldete Stand an seine Stelle. Woher sie stammen, steht auf der ersten
/// Wert sieht sonst aus wie ein stillstehendes Fahrzeug. /// Seite ein alter Wert sieht sonst aus wie ein stillstehendes Fahrzeug.
/// ///
/// Kalibriert wird hier nichts. Das gehört einmalig ans iPhone, mit ebenem /// Kalibriert wird hier nichts. Das gehört einmalig ans iPhone, mit ebenem
/// Fahrzeug und Ruhe dafür; auf der Uhr wäre der Knopf nur eine Gelegenheit, /// Fahrzeug und Ruhe dafür; auf der Uhr wäre der Knopf nur eine Gelegenheit,
/// die Nullage aus Versehen zu verstellen. /// die Nullage aus Versehen zu verstellen.
struct WatchLevelView: View { struct WatchLevelView: View {
@State private var page = Page.reading
enum Page: Hashable {
case reading, wedges, align
}
var body: some View {
TabView(selection: $page) {
LevelReadingPage()
.tag(Page.reading)
WedgePage()
.tag(Page.wedges)
WatchAlignView()
.tag(Page.align)
}
.tabViewStyle(.page)
}
}
/// Erste Seite: wie das Fahrzeug steht.
struct LevelReadingPage: View {
@Environment(PhoneLink.self) private var link @Environment(PhoneLink.self) private var link
@Environment(WatchLevelRadio.self) private var radio @Environment(WatchLevelRadio.self) private var radio
@AppStorage("levelDisplayStyle") private var showsVehicle = true @AppStorage("levelDisplayStyle") private var showsVehicle = true
@@ -36,18 +65,6 @@ struct WatchLevelView: View {
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(reading.state.isLevel ? .green : .primary) .foregroundStyle(reading.state.isLevel ? .green : .primary)
if let lift, !lift.isNegligible {
WheelLiftPlan(lift: lift, isCompact: true)
.padding(.top, 2)
}
NavigationLink {
WatchAlignView()
} label: {
Label("Ausrichten", systemImage: "arrow.left.and.right")
}
.buttonStyle(.borderedProminent)
Button { Button {
showsVehicle.toggle() showsVehicle.toggle()
} label: { } label: {
@@ -60,7 +77,7 @@ struct WatchLevelView: View {
} }
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
.navigationTitle("Nivellierung") .navigationTitle("Neigung")
} }
private func angle(_ title: String, _ value: Double?) -> some View { private func angle(_ title: String, _ value: Double?) -> some View {
@@ -73,6 +90,49 @@ struct WatchLevelView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
}
/// Zweite Seite: die Keilhöhen, als Draufsicht auf die vier Räder.
struct WedgePage: View {
@Environment(PhoneLink.self) private var link
@Environment(WatchLevelRadio.self) private var radio
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
var body: some View {
ScrollView {
VStack(spacing: 8) {
if let lift, !lift.isNegligible {
WheelLiftPlan(lift: lift, isCompact: true)
Text("Zentimeter unter das jeweilige Rad. Das höchste bleibt liegen.")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else if hasMeasurements {
Label("Keine Keile nötig", systemImage: "checkmark.circle.fill")
.font(.footnote)
.foregroundStyle(.green)
.padding(.top, 20)
} else {
// Ohne Fahrzeugmasse lässt sich nichts rechnen. Das gehört
// gesagt, sonst wirkt die Seite kaputt.
Label("Fahrzeugmasse fehlen", systemImage: "ruler")
.font(.footnote.weight(.medium))
Text("Spurweite und Radstand am iPhone im Fahrzeugprofil "
+ "hinterlegen, dann steht hier die Keilhöhe je Rad.")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
.padding(.horizontal, 4)
}
.navigationTitle("Keile")
}
private var hasMeasurements: Bool {
link.payload?.profile.trackWidth != nil && link.payload?.profile.wheelbase != nil
}
/// Wie hoch jedes Rad unterlegt werden muss reine Geometrie, und nur zu /// Wie hoch jedes Rad unterlegt werden muss reine Geometrie, und nur zu
/// haben, wenn im Fahrzeugprofil Spurweite und Radstand stehen. /// haben, wenn im Fahrzeugprofil Spurweite und Radstand stehen.
+28 -8
View File
@@ -6,10 +6,27 @@ struct WatchRootView: View {
@Environment(PhoneLink.self) private var link @Environment(PhoneLink.self) private var link
@Environment(WatchLevelRadio.self) private var radio @Environment(WatchLevelRadio.self) private var radio
/// Der Weg durch die Ansichten liegt hier, damit ihn auch die Komplikation
/// setzen kann: Ein Tipp aufs Zifferblatt springt direkt zur Nivellierung,
/// statt in der Geräteliste zu landen.
@State private var path: [WatchRoute] = []
var body: some View { var body: some View {
NavigationStack { NavigationStack(path: $path) {
content content
.navigationTitle(link.payload?.profile.name ?? "Camper") .navigationTitle(link.payload?.profile.name ?? "Camper")
.navigationDestination(for: WatchRoute.self) { route in
switch route {
case .level: WatchLevelView()
case .device(let id): WatchDeviceDetailView(deviceID: id)
}
}
}
.onOpenURL { url in
guard WatchDeepLink.isLevel(url) else { return }
// Nicht anhängen, sondern ersetzen: Wer vom Zifferblatt kommt, will
// die Nivellierung sehen und nicht einen Stapel von vorhin.
path = [.level]
} }
} }
@@ -18,9 +35,7 @@ struct WatchRootView: View {
// Die Nivellierung steht immer oben und immer da: Sie kommt vom // Die Nivellierung steht immer oben und immer da: Sie kommt vom
// Sensor selbst und braucht das iPhone nicht. // Sensor selbst und braucht das iPhone nicht.
Section { Section {
NavigationLink { NavigationLink(value: WatchRoute.level) {
WatchLevelView()
} label: {
LevelSummaryRow(reading: WatchLevelReading(radio: radio, phone: link)) LevelSummaryRow(reading: WatchLevelReading(radio: radio, phone: link))
} }
} }
@@ -28,9 +43,7 @@ struct WatchRootView: View {
if let payload = link.payload { if let payload = link.payload {
Section { Section {
ForEach(payload.devices.filter { $0.role != .leveling }) { device in ForEach(payload.devices.filter { $0.role != .leveling }) { device in
NavigationLink { NavigationLink(value: WatchRoute.device(device.id)) {
WatchDeviceDetailView(deviceID: device.id)
} label: {
DeviceRow(device: device) DeviceRow(device: device)
} }
} }
@@ -55,7 +68,7 @@ struct WatchRootView: View {
VStack(spacing: 4) { VStack(spacing: 4) {
Label("Warte auf das iPhone", systemImage: "iphone.gen3.radiowaves.left.and.right") Label("Warte auf das iPhone", systemImage: "iphone.gen3.radiowaves.left.and.right")
.font(.footnote.weight(.medium)) .font(.footnote.weight(.medium))
Text("Dort muss Camper Monitor geöffnet sein.") Text("Dort muss VanControl Pro geöffnet sein.")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -63,6 +76,13 @@ struct WatchRootView: View {
} }
} }
/// Wohin die Uhr springen kann. Als eigener Typ, weil der Weg von aussen
/// gesetzt wird von der Komplikation.
enum WatchRoute: Hashable {
case level
case device(UUID)
}
/// Eine Gerätezeile: Name klein, Hauptwert gross auf dem kleinen Bildschirm /// Eine Gerätezeile: Name klein, Hauptwert gross auf dem kleinen Bildschirm
/// zählt nur der eine Wert, wegen dem man den Arm hebt. /// zählt nur der eine Wert, wegen dem man den Arm hebt.
struct DeviceRow: View { struct DeviceRow: View {
+2 -1
View File
@@ -72,7 +72,8 @@ enum WatchDemo {
orientation: nil, fridge: nil), orientation: nil, fridge: nil),
WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge, WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge,
link: .live, snapshot: fridge, level: nil, orientation: nil, link: .live, snapshot: fridge, level: nil, orientation: nil,
fridge: WatchFridge(isPoweredOn: true, isEco: true, isLocked: false, fridge: WatchFridge(isLive: true, updated: now,
isPoweredOn: true, isEco: true, isLocked: false,
isDualZone: false, unitSymbol: "°C", isDualZone: false, unitSymbol: "°C",
leftTarget: 4, leftCurrent: 6, leftTarget: 4, leftCurrent: 6,
rightTarget: nil, rightCurrent: nil, rightTarget: nil, rightCurrent: nil,
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
+36 -11
View File
@@ -1,4 +1,4 @@
# Camper Monitor # VanControl Pro
iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest: iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest:
@@ -120,7 +120,7 @@ Protokoll erkannt wurde, steht in der Detailansicht unter **Diagnose**
zusammen mit der letzten Rohantwort. zusammen mit der letzten Rohantwort.
Nur **eine** App gleichzeitig kann mit dem BMS verbunden sein. Wenn die Nur **eine** App gleichzeitig kann mit dem BMS verbunden sein. Wenn die
Hersteller-App offen ist, bekommt Camper Monitor keine Verbindung. Hersteller-App offen ist, bekommt VanControl Pro keine Verbindung.
### Kühlbox ### Kühlbox
@@ -132,6 +132,23 @@ Beim Einrichten Art auf **Kühlbox** stellen. Die App meldet sich beim Verbinden
selbst an; steht **APP** im Display der Box, verlangt sie dabei einen selbst an; steht **APP** im Display der Box, verlangt sie dabei einen
Tastendruck am Gerät. Tastendruck am Gerät.
**Verbunden wird nur, während die Kühlbox geöffnet ist.** Jede Verbindung
meldet sich an ihrem Display an und stört, wer gerade davorsteht; und ihre Werte
ändern sich langsam. Auf der Übersicht steht deshalb nicht die gemessene
Innentemperatur, sondern der **zuletzt gestellte Stand** Sollwert, Ein/Aus,
Eco oder Max, dazu wann er gestellt wurde. Der bleibt richtig, auch wenn er von
gestern ist: Ein Sollwert ändert sich nur, wenn jemand ihn ändert. Eine
Innentemperatur von gestern sähe dagegen aus wie eine von jetzt.
Sobald du die Box öffnest, verbindet die App sich und zeigt alles live; beim
Verlassen der Ansicht trennt sie wieder. Dasselbe gilt für die Uhr: Auch von
dort wird die Verbindung nur angefordert, solange die Kühlbox-Ansicht offen ist.
Ein Stellbefehl geht ebenfalls immer durch ist die Box gerade nicht verbunden,
wird er gemerkt und geht raus, sobald sie antwortet.
BMS und Neigungsmesser bleiben dagegen dauerhaft verbunden. Sie stören dabei
nicht, und ihre Werte will man laufend sehen.
Neben den Messwerten gibt es hier als einzigem Gerät auch Bedienelemente: Neben den Messwerten gibt es hier als einzigem Gerät auch Bedienelemente:
Ein/Aus, Eco oder Max, Solltemperatur je Zone und die Bedienfeldsperre. Alle Ein/Aus, Eco oder Max, Solltemperatur je Zone und die Bedienfeldsperre. Alle
Schalter zeigen den Stand, den die Box zurückmeldet nach jedem Stellbefehl Schalter zeigen den Stand, den die Box zurückmeldet nach jedem Stellbefehl
@@ -263,19 +280,27 @@ den Arm hebt:
* **Übersicht** je Gerät der Hauptwert und ein Punkt für den Zustand der * **Übersicht** je Gerät der Hauptwert und ein Punkt für den Zustand der
Verbindung. Ganz unten steht, wie alt der Stand ist. Verbindung. Ganz unten steht, wie alt der Stand ist.
* **Nivellierung** wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und * **Nivellierung** drei Seiten zum Wischen, weil auf diesem Bildschirm ein
Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über Starts Wisch besser zu treffen ist als eine Scrollposition:
hinweg gemerkt. Dazu, was zu tun ist, und die Keilhöhe je Rad, falls im
Fahrzeugprofil Spurweite und Radstand stehen. Kalibriert wird nur am iPhone: 1. *Neigung* wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und
Das gehört einmalig auf ebenen Boden, und ein Knopf dafür an der Uhr wäre vor Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über
allem eine Gelegenheit, die Nullage aus Versehen zu verstellen. Starts hinweg gemerkt. Dazu, was zu tun ist, und woher die Werte kommen.
2. *Keile* die Höhe je Rad als Draufsicht. Fehlen Spurweite und Radstand im
Fahrzeugprofil, steht das dort statt einer leeren Seite.
3. *Ausrichten* der Assistent fürs Rangieren.
Kalibriert wird nur am iPhone: Das gehört einmalig auf ebenen Boden, und ein
Knopf dafür an der Uhr wäre vor allem eine Gelegenheit, die Nullage aus
Versehen zu verstellen.
* **Ausrichten** derselbe Assistent wie am iPhone: Tendenz, Rat und der * **Ausrichten** derselbe Assistent wie am iPhone: Tendenz, Rat und der
Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das
Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn
gegenüber dem iPhone beim Rangieren schaut niemand aufs Display, aber die gegenüber dem iPhone beim Rangieren schaut niemand aufs Display, aber die
Vibration am Handgelenk kommt an. Vibration am Handgelenk kommt an.
* **Kühlbox** ein/aus, Eco oder Max und die Solltemperatur, über die Krone * **Kühlbox** ein/aus, Eco oder Max und die Solltemperatur, über die Krone
gestellt. gestellt. Verbunden wird die Box erst beim Öffnen dieser Ansicht, davor steht
dort der zuletzt gestellte Stand.
### Wer mit wem funkt ### Wer mit wem funkt
@@ -305,7 +330,7 @@ auch dort nur, was das Gerät zurückmeldet, nicht der Tastendruck.
### Was das für den Betrieb heisst ### Was das für den Betrieb heisst
Für Batterie, Solar und Kühlbox muss Camper Monitor auf dem iPhone laufen. Für Batterie, Solar und Kühlbox muss VanControl Pro auf dem iPhone laufen.
Solange die Uhr meldet, dass jemand hinschaut, hält die App das Funkgerät auch Solange die Uhr meldet, dass jemand hinschaut, hält die App das Funkgerät auch
im Hintergrund am Leben; die Victron-Werbedaten stehen dabei still, weil iOS im im Hintergrund am Leben; die Victron-Werbedaten stehen dabei still, weil iOS im
Hintergrund kein ungefiltertes Suchen erlaubt. Hintergrund kein ungefiltertes Suchen erlaubt.
@@ -427,7 +452,7 @@ CamperMonitorWatch/ watchOS-App
├── WatchRootView.swift Übersicht und Altersangabe ├── WatchRootView.swift Übersicht und Altersangabe
├── WatchBubble.swift Libelle für den kleinen Bildschirm ├── WatchBubble.swift Libelle für den kleinen Bildschirm
├── WatchVehicleView.swift Fahrzeugansicht auf Uhrgrösse ├── WatchVehicleView.swift Fahrzeugansicht auf Uhrgrösse
├── WatchLevelView.swift Neigung, Keilhöhe, Herkunft der Werte ├── WatchLevelView.swift Die drei Seiten: Neigung, Keile, Ausrichten
├── WatchAlignView.swift Ausrichten mit Vibration ├── WatchAlignView.swift Ausrichten mit Vibration
├── WatchDeviceDetailView.swift Alle Werte eines Geräts ├── WatchDeviceDetailView.swift Alle Werte eines Geräts
└── WatchFridgeControls.swift Kühlbox stellen └── WatchFridgeControls.swift Kühlbox stellen
+12
View File
@@ -34,6 +34,18 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
} }
} }
/// Ob nur verbunden wird, während die Ansicht des Geräts offen ist.
///
/// Die Kühlbox meldet jede Verbindung an ihrem Display an und verlangt je
/// nach Modell sogar einen Tastendruck; dauerhaft verbunden zu sein ist
/// dort also nicht unsichtbar, sondern lästig. Ihre Werte ändern sich
/// ausserdem langsam, und was auf der Übersicht zählt der Sollwert
/// ändert sich überhaupt nur, wenn jemand ihn ändert.
///
/// BMS und Neigungsmesser bleiben dagegen verbunden: Sie stören nicht, und
/// ihre Werte will man laufend sehen.
var connectsOnDemand: Bool { self == .fridge }
/// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS /// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS
/// braucht eine echte GATT-Verbindung. /// braucht eine echte GATT-Verbindung.
var transport: DeviceTransport { var transport: DeviceTransport {
+47
View File
@@ -0,0 +1,47 @@
import Foundation
/// Der zuletzt bekannte Stand einer Kühlbox ohne Messwerte.
///
/// Die Box wird nur noch verbunden, während man ihre Ansicht offen hat: Jede
/// Verbindung meldet sich an ihrem Display an und stört, wer sie gerade
/// bedient. Auf der Übersicht steht deshalb, was zuletzt **eingestellt** war.
///
/// Messwerte gehören bewusst nicht dazu. Eine Innentemperatur von vorgestern
/// sähe aus wie eine von jetzt, und man würde ihr glauben eine Solltemperatur
/// dagegen ändert sich nur, wenn jemand sie ändert.
struct FridgeSettings: Codable, Equatable, Sendable {
var isPoweredOn: Bool
var isEco: Bool
var isLocked: Bool
var isDualZone: Bool
var usesFahrenheit: Bool
var leftTarget: Int?
var rightTarget: Int?
/// Wann dieser Stand von der Box kam.
var updated: Date
var unitSymbol: String { usesFahrenheit ? "°F" : "°C" }
/// In Worten, wie es auf der Kachel steht.
var stateText: String {
guard isPoweredOn else { return "Aus" }
return isEco ? "Eco" : "Max"
}
/// Was die Übersicht zeigt: die Sollwerte, sonst nichts.
func snapshot(deviceID: UUID) -> DeviceSnapshot {
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: updated, rssi: nil)
var metrics = [
Metric("target_left", isDualZone ? "Soll links" : "Solltemperatur",
leftTarget.map(Double.init), unit: unitSymbol, precision: 0, primary: true),
]
if isDualZone {
metrics.append(Metric("target_right", "Soll rechts",
rightTarget.map(Double.init), unit: unitSymbol, precision: 0))
}
snapshot.metrics = metrics
snapshot.state = stateText
if isLocked { snapshot.offReasons = ["Bedienfeld gesperrt"] }
return snapshot
}
}
+19
View File
@@ -0,0 +1,19 @@
import Foundation
/// Die Adressen, über die etwas in der Watch-App geöffnet wird.
///
/// Sie stehen hier, weil beide Seiten sie brauchen und sie sonst zweimal
/// dastünden: Die Komplikation hängt sie an ihre Anzeige, die App wertet sie
/// beim Öffnen aus. Ein Tippfehler auf einer Seite wäre stumm der Tipp aufs
/// Zifferblatt öffnete dann einfach die Geräteliste.
enum WatchDeepLink {
private static let scheme = "campermonitor"
/// Führt direkt auf die Nivellierung.
static let level = URL(string: "\(scheme)://nivellierung")!
/// Ob diese Adresse zur Nivellierung führt.
static func isLevel(_ url: URL) -> Bool {
url.scheme == scheme && (url.host() == "nivellierung")
}
}
+31
View File
@@ -103,6 +103,14 @@ struct WatchDevice: Codable, Equatable, Identifiable, Sendable {
/// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit /// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit
/// drin, den auf die Uhr zu schicken nichts brächte. /// drin, den auf die Uhr zu schicken nichts brächte.
struct WatchFridge: Codable, Equatable, Sendable { struct WatchFridge: Codable, Equatable, Sendable {
/// Ob die Box gerade wirklich verbunden ist.
///
/// Sie wird nur verbunden, während jemand ihre Ansicht offen hat jede
/// Verbindung meldet sich an ihrem Display an. Sonst stehen hier die
/// zuletzt gestellten Werte, und die Messwerte fehlen.
var isLive: Bool
/// Wann der Stand zuletzt von der Box kam.
var updated: Date
var isPoweredOn: Bool var isPoweredOn: Bool
var isEco: Bool var isEco: Bool
var isLocked: Bool var isLocked: Bool
@@ -133,6 +141,9 @@ enum WatchCommand: Codable, Equatable, Sendable {
case fridgeEco(device: UUID, eco: Bool) case fridgeEco(device: UUID, eco: Bool)
case fridgeLock(device: UUID, locked: Bool) case fridgeLock(device: UUID, locked: Bool)
case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int) case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int)
/// Die Kühlbox-Ansicht auf der Uhr wurde geöffnet oder geschlossen. Das
/// iPhone verbindet die Box daraufhin oder gibt sie wieder frei.
case fridgeSession(device: UUID, wanted: Bool)
/// Ob der Befehl den schnellen Sendetakt verlängert. /// Ob der Befehl den schnellen Sendetakt verlängert.
var wantsLiveUpdates: Bool { var wantsLiveUpdates: Bool {
@@ -149,6 +160,26 @@ enum WatchFridgeZone: String, Codable, Equatable, Sendable {
// MARK: - Verpacken // MARK: - Verpacken
extension WatchFridge {
/// Aus dem zuletzt gestellten Stand, ohne Messwerte.
init(_ settings: FridgeSettings) {
self.init(isLive: false,
updated: settings.updated,
isPoweredOn: settings.isPoweredOn,
isEco: settings.isEco,
isLocked: settings.isLocked,
isDualZone: settings.isDualZone,
unitSymbol: settings.unitSymbol,
leftTarget: settings.leftTarget,
leftCurrent: nil,
rightTarget: settings.rightTarget,
rightCurrent: nil,
minTarget: settings.usesFahrenheit ? -22 : -30,
maxTarget: settings.usesFahrenheit ? 68 : 20,
batteryVolts: nil)
}
}
extension WatchPayload { extension WatchPayload {
func encoded() throws -> Data { try WatchCoder.encoder.encode(self) } func encoded() throws -> Data { try WatchCoder.encoder.encode(self) }
+43 -1
View File
@@ -698,6 +698,44 @@ checkEqual("falsche Prüfsumme wird verworfen",
checkEqual("angefangenes Paket wird aufgehoben", checkEqual("angefangenes Paket wird aufgehoben",
AlpicoolProtocol.extractFrames(from: Array(fridgeStream.prefix(10))).frames.count, 0) AlpicoolProtocol.extractFrames(from: Array(fridgeStream.prefix(10))).frames.count, 0)
// MARK: 9b Kühlbox: der Stand, der die Verbindung überdauert
print("\nKühlbox zuletzt gestellter Stand")
// Derselbe Datensatz wie oben, nur diesmal auf das eingedampft, was ohne
// Verbindung noch gilt.
var boxState = AlpicoolState()
boxState.apply(AlpicoolProtocol.Frame(command: 0x01, payload: [
0x00, 0x01, 0x01, 0x00, 0x04, 0x14, 0xE2, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x06, 0x57, 0x0C, 0x07,
]))
guard let boxSettings = boxState.settings else {
check("Stand lässt sich aus dem Datensatz ableiten", false)
exit(1)
}
checkEqual("eingeschaltet", boxSettings.isPoweredOn, true)
checkEqual("Eco", boxSettings.isEco, true)
checkEqual("Solltemperatur", boxSettings.leftTarget, 4)
checkEqual("Einheit", boxSettings.unitSymbol, "°C")
checkEqual("in Worten", boxSettings.stateText, "Eco")
// Die Übersicht zeigt den Sollwert und ausdrücklich keine Innentemperatur.
let boxSnapshot = boxSettings.snapshot(deviceID: UUID())
checkEqual("Hauptwert ist der Sollwert", boxSnapshot.primaryMetric?.key, "target_left")
checkEqual("Sollwert steht drin", value(boxSnapshot, "target_left"), 4)
checkEqual("keine gemessene Temperatur", boxSnapshot.metrics.contains { $0.key == "temp_left" }, false)
checkEqual("keine Bordspannung", boxSnapshot.metrics.contains { $0.key == "supply_voltage" }, false)
// Ohne Antwort der Box gibt es auch nichts aufzuheben.
checkEqual("leerer Zustand ergibt keinen Stand", AlpicoolState().settings == nil, true)
// Auf der Uhr dasselbe Bild: Sollwerte ja, Messwerte nein.
let watchBox = WatchFridge(boxSettings)
checkEqual("auf der Uhr als nicht live gekennzeichnet", watchBox.isLive, false)
checkEqual("Sollwert kommt mit", watchBox.leftTarget, 4)
checkEqual("Messwert bleibt leer", watchBox.leftCurrent == nil, true)
// MARK: 10 VanAlign Neigungsmesser // MARK: 10 VanAlign Neigungsmesser
print("\nVanAlign-Neigungsmesser") print("\nVanAlign-Neigungsmesser")
@@ -1136,7 +1174,9 @@ var watchOrientation = SensorOrientation()
watchOrientation.longitudinalSource = .roll watchOrientation.longitudinalSource = .roll
watchOrientation.invertLateral = true watchOrientation.invertLateral = true
let watchFridge = WatchFridge(isPoweredOn: true, isEco: true, isLocked: false, let watchFridge = WatchFridge(isLive: true,
updated: Date(timeIntervalSince1970: 1_699_999_000),
isPoweredOn: true, isEco: true, isLocked: false,
isDualZone: false, unitSymbol: "°C", isDualZone: false, unitSymbol: "°C",
leftTarget: 4, leftCurrent: 6, leftTarget: 4, leftCurrent: 6,
rightTarget: nil, rightCurrent: nil, rightTarget: nil, rightCurrent: nil,
@@ -1229,6 +1269,8 @@ let commands: [WatchCommand] = [
.fridgeEco(device: watchFridgeID, eco: true), .fridgeEco(device: watchFridgeID, eco: true),
.fridgeLock(device: watchFridgeID, locked: true), .fridgeLock(device: watchFridgeID, locked: true),
.fridgeTarget(device: watchFridgeID, zone: .right, value: -3), .fridgeTarget(device: watchFridgeID, zone: .right, value: -3),
.fridgeSession(device: watchFridgeID, wanted: true),
.fridgeSession(device: watchFridgeID, wanted: false),
] ]
for command in commands { for command in commands {
guard let message = try? command.message(), guard let message = try? command.message(),
+3 -3
View File
@@ -26,7 +26,7 @@ darüber auffindbar).
| Roll | `…3425` | lesen | Querneigung, Float32 little-endian, Grad | | Roll | `…3425` | lesen | Querneigung, Float32 little-endian, Grad |
| Kalibrieren | `…3427` | schreiben | ein Byte: `0` setzt zurück, alles andere kalibriert | | Kalibrieren | `…3427` | schreiben | ein Byte: `0` setzt zurück, alles andere kalibriert |
Der Client fragt Pitch und Roll im Takt ab; Camper Monitor tut das zweimal je Der Client fragt Pitch und Roll im Takt ab; VanControl Pro tut das zweimal je
Sekunde. Sekunde.
Positiver Pitch heißt: das Heck steht höher. Positiver Roll: die rechte Seite Positiver Pitch heißt: das Heck steht höher. Positiver Roll: die rechte Seite
@@ -35,7 +35,7 @@ steht höher.
### Kalibrierung ### Kalibrierung
Fahrzeug eben stellen, dann ein beliebiges Byte ungleich `0` auf `…3427` Fahrzeug eben stellen, dann ein beliebiges Byte ungleich `0` auf `…3427`
schreiben oder in Camper Monitor auf *Auf aktuelle Lage kalibrieren* tippen. schreiben oder in VanControl Pro auf *Auf aktuelle Lage kalibrieren* tippen.
Die Offsets werden dauerhaft gespeichert und überstehen einen Neustart. Die Offsets werden dauerhaft gespeichert und überstehen einen Neustart.
## Was sich gegenüber 1.0 geändert hat ## Was sich gegenüber 1.0 geändert hat
@@ -70,7 +70,7 @@ Die UUIDs sind unverändert, die vorhandene WebApp läuft weiter.
## Clients ## Clients
* **Camper Monitor** (iOS) die App in diesem Projekt. Geräteart * **VanControl Pro** (iOS) die App in diesem Projekt. Geräteart
*Nivellierung*: grafische Libelle, Kalibrierung aus der App, zusammen mit *Nivellierung*: grafische Libelle, Kalibrierung aus der App, zusammen mit
den übrigen Geräten im Fahrzeug. den übrigen Geräten im Fahrzeug.
* Die ursprüngliche WebBLE-Oberfläche liegt weiterhin im Projekt * Die ursprüngliche WebBLE-Oberfläche liegt weiterhin im Projekt
+1
View File
@@ -15,6 +15,7 @@ swiftc -O -o "$OUT/tests" \
CamperMonitor/Bluetooth/AlpicoolProtocol.swift \ CamperMonitor/Bluetooth/AlpicoolProtocol.swift \
Shared/Bluetooth/VanAlignProtocol.swift \ Shared/Bluetooth/VanAlignProtocol.swift \
Shared/Models/DeviceSnapshot.swift \ Shared/Models/DeviceSnapshot.swift \
Shared/Models/FridgeSettings.swift \
Shared/Models/ConfiguredDevice.swift \ Shared/Models/ConfiguredDevice.swift \
Shared/Models/LevelState.swift \ Shared/Models/LevelState.swift \
Shared/Models/Profile.swift \ Shared/Models/Profile.swift \