Apple Watch: Übersicht, Ausrichten mit Vibration, Kühlbox am Handgelenk

Neues Target CamperMonitorWatch, eingebettet in die iPhone-App. Die Uhr
zeigt je Gerät den Hauptwert, die Nivellierung mit Libelle und Keilhöhen,
den Ausrichtungs-Assistenten und stellt die Kühlbox.

Die Uhr funkt nicht selbst, obwohl watchOS das könnte. BMS, Kühlbox und
Neigungsmesser lassen jeweils nur eine Verbindung zu – eine mitlesende Uhr
würde dem iPhone die Verbindung wegnehmen, statt sie zu ergänzen. Und die
Victron-Schlüssel liegen in der Keychain des iPhones; sie ein zweites Mal
auf der Uhr aufzubewahren brächte nichts. Das iPhone bleibt also das
Funkgerät und reicht fertige Messwerte über WatchConnectivity weiter.

Gesendet wird in zwei Takten: ein halber Sekundentakt als Nachricht,
solange die Watch-App im Vordergrund ist und das über eine Frist meldet,
sonst alle zwei Sekunden als Anwendungskontext und nur bei Änderungen.
Läuft die Frist ab, hört das iPhone von selbst wieder auf.

Solange die Uhr hinschaut, hält die App das Funkgerät auch im Hintergrund
am Leben (UIBackgroundModes in Config/CamperMonitor-Info.plist) – beim
Rangieren liegt das iPhone sonst mit dunklem Bildschirm in der Halterung
und die Anzeige am Handgelenk wäre genau dann tot. Die verbundenen Geräte
liefern dabei weiter, die Victron-Werbedaten nicht: ungefiltertes Suchen
lässt iOS im Hintergrund nicht zu.

Der plattformneutrale Modellcode wandert nach Shared/ und wird in beide
Targets übersetzt; LevelState ist dafür aus VanAlignProtocol.swift
herausgelöst. Datensatz und Befehle stehen in Shared/WatchLink und sind
in run-tests.sh abgesichert: Rundlauf, Schutz gegen fremde Formatversion,
Vergleich ohne Zeitstempel und die Auflösung der Zeitangaben.

Geprüft sind der Bau beider Targets samt Einbetten und der Testlauf; auf
echter Hardware noch nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-31 15:20:16 +02:00
co-authored by Claude Opus 5
parent 32659ff0b9
commit 6612b07fd5
31 changed files with 1904 additions and 101 deletions
+187 -4
View File
@@ -6,8 +6,38 @@
objectVersion = 77; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* CamperMonitorWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
AA0000000000000000000028 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA0000000000000000000009 /* Project object */;
proxyType = 1;
remoteGlobalIDString = AA0000000000000000000022;
remoteInfo = CamperMonitorWatch;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
AA0000000000000000000026 /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* 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; };
AA0000000000000000000030 /* CamperMonitor-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitor-Info.plist"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -16,6 +46,16 @@
path = CamperMonitor; path = CamperMonitor;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
AA0000000000000000000017 /* CamperMonitorWatch */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = CamperMonitorWatch;
sourceTree = "<group>";
};
AA0000000000000000000018 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Shared;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -26,6 +66,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AA0000000000000000000020 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@@ -33,6 +80,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AA0000000000000000000002 /* CamperMonitor */, AA0000000000000000000002 /* CamperMonitor */,
AA0000000000000000000017 /* CamperMonitorWatch */,
AA0000000000000000000018 /* Shared */,
AA0000000000000000000031 /* Config */,
AA0000000000000000000005 /* Products */, AA0000000000000000000005 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@@ -41,10 +91,19 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AA0000000000000000000001 /* CamperMonitor.app */, AA0000000000000000000001 /* CamperMonitor.app */,
AA0000000000000000000016 /* CamperMonitorWatch.app */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
AA0000000000000000000031 /* Config */ = {
isa = PBXGroup;
children = (
AA0000000000000000000030 /* CamperMonitor-Info.plist */,
);
path = Config;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -55,17 +114,41 @@
AA0000000000000000000007 /* Sources */, AA0000000000000000000007 /* Sources */,
AA0000000000000000000003 /* Frameworks */, AA0000000000000000000003 /* Frameworks */,
AA0000000000000000000008 /* Resources */, AA0000000000000000000008 /* Resources */,
AA0000000000000000000026 /* Embed Watch Content */,
);
buildRules = (
);
dependencies = (
AA0000000000000000000029 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
AA0000000000000000000002 /* CamperMonitor */,
AA0000000000000000000018 /* Shared */,
);
name = CamperMonitor;
productName = CamperMonitor;
productReference = AA0000000000000000000001 /* CamperMonitor.app */;
productType = "com.apple.product-type.application";
};
AA0000000000000000000022 /* CamperMonitorWatch */ = {
isa = PBXNativeTarget;
buildConfigurationList = AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "CamperMonitorWatch" */;
buildPhases = (
AA0000000000000000000019 /* Sources */,
AA0000000000000000000020 /* Frameworks */,
AA0000000000000000000021 /* Resources */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
AA0000000000000000000002 /* CamperMonitor */, AA0000000000000000000017 /* CamperMonitorWatch */,
AA0000000000000000000018 /* Shared */,
); );
name = CamperMonitor; name = CamperMonitorWatch;
productName = CamperMonitor; productName = CamperMonitorWatch;
productReference = AA0000000000000000000001 /* CamperMonitor.app */; productReference = AA0000000000000000000016 /* CamperMonitorWatch.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
@@ -81,6 +164,9 @@
AA0000000000000000000006 = { AA0000000000000000000006 = {
CreatedOnToolsVersion = 26.6; CreatedOnToolsVersion = 26.6;
}; };
AA0000000000000000000022 = {
CreatedOnToolsVersion = 26.6;
};
}; };
}; };
buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */; buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */;
@@ -99,6 +185,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
AA0000000000000000000006 /* CamperMonitor */, AA0000000000000000000006 /* CamperMonitor */,
AA0000000000000000000022 /* CamperMonitorWatch */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -111,6 +198,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AA0000000000000000000021 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -121,8 +215,24 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AA0000000000000000000019 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
AA0000000000000000000029 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
platformFilter = ios;
target = AA0000000000000000000022 /* CamperMonitorWatch */;
targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
AA0000000000000000000012 /* Debug */ = { AA0000000000000000000012 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
@@ -190,6 +300,7 @@
DEVELOPMENT_TEAM = 6AP73NYV5W; DEVELOPMENT_TEAM = 6AP73NYV5W;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
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;
@@ -220,6 +331,7 @@
DEVELOPMENT_TEAM = 6AP73NYV5W; DEVELOPMENT_TEAM = 6AP73NYV5W;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
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;
@@ -239,6 +351,68 @@
}; };
name = Release; name = Release;
}; };
AA0000000000000000000023 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6AP73NYV5W;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 11.6;
};
name = Debug;
};
AA0000000000000000000024 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6AP73NYV5W;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "watchos watchsimulator";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 11.6;
};
name = Release;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -260,6 +434,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "CamperMonitorWatch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
AA0000000000000000000023 /* Debug */,
AA0000000000000000000024 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = AA0000000000000000000009 /* Project object */; rootObject = AA0000000000000000000009 /* Project object */;
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion = "2660" version = "1.7">
<BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry buildForTesting = "YES" buildForRunning = "YES" buildForProfiling = "YES" buildForArchiving = "YES" buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "CamperMonitorWatch.app"
BlueprintName = "CamperMonitorWatch"
ReferencedContainer = "container:CamperMonitor.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES">
<Testables></Testables>
</TestAction>
<LaunchAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "CamperMonitorWatch.app"
BlueprintName = "CamperMonitorWatch"
ReferencedContainer = "container:CamperMonitor.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction buildConfiguration = "Release" shouldUseLaunchSchemeArgsEnv = "YES" savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AA0000000000000000000022"
BuildableName = "CamperMonitorWatch.app"
BlueprintName = "CamperMonitorWatch"
ReferencedContainer = "container:CamperMonitor.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration = "Debug"></AnalyzeAction>
<ArchiveAction buildConfiguration = "Release" revealArchiveInOrganizer = "YES"></ArchiveAction>
</Scheme>
@@ -49,82 +49,3 @@ enum VanAlignProtocol {
return (pitch, roll) return (pitch, roll)
} }
} }
/// Zustand des Neigungsmessers.
struct LevelState: Equatable {
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
/// Bereits auf die Einbaulage des Sensors umgerechnet.
var pitch: Double?
/// Querneigung: positiv bedeutet, die rechte Seite steht höher.
var roll: Double?
/// Wie der Sensor selbst meldet nötig, um die Einbaulage zu bestimmen.
var rawPitch: Double?
var rawRoll: Double?
var pitchOffset: Double?
var rollOffset: Double?
var hasReading: Bool { pitch != nil || roll != nil }
/// Ob kalibriert wurde oder nil, wenn das Gerät es nicht verrät.
///
/// Nicht jede Firmware stellt die Offsets bereit. Fehlen sie, heisst das
/// unbekannt und nicht nicht kalibriert: eine Warnung, die sich nie
/// abstellen lässt, ist schlimmer als keine.
var calibrationState: Bool? {
guard let pitchOffset, let rollOffset else { return nil }
return abs(pitchOffset) > 0.001 || abs(rollOffset) > 0.001
}
/// Nur wahr, wenn es auch belegt ist.
var isCalibrated: Bool { calibrationState == true }
/// Nur wahr, wenn das Gerät ausdrücklich Nulloffsets meldet.
var isKnownUncalibrated: Bool { calibrationState == false }
/// Bis hierhin gilt das Fahrzeug als eben genug.
static let levelTolerance = 0.5
var isLevel: Bool {
guard let pitch, let roll else { return false }
return abs(pitch) <= Self.levelTolerance && abs(roll) <= Self.levelTolerance
}
/// Die grössere der beiden Abweichungen das ist die, die man zuerst
/// ausgleichen will.
var largestDeviation: Double? {
switch (pitch, roll) {
case let (p?, r?): return max(abs(p), abs(r))
case let (p?, nil): return abs(p)
case let (nil, r?): return abs(r)
default: return nil
}
}
/// Was zu tun ist, in Worten. Beim Ausrichten schaut man aufs Handy,
/// nicht auf Vorzeichen.
var instruction: String? {
guard let pitch, let roll else { return nil }
if isLevel { return "Steht eben" }
var parts: [String] = []
if abs(pitch) > Self.levelTolerance {
parts.append(pitch > 0 ? "Heck steht höher" : "Front steht höher")
}
if abs(roll) > Self.levelTolerance {
parts.append(roll > 0 ? "rechts steht höher" : "links steht höher")
}
return parts.joined(separator: ", ")
}
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
snapshot.metrics = [
Metric("pitch", "Längsneigung", pitch, unit: "°", precision: 1, primary: true),
Metric("roll", "Querneigung", roll, unit: "°", precision: 1),
]
snapshot.state = instruction
if isKnownUncalibrated {
snapshot.offReasons = ["Nicht kalibriert"]
}
return snapshot
}
}
+20 -4
View File
@@ -4,12 +4,15 @@ import SwiftUI
struct CamperMonitorApp: App { struct CamperMonitorApp: App {
@State private var store: DeviceStore @State private var store: DeviceStore
@State private var bluetooth: BluetoothManager @State private var bluetooth: BluetoothManager
@State private var watch: PhoneWatchLink
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
init() { init() {
let store = DeviceStore() let store = DeviceStore()
let bluetooth = BluetoothManager(store: store)
_store = State(initialValue: store) _store = State(initialValue: store)
_bluetooth = State(initialValue: BluetoothManager(store: store)) _bluetooth = State(initialValue: bluetooth)
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
} }
var body: some Scene { var body: some Scene {
@@ -17,14 +20,27 @@ struct CamperMonitorApp: App {
DashboardView() DashboardView()
.environment(store) .environment(store)
.environment(bluetooth) .environment(bluetooth)
.environment(watch)
.task { watch.activate() }
} }
.onChange(of: scenePhase) { _, phase in .onChange(of: scenePhase) { _, phase in
// Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt // Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt
// werden, also Funk sparen und beim Zurückkommen neu starten. // werden, also Funk sparen und beim Zurückkommen neu starten.
//
// Ausnahme: Solange jemand auf die Uhr schaut, läuft es weiter. Beim
// Rangieren liegt das iPhone in der Halterung mit dunklem Bildschirm
// hörte das Funkgerät dann auf, wäre die Anzeige am Handgelenk
// genau dann tot, wenn sie gebraucht wird. Die verbundenen Geräte
// (Neigungsmesser, BMS, Kühlbox) liefern im Hintergrund weiter; die
// Victron-Werbedaten nicht, dafür müsste ohne Filter gesucht werden.
watch.isAppInBackground = phase != .active
switch phase { switch phase {
case .active: bluetooth.start() case .active:
case .background: bluetooth.stop() bluetooth.start()
default: break case .background:
if !watch.wantsLiveUpdates { bluetooth.stop() }
default:
break
} }
} }
} }
+11
View File
@@ -4,6 +4,7 @@ import SwiftUI
/// betrifft, steht bei diesem Gerät. /// betrifft, steht bei diesem Gerät.
struct SettingsView: View { struct SettingsView: View {
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false @AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
@Environment(PhoneWatchLink.self) private var watch
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
@@ -21,6 +22,16 @@ struct SettingsView: View {
+ "Meldet ein Gerät einen Fehler, werden die Angaben ohnehin " + "Meldet ein Gerät einen Fehler, werden die Angaben ohnehin "
+ "eingeblendet, auch wenn das hier ausgeschaltet ist.") + "eingeblendet, auch wenn das hier ausgeschaltet ist.")
} }
Section {
LabeledContent("Apple Watch", value: watch.statusText)
} footer: {
Text("Die Uhr zeigt die Werte dieses iPhones und steuert die "
+ "Kühlbox darüber sie funkt nicht selbst zu den Geräten. "
+ "Dafür muss diese App laufen; im Hintergrund liefern "
+ "Neigungsmesser, BMS und Kühlbox weiter, die "
+ "Victron-Werbedaten erst wieder im Vordergrund.")
}
} }
.navigationTitle("Einstellungen") .navigationTitle("Einstellungen")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
+267
View File
@@ -0,0 +1,267 @@
import Foundation
import Observation
import WatchConnectivity
/// Die Gegenstelle zur Apple Watch auf dem iPhone.
///
/// Das iPhone bleibt das Funkgerät (warum, steht bei `WatchLink`) und reicht
/// den fertigen Stand weiter. Zwei Wege, je nachdem, ob die Uhr gerade
/// hinschaut:
///
/// * **Anwendungskontext** ein Datensatz, den watchOS aufhebt und auch dann
/// zustellt, wenn die App auf der Uhr gerade nicht läuft. Damit steht beim
/// Aufwecken sofort etwas da statt eines leeren Bildschirms.
/// * **Nachricht** nur solange die Uhr erreichbar ist und gemeldet hat, dass
/// sie hinschaut. Dafür im halben Sekundentakt, was beim Ausrichten zählt.
///
/// Ohne gekoppelte Uhr läuft hier gar nichts: kein Timer, keine Sitzung.
@Observable
final class PhoneWatchLink: NSObject {
/// Ob die Uhr gerade Werte sehen will. Solange das gilt, hält die App das
/// Bluetooth auch im Hintergrund am Leben.
private(set) var wantsLiveUpdates = false
/// Nur für die Anzeige in den Einstellungen.
private(set) var statusText = "Keine Uhr gekoppelt"
private let store: DeviceStore
private let bluetooth: BluetoothManager
private var session: WCSession?
private var timer: Timer?
/// Bis wann der Wunsch der Uhr nach schnellen Werten gilt.
private var liveUntil = Date.distantPast
private var lastSentContent: WatchPayload?
private var lastContextSent = Date.distantPast
/// Setzt die App, damit beim Ablauf des Wunsches im Hintergrund das Funken
/// wieder eingestellt wird.
var isAppInBackground = false
init(store: DeviceStore, bluetooth: BluetoothManager) {
self.store = store
self.bluetooth = bluetooth
super.init()
}
func activate() {
guard WCSession.isSupported() else {
statusText = "Dieses Gerät kann keine Uhr koppeln"
return
}
let session = WCSession.default
session.delegate = self
self.session = session
session.activate()
}
// MARK: - Takt
private func updateTimer() {
let wanted = session?.activationState == .activated && session?.isPaired == true
if wanted && timer == nil {
// Der schnelle Takt ist der Grundtakt; ob wirklich gesendet wird,
// entscheidet `tick` so muss der Timer nie umgestellt werden.
let timer = Timer(timeInterval: WatchLink.liveInterval, repeats: true) { [weak self] _ in
self?.tick()
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
} else if !wanted {
timer?.invalidate()
timer = nil
}
}
private func tick() {
let live = Date() < liveUntil
if live != wantsLiveUpdates {
wantsLiveUpdates = live
// Der Wunsch ist gerade abgelaufen und niemand schaut mehr hin:
// dann darf das Funkgerät im Hintergrund auch wieder ruhen.
if !live && isAppInBackground { bluetooth.stop() }
}
// Den Stand erst zusammenstellen, wenn er auch rausgeht: im Ruhetakt
// ist das nur jeder vierte Aufruf, und zusammengestellt wird auf dem
// Hauptthread.
let isContextDue = Date().timeIntervalSince(lastContextSent) >= WatchLink.idleInterval
guard let session, live || isContextDue else { return }
guard let payload = buildPayload() else { return }
if live && session.isReachable {
send(payload, over: session)
return
}
guard isContextDue,
lastSentContent?.hasSameContent(as: payload) != true,
session.isPaired, session.isWatchAppInstalled else { return }
do {
try session.updateApplicationContext(payload.message())
lastSentContent = payload
lastContextSent = Date()
} catch {
statusText = "Uhr: \(error.localizedDescription)"
}
}
/// Nachrichten werden ohne Antwort verschickt: Ein verlorener Datensatz ist
/// in einer halben Sekunde ohnehin überholt, ein Wiederholungsversuch käme
/// zu spät und stünde nur dem nächsten im Weg.
private func send(_ payload: WatchPayload, over session: WCSession) {
guard let message = try? payload.message() else { return }
session.sendMessage(message, replyHandler: nil, errorHandler: nil)
lastSentContent = payload
}
/// Nach Änderungen an Geräten oder Profilen aufrufen, damit die Uhr nicht
/// bis zum nächsten Takt einen überholten Fahrzeugnamen zeigt.
func sendNow() {
lastSentContent = nil
lastContextSent = .distantPast
tick()
}
// MARK: - Stand zusammenstellen
private func buildPayload() -> WatchPayload? {
guard let profile = store.activeProfile else { return nil }
let devices = store.activeDevices.map { device in
WatchDevice(id: device.id,
name: device.name,
role: device.role,
link: bluetooth.linkStates[device.id] ?? .searching,
snapshot: bluetooth.snapshots[device.id],
level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil,
fridge: device.role == .fridge
? bluetooth.fridgeStates[device.id].map(WatchFridge.init) : nil)
}
return WatchPayload(generatedAt: Date(),
profile: profile,
isRadioReady: bluetooth.isBluetoothReady,
radioStatus: bluetooth.bluetoothStatusText,
devices: devices)
}
// MARK: - Befehle von der Uhr
private func handle(_ command: WatchCommand) {
if command.wantsLiveUpdates {
liveUntil = Date().addingTimeInterval(WatchLink.liveLease)
wantsLiveUpdates = true
// Die Uhr kann das iPhone aus dem Hintergrund wecken. Dann steht
// das Funkgerät und muss erst wieder anlaufen, sonst geht der
// Stellbefehl ins Leere.
if isAppInBackground { bluetooth.start() }
}
switch command {
case .hello:
break
case .fridgePower(let device, let on):
bluetooth.setFridgePower(on, for: device)
case .fridgeEco(let device, let eco):
bluetooth.setFridgeEco(eco, for: device)
case .fridgeLock(let device, let locked):
bluetooth.setFridgeLock(locked, for: device)
case .fridgeTarget(let device, let zone, let value):
bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device)
case .calibrateLevel(let device):
bluetooth.calibrateLevel(for: device)
}
}
}
// MARK: - WCSessionDelegate
extension PhoneWatchLink: WCSessionDelegate {
func session(_ session: WCSession,
activationDidCompleteWith state: WCSessionActivationState,
error: Error?) {
DispatchQueue.main.async {
if let error {
self.statusText = "Uhr: \(error.localizedDescription)"
} else {
self.updateStatus(session)
}
self.updateTimer()
self.sendNow()
}
}
private func updateStatus(_ session: WCSession) {
if !session.isPaired {
statusText = "Keine Uhr gekoppelt"
} else if !session.isWatchAppInstalled {
statusText = "Uhr gekoppelt, App dort nicht installiert"
} else {
statusText = "Uhr bereit"
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
guard let command = WatchCommand.decode(from: message) else { return }
DispatchQueue.main.async {
self.handle(command)
// Auf einen Stellbefehl gleich den frischen Stand hinterherschicken.
// Der zeigt zwar noch den alten Wert die Box antwortet erst ,
// aber die Uhr weiss dadurch sofort, dass der Befehl ankam.
self.tick()
}
}
/// Mit Antwort, wenn die Uhr auf eine wartet: das ist der Weg, auf dem sie
/// beim Öffnen ohne Verzögerung an einen Stand kommt.
func session(_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
DispatchQueue.main.async {
if let command = WatchCommand.decode(from: message) { self.handle(command) }
replyHandler((try? self.buildPayload()?.message()) ?? [:])
}
}
func sessionReachabilityDidChange(_ session: WCSession) {
DispatchQueue.main.async { self.sendNow() }
}
/// Die Uhr wurde gekoppelt, gewechselt oder die App dort installiert.
func sessionWatchStateDidChange(_ session: WCSession) {
DispatchQueue.main.async {
self.updateStatus(session)
self.updateTimer()
self.sendNow()
}
}
// Beim Wechsel auf eine andere Uhr muss die Sitzung neu aktiviert werden.
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
}
// MARK: - Umformen
extension WatchFridge {
/// Aus dem Zustand der Box wird nur übernommen, was die Uhr anzeigt oder
/// stellt der Rohdatensatz bleibt auf dem iPhone.
init(_ state: AlpicoolState) {
let range = state.targetRange
self.init(isPoweredOn: state.isPoweredOn,
isEco: state.isEco,
isLocked: state.isLocked,
isDualZone: state.isDualZone,
unitSymbol: state.unitSymbol,
leftTarget: state.leftTarget,
leftCurrent: state.leftCurrent,
rightTarget: state.isDualZone ? state.rightTarget : nil,
rightCurrent: state.isDualZone ? state.rightCurrent : nil,
minTarget: range.lowerBound,
maxTarget: range.upperBound,
batteryVolts: state.batteryVolts)
}
}
@@ -0,0 +1,4 @@
{
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.400", "green" : "0.620", "red" : "0.110" } }, "idiom" : "universal" } ],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,11 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : { "author" : "xcode", "version" : 1 }
}
@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }
+26
View File
@@ -0,0 +1,26 @@
import SwiftUI
/// Camper Monitor am Handgelenk.
///
/// Die Uhr ist Anzeige und Fernbedienung; gefunkt wird über das iPhone. Was
/// hier steht, kam von dort deshalb steht auf jedem Bildschirm auch, wie alt
/// es ist.
@main
struct CamperWatchApp: App {
@State private var link = PhoneLink()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
WatchRootView()
.environment(link)
.task { link.activate() }
}
.onChange(of: scenePhase) { _, phase in
// Der schnelle Takt kostet auf beiden Seiten Strom und lohnt nur,
// solange jemand hinschaut. Genau das sagt die Bildschirmphase aus:
// im Vordergrund schaut jemand hin, sonst nicht.
link.setLive(phase == .active)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
import Foundation
import Observation
import WatchConnectivity
/// Die Gegenstelle zum iPhone auf der Uhr.
///
/// Hält den zuletzt empfangenen Stand und schickt Stellbefehle zurück. Gefunkt
/// wird nur zum iPhone zu den Geräten im Fahrzeug nie, siehe `WatchLink`.
///
/// Zwei Quellen laufen zusammen:
///
/// * Der **Anwendungskontext** liegt schon da, wenn die App startet. Deshalb
/// steht sofort etwas auf dem Bildschirm, auch wenn das iPhone in der Tasche
/// steckt und gerade nicht antwortet mit Altersangabe, versteht sich.
/// * **Nachrichten** kommen im halben Sekundentakt, solange wir gemeldet haben,
/// dass jemand hinschaut. Das Melden wird regelmässig erneuert: Schläft die
/// Uhr ein, bleibt die Erneuerung aus und das iPhone hört von selbst auf.
@Observable
final class PhoneLink: NSObject {
private(set) var payload: WatchPayload?
/// Wann der Stand hier ankam nicht, wann er entstand. Beides zusammen
/// unterscheidet iPhone weg von Gerät im Fahrzeug antwortet nicht.
private(set) var receivedAt: Date?
private(set) var isReachable = false
private(set) var isActivated = false
private var session: WCSession?
/// Erneuert die Meldung hier schaut jemand hin im Vorführbetrieb
/// stattdessen der Taktgeber der erfundenen Werte.
private var timer: Timer?
private var isLive = false
/// Ohne iPhone lässt sich auf der Uhr sonst nichts vorführen.
private let isDemo = ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
func activate() {
if isDemo {
startDemo()
return
}
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
self.session = session
session.activate()
}
/// Meldet dem iPhone, ob gerade jemand hinschaut. Beim Ausrichten zählt
/// jeder halbe Sekundentakt, beim Nachsehen des Ladezustands nicht.
func setLive(_ live: Bool) {
guard !isDemo else { return }
isLive = live
timer?.invalidate()
timer = nil
guard live else {
send(.hello(live: false))
return
}
send(.hello(live: true))
// Vor Ablauf der Frist erneuern, damit zwischendurch keine Lücke
// entsteht. Bleibt die Erneuerung aus, endet der schnelle Takt von
// selbst niemand muss ihn abbestellen.
let renew = Timer(timeInterval: WatchLink.liveLease / 3, repeats: true) { [weak self] _ in
self?.send(.hello(live: true))
}
RunLoop.main.add(renew, forMode: .common)
timer = renew
}
/// Schickt einen Befehl und übernimmt die Antwort, falls eine kommt.
///
/// Die Antwort ist der Stand *vor* der Wirkung die Kühlbox antwortet dem
/// iPhone erst ein paar Zehntel später. Sie zeigt also nicht, dass der
/// Befehl gewirkt hat, sondern nur, dass er angekommen ist. Der gestellte
/// Wert erscheint, wenn das Gerät ihn zurückmeldet, so wie am iPhone auch.
func send(_ command: WatchCommand) {
guard !isDemo, let session, session.activationState == .activated,
let message = try? command.message() else { return }
guard session.isReachable else { return }
session.sendMessage(message, replyHandler: { [weak self] reply in
guard let payload = WatchPayload.decode(from: reply) else { return }
DispatchQueue.main.async { self?.accept(payload) }
}, errorHandler: nil)
}
private func accept(_ payload: WatchPayload) {
// Ein Nachzügler darf einen neueren Stand nicht überschreiben.
if let current = self.payload, current.generatedAt > payload.generatedAt { return }
self.payload = payload
receivedAt = Date()
}
// MARK: - Vorführbetrieb
private func startDemo() {
payload = WatchDemo.payload(at: 0)
receivedAt = Date()
isReachable = true
isActivated = true
var step = 0.0
let tick = Timer(timeInterval: 0.5, repeats: true) { [weak self] _ in
step += 0.5
self?.payload = WatchDemo.payload(at: step)
self?.receivedAt = Date()
}
RunLoop.main.add(tick, forMode: .common)
timer = tick
}
}
// MARK: - WCSessionDelegate
extension PhoneLink: WCSessionDelegate {
func session(_ session: WCSession,
activationDidCompleteWith state: WCSessionActivationState,
error: Error?) {
DispatchQueue.main.async {
self.isActivated = state == .activated
self.isReachable = session.isReachable
// Was schon dalag, gilt sofort sonst bliebe der Bildschirm leer,
// bis das iPhone das nächste Mal von sich aus sendet.
if let payload = WatchPayload.decode(from: session.receivedApplicationContext) {
self.accept(payload)
}
if self.isLive { self.send(.hello(live: true)) }
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
guard let payload = WatchPayload.decode(from: message) else { return }
DispatchQueue.main.async { self.accept(payload) }
}
func session(_ session: WCSession, didReceiveApplicationContext context: [String: Any]) {
guard let payload = WatchPayload.decode(from: context) else { return }
DispatchQueue.main.async { self.accept(payload) }
}
func sessionReachabilityDidChange(_ session: WCSession) {
DispatchQueue.main.async {
self.isReachable = session.isReachable
if session.isReachable, self.isLive { self.send(.hello(live: true)) }
}
}
}
@@ -0,0 +1,111 @@
import SwiftUI
import WatchKit
/// Der Ausrichtungs-Assistent am Handgelenk der eigentliche Grund für die
/// Uhr: Beim Rangieren hat man das iPhone nicht in der Hand, aber den Arm am
/// Lenkrad. Ein Blick nach unten genügt, und die Rückmeldung steht eben kommt
/// zusätzlich als Vibration, ganz ohne Blick.
///
/// Gerechnet wird mit demselben `AlignmentAssistant` wie am iPhone: kein
/// Ortsbezug, nur der zeitliche Verlauf der Neigung. Was besser und was
/// schlechter wird, steckt vollständig darin.
struct WatchAlignView: View {
let deviceID: UUID
@Environment(PhoneLink.self) private var link
@State private var assistant = AlignmentAssistant()
/// Damit die Vibration einmal kommt und nicht im Sekundentakt.
@State private var hasAnnouncedTarget = false
private var state: LevelState? {
link.payload?.devices.first { $0.id == deviceID }?.level
}
var body: some View {
ScrollView {
VStack(spacing: 8) {
if let current = assistant.current {
Text(String(format: "%.1f°", current.deviation))
.font(.system(size: 40, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(assistant.hasReachedTarget ? .green : .primary)
.contentTransition(.numericText())
} else {
ProgressView()
.padding(.vertical, 12)
}
Label(assistant.trend.text, systemImage: assistant.trend.symbol)
.font(.footnote.weight(.medium))
.foregroundStyle(trendColor)
Text(assistant.advice)
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
if let state {
HStack(spacing: 12) {
WatchBubble(pitch: state.pitch, roll: state.roll)
.frame(width: 56, height: 56)
VStack(alignment: .leading, spacing: 2) {
Text(String(format: "Längs %.1f°", state.pitch ?? 0))
Text(String(format: "Quer %.1f°", state.roll ?? 0))
}
.font(.caption2)
.monospacedDigit()
.foregroundStyle(.secondary)
}
.padding(.top, 2)
}
Button("Von vorn messen", systemImage: "arrow.counterclockwise") {
assistant.reset()
hasAnnouncedTarget = false
}
.font(.caption2)
.padding(.top, 4)
}
.padding(.horizontal, 4)
}
.navigationTitle("Ausrichten")
.onChange(of: reading, initial: true) { _, reading in
guard let reading else { return }
assistant.add(pitch: reading.pitch, roll: reading.roll)
announceIfLevel()
}
}
/// Ein Wertepaar als Auslöser: nur wenn sich wirklich eine Neigung ändert,
/// wird gerechnet nicht bei jedem Datensatz des iPhones.
private var reading: Reading? {
guard let state, let pitch = state.pitch, let roll = state.roll else { return nil }
return Reading(pitch: pitch, roll: roll)
}
private struct Reading: Equatable {
let pitch: Double
let roll: Double
}
private var trendColor: Color {
switch assistant.trend {
case .improving: return .green
case .worsening: return .orange
default: return .secondary
}
}
/// Die Vibration ist der Punkt: Sie sagt anhalten, ohne dass jemand
/// hinschauen muss. Erneut gemeldet wird erst, wenn das Fahrzeug die
/// Toleranz zwischendurch wieder verlassen hat.
private func announceIfLevel() {
if assistant.hasReachedTarget {
guard !hasAnnouncedTarget else { return }
hasAnnouncedTarget = true
WKInterfaceDevice.current().play(.success)
} else {
hasAnnouncedTarget = false
}
}
}
@@ -0,0 +1,77 @@
import SwiftUI
/// Libelle für die Uhr: eine Blase, die dorthin wandert, wo das Fahrzeug
/// **höher** steht wie bei einer echten Wasserwaage.
///
/// Bewusst schlichter als am iPhone: Auf 40 mm Bildschirm ist der innere Ring
/// (die Toleranz) die einzige Hilfslinie, die sich noch ablesen lässt.
struct WatchBubble: View {
let pitch: Double?
let roll: Double?
/// Bis zu welcher Neigung die Anzeige ausschlägt.
var range: Double = 4
var body: some View {
GeometryReader { geometry in
let side = min(geometry.size.width, geometry.size.height)
let radius = side / 2
let bubble = side * 0.22
let travel = radius - bubble / 2 - 2
let tolerance = max(LevelState.levelTolerance / range * travel, bubble * 0.55)
ZStack {
Circle().fill(Color.gray.opacity(0.25))
Circle().strokeBorder(Color.gray.opacity(0.5), lineWidth: 1)
Circle()
.strokeBorder(isLevel ? Color.green : Color.gray.opacity(0.6),
lineWidth: isLevel ? 2 : 1)
.frame(width: tolerance * 2, height: tolerance * 2)
Circle()
.fill(color)
.frame(width: bubble, height: bubble)
// Positiver Pitch heisst: das Heck steht höher, die Blase
// wandert nach oben in der Ansicht also nach hinten.
.offset(x: clamped(roll) / range * travel,
y: -clamped(pitch) / range * travel)
.animation(.spring(duration: 0.3), value: pitch)
.animation(.spring(duration: 0.3), value: roll)
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
}
.frame(width: side, height: side)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.aspectRatio(1, contentMode: .fit)
.accessibilityLabel(accessibilityText)
}
/// Hält die Blase im Ring, auch wenn das Fahrzeug stark schräg steht.
private func clamped(_ value: Double?) -> Double {
guard let value else { return 0 }
return min(max(value, -range), range)
}
private var deviation: Double? {
switch (pitch, roll) {
case let (p?, r?): return max(abs(p), abs(r))
case let (p?, nil): return abs(p)
case let (nil, r?): return abs(r)
default: return nil
}
}
private var isLevel: Bool { (deviation ?? .infinity) <= LevelState.levelTolerance }
/// Grün nur, wenn es wirklich eben ist die Akzentfarbe der App ist selbst
/// grün, sonst sähe schief aus wie eben.
private var color: Color {
guard let deviation else { return .gray }
if deviation <= LevelState.levelTolerance { return .green }
return deviation <= 2 ? .orange : .red
}
private var accessibilityText: String {
guard let pitch, let roll else { return "Keine Messwerte" }
return String(format: "Längsneigung %.1f Grad, Querneigung %.1f Grad", pitch, roll)
}
}
@@ -0,0 +1,59 @@
import SwiftUI
/// Alle Werte eines Geräts. Am iPhone stehen hier zusätzlich Verlauf und
/// Diagnose auf der Uhr wäre beides unlesbar und ist deshalb weggelassen.
struct WatchDeviceDetailView: View {
let deviceID: UUID
@Environment(PhoneLink.self) private var link
private var device: WatchDevice? {
link.payload?.devices.first { $0.id == deviceID }
}
var body: some View {
List {
if let device {
if let fridge = device.fridge {
WatchFridgeControls(deviceID: device.id, fridge: fridge)
}
Section {
ForEach(device.snapshot?.metrics.filter { $0.value != nil } ?? []) { metric in
LabeledContent(metric.label) {
Text(metric.formattedWithUnit)
.monospacedDigit()
}
.font(.footnote)
}
} header: {
Text("Messwerte")
}
Section {
if let fault = device.snapshot?.fault {
Label(fault, systemImage: "exclamationmark.triangle.fill")
.font(.caption2)
.foregroundStyle(.red)
}
if let state = device.snapshot?.state {
Text(state).font(.caption2)
}
ForEach(device.snapshot?.offReasons ?? [], id: \.self) { reason in
Text(reason)
.font(.caption2)
.foregroundStyle(.secondary)
}
Label(device.link.label, systemImage: "antenna.radiowaves.left.and.right")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else {
Text("Gerät ist nicht mehr im Fahrzeugprofil.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle(device?.name ?? "Gerät")
}
}
@@ -0,0 +1,63 @@
import SwiftUI
/// Die Kühlbox von der Uhr aus stellen das einzige Gerät, das sich stellen
/// lässt.
///
/// Wie am iPhone folgen die Schalter dem Gerät, nicht der Vermutung: Getippt
/// wird ein Befehl, angezeigt wird, was die Box zurückmeldet. Über die Uhr
/// dauert das einen Wimpernschlag länger der Weg geht über das iPhone ,
/// dafür kann kein Schalter etwas behaupten, das die Box nie bestätigt hat.
struct WatchFridgeControls: View {
let deviceID: UUID
let fridge: WatchFridge
@Environment(PhoneLink.self) private var link
var body: some View {
Section {
Toggle("Ein", isOn: Binding(
get: { fridge.isPoweredOn },
set: { link.send(.fridgePower(device: deviceID, on: $0)) }
))
Toggle("Eco", isOn: Binding(
get: { fridge.isEco },
set: { link.send(.fridgeEco(device: deviceID, eco: $0)) }
))
.disabled(!fridge.isPoweredOn)
target(zone: .left,
title: fridge.isDualZone ? "Soll links" : "Soll",
value: fridge.leftTarget)
if fridge.isDualZone {
target(zone: .right, title: "Soll rechts", value: fridge.rightTarget)
}
} header: {
Text("Kühlbox")
} footer: {
if !link.isReachable {
Text("iPhone gerade nicht erreichbar Stellbefehle gehen erst "
+ "wieder durch, wenn es antwortet.")
.font(.caption2)
}
}
}
private func target(zone: WatchFridgeZone, title: String, value: Int?) -> some View {
Stepper(value: Binding(
get: { value ?? fridge.targetRange.lowerBound },
set: { link.send(.fridgeTarget(device: deviceID, zone: zone, value: $0)) }
), in: fridge.targetRange) {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
Text(value.map { "\($0) \(fridge.unitSymbol)" } ?? "")
.font(.headline)
.monospacedDigit()
}
}
.disabled(!fridge.isPoweredOn || value == nil)
}
}
@@ -0,0 +1,94 @@
import SwiftUI
/// Nivellierung auf der Uhr: Libelle, Zahlen, und was zu tun ist.
struct WatchLevelView: View {
/// Nur für den Einstieg angezeigt wird immer der Stand aus dem letzten
/// Datensatz, sonst bliebe die Ansicht auf dem Wert vom Öffnen stehen.
let device: WatchDevice
@Environment(PhoneLink.self) private var link
@State private var isCalibrating = false
private var current: WatchDevice { link.payload?.levelDevice ?? device }
private var state: LevelState { current.level ?? LevelState() }
var body: some View {
ScrollView {
VStack(spacing: 10) {
WatchBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: 110)
HStack(spacing: 14) {
angle("Längs", state.pitch)
angle("Quer", state.roll)
}
Text(state.instruction ?? "Keine Werte")
.font(.footnote.weight(.medium))
.multilineTextAlignment(.center)
.foregroundStyle(state.isLevel ? .green : .primary)
if let wedges = wedgeText {
Text(wedges)
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
NavigationLink {
WatchAlignView(deviceID: current.id)
} label: {
Label("Ausrichten", systemImage: "car.side.rear.and.collision.and.car.side.front")
}
.buttonStyle(.borderedProminent)
Button("Kalibrieren", systemImage: "scope") { isCalibrating = true }
.font(.footnote)
if state.isKnownUncalibrated {
Text("Noch nicht kalibriert die Anzeige zeigt die Lage des "
+ "Sensors, nicht die des Fahrzeugs.")
.font(.caption2)
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
}
}
.padding(.horizontal, 4)
}
.navigationTitle("Nivellierung")
.confirmationDialog("Auf aktuelle Lage kalibrieren?",
isPresented: $isCalibrating, titleVisibility: .visible) {
Button("Kalibrieren") { link.send(.calibrateLevel(device: current.id)) }
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Nur sinnvoll, wenn das Fahrzeug jetzt eben steht.")
}
}
private func angle(_ title: String, _ value: Double?) -> some View {
VStack(spacing: 0) {
Text(value.map { String(format: "%.1f°", $0) } ?? "")
.font(.title3.weight(.semibold))
.monospacedDigit()
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
/// Wie hoch die Auffahrkeile sein müssen reine Geometrie, und nur zu
/// haben, wenn im Fahrzeugprofil Spurweite und Radstand stehen.
private var wedgeText: String? {
guard let profile = link.payload?.profile else { return nil }
var parts: [String] = []
if let roll = state.roll, let width = profile.trackWidth,
let wedge = LevelingWedge.across(roll: roll, trackWidth: width) {
parts.append(String(format: "%.0f cm %@", wedge.heightInCentimetres, wedge.side.text))
}
if let pitch = state.pitch, let base = profile.wheelbase,
let wedge = LevelingWedge.along(pitch: pitch, wheelbase: base) {
parts.append(String(format: "%.0f cm %@", wedge.heightInCentimetres, wedge.side.text))
}
return parts.isEmpty ? nil : "Keile: " + parts.joined(separator: ", ")
}
}
@@ -0,0 +1,164 @@
import SwiftUI
/// Der Einstieg: das Wichtigste untereinander, in der Reihenfolge, in der man
/// im Wohnmobil danach schaut.
struct WatchRootView: View {
@Environment(PhoneLink.self) private var link
var body: some View {
NavigationStack {
Group {
if let payload = link.payload {
content(for: payload)
} else {
waiting
}
}
.navigationTitle(link.payload?.profile.name ?? "Camper")
}
}
private func content(for payload: WatchPayload) -> some View {
List {
if let level = payload.levelDevice {
Section {
NavigationLink {
WatchLevelView(device: level)
} label: {
LevelSummaryRow(state: level.level, link: level.link)
}
}
}
Section {
ForEach(payload.devices.filter { $0.role != .leveling }) { device in
NavigationLink {
WatchDeviceDetailView(deviceID: device.id)
} label: {
DeviceRow(device: device)
}
}
}
Section {
FreshnessNote(payload: payload, receivedAt: link.receivedAt)
}
}
}
private var waiting: some View {
VStack(spacing: 8) {
Image(systemName: "iphone.gen3.radiowaves.left.and.right")
.font(.title2)
.foregroundStyle(.tint)
Text("Warte auf das iPhone")
.font(.headline)
Text("Die Werte kommen vom iPhone. Dort muss Camper Monitor "
+ "geöffnet sein.")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 4)
}
}
/// Eine Gerätezeile: Name klein, Hauptwert gross auf dem kleinen Bildschirm
/// zählt nur der eine Wert, wegen dem man den Arm hebt.
struct DeviceRow: View {
let device: WatchDevice
var body: some View {
HStack(spacing: 8) {
Image(systemName: device.role.symbol)
.foregroundStyle(.tint)
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(device.name)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
if let metric = device.snapshot?.primaryMetric, metric.value != nil {
Text(metric.formattedWithUnit)
.font(.title3.weight(.semibold))
.monospacedDigit()
.foregroundStyle(device.isLive ? .primary : .secondary)
} else {
Text(device.link.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 0)
Circle()
.fill(device.statusColor)
.frame(width: 7, height: 7)
}
}
}
/// Die Nivellierung bekommt eine eigene, grössere Zeile: sie ist der Grund,
/// aus dem man die Uhr im Fahrzeug überhaupt anschaut.
struct LevelSummaryRow: View {
let state: LevelState?
let link: DeviceLinkState
var body: some View {
HStack(spacing: 10) {
WatchBubble(pitch: state?.pitch, roll: state?.roll)
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("Nivellierung")
.font(.caption2)
.foregroundStyle(.secondary)
// Ohne Messwerte steht hier, woran es liegt, statt eines
// nichtssagenden Strichs.
Text(state?.instruction ?? link.label)
.font(.footnote.weight(.medium))
.lineLimit(2)
}
Spacer(minLength: 0)
}
.padding(.vertical, 2)
}
}
/// Wie alt der Stand ist auf der Uhr die wichtigste Nebenangabe. Alles hier
/// ist weitergereicht; eine abgerissene Strecke zum iPhone sieht sonst genauso
/// aus wie ein Fahrzeug, an dem sich nichts tut.
struct FreshnessNote: View {
let payload: WatchPayload
let receivedAt: Date?
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
let age = context.date.timeIntervalSince(receivedAt ?? payload.generatedAt)
Label {
Text(text(age: age))
.font(.caption2)
} icon: {
Image(systemName: age > 10 ? "exclamationmark.triangle.fill" : "iphone.gen3")
}
.foregroundStyle(age > 10 ? .orange : .secondary)
}
}
private func text(age: TimeInterval) -> String {
if !payload.isRadioReady { return payload.radioStatus }
if age < 3 { return "Stand: eben" }
if age < 90 { return String(format: "Stand: vor %.0f s", age) }
return String(format: "Stand: vor %.0f min iPhone erreichbar?", age / 60)
}
}
extension WatchDevice {
/// Grün heisst: der Wert ist frisch und kommt vom Gerät selbst.
var statusColor: Color {
switch link {
case .live: return (snapshot?.isStale ?? true) ? .orange : .green
case .needsKey: return .orange
case .failed: return .red
default: return .gray
}
}
}
+81
View File
@@ -0,0 +1,81 @@
import Foundation
/// Erfundene Werte für den Simulator, damit sich die Ansichten ohne iPhone und
/// ohne Fahrzeug prüfen lassen. Eingeschaltet über `CAMPER_DEMO=1`:
///
/// xcrun simctl launch --terminate-running-process booted \
/// de.fritob.CamperMonitor.watchkitapp
/// # mit SIMCTL_CHILD_CAMPER_DEMO=1 davor
///
/// Im normalen Betrieb wird hiervon nichts ausgeführt.
enum WatchDemo {
private static let profile = Profile(name: "Kastenwagen", symbol: "box.truck",
trackWidth: 1.85, wheelbase: 3.50)
private static let solarID = UUID(uuidString: "00000000-0000-0000-0000-000000000050")!
private static let batteryID = UUID(uuidString: "00000000-0000-0000-0000-0000000000A0")!
private static let fridgeID = UUID(uuidString: "00000000-0000-0000-0000-0000000000F0")!
private static let levelID = UUID(uuidString: "00000000-0000-0000-0000-0000000000E0")!
/// `seconds` läuft weiter, damit sich die Neigung bewegt sonst zeigt der
/// Ausrichtungs-Assistent nie einen Trend.
static func payload(at seconds: Double) -> WatchPayload {
let now = Date()
let swing = sin(seconds / 8)
var level = LevelState()
level.pitch = 1.4 * swing + 0.3
level.roll = -0.9 * cos(seconds / 11)
level.pitchOffset = 0.4
level.rollOffset = -0.2
var solar = DeviceSnapshot(deviceID: solarID, timestamp: now, rssi: -58)
solar.state = "Konstantspannung (Absorption)"
solar.metrics = [
Metric("pv_power", "PV-Leistung", 284 + 40 * swing, unit: "W", precision: 0, primary: true),
Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2),
Metric("battery_current", "Ladestrom", 18.6, unit: "A", precision: 1),
]
var battery = DeviceSnapshot(deviceID: batteryID, timestamp: now, rssi: -64)
battery.state = "Lädt"
battery.metrics = [
Metric("soc", "Ladezustand", 78.4, unit: "%", precision: 1, primary: true),
Metric("voltage", "Spannung", 13.66, unit: "V", precision: 2),
Metric("current", "Strom", 18.6, unit: "A", precision: 1),
Metric("capacity", "Restkapazität", 156.8, unit: "Ah", precision: 1),
]
var fridge = DeviceSnapshot(deviceID: fridgeID, timestamp: now, rssi: -66)
fridge.state = "Kompressor läuft"
fridge.metrics = [
Metric("temperature", "Innentemperatur", 6, unit: "°C", precision: 0, primary: true),
Metric("target", "Solltemperatur", 4, unit: "°C", precision: 0),
Metric("battery_voltage", "Bordspannung", 12.7, unit: "V", precision: 1),
]
return WatchPayload(
generatedAt: now,
profile: profile,
isRadioReady: true,
radioStatus: "Demo-Modus",
devices: [
WatchDevice(id: levelID, name: "Nivellierung", role: .leveling,
link: .live, snapshot: level.snapshot(deviceID: levelID, rssi: -70),
level: level, fridge: nil),
WatchDevice(id: batteryID, name: "Bulltron 200 Ah", role: .bms,
link: .live, snapshot: battery, level: nil, fridge: nil),
WatchDevice(id: solarID, name: "Solar Dach", role: .solarCharger,
link: .live, snapshot: solar, level: nil, fridge: nil),
WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge,
link: .live, snapshot: fridge, level: nil,
fridge: WatchFridge(isPoweredOn: true, isEco: true, isLocked: false,
isDualZone: false, unitSymbol: "°C",
leftTarget: 4, leftCurrent: 6,
rightTarget: nil, rightCurrent: nil,
minTarget: -20, maxTarget: 20,
batteryVolts: 12.7)),
])
}
}
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Alles Übrige erzeugt Xcode aus den INFOPLIST_KEY_-Einstellungen des
Targets. Hier steht nur, was sich dort nicht ausdrücken lässt.
Der Hintergrundbetrieb ist für die Apple Watch da: Beim Rangieren liegt
das iPhone in der Halterung mit dunklem Bildschirm, und die Anzeige am
Handgelenk wäre sonst genau dann tot, wenn sie gebraucht wird.
Verbundene Geräte (Neigungsmesser, BMS, Kühlbox) liefern damit weiter.
Die Victron-Werbedaten nicht: dafür müsste ohne Dienstfilter gesucht
werden, und das lässt iOS im Hintergrund grundsätzlich nicht zu. -->
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
</dict>
</plist>
+105 -4
View File
@@ -12,6 +12,11 @@ iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest:
Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt. Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt.
Dazu gehört eine **Apple-Watch-App**: Übersicht, Nivellierung samt
Ausrichtungs-Assistent mit Vibration und die Steuerung der Kühlbox am
Handgelenk. Sie funkt nicht selbst, sondern hängt am iPhone warum, steht
unter [Apple Watch](#apple-watch).
Die Firmware des Neigungsmessers liegt mit im Projekt, siehe Die Firmware des Neigungsmessers liegt mit im Projekt, siehe
[firmware/vanalign](firmware/vanalign/README.md). [firmware/vanalign](firmware/vanalign/README.md).
@@ -25,8 +30,21 @@ Dann in Xcode:
1. Target `CamperMonitor`**Signing & Capabilities** → dein Apple-Team auswählen. 1. Target `CamperMonitor`**Signing & Capabilities** → dein Apple-Team auswählen.
Die Bundle-ID `de.fritob.CamperMonitor` ggf. anpassen, falls sie schon vergeben ist. Die Bundle-ID `de.fritob.CamperMonitor` ggf. anpassen, falls sie schon vergeben ist.
Dasselbe beim Target `CamperMonitorWatch`; dessen Bundle-ID muss die des
iPhones mit angehängtem `.watchkitapp` bleiben.
2. iPhone per Kabel anschließen, oben als Ziel wählen, ⌘R. 2. iPhone per Kabel anschließen, oben als Ziel wählen, ⌘R.
Das Schema `CamperMonitor` baut die Watch-App mit und bettet sie ein. Dafür
muss die watchOS-Plattform in Xcode installiert sein sonst bricht schon das
Übersetzen des Symbolkatalogs ab:
```bash
xcodebuild -downloadPlatform watchOS
```
Auf die Uhr kommt die App danach von selbst: Sie steckt in der iPhone-App und
taucht in der Watch-App des iPhones unter *Verfügbare Apps* auf.
Wichtig: **Der Simulator hat kein Bluetooth.** Die App startet dort, findet aber Wichtig: **Der Simulator hat kein Bluetooth.** Die App startet dort, findet aber
nie ein Gerät. Zum Testen muss sie auf ein echtes iPhone. nie ein Gerät. Zum Testen muss sie auf ein echtes iPhone.
@@ -191,6 +209,57 @@ damit exakt: 2,0° Querneigung bei 2,00 m Spurweite ergeben 7,0 cm unter die
tieferstehende Seite. Die Maße stehen im Fahrzeugprofil, erreichbar über das tieferstehende Seite. Die Maße stehen im Fahrzeugprofil, erreichbar über das
ⓘ in der Fahrzeugliste. ⓘ in der Fahrzeugliste.
## Apple Watch
Die Uhr zeigt dasselbe wie das Dashboard, nur auf das eingedampft, wofür man
den Arm hebt:
* **Übersicht** je Gerät der Hauptwert und ein Punkt für den Zustand der
Verbindung. Ganz unten steht, wie alt der Stand ist.
* **Nivellierung** Libelle, beide Winkel, was zu tun ist, und die nötige
Keilhöhe, falls im Fahrzeugprofil Spurweite und Radstand stehen.
* **Ausrichten** derselbe Assistent wie am iPhone: Tendenz, Rat und der
Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das
Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn
gegenüber dem iPhone beim Rangieren schaut niemand aufs Display, aber die
Vibration am Handgelenk kommt an.
* **Kühlbox** ein/aus, Eco oder Max und die Solltemperatur, über die Krone
gestellt.
### Warum die Uhr nicht selbst funkt
watchOS könnte Bluetooth, und trotzdem geht alles über das iPhone. Zwei Gründe,
beide hart:
* BMS, Kühlbox und Neigungsmesser lassen jeweils nur **eine** Verbindung zu.
Eine mitlesende Uhr würde dem iPhone die Verbindung wegnehmen, statt sie zu
ergänzen.
* Die Victron-Schlüssel liegen in der Keychain des iPhones. Sie auf die Uhr zu
kopieren hiesse, sie ein zweites Mal aufzubewahren, ohne dass der zweite Ort
irgendetwas brächte.
Das iPhone bleibt also das Funkgerät und reicht fertige Messwerte weiter. Was
die Uhr stellt, stellt in Wahrheit das iPhone und angezeigt wird auch dort
nur, was das Gerät zurückmeldet, nicht der Tastendruck.
### Was das für den Betrieb heisst
Auf dem iPhone muss Camper Monitor laufen. Solange die Uhr meldet, dass jemand
hinschaut, hält die App das Funkgerät auch im Hintergrund am Leben sonst wäre
die Anzeige am Handgelenk beim Rangieren tot, denn dann liegt das iPhone mit
dunklem Bildschirm in der Halterung. Die verbundenen Geräte liefern dabei
weiter; die Victron-Werbedaten nicht, weil iOS im Hintergrund kein ungefiltertes
Suchen erlaubt.
Gesendet wird in zwei Takten: ein halber Sekundentakt, solange die Watch-App im
Vordergrund ist, sonst alle zwei Sekunden und nur bei Änderungen. Der schnelle
Takt läuft über eine Frist, die die Uhr regelmässig erneuert schläft sie ein,
hört das iPhone von selbst wieder auf.
Was die Uhr zeigt, ist immer Weitergereichtes. Deshalb steht auf jedem
Bildschirm das Alter des Standes: Eine abgerissene Strecke zum iPhone sähe sonst
genauso aus wie ein Fahrzeug, an dem sich nichts tut.
## Diagnose ## Diagnose
Im Alltag zeigt die App nur Messwerte und Bedienelemente. Die technischen Im Alltag zeigt die App nur Messwerte und Bedienelemente. Die technischen
@@ -218,6 +287,10 @@ ohne Bluetooth prüfen lassen. In Xcode unter *Product → Scheme → Edit Schem
Run → Arguments* die Umgebungsvariable `CAMPER_DEMO` auf `1` setzen. Er greift Run → Arguments* die Umgebungsvariable `CAMPER_DEMO` auf `1` setzen. Er greift
nur in Debug-Builds. nur in Debug-Builds.
Die Watch-App kennt denselben Schalter und braucht dann kein iPhone: Im Schema
`CamperMonitorWatch` dieselbe Variable setzen. Die Neigung wandert dort
langsam hin und her, sonst hätte der Ausrichtungs-Assistent nichts zu zeigen.
## Protokolle prüfen ## Protokolle prüfen
```bash ```bash
@@ -235,14 +308,19 @@ firmware/vanalign/ Firmware des Neigungsmessers (ESPHome)
├── esp32_ble.yaml Neigungsmessung und Bluetooth-Schnittstelle ├── esp32_ble.yaml Neigungsmessung und Bluetooth-Schnittstelle
└── experimente/ Nicht für den Betrieb nötig └── experimente/ Nicht für den Betrieb nötig
CamperMonitor/ Shared/ In iPhone- und Watch-App übersetzt
├── Models/ ├── Models/
│ ├── Profile.swift Fahrzeug samt Maßen │ ├── Profile.swift Fahrzeug samt Maßen
│ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilberechnung │ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilberechnung
│ ├── SensorOrientation.swift Einbaulage und ihre Erkennung │ ├── SensorOrientation.swift Einbaulage und ihre Erkennung
│ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart │ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart
│ ├── DeviceSnapshot.swift Messwerte in Anzeigeform │ ├── DeviceSnapshot.swift Messwerte in Anzeigeform
│ ├── LevelState.swift Neigung, Toleranz und Klartext dazu
│ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes │ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
└── WatchLink/
└── WatchLink.swift Datensatz und Befehle zwischen iPhone und Uhr
CamperMonitor/
├── Bluetooth/ ├── Bluetooth/
│ ├── BluetoothManager.swift Zentraler Scan, Verbindungen, Verlauf │ ├── BluetoothManager.swift Zentraler Scan, Verbindungen, Verlauf
│ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten │ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten
@@ -259,6 +337,8 @@ CamperMonitor/
├── Store/ ├── Store/
│ ├── DeviceStore.swift Geräteliste, Persistenz │ ├── DeviceStore.swift Geräteliste, Persistenz
│ └── KeychainStore.swift Victron-Schlüssel │ └── KeychainStore.swift Victron-Schlüssel
├── Watch/
│ └── PhoneWatchLink.swift Sendet den Stand, nimmt Befehle der Uhr an
└── Views/ └── Views/
├── DashboardView.swift Kachelübersicht ├── DashboardView.swift Kachelübersicht
├── DeviceCard.swift Eine Kachel ├── DeviceCard.swift Eine Kachel
@@ -271,13 +351,34 @@ CamperMonitor/
├── SettingsView.swift App-Einstellungen ├── SettingsView.swift App-Einstellungen
├── SensorSetupView.swift Assistent für die Einbaulage ├── SensorSetupView.swift Assistent für die Einbaulage
└── AddDeviceView.swift Scannen und Einrichten └── AddDeviceView.swift Scannen und Einrichten
CamperMonitorWatch/ watchOS-App
├── CamperWatchApp.swift Einstieg, meldet dem iPhone das Hinschauen
├── PhoneLink.swift Gegenstelle zum iPhone
├── WatchDemo.swift Erfundene Werte für den Simulator
└── Views/
├── WatchRootView.swift Übersicht und Altersangabe
├── WatchBubble.swift Libelle für den kleinen Bildschirm
├── WatchLevelView.swift Neigung, Keilhöhe, Kalibrierung
├── WatchAlignView.swift Ausrichten mit Vibration
├── WatchDeviceDetailView.swift Alle Werte eines Geräts
└── WatchFridgeControls.swift Kühlbox stellen
Config/
└── CamperMonitor-Info.plist Nur der Hintergrundbetrieb für die Uhr
``` ```
## Bekannte Grenzen ## Bekannte Grenzen
* **Kein Hintergrundbetrieb.** iOS erlaubt das ungefilterte Scannen nach * **Im Hintergrund nur die verbundenen Geräte.** iOS erlaubt das ungefilterte
Advertisements nur im Vordergrund. Die App pausiert, sobald sie in den Scannen nach Advertisements nur im Vordergrund; die Victron-Werte stehen im
Hintergrund geht, und nimmt beim Zurückkommen wieder auf. Hintergrund also still. Verbundene Geräte Neigungsmesser, BMS, Kühlbox
laufen weiter, aber nur solange die Uhr meldet, dass jemand hinschaut. Ohne
Uhr pausiert die App im Hintergrund vollständig und nimmt beim Zurückkommen
wieder auf.
* **Die Uhr braucht das iPhone.** Sie zeigt nur, was von dort kommt. Steckt das
iPhone ausser Reichweite, steht auf der Uhr, wie alt der letzte Stand ist
aber kein neuer.
* **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen * **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen
Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind
gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel
@@ -1,7 +1,7 @@
import Foundation import Foundation
/// Eine einzelne Messgrösse in einer bereits formatierten Form. /// Eine einzelne Messgrösse in einer bereits formatierten Form.
struct Metric: Identifiable, Hashable, Sendable { struct Metric: Identifiable, Hashable, Codable, Sendable {
let key: String let key: String
let label: String let label: String
let value: Double? let value: Double?
@@ -39,7 +39,7 @@ struct Metric: Identifiable, Hashable, Sendable {
} }
/// Der komplette, zuletzt empfangene Zustand eines Geräts. /// Der komplette, zuletzt empfangene Zustand eines Geräts.
struct DeviceSnapshot: Identifiable, Sendable { struct DeviceSnapshot: Identifiable, Codable, Equatable, Sendable {
var id: UUID { deviceID } var id: UUID { deviceID }
var deviceID: UUID var deviceID: UUID
var timestamp: Date var timestamp: Date
@@ -58,7 +58,7 @@ struct DeviceSnapshot: Identifiable, Sendable {
/// Feste Angaben des Geräts, etwa Modell oder Seriennummer. /// Feste Angaben des Geräts, etwa Modell oder Seriennummer.
var info: [InfoItem] = [] var info: [InfoItem] = []
struct InfoItem: Identifiable, Hashable, Sendable { struct InfoItem: Identifiable, Hashable, Codable, Sendable {
let label: String let label: String
let value: String let value: String
var id: String { label } var id: String { label }
@@ -76,7 +76,7 @@ struct DeviceSnapshot: Identifiable, Sendable {
} }
/// Verbindungszustand für die UI. /// Verbindungszustand für die UI.
enum DeviceLinkState: Equatable, Sendable { enum DeviceLinkState: Equatable, Codable, Sendable {
case idle case idle
case searching case searching
case connecting case connecting
+80
View File
@@ -0,0 +1,80 @@
import Foundation
/// Zustand des Neigungsmessers.
struct LevelState: Equatable, Codable, Sendable {
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
/// Bereits auf die Einbaulage des Sensors umgerechnet.
var pitch: Double?
/// Querneigung: positiv bedeutet, die rechte Seite steht höher.
var roll: Double?
/// Wie der Sensor selbst meldet nötig, um die Einbaulage zu bestimmen.
var rawPitch: Double?
var rawRoll: Double?
var pitchOffset: Double?
var rollOffset: Double?
var hasReading: Bool { pitch != nil || roll != nil }
/// Ob kalibriert wurde oder nil, wenn das Gerät es nicht verrät.
///
/// Nicht jede Firmware stellt die Offsets bereit. Fehlen sie, heisst das
/// unbekannt und nicht nicht kalibriert: eine Warnung, die sich nie
/// abstellen lässt, ist schlimmer als keine.
var calibrationState: Bool? {
guard let pitchOffset, let rollOffset else { return nil }
return abs(pitchOffset) > 0.001 || abs(rollOffset) > 0.001
}
/// Nur wahr, wenn es auch belegt ist.
var isCalibrated: Bool { calibrationState == true }
/// Nur wahr, wenn das Gerät ausdrücklich Nulloffsets meldet.
var isKnownUncalibrated: Bool { calibrationState == false }
/// Bis hierhin gilt das Fahrzeug als eben genug.
static let levelTolerance = 0.5
var isLevel: Bool {
guard let pitch, let roll else { return false }
return abs(pitch) <= Self.levelTolerance && abs(roll) <= Self.levelTolerance
}
/// Die grössere der beiden Abweichungen das ist die, die man zuerst
/// ausgleichen will.
var largestDeviation: Double? {
switch (pitch, roll) {
case let (p?, r?): return max(abs(p), abs(r))
case let (p?, nil): return abs(p)
case let (nil, r?): return abs(r)
default: return nil
}
}
/// Was zu tun ist, in Worten. Beim Ausrichten schaut man aufs Handy,
/// nicht auf Vorzeichen.
var instruction: String? {
guard let pitch, let roll else { return nil }
if isLevel { return "Steht eben" }
var parts: [String] = []
if abs(pitch) > Self.levelTolerance {
parts.append(pitch > 0 ? "Heck steht höher" : "Front steht höher")
}
if abs(roll) > Self.levelTolerance {
parts.append(roll > 0 ? "rechts steht höher" : "links steht höher")
}
return parts.joined(separator: ", ")
}
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
snapshot.metrics = [
Metric("pitch", "Längsneigung", pitch, unit: "°", precision: 1, primary: true),
Metric("roll", "Querneigung", roll, unit: "°", precision: 1),
]
snapshot.state = instruction
if isKnownUncalibrated {
snapshot.offReasons = ["Nicht kalibriert"]
}
return snapshot
}
}
+185
View File
@@ -0,0 +1,185 @@
import Foundation
/// Was zwischen iPhone und Uhr hin- und hergeht.
///
/// Die Uhr funkt **nicht** selbst zu den Geräten im Fahrzeug, obwohl watchOS
/// das könnte. Zwei Gründe sprechen dagegen, und beide sind hart:
///
/// * BMS, Kühlbox und Neigungsmesser lassen jeweils nur **eine** Verbindung zu.
/// Eine mitlesende Uhr würde dem iPhone die Verbindung wegnehmen, statt sie
/// zu ergänzen.
/// * Die Victron-Schlüssel liegen in der Keychain des iPhones. Die Uhr hat ihre
/// eigene; die Schlüssel dorthin zu kopieren hiesse, sie ein zweites Mal
/// aufzubewahren, ohne dass ein zweiter Aufbewahrungsort etwas brächte.
///
/// Das iPhone bleibt also das Funkgerät und schickt fertige Messwerte weiter,
/// die Uhr ist Anzeige und Fernbedienung.
enum WatchLink {
/// Schlüssel im WatchConnectivity-Wörterbuch. Übertragen wird jeweils ein
/// JSON-`Data`, nicht ein aufgedröseltes Wörterbuch: die Typen unten sind
/// damit die einzige Stelle, an der das Format steht.
static let payloadKey = "payload"
static let commandKey = "command"
/// Wie lange ein ich schaue gerade hin der Uhr gilt, ohne erneuert zu
/// werden. Bricht die Verbindung ab oder wandert die App auf der Uhr in den
/// Hintergrund, hört das iPhone von selbst wieder auf, im Sekundentakt zu
/// senden.
static let liveLease: TimeInterval = 12
/// Sendetakt, solange die Uhr hinschaut. Der Neigungsmesser wird zweimal je
/// Sekunde abgefragt schneller zu senden brächte nichts.
static let liveInterval: TimeInterval = 0.5
/// Sendetakt sonst. Geht als Anwendungskontext raus, den watchOS auch dann
/// noch zustellt, wenn die App auf der Uhr gerade nicht läuft.
static let idleInterval: TimeInterval = 2
}
/// Der gesamte Stand eines Fahrzeugs, so wie ihn die Uhr anzeigt.
struct WatchPayload: Codable, Equatable, Sendable {
/// Steigt, wenn sich das Format ändert. Eine Uhr mit älterer App bekommt
/// sonst Werte, die sie falsch versteht lieber sagt sie App aktualisieren.
static let currentVersion = 1
var version = WatchPayload.currentVersion
var generatedAt: Date
/// Das am iPhone gewählte Fahrzeug.
var profile: Profile
/// Ob das iPhone gerade überhaupt funken kann.
var isRadioReady: Bool
/// Klartext dazu, falls nicht.
var radioStatus: String
var devices: [WatchDevice]
/// Nach der Rolle gesucht, nicht nach vorhandenen Werten: Ein
/// Neigungsmesser, der gerade nicht antwortet, gehört trotzdem an seinen
/// Platz sonst rutschte er beim Verbindungsabriss in die Geräteliste.
var levelDevice: WatchDevice? { devices.first { $0.role == .leveling } }
var fridgeDevice: WatchDevice? { devices.first { $0.role == .fridge } }
/// Ob sich inhaltlich nichts geändert hat. Der Zeitstempel zählt dabei
/// nicht mit: sonst gälte jeder Datensatz als neu und die Uhr bekäme rund
/// um die Uhr Funkverkehr, auch wenn das Fahrzeug still steht.
func hasSameContent(as other: WatchPayload) -> Bool {
var mine = self, theirs = other
mine.generatedAt = .distantPast
theirs.generatedAt = .distantPast
return mine == theirs
}
/// Wie alt der Stand ist. Auf der Uhr die wichtigste Angabe überhaupt:
/// alles hier ist Weitergereichtes, und eine abgerissene Strecke zum
/// iPhone sieht sonst aus wie ein stillstehendes Fahrzeug.
func age(now: Date = Date()) -> TimeInterval { now.timeIntervalSince(generatedAt) }
}
/// Ein Gerät mitsamt seinem letzten Messwertsatz.
struct WatchDevice: Codable, Equatable, Identifiable, Sendable {
var id: UUID
var name: String
var role: DeviceRole
var link: DeviceLinkState
var snapshot: DeviceSnapshot?
/// Nur beim Neigungsmesser belegt.
var level: LevelState?
/// Nur bei der Kühlbox belegt.
var fridge: WatchFridge?
var isLive: Bool { link == .live && !(snapshot?.isStale ?? true) }
}
/// Zustand und Grenzen der Kühlbox, so weit die Uhr sie braucht.
///
/// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit
/// drin, den auf die Uhr zu schicken nichts brächte.
struct WatchFridge: Codable, Equatable, Sendable {
var isPoweredOn: Bool
var isEco: Bool
var isLocked: Bool
var isDualZone: Bool
var unitSymbol: String
var leftTarget: Int?
var leftCurrent: Int?
var rightTarget: Int?
var rightCurrent: Int?
var minTarget: Int
var maxTarget: Int
var batteryVolts: Double?
var targetRange: ClosedRange<Int> {
minTarget < maxTarget ? minTarget...maxTarget : -30...20
}
}
/// Was die Uhr das iPhone tun lässt.
///
/// Gestellt wird nur, was auch am iPhone gestellt werden kann; die Uhr bekommt
/// keine eigenen Fähigkeiten. Das Ergebnis liest sie wie jeder andere Wert aus
/// dem nächsten Datensatz bestätigt wird also das Gerät, nicht der Tastendruck.
enum WatchCommand: Codable, Equatable, Sendable {
/// Die Uhr schaut gerade hin und möchte den schnellen Takt.
case hello(live: Bool)
case fridgePower(device: UUID, on: Bool)
case fridgeEco(device: UUID, eco: Bool)
case fridgeLock(device: UUID, locked: Bool)
case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int)
case calibrateLevel(device: UUID)
/// Ob der Befehl den schnellen Sendetakt verlängert.
var wantsLiveUpdates: Bool {
if case .hello(let live) = self { return live }
// Wer stellt, schaut auch hin.
return true
}
}
/// Spiegelt `AlpicoolState.Zone`, ohne das Protokoll auf die Uhr zu ziehen.
enum WatchFridgeZone: String, Codable, Equatable, Sendable {
case left, right
}
// MARK: - Verpacken
extension WatchPayload {
func encoded() throws -> Data { try WatchCoder.encoder.encode(self) }
/// Aus einem WatchConnectivity-Wörterbuch zurücklesen. Fremde oder zu neue
/// Datensätze ergeben nil statt eines halb gefüllten Zustands.
static func decode(from message: [String: Any]) -> WatchPayload? {
guard let data = message[WatchLink.payloadKey] as? Data,
let payload = try? WatchCoder.decoder.decode(WatchPayload.self, from: data),
payload.version == WatchPayload.currentVersion else { return nil }
return payload
}
func message() throws -> [String: Any] { [WatchLink.payloadKey: try encoded()] }
}
extension WatchCommand {
func message() throws -> [String: Any] {
[WatchLink.commandKey: try WatchCoder.encoder.encode(self)]
}
static func decode(from message: [String: Any]) -> WatchCommand? {
guard let data = message[WatchLink.commandKey] as? Data else { return nil }
return try? WatchCoder.decoder.decode(WatchCommand.self, from: data)
}
}
/// Feste Einstellungen für beide Richtungen. Ohne das würde ein Datum auf der
/// einen Seite anders geschrieben als auf der anderen gelesen.
enum WatchCoder {
static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .millisecondsSince1970
return encoder
}()
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970
return decoder
}()
}
+137
View File
@@ -949,5 +949,142 @@ let missing = SensorOrientation.identity.apply(pitch: nil, roll: 2)
checkEqual("fehlende Werte bleiben leer", missing.pitch == nil, true) checkEqual("fehlende Werte bleiben leer", missing.pitch == nil, true)
checkEqual("vorhandene Werte kommen durch", missing.roll, 2) checkEqual("vorhandene Werte kommen durch", missing.roll, 2)
// MARK: 13 Strecke zur Apple Watch
print("\nApple Watch Datensatz und Befehle")
let watchProfile = Profile(name: "Kastenwagen", symbol: "box.truck",
trackWidth: 1.85, wheelbase: 3.50)
let watchLevelID = UUID()
let watchFridgeID = UUID()
let watchBoosterID = UUID()
var watchLevel = LevelState()
watchLevel.pitch = 1.8
watchLevel.roll = -0.9
watchLevel.pitchOffset = 0.4
watchLevel.rollOffset = -0.2
// Feste Zeitstempel, damit der Vergleich unten wirklich den Inhalt prüft: über
// die Strecke bleibt die Millisekunde stehen, ein `Date()` wäre feiner.
let watchStamp = Date(timeIntervalSince1970: 1_699_999_999)
var watchLevelSnapshot = watchLevel.snapshot(deviceID: watchLevelID, rssi: -70)
watchLevelSnapshot.timestamp = watchStamp
var boosterSnapshot = DeviceSnapshot(deviceID: watchBoosterID, timestamp: watchStamp, rssi: -71)
boosterSnapshot.state = "Aus"
boosterSnapshot.offReasons = ["Keine Eingangsspannung"]
boosterSnapshot.metrics = [
Metric("output_voltage", "Ausgang", 14.09, unit: "V", precision: 2, primary: true),
Metric("input_voltage", "Eingang", 12.42, unit: "V", precision: 2),
]
let watchFridge = WatchFridge(isPoweredOn: true, isEco: true, isLocked: false,
isDualZone: false, unitSymbol: "°C",
leftTarget: 4, leftCurrent: 6,
rightTarget: nil, rightCurrent: nil,
minTarget: -20, maxTarget: 20, batteryVolts: 12.7)
let watchPayload = WatchPayload(
generatedAt: Date(timeIntervalSince1970: 1_700_000_000),
profile: watchProfile,
isRadioReady: true,
radioStatus: "Bereit",
devices: [
WatchDevice(id: watchLevelID, name: "Nivellierung", role: .leveling,
link: .live, snapshot: watchLevelSnapshot,
level: watchLevel, fridge: nil),
WatchDevice(id: watchBoosterID, name: "Ladebooster", role: .chargeBooster,
link: .failed("Nicht gefunden"), snapshot: boosterSnapshot,
level: nil, fridge: nil),
WatchDevice(id: watchFridgeID, name: "Kühlbox", role: .fridge,
link: .live, snapshot: nil, level: nil, fridge: watchFridge),
])
// Der ganze Weg: verpacken, als WatchConnectivity-Wörterbuch lesen, vergleichen.
if let message = try? watchPayload.message(),
let restored = WatchPayload.decode(from: message) {
checkEqual("Datensatz kommt unverändert an", restored, watchPayload)
checkEqual("Zeitstempel überlebt das Verpacken",
restored.generatedAt.timeIntervalSince1970, 1_700_000_000)
checkEqual("Fehlerklartext bleibt am Gerät",
restored.devices[1].link, .failed("Nicht gefunden"))
checkEqual("Neigung wird gefunden", restored.levelDevice?.id, watchLevelID)
checkEqual("Kühlbox wird gefunden", restored.fridgeDevice?.id, watchFridgeID)
checkEqual("Sollwert der Box überlebt", restored.fridgeDevice?.fridge?.leftTarget, 4)
checkEqual("Fahrzeugmasse kommen mit", restored.profile.trackWidth, 1.85)
} else {
check("Datensatz lässt sich verpacken und wieder lesen", false)
}
// Eine Uhr mit älterer App darf einen neueren Datensatz nicht halb verstehen.
var futurePayload = watchPayload
futurePayload.version = WatchPayload.currentVersion + 1
if let message = try? futurePayload.message() {
checkEqual("fremde Formatversion wird abgelehnt",
WatchPayload.decode(from: message) == nil, true)
} else {
check("fremde Formatversion lässt sich verpacken", false)
}
// Zeitpunkte reisen als Millisekunden seit 1970 in einer Fliesskommazahl. Auf
// die Millisekunde genau kommen sie zurück, aufs letzte Bit nicht deshalb
// vergleicht der Test oben feste Zeitstempel und nicht ein `Date()`.
var preciseSnapshot = boosterSnapshot
preciseSnapshot.timestamp = Date(timeIntervalSince1970: 1_699_999_999.123_456)
var precisePayload = watchPayload
precisePayload.devices[1].snapshot = preciseSnapshot
if let message = try? precisePayload.message(),
let restored = WatchPayload.decode(from: message)?.devices[1].snapshot?.timestamp {
checkEqual("feiner Zeitstempel kommt auf die Millisekunde genau zurück",
abs(restored.timeIntervalSince1970 - 1_699_999_999.123_456) < 0.001, true)
} else {
check("Datensatz mit feinem Zeitstempel lässt sich verpacken", false)
}
checkEqual("leeres Wörterbuch ergibt keinen Datensatz",
WatchPayload.decode(from: [:]) == nil, true)
checkEqual("Unsinn im Nutzdatenfeld ergibt keinen Datensatz",
WatchPayload.decode(from: [WatchLink.payloadKey: Data([0x00, 0x01])]) == nil, true)
// Der Vergleich entscheidet, ob überhaupt gefunkt wird: der Zeitstempel allein
// darf keinen Funkverkehr auslösen, ein geänderter Messwert schon.
var laterPayload = watchPayload
laterPayload.generatedAt = watchPayload.generatedAt.addingTimeInterval(30)
checkEqual("gleicher Inhalt, neuer Zeitstempel gilt als unverändert",
watchPayload.hasSameContent(as: laterPayload), true)
var changedPayload = laterPayload
changedPayload.devices[2].fridge?.leftTarget = 5
checkEqual("geänderter Sollwert gilt als Änderung",
watchPayload.hasSameContent(as: changedPayload), false)
// Befehle in die Gegenrichtung.
let commands: [WatchCommand] = [
.hello(live: true),
.hello(live: false),
.fridgePower(device: watchFridgeID, on: false),
.fridgeEco(device: watchFridgeID, eco: true),
.fridgeLock(device: watchFridgeID, locked: true),
.fridgeTarget(device: watchFridgeID, zone: .right, value: -3),
.calibrateLevel(device: watchLevelID),
]
for command in commands {
guard let message = try? command.message(),
let restored = WatchCommand.decode(from: message) else {
check("Befehl \(command) lässt sich verpacken", false)
continue
}
checkEqual("Befehl kommt unverändert an", restored, command)
}
checkEqual("Anmeldung ohne Hinschauen verlängert den schnellen Takt nicht",
WatchCommand.hello(live: false).wantsLiveUpdates, false)
checkEqual("wer stellt, schaut auch hin",
WatchCommand.fridgePower(device: watchFridgeID, on: true).wantsLiveUpdates, true)
checkEqual("ein Datensatz ist kein Befehl",
WatchCommand.decode(from: (try? watchPayload.message()) ?? [:]) == nil, true)
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.") print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
exit(failures == 0 ? 0 : 1) exit(failures == 0 ? 0 : 1)
+8 -6
View File
@@ -14,12 +14,14 @@ swiftc -O -o "$OUT/tests" \
CamperMonitor/Bluetooth/WattCycleProtocol.swift \ CamperMonitor/Bluetooth/WattCycleProtocol.swift \
CamperMonitor/Bluetooth/AlpicoolProtocol.swift \ CamperMonitor/Bluetooth/AlpicoolProtocol.swift \
CamperMonitor/Bluetooth/VanAlignProtocol.swift \ CamperMonitor/Bluetooth/VanAlignProtocol.swift \
CamperMonitor/Models/DeviceSnapshot.swift \ Shared/Models/DeviceSnapshot.swift \
CamperMonitor/Models/ConfiguredDevice.swift \ Shared/Models/ConfiguredDevice.swift \
CamperMonitor/Models/Profile.swift \ Shared/Models/LevelState.swift \
CamperMonitor/Models/AlignmentAssistant.swift \ Shared/Models/Profile.swift \
CamperMonitor/Models/SensorOrientation.swift \ Shared/Models/AlignmentAssistant.swift \
CamperMonitor/Models/VictronCodes.swift \ Shared/Models/SensorOrientation.swift \
Shared/Models/VictronCodes.swift \
Shared/WatchLink/WatchLink.swift \
CamperMonitor/Store/KeychainStore.swift \ CamperMonitor/Store/KeychainStore.swift \
Tests/main.swift Tests/main.swift
"$OUT/tests" "$OUT/tests"