Compare commits
4
Commits
f0df433421
...
5fec4a55dd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fec4a55dd | ||
|
|
8aa8658b5c | ||
|
|
82df240ebc | ||
|
|
0e05ca22d7 |
@@ -59,7 +59,7 @@ jobs:
|
||||
-PversionName="$VERSION" \
|
||||
-PversionCode="$CODE" --no-daemon
|
||||
mv app/build/outputs/apk/release/app-release.apk \
|
||||
"../CamperMonitor-$VERSION.apk"
|
||||
"../VanControlPro-$VERSION.apk"
|
||||
|
||||
- name: An das Release hängen
|
||||
env:
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
fi
|
||||
|
||||
curl -sf -X POST -H "$AUTH" \
|
||||
-F "attachment=@CamperMonitor-$VERSION.apk" \
|
||||
"$API/$ID/assets?name=CamperMonitor-$VERSION.apk" > /dev/null
|
||||
-F "attachment=@VanControlPro-$VERSION.apk" \
|
||||
"$API/$ID/assets?name=VanControlPro-$VERSION.apk" > /dev/null
|
||||
|
||||
echo "CamperMonitor-$VERSION.apk hängt an Release $GITHUB_REF_NAME."
|
||||
echo "VanControlPro-$VERSION.apk hängt an Release $GITHUB_REF_NAME."
|
||||
|
||||
@@ -89,7 +89,7 @@ fun WithBluetoothPermission(content: @Composable () -> Unit) {
|
||||
if (asked) {
|
||||
Text(
|
||||
"Die Freigabe wurde abgelehnt. Sie lässt sich in den " +
|
||||
"Android-Einstellungen unter „Apps → Camper Monitor → " +
|
||||
"Android-Einstellungen unter „Apps → VanControl Pro → " +
|
||||
"Berechtigungen“ nachholen.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
|
||||
@@ -65,7 +65,7 @@ fun SettingsScreen(store: DeviceStore, onBack: () -> Unit) {
|
||||
)
|
||||
|
||||
Text(
|
||||
"Camper Monitor liest Victron-Geräte über ihr Advertisement mit " +
|
||||
"VanControl Pro liest Victron-Geräte über ihr Advertisement mit " +
|
||||
"(„Instant Readout“) und spricht Batterie, Kühlbox und " +
|
||||
"Neigungsmesser direkt über Bluetooth an.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Camper Monitor</string>
|
||||
<string name="app_name">VanControl Pro</string>
|
||||
</resources>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* CamperMonitorWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
AA0000000000000000000042 /* CamperMonitorComplication.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000032 /* CamperMonitorComplication.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -18,6 +19,13 @@
|
||||
remoteGlobalIDString = AA0000000000000000000022;
|
||||
remoteInfo = CamperMonitorWatch;
|
||||
};
|
||||
AA0000000000000000000043 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AA0000000000000000000009 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = AA0000000000000000000037;
|
||||
remoteInfo = CamperMonitorComplication;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -32,12 +40,25 @@
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000041 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
AA0000000000000000000042 /* CamperMonitorComplication.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
AA0000000000000000000001 /* CamperMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AA0000000000000000000016 /* CamperMonitorWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitorWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AA0000000000000000000030 /* CamperMonitor-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitor-Info.plist"; sourceTree = "<group>"; };
|
||||
AA0000000000000000000032 /* CamperMonitorComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CamperMonitorComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AA0000000000000000000045 /* CamperMonitorComplication-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitorComplication-Info.plist"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -56,6 +77,11 @@
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AA0000000000000000000033 /* CamperMonitorComplication */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = CamperMonitorComplication;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -73,6 +99,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000035 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -81,6 +114,7 @@
|
||||
children = (
|
||||
AA0000000000000000000002 /* CamperMonitor */,
|
||||
AA0000000000000000000017 /* CamperMonitorWatch */,
|
||||
AA0000000000000000000033 /* CamperMonitorComplication */,
|
||||
AA0000000000000000000018 /* Shared */,
|
||||
AA0000000000000000000031 /* Config */,
|
||||
AA0000000000000000000005 /* Products */,
|
||||
@@ -92,6 +126,7 @@
|
||||
children = (
|
||||
AA0000000000000000000001 /* CamperMonitor.app */,
|
||||
AA0000000000000000000016 /* CamperMonitorWatch.app */,
|
||||
AA0000000000000000000032 /* CamperMonitorComplication.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -100,6 +135,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0000000000000000000030 /* CamperMonitor-Info.plist */,
|
||||
AA0000000000000000000045 /* CamperMonitorComplication-Info.plist */,
|
||||
);
|
||||
path = Config;
|
||||
sourceTree = "<group>";
|
||||
@@ -137,10 +173,12 @@
|
||||
AA0000000000000000000019 /* Sources */,
|
||||
AA0000000000000000000020 /* Frameworks */,
|
||||
AA0000000000000000000021 /* Resources */,
|
||||
AA0000000000000000000041 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
AA0000000000000000000044 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
AA0000000000000000000017 /* CamperMonitorWatch */,
|
||||
@@ -151,6 +189,27 @@
|
||||
productReference = AA0000000000000000000016 /* CamperMonitorWatch.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
AA0000000000000000000037 /* CamperMonitorComplication */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "CamperMonitorComplication" */;
|
||||
buildPhases = (
|
||||
AA0000000000000000000034 /* Sources */,
|
||||
AA0000000000000000000035 /* Frameworks */,
|
||||
AA0000000000000000000036 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
AA0000000000000000000018 /* Shared */,
|
||||
AA0000000000000000000033 /* CamperMonitorComplication */,
|
||||
);
|
||||
name = CamperMonitorComplication;
|
||||
productName = CamperMonitorComplication;
|
||||
productReference = AA0000000000000000000032 /* CamperMonitorComplication.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -167,6 +226,9 @@
|
||||
AA0000000000000000000022 = {
|
||||
CreatedOnToolsVersion = 26.6;
|
||||
};
|
||||
AA0000000000000000000037 = {
|
||||
CreatedOnToolsVersion = 26.6;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */;
|
||||
@@ -186,6 +248,7 @@
|
||||
targets = (
|
||||
AA0000000000000000000006 /* CamperMonitor */,
|
||||
AA0000000000000000000022 /* CamperMonitorWatch */,
|
||||
AA0000000000000000000037 /* CamperMonitorComplication */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -205,6 +268,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000036 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -222,6 +292,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AA0000000000000000000034 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
@@ -231,6 +308,11 @@
|
||||
target = AA0000000000000000000022 /* CamperMonitorWatch */;
|
||||
targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */;
|
||||
};
|
||||
AA0000000000000000000044 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = AA0000000000000000000037 /* CamperMonitorComplication */;
|
||||
targetProxy = AA0000000000000000000043 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@@ -301,7 +383,7 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
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_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
@@ -332,7 +414,7 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
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_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
@@ -361,7 +443,7 @@
|
||||
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
||||
ENABLE_PREVIEWS = 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_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -393,7 +475,7 @@
|
||||
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
||||
ENABLE_PREVIEWS = 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_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -415,6 +497,64 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
AA0000000000000000000038 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp.levelwidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
AA0000000000000000000039 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6AP73NYV5W;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp.levelwidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "watchos watchsimulator";
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -445,6 +585,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
AA0000000000000000000040 /* Build configuration list for PBXNativeTarget "CamperMonitorComplication" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
AA0000000000000000000038 /* Debug */,
|
||||
AA0000000000000000000039 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = AA0000000000000000000009 /* Project object */;
|
||||
|
||||
@@ -232,6 +232,19 @@ struct AlpicoolState: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Stand, der die Verbindung überdauert – ohne Messwerte.
|
||||
var settings: FridgeSettings? {
|
||||
guard hasStatus else { return nil }
|
||||
return FridgeSettings(isPoweredOn: isPoweredOn,
|
||||
isEco: isEco,
|
||||
isLocked: isLocked,
|
||||
isDualZone: isDualZone,
|
||||
usesFahrenheit: usesFahrenheit,
|
||||
leftTarget: leftTarget,
|
||||
rightTarget: isDualZone ? rightTarget : nil,
|
||||
updated: Date())
|
||||
}
|
||||
|
||||
/// Die Bytes, die ein Stellbefehl ändert.
|
||||
///
|
||||
/// Messwerte gehören nicht dazu: Temperatur und Spannung schwanken
|
||||
|
||||
@@ -178,6 +178,11 @@ final class BluetoothManager: NSObject {
|
||||
}
|
||||
private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID
|
||||
|
||||
/// Geräte, die nur verbunden werden, solange ihre Ansicht offen ist.
|
||||
/// Schlüssel ist die Gerätekennung, gezählt wird nicht – eine Ansicht ist
|
||||
/// zu einer Zeit offen.
|
||||
private var requestedSessions: Set<UUID> = []
|
||||
|
||||
private var bmsSessions: [UUID: BMSSession] = [:]
|
||||
private var levelSessions: [UUID: LevelSession] = [:]
|
||||
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
|
||||
@@ -298,6 +303,14 @@ final class BluetoothManager: NSObject {
|
||||
fridgeStates = fridgeStates.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) }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
func updateFridgeZoneMode(for device: ConfiguredDevice) {
|
||||
@@ -431,8 +495,10 @@ final class BluetoothManager: NSObject {
|
||||
private func applyConfiguration(_ devices: [ManagedDevice]) {
|
||||
managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) })
|
||||
|
||||
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören.
|
||||
let wanted = Set(devices.filter { $0.transport == .connect }.map(\.peripheralID))
|
||||
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören – und zu
|
||||
// denen, die nur auf Anforderung verbunden werden und gerade niemand
|
||||
// anschaut.
|
||||
let wanted = Set(devices.filter { shouldStayConnected($0) }.map(\.peripheralID))
|
||||
for (peripheralID, session) in bmsSessions where !wanted.contains(peripheralID) {
|
||||
session.stop()
|
||||
bmsSessions[peripheralID] = nil
|
||||
@@ -509,11 +575,22 @@ final class BluetoothManager: NSObject {
|
||||
|
||||
private func connectManagedPeripherals() {
|
||||
guard central?.state == .poweredOn else { return }
|
||||
for device in managed.values where device.transport == .connect {
|
||||
for device in managed.values where shouldStayConnected(device) {
|
||||
connectIfNeeded(device)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob dieses Gerät von sich aus verbunden gehalten wird.
|
||||
///
|
||||
/// Geräte auf Anforderung – die Kühlbox – nur dann, wenn ihre Ansicht
|
||||
/// gerade offen ist oder ein Stellbefehl darauf wartet.
|
||||
private func shouldStayConnected(_ device: ManagedDevice) -> Bool {
|
||||
guard device.transport == .connect else { return false }
|
||||
guard device.role.connectsOnDemand else { return true }
|
||||
return requestedSessions.contains(device.id)
|
||||
|| pendingControls[device.peripheralID]?.isEmpty == false
|
||||
}
|
||||
|
||||
private func connectIfNeeded(_ device: ManagedDevice) {
|
||||
if let existing = connectedPeripherals[device.peripheralID],
|
||||
existing.state == .connected || existing.state == .connecting {
|
||||
@@ -736,8 +813,11 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
||||
handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi)
|
||||
|
||||
case .connect:
|
||||
// Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt aufbauen.
|
||||
if connectedPeripherals[identifier] == nil {
|
||||
// Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt
|
||||
// aufbauen. Ausser bei Geräten auf Anforderung: Die Kühlbox piept
|
||||
// bei jeder Verbindung, und dass sie in Reichweite ist, ist kein
|
||||
// Grund, sie anzufassen.
|
||||
if connectedPeripherals[identifier] == nil, shouldStayConnected(device) {
|
||||
connectIfNeeded(device)
|
||||
}
|
||||
}
|
||||
@@ -786,7 +866,14 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
||||
)
|
||||
session.onFridgeState = { [weak self] state in
|
||||
self?.fridgeStateCache[peripheral.identifier] = state
|
||||
self?.publish { self?.fridgeStates[device.id] = state }
|
||||
self?.publish {
|
||||
self?.fridgeStates[device.id] = state
|
||||
// Für die Übersicht aufheben: Sie zeigt den Stand auch dann,
|
||||
// wenn längst niemand mehr verbunden ist.
|
||||
if let settings = state.settings {
|
||||
self?.store.setLastFridgeSettings(settings, for: device.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
session.fridgeZoneMode = device.fridgeZoneMode
|
||||
bmsSessions[peripheral.identifier] = session
|
||||
@@ -817,7 +904,15 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
||||
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
|
||||
.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 }
|
||||
if lifetime >= stableConnection {
|
||||
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
|
||||
|
||||
@@ -11,6 +11,7 @@ final class DeviceStore {
|
||||
private static let activeProfileKey = "activeProfileID"
|
||||
/// Hierhin wird gerettet, was sich nicht lesen liess – siehe `loadDevices`.
|
||||
private static let unreadableDevicesKey = "configuredDevices.unreadable"
|
||||
private static let fridgeSettingsKey = "lastFridgeSettings"
|
||||
|
||||
/// Alle Geräte über alle Profile hinweg.
|
||||
private(set) var devices: [ConfiguredDevice] = []
|
||||
@@ -144,6 +145,36 @@ final class DeviceStore {
|
||||
return bytes
|
||||
}
|
||||
|
||||
// MARK: - Zuletzt bekannter Stand der Kühlbox
|
||||
|
||||
/// Die Kühlbox wird nur verbunden, während ihre Ansicht offen ist. Damit
|
||||
/// die Übersicht trotzdem etwas zeigt, wird der zuletzt gemeldete Stand
|
||||
/// aufgehoben – die Einstellungen, nicht die Messwerte.
|
||||
func lastFridgeSettings(for deviceID: UUID) -> FridgeSettings? {
|
||||
allFridgeSettings()[deviceID.uuidString]
|
||||
}
|
||||
|
||||
/// Der Zeitstempel bedeutet „zuletzt **geändert**“, nicht „zuletzt
|
||||
/// gesehen“: Sonst wanderte er im Sekundentakt und die Übersicht behauptete
|
||||
/// Frische, wo sich nichts getan hat.
|
||||
func setLastFridgeSettings(_ settings: FridgeSettings, for deviceID: UUID) {
|
||||
var all = allFridgeSettings()
|
||||
if var existing = all[deviceID.uuidString] {
|
||||
existing.updated = settings.updated
|
||||
guard existing != settings else { return }
|
||||
}
|
||||
all[deviceID.uuidString] = settings
|
||||
guard let data = try? JSONEncoder().encode(all) else { return }
|
||||
UserDefaults.standard.set(data, forKey: Self.fridgeSettingsKey)
|
||||
}
|
||||
|
||||
private func allFridgeSettings() -> [String: FridgeSettings] {
|
||||
guard let data = UserDefaults.standard.data(forKey: Self.fridgeSettingsKey),
|
||||
let decoded = try? JSONDecoder().decode([String: FridgeSettings].self, from: data)
|
||||
else { return [:] }
|
||||
return decoded
|
||||
}
|
||||
|
||||
// MARK: - Persistenz
|
||||
|
||||
private func load() {
|
||||
|
||||
@@ -76,7 +76,14 @@ struct DeviceCard: View {
|
||||
|
||||
@ViewBuilder
|
||||
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")
|
||||
.font(.caption)
|
||||
.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 {
|
||||
switch linkState {
|
||||
case .needsKey: return "Verschlüsselungsschlüssel fehlt – im Detail eintragen."
|
||||
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)
|
||||
.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 {
|
||||
editedName = currentDevice.name
|
||||
keyInput = store.victronKeyText(for: device.id) ?? ""
|
||||
bluetooth.beginSession(for: currentDevice)
|
||||
}
|
||||
.confirmationDialog("Gerät entfernen?",
|
||||
isPresented: $showDeleteConfirmation,
|
||||
|
||||
@@ -135,8 +135,7 @@ final class PhoneWatchLink: NSObject {
|
||||
snapshot: bluetooth.snapshots[device.id],
|
||||
level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil,
|
||||
orientation: device.role == .leveling ? device.sensorOrientation : nil,
|
||||
fridge: device.role == .fridge
|
||||
? bluetooth.fridgeStates[device.id].map(WatchFridge.init) : nil)
|
||||
fridge: device.role == .fridge ? fridgeState(for: device) : nil)
|
||||
}
|
||||
return WatchPayload(generatedAt: Date(),
|
||||
profile: profile,
|
||||
@@ -145,6 +144,19 @@ final class PhoneWatchLink: NSObject {
|
||||
devices: devices)
|
||||
}
|
||||
|
||||
/// Live, solange die Box verbunden ist – sonst der zuletzt gestellte Stand
|
||||
/// aus dem Speicher, ohne Messwerte.
|
||||
private func fridgeState(for device: ConfiguredDevice) -> WatchFridge? {
|
||||
if linkStateIsLive(device), let state = bluetooth.fridgeStates[device.id] {
|
||||
return WatchFridge(state)
|
||||
}
|
||||
return store.lastFridgeSettings(for: device.id).map(WatchFridge.init)
|
||||
}
|
||||
|
||||
private func linkStateIsLive(_ device: ConfiguredDevice) -> Bool {
|
||||
bluetooth.linkStates[device.id] == .live
|
||||
}
|
||||
|
||||
// MARK: - Befehle von der Uhr
|
||||
|
||||
private func handle(_ command: WatchCommand) {
|
||||
@@ -168,6 +180,13 @@ final class PhoneWatchLink: NSObject {
|
||||
bluetooth.setFridgeLock(locked, for: device)
|
||||
case .fridgeTarget(let device, let zone, let value):
|
||||
bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device)
|
||||
case .fridgeSession(let deviceID, let wanted):
|
||||
guard let device = store.devices.first(where: { $0.id == deviceID }) else { break }
|
||||
if wanted {
|
||||
bluetooth.beginSession(for: device)
|
||||
} else {
|
||||
bluetooth.endSession(for: device)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,7 +269,9 @@ extension WatchFridge {
|
||||
/// stellt – der Rohdatensatz bleibt auf dem iPhone.
|
||||
init(_ state: AlpicoolState) {
|
||||
let range = state.targetRange
|
||||
self.init(isPoweredOn: state.isPoweredOn,
|
||||
self.init(isLive: true,
|
||||
updated: Date(),
|
||||
isPoweredOn: state.isPoweredOn,
|
||||
isEco: state.isEco,
|
||||
isLocked: state.isLocked,
|
||||
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
|
||||
|
||||
/// Camper Monitor am Handgelenk.
|
||||
/// VanControl Pro am Handgelenk.
|
||||
///
|
||||
/// Zwei Quellen, jede dort, wo sie hingehört:
|
||||
///
|
||||
|
||||
@@ -20,52 +20,58 @@ struct WatchAlignView: View {
|
||||
private var state: LevelState? { reading.hasReading ? reading.state : nil }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
// Ohne ScrollView: Beim Rangieren schaut man kurz hin, da darf nichts
|
||||
// ausserhalb des Bildes liegen. Alles ist deshalb so bemessen, dass es
|
||||
// auch auf der kleinsten Uhr auf einen Blick passt.
|
||||
VStack(spacing: 3) {
|
||||
if let current = assistant.current {
|
||||
Text(String(format: "%.1f°", current.deviation))
|
||||
.font(.system(size: 40, weight: .semibold, design: .rounded))
|
||||
.font(.system(size: 30, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(assistant.hasReachedTarget ? .green : .primary)
|
||||
.contentTransition(.numericText())
|
||||
} else {
|
||||
ProgressView()
|
||||
.padding(.vertical, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
Label(assistant.trend.text, systemImage: assistant.trend.symbol)
|
||||
.font(.footnote.weight(.medium))
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(trendColor)
|
||||
|
||||
Text(assistant.advice)
|
||||
.font(.caption)
|
||||
.font(.system(size: 11))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
|
||||
if let state {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
WatchBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(width: 56, height: 56)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
.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(.caption2)
|
||||
.font(.system(size: 11))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
Button("Von vorn messen", systemImage: "arrow.counterclockwise") {
|
||||
Button {
|
||||
assistant.reset()
|
||||
hasAnnouncedTarget = false
|
||||
} label: {
|
||||
Label("Von vorn", systemImage: "arrow.counterclockwise")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.font(.caption2)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.horizontal, 2)
|
||||
.navigationTitle("Ausrichten")
|
||||
.onChange(of: tilt, initial: true) { _, tilt in
|
||||
guard let tilt else { return }
|
||||
|
||||
@@ -55,5 +55,15 @@ struct WatchDeviceDetailView: View {
|
||||
}
|
||||
}
|
||||
.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 "
|
||||
+ "wieder durch, wenn es antwortet.")
|
||||
.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
|
||||
|
||||
/// 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
|
||||
/// wenn der gerade nicht erreichbar ist, tritt der zuletzt über das iPhone
|
||||
/// gemeldete Stand an seine Stelle. Woher sie stammen, steht unten – ein alter
|
||||
/// Wert sieht sonst aus wie ein stillstehendes Fahrzeug.
|
||||
/// gemeldete Stand an seine Stelle. Woher sie stammen, steht auf der ersten
|
||||
/// Seite – ein alter Wert sieht sonst aus wie ein stillstehendes Fahrzeug.
|
||||
///
|
||||
/// 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,
|
||||
/// die Nullage aus Versehen zu verstellen.
|
||||
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(WatchLevelRadio.self) private var radio
|
||||
@AppStorage("levelDisplayStyle") private var showsVehicle = true
|
||||
@@ -36,18 +65,6 @@ struct WatchLevelView: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.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 {
|
||||
showsVehicle.toggle()
|
||||
} label: {
|
||||
@@ -60,7 +77,7 @@ struct WatchLevelView: View {
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.navigationTitle("Nivellierung")
|
||||
.navigationTitle("Neigung")
|
||||
}
|
||||
|
||||
private func angle(_ title: String, _ value: Double?) -> some View {
|
||||
@@ -73,6 +90,49 @@ struct WatchLevelView: View {
|
||||
.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
|
||||
/// haben, wenn im Fahrzeugprofil Spurweite und Radstand stehen.
|
||||
|
||||
@@ -6,10 +6,27 @@ struct WatchRootView: View {
|
||||
@Environment(PhoneLink.self) private var link
|
||||
@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 {
|
||||
NavigationStack {
|
||||
NavigationStack(path: $path) {
|
||||
content
|
||||
.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
|
||||
// Sensor selbst und braucht das iPhone nicht.
|
||||
Section {
|
||||
NavigationLink {
|
||||
WatchLevelView()
|
||||
} label: {
|
||||
NavigationLink(value: WatchRoute.level) {
|
||||
LevelSummaryRow(reading: WatchLevelReading(radio: radio, phone: link))
|
||||
}
|
||||
}
|
||||
@@ -28,9 +43,7 @@ struct WatchRootView: View {
|
||||
if let payload = link.payload {
|
||||
Section {
|
||||
ForEach(payload.devices.filter { $0.role != .leveling }) { device in
|
||||
NavigationLink {
|
||||
WatchDeviceDetailView(deviceID: device.id)
|
||||
} label: {
|
||||
NavigationLink(value: WatchRoute.device(device.id)) {
|
||||
DeviceRow(device: device)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +68,7 @@ struct WatchRootView: View {
|
||||
VStack(spacing: 4) {
|
||||
Label("Warte auf das iPhone", systemImage: "iphone.gen3.radiowaves.left.and.right")
|
||||
.font(.footnote.weight(.medium))
|
||||
Text("Dort muss Camper Monitor geöffnet sein.")
|
||||
Text("Dort muss VanControl Pro geöffnet sein.")
|
||||
.font(.caption2)
|
||||
.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
|
||||
/// zählt nur der eine Wert, wegen dem man den Arm hebt.
|
||||
struct DeviceRow: View {
|
||||
|
||||
@@ -72,7 +72,8 @@ enum WatchDemo {
|
||||
orientation: nil, fridge: nil),
|
||||
WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge,
|
||||
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",
|
||||
leftTarget: 4, leftCurrent: 6,
|
||||
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:
|
||||
|
||||
@@ -120,7 +120,7 @@ Protokoll erkannt wurde, steht in der Detailansicht unter **Diagnose** –
|
||||
zusammen mit der letzten Rohantwort.
|
||||
|
||||
Nur **eine** App gleichzeitig kann mit dem BMS verbunden sein. Wenn die
|
||||
Hersteller-App offen ist, bekommt Camper Monitor keine Verbindung.
|
||||
Hersteller-App offen ist, bekommt VanControl Pro keine Verbindung.
|
||||
|
||||
### Kühlbox
|
||||
|
||||
@@ -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
|
||||
Tastendruck am Gerät.
|
||||
|
||||
**Verbunden wird nur, während die Kühlbox geöffnet ist.** Jede Verbindung
|
||||
meldet sich an ihrem Display an und stört, wer gerade davorsteht; und ihre Werte
|
||||
ändern sich langsam. Auf der Übersicht steht deshalb nicht die gemessene
|
||||
Innentemperatur, sondern der **zuletzt gestellte Stand** – Sollwert, Ein/Aus,
|
||||
Eco oder Max, dazu wann er gestellt wurde. Der bleibt richtig, auch wenn er von
|
||||
gestern ist: Ein Sollwert ändert sich nur, wenn jemand ihn ändert. Eine
|
||||
Innentemperatur von gestern sähe dagegen aus wie eine von jetzt.
|
||||
|
||||
Sobald du die Box öffnest, verbindet die App sich und zeigt alles live; beim
|
||||
Verlassen der Ansicht trennt sie wieder. Dasselbe gilt für die Uhr: Auch von
|
||||
dort wird die Verbindung nur angefordert, solange die Kühlbox-Ansicht offen ist.
|
||||
Ein Stellbefehl geht ebenfalls immer durch – ist die Box gerade nicht verbunden,
|
||||
wird er gemerkt und geht raus, sobald sie antwortet.
|
||||
|
||||
BMS und Neigungsmesser bleiben dagegen dauerhaft verbunden. Sie stören dabei
|
||||
nicht, und ihre Werte will man laufend sehen.
|
||||
|
||||
Neben den Messwerten gibt es hier als einzigem Gerät auch Bedienelemente:
|
||||
Ein/Aus, Eco oder Max, Solltemperatur je Zone und die Bedienfeldsperre. Alle
|
||||
Schalter zeigen den Stand, den die Box zurückmeldet – nach jedem Stellbefehl
|
||||
@@ -263,19 +280,27 @@ den Arm hebt:
|
||||
|
||||
* **Übersicht** – je Gerät der Hauptwert und ein Punkt für den Zustand der
|
||||
Verbindung. Ganz unten steht, wie alt der Stand ist.
|
||||
* **Nivellierung** – wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und
|
||||
Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über Starts
|
||||
hinweg gemerkt. Dazu, was zu tun ist, und die Keilhöhe je Rad, falls im
|
||||
Fahrzeugprofil Spurweite und Radstand stehen. 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.
|
||||
* **Nivellierung** – drei Seiten zum Wischen, weil auf diesem Bildschirm ein
|
||||
Wisch besser zu treffen ist als eine Scrollposition:
|
||||
|
||||
1. *Neigung* – wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und
|
||||
Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über
|
||||
Starts hinweg gemerkt. Dazu, was zu tun ist, und woher die Werte kommen.
|
||||
2. *Keile* – die Höhe je Rad als Draufsicht. Fehlen Spurweite und Radstand im
|
||||
Fahrzeugprofil, steht das dort statt einer leeren Seite.
|
||||
3. *Ausrichten* – der Assistent fürs Rangieren.
|
||||
|
||||
Kalibriert wird nur am iPhone: Das gehört einmalig auf ebenen Boden, und ein
|
||||
Knopf dafür an der Uhr wäre vor allem eine Gelegenheit, die Nullage aus
|
||||
Versehen zu verstellen.
|
||||
* **Ausrichten** – derselbe Assistent wie am iPhone: Tendenz, Rat und der
|
||||
Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das
|
||||
Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn
|
||||
gegenüber dem iPhone – beim Rangieren schaut niemand aufs Display, aber die
|
||||
Vibration am Handgelenk kommt an.
|
||||
* **Kühlbox** – ein/aus, Eco oder Max und die Solltemperatur, über die Krone
|
||||
gestellt.
|
||||
gestellt. Verbunden wird die Box erst beim Öffnen dieser Ansicht, davor steht
|
||||
dort der zuletzt gestellte Stand.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
im Hintergrund am Leben; die Victron-Werbedaten stehen dabei still, weil iOS im
|
||||
Hintergrund kein ungefiltertes Suchen erlaubt.
|
||||
@@ -427,7 +452,7 @@ CamperMonitorWatch/ watchOS-App
|
||||
├── WatchRootView.swift Übersicht und Altersangabe
|
||||
├── WatchBubble.swift Libelle für den kleinen Bildschirm
|
||||
├── 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
|
||||
├── WatchDeviceDetailView.swift Alle Werte eines Geräts
|
||||
└── 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
|
||||
/// braucht eine echte GATT-Verbindung.
|
||||
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
|
||||
/// drin, den auf die Uhr zu schicken nichts brächte.
|
||||
struct WatchFridge: Codable, Equatable, Sendable {
|
||||
/// Ob die Box gerade wirklich verbunden ist.
|
||||
///
|
||||
/// Sie wird nur verbunden, während jemand ihre Ansicht offen hat – jede
|
||||
/// Verbindung meldet sich an ihrem Display an. Sonst stehen hier die
|
||||
/// zuletzt gestellten Werte, und die Messwerte fehlen.
|
||||
var isLive: Bool
|
||||
/// Wann der Stand zuletzt von der Box kam.
|
||||
var updated: Date
|
||||
var isPoweredOn: Bool
|
||||
var isEco: Bool
|
||||
var isLocked: Bool
|
||||
@@ -133,6 +141,9 @@ enum WatchCommand: Codable, Equatable, Sendable {
|
||||
case fridgeEco(device: UUID, eco: Bool)
|
||||
case fridgeLock(device: UUID, locked: Bool)
|
||||
case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int)
|
||||
/// Die Kühlbox-Ansicht auf der Uhr wurde geöffnet oder geschlossen. Das
|
||||
/// iPhone verbindet die Box daraufhin – oder gibt sie wieder frei.
|
||||
case fridgeSession(device: UUID, wanted: Bool)
|
||||
|
||||
/// Ob der Befehl den schnellen Sendetakt verlängert.
|
||||
var wantsLiveUpdates: Bool {
|
||||
@@ -149,6 +160,26 @@ enum WatchFridgeZone: String, Codable, Equatable, Sendable {
|
||||
|
||||
// MARK: - Verpacken
|
||||
|
||||
extension WatchFridge {
|
||||
/// Aus dem zuletzt gestellten Stand, ohne Messwerte.
|
||||
init(_ settings: FridgeSettings) {
|
||||
self.init(isLive: false,
|
||||
updated: settings.updated,
|
||||
isPoweredOn: settings.isPoweredOn,
|
||||
isEco: settings.isEco,
|
||||
isLocked: settings.isLocked,
|
||||
isDualZone: settings.isDualZone,
|
||||
unitSymbol: settings.unitSymbol,
|
||||
leftTarget: settings.leftTarget,
|
||||
leftCurrent: nil,
|
||||
rightTarget: settings.rightTarget,
|
||||
rightCurrent: nil,
|
||||
minTarget: settings.usesFahrenheit ? -22 : -30,
|
||||
maxTarget: settings.usesFahrenheit ? 68 : 20,
|
||||
batteryVolts: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchPayload {
|
||||
func encoded() throws -> Data { try WatchCoder.encoder.encode(self) }
|
||||
|
||||
|
||||
+43
-1
@@ -698,6 +698,44 @@ checkEqual("falsche Prüfsumme wird verworfen",
|
||||
checkEqual("angefangenes Paket wird aufgehoben",
|
||||
AlpicoolProtocol.extractFrames(from: Array(fridgeStream.prefix(10))).frames.count, 0)
|
||||
|
||||
// MARK: 9b – Kühlbox: der Stand, der die Verbindung überdauert
|
||||
|
||||
print("\nKühlbox – zuletzt gestellter Stand")
|
||||
|
||||
// Derselbe Datensatz wie oben, nur diesmal auf das eingedampft, was ohne
|
||||
// Verbindung noch gilt.
|
||||
var boxState = AlpicoolState()
|
||||
boxState.apply(AlpicoolProtocol.Frame(command: 0x01, payload: [
|
||||
0x00, 0x01, 0x01, 0x00, 0x04, 0x14, 0xE2, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x06, 0x57, 0x0C, 0x07,
|
||||
]))
|
||||
|
||||
guard let boxSettings = boxState.settings else {
|
||||
check("Stand lässt sich aus dem Datensatz ableiten", false)
|
||||
exit(1)
|
||||
}
|
||||
checkEqual("eingeschaltet", boxSettings.isPoweredOn, true)
|
||||
checkEqual("Eco", boxSettings.isEco, true)
|
||||
checkEqual("Solltemperatur", boxSettings.leftTarget, 4)
|
||||
checkEqual("Einheit", boxSettings.unitSymbol, "°C")
|
||||
checkEqual("in Worten", boxSettings.stateText, "Eco")
|
||||
|
||||
// Die Übersicht zeigt den Sollwert – und ausdrücklich keine Innentemperatur.
|
||||
let boxSnapshot = boxSettings.snapshot(deviceID: UUID())
|
||||
checkEqual("Hauptwert ist der Sollwert", boxSnapshot.primaryMetric?.key, "target_left")
|
||||
checkEqual("Sollwert steht drin", value(boxSnapshot, "target_left"), 4)
|
||||
checkEqual("keine gemessene Temperatur", boxSnapshot.metrics.contains { $0.key == "temp_left" }, false)
|
||||
checkEqual("keine Bordspannung", boxSnapshot.metrics.contains { $0.key == "supply_voltage" }, false)
|
||||
|
||||
// Ohne Antwort der Box gibt es auch nichts aufzuheben.
|
||||
checkEqual("leerer Zustand ergibt keinen Stand", AlpicoolState().settings == nil, true)
|
||||
|
||||
// Auf der Uhr dasselbe Bild: Sollwerte ja, Messwerte nein.
|
||||
let watchBox = WatchFridge(boxSettings)
|
||||
checkEqual("auf der Uhr als nicht live gekennzeichnet", watchBox.isLive, false)
|
||||
checkEqual("Sollwert kommt mit", watchBox.leftTarget, 4)
|
||||
checkEqual("Messwert bleibt leer", watchBox.leftCurrent == nil, true)
|
||||
|
||||
// MARK: 10 – VanAlign Neigungsmesser
|
||||
print("\nVanAlign-Neigungsmesser")
|
||||
|
||||
@@ -1136,7 +1174,9 @@ var watchOrientation = SensorOrientation()
|
||||
watchOrientation.longitudinalSource = .roll
|
||||
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",
|
||||
leftTarget: 4, leftCurrent: 6,
|
||||
rightTarget: nil, rightCurrent: nil,
|
||||
@@ -1229,6 +1269,8 @@ let commands: [WatchCommand] = [
|
||||
.fridgeEco(device: watchFridgeID, eco: true),
|
||||
.fridgeLock(device: watchFridgeID, locked: true),
|
||||
.fridgeTarget(device: watchFridgeID, zone: .right, value: -3),
|
||||
.fridgeSession(device: watchFridgeID, wanted: true),
|
||||
.fridgeSession(device: watchFridgeID, wanted: false),
|
||||
]
|
||||
for command in commands {
|
||||
guard let message = try? command.message(),
|
||||
|
||||
@@ -26,7 +26,7 @@ darüber auffindbar).
|
||||
| Roll | `…3425` | lesen | Querneigung, Float32 little-endian, Grad |
|
||||
| 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.
|
||||
|
||||
Positiver Pitch heißt: das Heck steht höher. Positiver Roll: die rechte Seite
|
||||
@@ -35,7 +35,7 @@ steht höher.
|
||||
### Kalibrierung
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
* **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
|
||||
den übrigen Geräten im Fahrzeug.
|
||||
* Die ursprüngliche WebBLE-Oberfläche liegt weiterhin im Projekt
|
||||
|
||||
@@ -15,6 +15,7 @@ swiftc -O -o "$OUT/tests" \
|
||||
CamperMonitor/Bluetooth/AlpicoolProtocol.swift \
|
||||
Shared/Bluetooth/VanAlignProtocol.swift \
|
||||
Shared/Models/DeviceSnapshot.swift \
|
||||
Shared/Models/FridgeSettings.swift \
|
||||
Shared/Models/ConfiguredDevice.swift \
|
||||
Shared/Models/LevelState.swift \
|
||||
Shared/Models/Profile.swift \
|
||||
|
||||
Reference in New Issue
Block a user