Compare commits
4
Commits
f0df433421
...
5fec4a55dd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fec4a55dd | ||
|
|
8aa8658b5c | ||
|
|
82df240ebc | ||
|
|
0e05ca22d7 |
@@ -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,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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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] = [:]
|
||||||
@@ -298,6 +303,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 +322,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 +495,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 +575,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 +813,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 +866,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 +904,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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -76,7 +76,14 @@ struct DeviceCard: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var footer: some View {
|
private var footer: some View {
|
||||||
if let fault = snapshot?.fault {
|
if device.role.connectsOnDemand, linkState != .live, 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 +103,29 @@ struct DeviceCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Was unter einem Gerät steht, das nur beim Öffnen verbunden wird.
|
||||||
|
private func lastSetText(_ snapshot: DeviceSnapshot) -> String {
|
||||||
|
let age = relativeUpdate(snapshot.timestamp)
|
||||||
|
guard let state = snapshot.state else { return "Zuletzt gestellt \(age)" }
|
||||||
|
return "\(state) · zuletzt gestellt \(age)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „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…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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:
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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
|
||||||
if let current = assistant.current {
|
// auch auf der kleinsten Uhr auf einen Blick passt.
|
||||||
Text(String(format: "%.1f°", current.deviation))
|
VStack(spacing: 3) {
|
||||||
.font(.system(size: 40, weight: .semibold, design: .rounded))
|
if let current = assistant.current {
|
||||||
.monospacedDigit()
|
Text(String(format: "%.1f°", current.deviation))
|
||||||
.foregroundStyle(assistant.hasReachedTarget ? .green : .primary)
|
.font(.system(size: 30, weight: .semibold, design: .rounded))
|
||||||
.contentTransition(.numericText())
|
.monospacedDigit()
|
||||||
} else {
|
.foregroundStyle(assistant.hasReachedTarget ? .green : .primary)
|
||||||
ProgressView()
|
.contentTransition(.numericText())
|
||||||
.padding(.vertical, 12)
|
} else {
|
||||||
}
|
ProgressView()
|
||||||
|
.padding(.vertical, 6)
|
||||||
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)
|
|
||||||
|
Label(assistant.trend.text, systemImage: assistant.trend.symbol)
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundStyle(trendColor)
|
||||||
|
|
||||||
|
Text(assistant.advice)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
|
||||||
|
if let state {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
WatchBubble(pitch: state.pitch, roll: state.roll)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(String(format: "Längs %.1f°", state.pitch ?? 0))
|
||||||
|
Text(String(format: "Quer %.1f°", state.roll ?? 0))
|
||||||
|
}
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
assistant.reset()
|
||||||
|
hasAnnouncedTarget = false
|
||||||
|
} label: {
|
||||||
|
Label("Von vorn", systemImage: "arrow.counterclockwise")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding(.horizontal, 2)
|
||||||
.navigationTitle("Ausrichten")
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
Reference in New Issue
Block a user