diff --git a/CamperMonitor.xcodeproj/project.pbxproj b/CamperMonitor.xcodeproj/project.pbxproj index bb03511..5c8c860 100644 --- a/CamperMonitor.xcodeproj/project.pbxproj +++ b/CamperMonitor.xcodeproj/project.pbxproj @@ -6,8 +6,38 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* CamperMonitorWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + AA0000000000000000000028 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AA0000000000000000000009 /* Project object */; + proxyType = 1; + remoteGlobalIDString = AA0000000000000000000022; + remoteInfo = CamperMonitorWatch; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + AA0000000000000000000026 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ AA0000000000000000000001 /* CamperMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AA0000000000000000000016 /* CamperMonitorWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitorWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AA0000000000000000000030 /* CamperMonitor-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitor-Info.plist"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -16,6 +46,16 @@ path = CamperMonitor; sourceTree = ""; }; + AA0000000000000000000017 /* CamperMonitorWatch */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CamperMonitorWatch; + sourceTree = ""; + }; + AA0000000000000000000018 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -26,6 +66,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000000000000000000020 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -33,6 +80,9 @@ isa = PBXGroup; children = ( AA0000000000000000000002 /* CamperMonitor */, + AA0000000000000000000017 /* CamperMonitorWatch */, + AA0000000000000000000018 /* Shared */, + AA0000000000000000000031 /* Config */, AA0000000000000000000005 /* Products */, ); sourceTree = ""; @@ -41,10 +91,19 @@ isa = PBXGroup; children = ( AA0000000000000000000001 /* CamperMonitor.app */, + AA0000000000000000000016 /* CamperMonitorWatch.app */, ); name = Products; sourceTree = ""; }; + AA0000000000000000000031 /* Config */ = { + isa = PBXGroup; + children = ( + AA0000000000000000000030 /* CamperMonitor-Info.plist */, + ); + path = Config; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -55,17 +114,41 @@ AA0000000000000000000007 /* Sources */, AA0000000000000000000003 /* Frameworks */, AA0000000000000000000008 /* Resources */, + AA0000000000000000000026 /* Embed Watch Content */, + ); + buildRules = ( + ); + dependencies = ( + AA0000000000000000000029 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + AA0000000000000000000002 /* CamperMonitor */, + AA0000000000000000000018 /* Shared */, + ); + name = CamperMonitor; + productName = CamperMonitor; + productReference = AA0000000000000000000001 /* CamperMonitor.app */; + productType = "com.apple.product-type.application"; + }; + AA0000000000000000000022 /* CamperMonitorWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "CamperMonitorWatch" */; + buildPhases = ( + AA0000000000000000000019 /* Sources */, + AA0000000000000000000020 /* Frameworks */, + AA0000000000000000000021 /* Resources */, ); buildRules = ( ); dependencies = ( ); fileSystemSynchronizedGroups = ( - AA0000000000000000000002 /* CamperMonitor */, + AA0000000000000000000017 /* CamperMonitorWatch */, + AA0000000000000000000018 /* Shared */, ); - name = CamperMonitor; - productName = CamperMonitor; - productReference = AA0000000000000000000001 /* CamperMonitor.app */; + name = CamperMonitorWatch; + productName = CamperMonitorWatch; + productReference = AA0000000000000000000016 /* CamperMonitorWatch.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -81,6 +164,9 @@ AA0000000000000000000006 = { CreatedOnToolsVersion = 26.6; }; + AA0000000000000000000022 = { + CreatedOnToolsVersion = 26.6; + }; }; }; buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */; @@ -99,6 +185,7 @@ projectRoot = ""; targets = ( AA0000000000000000000006 /* CamperMonitor */, + AA0000000000000000000022 /* CamperMonitorWatch */, ); }; /* End PBXProject section */ @@ -111,6 +198,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000000000000000000021 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -121,8 +215,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000000000000000000019 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + AA0000000000000000000029 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = AA0000000000000000000022 /* CamperMonitorWatch */; + targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ AA0000000000000000000012 /* Debug */ = { isa = XCBuildConfiguration; @@ -190,6 +300,7 @@ DEVELOPMENT_TEAM = 6AP73NYV5W; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Config/CamperMonitor-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -220,6 +331,7 @@ DEVELOPMENT_TEAM = 6AP73NYV5W; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Config/CamperMonitor-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -239,6 +351,68 @@ }; name = Release; }; + AA0000000000000000000023 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6AP73NYV5W; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 11.6; + }; + name = Debug; + }; + AA0000000000000000000024 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6AP73NYV5W; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.fritob.CamperMonitor; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 11.6; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -260,6 +434,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + AA0000000000000000000025 /* Build configuration list for PBXNativeTarget "CamperMonitorWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000000023 /* Debug */, + AA0000000000000000000024 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = AA0000000000000000000009 /* Project object */; diff --git a/CamperMonitor.xcodeproj/xcshareddata/xcschemes/CamperMonitorWatch.xcscheme b/CamperMonitor.xcodeproj/xcshareddata/xcschemes/CamperMonitorWatch.xcscheme new file mode 100644 index 0000000..85fb1da --- /dev/null +++ b/CamperMonitor.xcodeproj/xcshareddata/xcschemes/CamperMonitorWatch.xcscheme @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CamperMonitor/Bluetooth/VanAlignProtocol.swift b/CamperMonitor/Bluetooth/VanAlignProtocol.swift index fff1487..f79e1ce 100644 --- a/CamperMonitor/Bluetooth/VanAlignProtocol.swift +++ b/CamperMonitor/Bluetooth/VanAlignProtocol.swift @@ -49,82 +49,3 @@ enum VanAlignProtocol { return (pitch, roll) } } - -/// Zustand des Neigungsmessers. -struct LevelState: Equatable { - /// Längsneigung: positiv bedeutet, das Heck steht höher als die Front. - /// Bereits auf die Einbaulage des Sensors umgerechnet. - var pitch: Double? - /// Querneigung: positiv bedeutet, die rechte Seite steht höher. - var roll: Double? - /// Wie der Sensor selbst meldet – nötig, um die Einbaulage zu bestimmen. - var rawPitch: Double? - var rawRoll: Double? - var pitchOffset: Double? - var rollOffset: Double? - - var hasReading: Bool { pitch != nil || roll != nil } - - /// Ob kalibriert wurde – oder nil, wenn das Gerät es nicht verrät. - /// - /// Nicht jede Firmware stellt die Offsets bereit. Fehlen sie, heisst das - /// „unbekannt“ und nicht „nicht kalibriert“: eine Warnung, die sich nie - /// abstellen lässt, ist schlimmer als keine. - var calibrationState: Bool? { - guard let pitchOffset, let rollOffset else { return nil } - return abs(pitchOffset) > 0.001 || abs(rollOffset) > 0.001 - } - - /// Nur wahr, wenn es auch belegt ist. - var isCalibrated: Bool { calibrationState == true } - - /// Nur wahr, wenn das Gerät ausdrücklich Nulloffsets meldet. - var isKnownUncalibrated: Bool { calibrationState == false } - - /// Bis hierhin gilt das Fahrzeug als eben genug. - static let levelTolerance = 0.5 - - var isLevel: Bool { - guard let pitch, let roll else { return false } - return abs(pitch) <= Self.levelTolerance && abs(roll) <= Self.levelTolerance - } - - /// Die grössere der beiden Abweichungen – das ist die, die man zuerst - /// ausgleichen will. - var largestDeviation: Double? { - switch (pitch, roll) { - case let (p?, r?): return max(abs(p), abs(r)) - case let (p?, nil): return abs(p) - case let (nil, r?): return abs(r) - default: return nil - } - } - - /// Was zu tun ist, in Worten. Beim Ausrichten schaut man aufs Handy, - /// nicht auf Vorzeichen. - var instruction: String? { - guard let pitch, let roll else { return nil } - if isLevel { return "Steht eben" } - var parts: [String] = [] - if abs(pitch) > Self.levelTolerance { - parts.append(pitch > 0 ? "Heck steht höher" : "Front steht höher") - } - if abs(roll) > Self.levelTolerance { - parts.append(roll > 0 ? "rechts steht höher" : "links steht höher") - } - return parts.joined(separator: ", ") - } - - func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot { - var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi) - snapshot.metrics = [ - Metric("pitch", "Längsneigung", pitch, unit: "°", precision: 1, primary: true), - Metric("roll", "Querneigung", roll, unit: "°", precision: 1), - ] - snapshot.state = instruction - if isKnownUncalibrated { - snapshot.offReasons = ["Nicht kalibriert"] - } - return snapshot - } -} diff --git a/CamperMonitor/CamperMonitorApp.swift b/CamperMonitor/CamperMonitorApp.swift index 453c23c..bf4eacc 100644 --- a/CamperMonitor/CamperMonitorApp.swift +++ b/CamperMonitor/CamperMonitorApp.swift @@ -4,12 +4,15 @@ import SwiftUI struct CamperMonitorApp: App { @State private var store: DeviceStore @State private var bluetooth: BluetoothManager + @State private var watch: PhoneWatchLink @Environment(\.scenePhase) private var scenePhase init() { let store = DeviceStore() + let bluetooth = BluetoothManager(store: store) _store = State(initialValue: store) - _bluetooth = State(initialValue: BluetoothManager(store: store)) + _bluetooth = State(initialValue: bluetooth) + _watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth)) } var body: some Scene { @@ -17,14 +20,27 @@ struct CamperMonitorApp: App { DashboardView() .environment(store) .environment(bluetooth) + .environment(watch) + .task { watch.activate() } } .onChange(of: scenePhase) { _, phase in // Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt // werden, also Funk sparen und beim Zurückkommen neu starten. + // + // Ausnahme: Solange jemand auf die Uhr schaut, läuft es weiter. Beim + // Rangieren liegt das iPhone in der Halterung mit dunklem Bildschirm + // – hörte das Funkgerät dann auf, wäre die Anzeige am Handgelenk + // genau dann tot, wenn sie gebraucht wird. Die verbundenen Geräte + // (Neigungsmesser, BMS, Kühlbox) liefern im Hintergrund weiter; die + // Victron-Werbedaten nicht, dafür müsste ohne Filter gesucht werden. + watch.isAppInBackground = phase != .active switch phase { - case .active: bluetooth.start() - case .background: bluetooth.stop() - default: break + case .active: + bluetooth.start() + case .background: + if !watch.wantsLiveUpdates { bluetooth.stop() } + default: + break } } } diff --git a/CamperMonitor/Views/SettingsView.swift b/CamperMonitor/Views/SettingsView.swift index f3c63c3..aeb5b44 100644 --- a/CamperMonitor/Views/SettingsView.swift +++ b/CamperMonitor/Views/SettingsView.swift @@ -4,6 +4,7 @@ import SwiftUI /// betrifft, steht bei diesem Gerät. struct SettingsView: View { @AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false + @Environment(PhoneWatchLink.self) private var watch @Environment(\.dismiss) private var dismiss var body: some View { @@ -21,6 +22,16 @@ struct SettingsView: View { + "Meldet ein Gerät einen Fehler, werden die Angaben ohnehin " + "eingeblendet, auch wenn das hier ausgeschaltet ist.") } + + Section { + LabeledContent("Apple Watch", value: watch.statusText) + } footer: { + Text("Die Uhr zeigt die Werte dieses iPhones und steuert die " + + "Kühlbox darüber – sie funkt nicht selbst zu den Geräten. " + + "Dafür muss diese App laufen; im Hintergrund liefern " + + "Neigungsmesser, BMS und Kühlbox weiter, die " + + "Victron-Werbedaten erst wieder im Vordergrund.") + } } .navigationTitle("Einstellungen") .navigationBarTitleDisplayMode(.inline) diff --git a/CamperMonitor/Watch/PhoneWatchLink.swift b/CamperMonitor/Watch/PhoneWatchLink.swift new file mode 100644 index 0000000..ae2c29c --- /dev/null +++ b/CamperMonitor/Watch/PhoneWatchLink.swift @@ -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) + } +} diff --git a/CamperMonitorWatch/Assets.xcassets/AccentColor.colorset/Contents.json b/CamperMonitorWatch/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..965ee4d --- /dev/null +++ b/CamperMonitorWatch/Assets.xcassets/AccentColor.colorset/Contents.json @@ -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 } +} diff --git a/CamperMonitorWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/CamperMonitorWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..554608c Binary files /dev/null and b/CamperMonitorWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/CamperMonitorWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/CamperMonitorWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..5651d64 --- /dev/null +++ b/CamperMonitorWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,11 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/CamperMonitorWatch/Assets.xcassets/Contents.json b/CamperMonitorWatch/Assets.xcassets/Contents.json new file mode 100644 index 0000000..46cba7d --- /dev/null +++ b/CamperMonitorWatch/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info" : { "author" : "xcode", "version" : 1 } } diff --git a/CamperMonitorWatch/CamperWatchApp.swift b/CamperMonitorWatch/CamperWatchApp.swift new file mode 100644 index 0000000..240b0c0 --- /dev/null +++ b/CamperMonitorWatch/CamperWatchApp.swift @@ -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) + } + } +} diff --git a/CamperMonitorWatch/PhoneLink.swift b/CamperMonitorWatch/PhoneLink.swift new file mode 100644 index 0000000..6511a39 --- /dev/null +++ b/CamperMonitorWatch/PhoneLink.swift @@ -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)) } + } + } +} diff --git a/CamperMonitorWatch/Views/WatchAlignView.swift b/CamperMonitorWatch/Views/WatchAlignView.swift new file mode 100644 index 0000000..1ecd1d9 --- /dev/null +++ b/CamperMonitorWatch/Views/WatchAlignView.swift @@ -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 + } + } +} diff --git a/CamperMonitorWatch/Views/WatchBubble.swift b/CamperMonitorWatch/Views/WatchBubble.swift new file mode 100644 index 0000000..65a9a87 --- /dev/null +++ b/CamperMonitorWatch/Views/WatchBubble.swift @@ -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) + } +} diff --git a/CamperMonitorWatch/Views/WatchDeviceDetailView.swift b/CamperMonitorWatch/Views/WatchDeviceDetailView.swift new file mode 100644 index 0000000..baa7ccc --- /dev/null +++ b/CamperMonitorWatch/Views/WatchDeviceDetailView.swift @@ -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") + } +} diff --git a/CamperMonitorWatch/Views/WatchFridgeControls.swift b/CamperMonitorWatch/Views/WatchFridgeControls.swift new file mode 100644 index 0000000..b276cc5 --- /dev/null +++ b/CamperMonitorWatch/Views/WatchFridgeControls.swift @@ -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) + } +} diff --git a/CamperMonitorWatch/Views/WatchLevelView.swift b/CamperMonitorWatch/Views/WatchLevelView.swift new file mode 100644 index 0000000..8572d23 --- /dev/null +++ b/CamperMonitorWatch/Views/WatchLevelView.swift @@ -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: ", ") + } +} diff --git a/CamperMonitorWatch/Views/WatchRootView.swift b/CamperMonitorWatch/Views/WatchRootView.swift new file mode 100644 index 0000000..daa065c --- /dev/null +++ b/CamperMonitorWatch/Views/WatchRootView.swift @@ -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 + } + } +} diff --git a/CamperMonitorWatch/WatchDemo.swift b/CamperMonitorWatch/WatchDemo.swift new file mode 100644 index 0000000..8b254ad --- /dev/null +++ b/CamperMonitorWatch/WatchDemo.swift @@ -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)), + ]) + } +} diff --git a/Config/CamperMonitor-Info.plist b/Config/CamperMonitor-Info.plist new file mode 100644 index 0000000..d7bc36e --- /dev/null +++ b/Config/CamperMonitor-Info.plist @@ -0,0 +1,19 @@ + + + + + + UIBackgroundModes + + bluetooth-central + + + diff --git a/README.md b/README.md index 912793e..7309635 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest: Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt. +Dazu gehört eine **Apple-Watch-App**: Übersicht, Nivellierung samt +Ausrichtungs-Assistent mit Vibration und die Steuerung der Kühlbox am +Handgelenk. Sie funkt nicht selbst, sondern hängt am iPhone – warum, steht +unter [Apple Watch](#apple-watch). + Die Firmware des Neigungsmessers liegt mit im Projekt, siehe [firmware/vanalign](firmware/vanalign/README.md). @@ -25,8 +30,21 @@ Dann in Xcode: 1. Target `CamperMonitor` → **Signing & Capabilities** → dein Apple-Team auswählen. Die Bundle-ID `de.fritob.CamperMonitor` ggf. anpassen, falls sie schon vergeben ist. + Dasselbe beim Target `CamperMonitorWatch`; dessen Bundle-ID muss die des + iPhones mit angehängtem `.watchkitapp` bleiben. 2. iPhone per Kabel anschließen, oben als Ziel wählen, ⌘R. +Das Schema `CamperMonitor` baut die Watch-App mit und bettet sie ein. Dafür +muss die watchOS-Plattform in Xcode installiert sein – sonst bricht schon das +Übersetzen des Symbolkatalogs ab: + +```bash +xcodebuild -downloadPlatform watchOS +``` + +Auf die Uhr kommt die App danach von selbst: Sie steckt in der iPhone-App und +taucht in der Watch-App des iPhones unter *Verfügbare Apps* auf. + Wichtig: **Der Simulator hat kein Bluetooth.** Die App startet dort, findet aber nie ein Gerät. Zum Testen muss sie auf ein echtes iPhone. @@ -191,6 +209,57 @@ damit exakt: 2,0° Querneigung bei 2,00 m Spurweite ergeben 7,0 cm unter die tieferstehende Seite. Die Maße stehen im Fahrzeugprofil, erreichbar über das ⓘ in der Fahrzeugliste. +## Apple Watch + +Die Uhr zeigt dasselbe wie das Dashboard, nur auf das eingedampft, wofür man +den Arm hebt: + +* **Übersicht** – je Gerät der Hauptwert und ein Punkt für den Zustand der + Verbindung. Ganz unten steht, wie alt der Stand ist. +* **Nivellierung** – Libelle, beide Winkel, was zu tun ist, und die nötige + Keilhöhe, falls im Fahrzeugprofil Spurweite und Radstand stehen. +* **Ausrichten** – derselbe Assistent wie am iPhone: Tendenz, Rat und der + Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das + Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn + gegenüber dem iPhone – beim Rangieren schaut niemand aufs Display, aber die + Vibration am Handgelenk kommt an. +* **Kühlbox** – ein/aus, Eco oder Max und die Solltemperatur, über die Krone + gestellt. + +### Warum die Uhr nicht selbst funkt + +watchOS könnte Bluetooth, und trotzdem geht alles über das iPhone. Zwei Gründe, +beide hart: + +* BMS, Kühlbox und Neigungsmesser lassen jeweils nur **eine** Verbindung zu. + Eine mitlesende Uhr würde dem iPhone die Verbindung wegnehmen, statt sie zu + ergänzen. +* Die Victron-Schlüssel liegen in der Keychain des iPhones. Sie auf die Uhr zu + kopieren hiesse, sie ein zweites Mal aufzubewahren, ohne dass der zweite Ort + irgendetwas brächte. + +Das iPhone bleibt also das Funkgerät und reicht fertige Messwerte weiter. Was +die Uhr stellt, stellt in Wahrheit das iPhone – und angezeigt wird auch dort +nur, was das Gerät zurückmeldet, nicht der Tastendruck. + +### Was das für den Betrieb heisst + +Auf dem iPhone muss Camper Monitor laufen. Solange die Uhr meldet, dass jemand +hinschaut, hält die App das Funkgerät auch im Hintergrund am Leben – sonst wäre +die Anzeige am Handgelenk beim Rangieren tot, denn dann liegt das iPhone mit +dunklem Bildschirm in der Halterung. Die verbundenen Geräte liefern dabei +weiter; die Victron-Werbedaten nicht, weil iOS im Hintergrund kein ungefiltertes +Suchen erlaubt. + +Gesendet wird in zwei Takten: ein halber Sekundentakt, solange die Watch-App im +Vordergrund ist, sonst alle zwei Sekunden und nur bei Änderungen. Der schnelle +Takt läuft über eine Frist, die die Uhr regelmässig erneuert – schläft sie ein, +hört das iPhone von selbst wieder auf. + +Was die Uhr zeigt, ist immer Weitergereichtes. Deshalb steht auf jedem +Bildschirm das Alter des Standes: Eine abgerissene Strecke zum iPhone sähe sonst +genauso aus wie ein Fahrzeug, an dem sich nichts tut. + ## Diagnose Im Alltag zeigt die App nur Messwerte und Bedienelemente. Die technischen @@ -218,6 +287,10 @@ ohne Bluetooth prüfen lassen. In Xcode unter *Product → Scheme → Edit Schem Run → Arguments* die Umgebungsvariable `CAMPER_DEMO` auf `1` setzen. Er greift nur in Debug-Builds. +Die Watch-App kennt denselben Schalter und braucht dann kein iPhone: Im Schema +`CamperMonitorWatch` dieselbe Variable setzen. Die Neigung wandert dort +langsam hin und her, sonst hätte der Ausrichtungs-Assistent nichts zu zeigen. + ## Protokolle prüfen ```bash @@ -235,14 +308,19 @@ firmware/vanalign/ Firmware des Neigungsmessers (ESPHome) ├── esp32_ble.yaml Neigungsmessung und Bluetooth-Schnittstelle └── experimente/ Nicht für den Betrieb nötig -CamperMonitor/ +Shared/ In iPhone- und Watch-App übersetzt ├── Models/ │ ├── Profile.swift Fahrzeug samt Maßen │ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilberechnung │ ├── SensorOrientation.swift Einbaulage und ihre Erkennung │ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart │ ├── DeviceSnapshot.swift Messwerte in Anzeigeform +│ ├── LevelState.swift Neigung, Toleranz und Klartext dazu │ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes +└── WatchLink/ + └── WatchLink.swift Datensatz und Befehle zwischen iPhone und Uhr + +CamperMonitor/ ├── Bluetooth/ │ ├── BluetoothManager.swift Zentraler Scan, Verbindungen, Verlauf │ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten @@ -259,6 +337,8 @@ CamperMonitor/ ├── Store/ │ ├── DeviceStore.swift Geräteliste, Persistenz │ └── KeychainStore.swift Victron-Schlüssel +├── Watch/ +│ └── PhoneWatchLink.swift Sendet den Stand, nimmt Befehle der Uhr an └── Views/ ├── DashboardView.swift Kachelübersicht ├── DeviceCard.swift Eine Kachel @@ -271,13 +351,34 @@ CamperMonitor/ ├── SettingsView.swift App-Einstellungen ├── SensorSetupView.swift Assistent für die Einbaulage └── AddDeviceView.swift Scannen und Einrichten + +CamperMonitorWatch/ watchOS-App +├── CamperWatchApp.swift Einstieg, meldet dem iPhone das Hinschauen +├── PhoneLink.swift Gegenstelle zum iPhone +├── WatchDemo.swift Erfundene Werte für den Simulator +└── Views/ + ├── WatchRootView.swift Übersicht und Altersangabe + ├── WatchBubble.swift Libelle für den kleinen Bildschirm + ├── WatchLevelView.swift Neigung, Keilhöhe, Kalibrierung + ├── WatchAlignView.swift Ausrichten mit Vibration + ├── WatchDeviceDetailView.swift Alle Werte eines Geräts + └── WatchFridgeControls.swift Kühlbox stellen + +Config/ +└── CamperMonitor-Info.plist Nur der Hintergrundbetrieb für die Uhr ``` ## Bekannte Grenzen -* **Kein Hintergrundbetrieb.** iOS erlaubt das ungefilterte Scannen nach - Advertisements nur im Vordergrund. Die App pausiert, sobald sie in den - Hintergrund geht, und nimmt beim Zurückkommen wieder auf. +* **Im Hintergrund nur die verbundenen Geräte.** iOS erlaubt das ungefilterte + Scannen nach Advertisements nur im Vordergrund; die Victron-Werte stehen im + Hintergrund also still. Verbundene Geräte – Neigungsmesser, BMS, Kühlbox – + laufen weiter, aber nur solange die Uhr meldet, dass jemand hinschaut. Ohne + Uhr pausiert die App im Hintergrund vollständig und nimmt beim Zurückkommen + wieder auf. +* **Die Uhr braucht das iPhone.** Sie zeigt nur, was von dort kommt. Steckt das + iPhone ausser Reichweite, steht auf der Uhr, wie alt der letzte Stand ist – + aber kein neuer. * **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel diff --git a/CamperMonitor/Models/AlignmentAssistant.swift b/Shared/Models/AlignmentAssistant.swift similarity index 100% rename from CamperMonitor/Models/AlignmentAssistant.swift rename to Shared/Models/AlignmentAssistant.swift diff --git a/CamperMonitor/Models/ConfiguredDevice.swift b/Shared/Models/ConfiguredDevice.swift similarity index 100% rename from CamperMonitor/Models/ConfiguredDevice.swift rename to Shared/Models/ConfiguredDevice.swift diff --git a/CamperMonitor/Models/DeviceSnapshot.swift b/Shared/Models/DeviceSnapshot.swift similarity index 91% rename from CamperMonitor/Models/DeviceSnapshot.swift rename to Shared/Models/DeviceSnapshot.swift index 80b01ef..ffe9de6 100644 --- a/CamperMonitor/Models/DeviceSnapshot.swift +++ b/Shared/Models/DeviceSnapshot.swift @@ -1,7 +1,7 @@ import Foundation /// Eine einzelne Messgrösse in einer bereits formatierten Form. -struct Metric: Identifiable, Hashable, Sendable { +struct Metric: Identifiable, Hashable, Codable, Sendable { let key: String let label: String let value: Double? @@ -39,7 +39,7 @@ struct Metric: Identifiable, Hashable, Sendable { } /// Der komplette, zuletzt empfangene Zustand eines Geräts. -struct DeviceSnapshot: Identifiable, Sendable { +struct DeviceSnapshot: Identifiable, Codable, Equatable, Sendable { var id: UUID { deviceID } var deviceID: UUID var timestamp: Date @@ -58,7 +58,7 @@ struct DeviceSnapshot: Identifiable, Sendable { /// Feste Angaben des Geräts, etwa Modell oder Seriennummer. var info: [InfoItem] = [] - struct InfoItem: Identifiable, Hashable, Sendable { + struct InfoItem: Identifiable, Hashable, Codable, Sendable { let label: String let value: String var id: String { label } @@ -76,7 +76,7 @@ struct DeviceSnapshot: Identifiable, Sendable { } /// Verbindungszustand für die UI. -enum DeviceLinkState: Equatable, Sendable { +enum DeviceLinkState: Equatable, Codable, Sendable { case idle case searching case connecting diff --git a/Shared/Models/LevelState.swift b/Shared/Models/LevelState.swift new file mode 100644 index 0000000..688a9cb --- /dev/null +++ b/Shared/Models/LevelState.swift @@ -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 + } +} diff --git a/CamperMonitor/Models/Profile.swift b/Shared/Models/Profile.swift similarity index 100% rename from CamperMonitor/Models/Profile.swift rename to Shared/Models/Profile.swift diff --git a/CamperMonitor/Models/SensorOrientation.swift b/Shared/Models/SensorOrientation.swift similarity index 100% rename from CamperMonitor/Models/SensorOrientation.swift rename to Shared/Models/SensorOrientation.swift diff --git a/CamperMonitor/Models/VictronCodes.swift b/Shared/Models/VictronCodes.swift similarity index 100% rename from CamperMonitor/Models/VictronCodes.swift rename to Shared/Models/VictronCodes.swift diff --git a/Shared/WatchLink/WatchLink.swift b/Shared/WatchLink/WatchLink.swift new file mode 100644 index 0000000..5b5fd86 --- /dev/null +++ b/Shared/WatchLink/WatchLink.swift @@ -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 { + 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 + }() +} diff --git a/Tests/main.swift b/Tests/main.swift index cc5ac8a..a05cb44 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -949,5 +949,142 @@ let missing = SensorOrientation.identity.apply(pitch: nil, roll: 2) checkEqual("fehlende Werte bleiben leer", missing.pitch == nil, true) checkEqual("vorhandene Werte kommen durch", missing.roll, 2) +// MARK: 13 – Strecke zur Apple Watch + +print("\nApple Watch – Datensatz und Befehle") + +let watchProfile = Profile(name: "Kastenwagen", symbol: "box.truck", + trackWidth: 1.85, wheelbase: 3.50) +let watchLevelID = UUID() +let watchFridgeID = UUID() +let watchBoosterID = UUID() + +var watchLevel = LevelState() +watchLevel.pitch = 1.8 +watchLevel.roll = -0.9 +watchLevel.pitchOffset = 0.4 +watchLevel.rollOffset = -0.2 + +// Feste Zeitstempel, damit der Vergleich unten wirklich den Inhalt prüft: über +// die Strecke bleibt die Millisekunde stehen, ein `Date()` wäre feiner. +let watchStamp = Date(timeIntervalSince1970: 1_699_999_999) + +var watchLevelSnapshot = watchLevel.snapshot(deviceID: watchLevelID, rssi: -70) +watchLevelSnapshot.timestamp = watchStamp + +var boosterSnapshot = DeviceSnapshot(deviceID: watchBoosterID, timestamp: watchStamp, rssi: -71) +boosterSnapshot.state = "Aus" +boosterSnapshot.offReasons = ["Keine Eingangsspannung"] +boosterSnapshot.metrics = [ + Metric("output_voltage", "Ausgang", 14.09, unit: "V", precision: 2, primary: true), + Metric("input_voltage", "Eingang", 12.42, unit: "V", precision: 2), +] + +let watchFridge = WatchFridge(isPoweredOn: true, isEco: true, isLocked: false, + isDualZone: false, unitSymbol: "°C", + leftTarget: 4, leftCurrent: 6, + rightTarget: nil, rightCurrent: nil, + minTarget: -20, maxTarget: 20, batteryVolts: 12.7) + +let watchPayload = WatchPayload( + generatedAt: Date(timeIntervalSince1970: 1_700_000_000), + profile: watchProfile, + isRadioReady: true, + radioStatus: "Bereit", + devices: [ + WatchDevice(id: watchLevelID, name: "Nivellierung", role: .leveling, + link: .live, snapshot: watchLevelSnapshot, + level: watchLevel, fridge: nil), + WatchDevice(id: watchBoosterID, name: "Ladebooster", role: .chargeBooster, + link: .failed("Nicht gefunden"), snapshot: boosterSnapshot, + level: nil, fridge: nil), + WatchDevice(id: watchFridgeID, name: "Kühlbox", role: .fridge, + link: .live, snapshot: nil, level: nil, fridge: watchFridge), + ]) + +// Der ganze Weg: verpacken, als WatchConnectivity-Wörterbuch lesen, vergleichen. +if let message = try? watchPayload.message(), + let restored = WatchPayload.decode(from: message) { + checkEqual("Datensatz kommt unverändert an", restored, watchPayload) + checkEqual("Zeitstempel überlebt das Verpacken", + restored.generatedAt.timeIntervalSince1970, 1_700_000_000) + checkEqual("Fehlerklartext bleibt am Gerät", + restored.devices[1].link, .failed("Nicht gefunden")) + checkEqual("Neigung wird gefunden", restored.levelDevice?.id, watchLevelID) + checkEqual("Kühlbox wird gefunden", restored.fridgeDevice?.id, watchFridgeID) + checkEqual("Sollwert der Box überlebt", restored.fridgeDevice?.fridge?.leftTarget, 4) + checkEqual("Fahrzeugmasse kommen mit", restored.profile.trackWidth, 1.85) +} else { + check("Datensatz lässt sich verpacken und wieder lesen", false) +} + +// Eine Uhr mit älterer App darf einen neueren Datensatz nicht halb verstehen. +var futurePayload = watchPayload +futurePayload.version = WatchPayload.currentVersion + 1 +if let message = try? futurePayload.message() { + checkEqual("fremde Formatversion wird abgelehnt", + WatchPayload.decode(from: message) == nil, true) +} else { + check("fremde Formatversion lässt sich verpacken", false) +} + +// Zeitpunkte reisen als Millisekunden seit 1970 in einer Fliesskommazahl. Auf +// die Millisekunde genau kommen sie zurück, aufs letzte Bit nicht – deshalb +// vergleicht der Test oben feste Zeitstempel und nicht ein `Date()`. +var preciseSnapshot = boosterSnapshot +preciseSnapshot.timestamp = Date(timeIntervalSince1970: 1_699_999_999.123_456) +var precisePayload = watchPayload +precisePayload.devices[1].snapshot = preciseSnapshot +if let message = try? precisePayload.message(), + let restored = WatchPayload.decode(from: message)?.devices[1].snapshot?.timestamp { + checkEqual("feiner Zeitstempel kommt auf die Millisekunde genau zurück", + abs(restored.timeIntervalSince1970 - 1_699_999_999.123_456) < 0.001, true) +} else { + check("Datensatz mit feinem Zeitstempel lässt sich verpacken", false) +} + +checkEqual("leeres Wörterbuch ergibt keinen Datensatz", + WatchPayload.decode(from: [:]) == nil, true) +checkEqual("Unsinn im Nutzdatenfeld ergibt keinen Datensatz", + WatchPayload.decode(from: [WatchLink.payloadKey: Data([0x00, 0x01])]) == nil, true) + +// Der Vergleich entscheidet, ob überhaupt gefunkt wird: der Zeitstempel allein +// darf keinen Funkverkehr auslösen, ein geänderter Messwert schon. +var laterPayload = watchPayload +laterPayload.generatedAt = watchPayload.generatedAt.addingTimeInterval(30) +checkEqual("gleicher Inhalt, neuer Zeitstempel gilt als unverändert", + watchPayload.hasSameContent(as: laterPayload), true) + +var changedPayload = laterPayload +changedPayload.devices[2].fridge?.leftTarget = 5 +checkEqual("geänderter Sollwert gilt als Änderung", + watchPayload.hasSameContent(as: changedPayload), false) + +// Befehle in die Gegenrichtung. +let commands: [WatchCommand] = [ + .hello(live: true), + .hello(live: false), + .fridgePower(device: watchFridgeID, on: false), + .fridgeEco(device: watchFridgeID, eco: true), + .fridgeLock(device: watchFridgeID, locked: true), + .fridgeTarget(device: watchFridgeID, zone: .right, value: -3), + .calibrateLevel(device: watchLevelID), +] +for command in commands { + guard let message = try? command.message(), + let restored = WatchCommand.decode(from: message) else { + check("Befehl \(command) lässt sich verpacken", false) + continue + } + checkEqual("Befehl kommt unverändert an", restored, command) +} + +checkEqual("Anmeldung ohne Hinschauen verlängert den schnellen Takt nicht", + WatchCommand.hello(live: false).wantsLiveUpdates, false) +checkEqual("wer stellt, schaut auch hin", + WatchCommand.fridgePower(device: watchFridgeID, on: true).wantsLiveUpdates, true) +checkEqual("ein Datensatz ist kein Befehl", + WatchCommand.decode(from: (try? watchPayload.message()) ?? [:]) == nil, true) + print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.") exit(failures == 0 ? 0 : 1) diff --git a/run-tests.sh b/run-tests.sh index b4d227b..0bf8401 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -14,12 +14,14 @@ swiftc -O -o "$OUT/tests" \ CamperMonitor/Bluetooth/WattCycleProtocol.swift \ CamperMonitor/Bluetooth/AlpicoolProtocol.swift \ CamperMonitor/Bluetooth/VanAlignProtocol.swift \ - CamperMonitor/Models/DeviceSnapshot.swift \ - CamperMonitor/Models/ConfiguredDevice.swift \ - CamperMonitor/Models/Profile.swift \ - CamperMonitor/Models/AlignmentAssistant.swift \ - CamperMonitor/Models/SensorOrientation.swift \ - CamperMonitor/Models/VictronCodes.swift \ + Shared/Models/DeviceSnapshot.swift \ + Shared/Models/ConfiguredDevice.swift \ + Shared/Models/LevelState.swift \ + Shared/Models/Profile.swift \ + Shared/Models/AlignmentAssistant.swift \ + Shared/Models/SensorOrientation.swift \ + Shared/Models/VictronCodes.swift \ + Shared/WatchLink/WatchLink.swift \ CamperMonitor/Store/KeychainStore.swift \ Tests/main.swift "$OUT/tests"