diff --git a/CamperMonitor.xcodeproj/project.pbxproj b/CamperMonitor.xcodeproj/project.pbxproj index b555e2b..35bbf90 100644 --- a/CamperMonitor.xcodeproj/project.pbxproj +++ b/CamperMonitor.xcodeproj/project.pbxproj @@ -57,8 +57,8 @@ 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 = ""; }; - AA0000000000000000000045 /* CamperMonitorComplication-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitorComplication-Info.plist"; sourceTree = ""; }; AA0000000000000000000032 /* CamperMonitorComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CamperMonitorComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + AA0000000000000000000045 /* CamperMonitorComplication-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitorComplication-Info.plist"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -72,16 +72,16 @@ path = CamperMonitorWatch; sourceTree = ""; }; - AA0000000000000000000033 /* CamperMonitorComplication */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = CamperMonitorComplication; - sourceTree = ""; - }; AA0000000000000000000018 /* Shared */ = { isa = PBXFileSystemSynchronizedRootGroup; path = Shared; sourceTree = ""; }; + AA0000000000000000000033 /* CamperMonitorComplication */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CamperMonitorComplication; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -202,8 +202,8 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( - AA0000000000000000000033 /* CamperMonitorComplication */, AA0000000000000000000018 /* Shared */, + AA0000000000000000000033 /* CamperMonitorComplication */, ); name = CamperMonitorComplication; productName = CamperMonitorComplication; @@ -497,7 +497,7 @@ }; name = Release; }; - AA0000000000000000000038 = { + AA0000000000000000000038 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; @@ -505,7 +505,7 @@ DEVELOPMENT_TEAM = 6AP73NYV5W; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "Nivellierung"; + INFOPLIST_KEY_CFBundleDisplayName = Nivellierung; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -517,16 +517,16 @@ SDKROOT = watchos; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; WATCHOS_DEPLOYMENT_TARGET = 10.0; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; - AA0000000000000000000039 = { + AA0000000000000000000039 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; @@ -534,7 +534,7 @@ DEVELOPMENT_TEAM = 6AP73NYV5W; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "Nivellierung"; + INFOPLIST_KEY_CFBundleDisplayName = Nivellierung; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -546,12 +546,12 @@ 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; - WATCHOS_DEPLOYMENT_TARGET = 10.0; - SWIFT_COMPILATION_MODE = wholemodule; VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Release; }; diff --git a/CamperMonitor/Bluetooth/AlpicoolProtocol.swift b/CamperMonitor/Bluetooth/AlpicoolProtocol.swift index ed24f0b..3f76e29 100644 --- a/CamperMonitor/Bluetooth/AlpicoolProtocol.swift +++ b/CamperMonitor/Bluetooth/AlpicoolProtocol.swift @@ -232,6 +232,19 @@ struct AlpicoolState: Equatable { } } + /// Der Stand, der die Verbindung überdauert – ohne Messwerte. + var settings: FridgeSettings? { + guard hasStatus else { return nil } + return FridgeSettings(isPoweredOn: isPoweredOn, + isEco: isEco, + isLocked: isLocked, + isDualZone: isDualZone, + usesFahrenheit: usesFahrenheit, + leftTarget: leftTarget, + rightTarget: isDualZone ? rightTarget : nil, + updated: Date()) + } + /// Die Bytes, die ein Stellbefehl ändert. /// /// Messwerte gehören nicht dazu: Temperatur und Spannung schwanken diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index 89caeca..2c2f77b 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -178,6 +178,11 @@ final class BluetoothManager: NSObject { } private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID + /// Geräte, die nur verbunden werden, solange ihre Ansicht offen ist. + /// Schlüssel ist die Gerätekennung, gezählt wird nicht – eine Ansicht ist + /// zu einer Zeit offen. + private var requestedSessions: Set = [] + private var bmsSessions: [UUID: BMSSession] = [:] private var levelSessions: [UUID: LevelSession] = [:] private var connectedPeripherals: [UUID: CBPeripheral] = [:] @@ -298,6 +303,14 @@ final class BluetoothManager: NSObject { fridgeStates = fridgeStates.filter { known.contains($0.key) } levelStates = levelStates.filter { known.contains($0.key) } + // Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin + // ihren zuletzt gestellten Stand. + for device in store.activeDevices where device.role.connectsOnDemand { + guard linkStates[device.id] != .live else { continue } + linkStates[device.id] = .idle + showLastKnownFridgeSettings(for: device.id) + } + queue.async { self.applyConfiguration(devices) } } @@ -309,6 +322,57 @@ final class BluetoothManager: NSObject { } } + // MARK: - Verbindung auf Anforderung (vom Hauptthread aufgerufen) + + /// Verbindet ein Gerät, das sonst unverbunden bleibt – solange seine + /// Ansicht offen ist. + /// + /// Für die Kühlbox: Jede Verbindung meldet sich an ihrem Display an, manche + /// Modelle verlangen dabei sogar einen Tastendruck. Dauerhaft verbunden zu + /// sein ist dort also nicht unsichtbar, sondern lästig. + func beginSession(for device: ConfiguredDevice) { + guard device.role.connectsOnDemand else { return } + let deviceID = device.id + queue.async { + self.requestedSessions.insert(deviceID) + guard let entry = self.managed.values.first(where: { $0.id == deviceID }) else { return } + // Der Wunsch des Benutzers hebt die Wartesperre auf. + self.clearBackOff(entry.peripheralID) + self.connectIfNeeded(entry) + } + } + + /// Gibt das Gerät wieder frei. Der zuletzt gemeldete Stand bleibt in der + /// Übersicht stehen, damit dort nicht plötzlich eine Lücke klafft. + func endSession(for device: ConfiguredDevice) { + guard device.role.connectsOnDemand else { return } + let deviceID = device.id + queue.async { + self.requestedSessions.remove(deviceID) + guard let entry = self.managed.values.first(where: { $0.id == deviceID }) else { return } + self.bmsSessions[entry.peripheralID]?.stop() + self.bmsSessions[entry.peripheralID] = nil + self.disconnect(entry.peripheralID) + self.publish { + self.linkStates[deviceID] = .idle + self.showLastKnownFridgeSettings(for: deviceID) + } + } + } + + /// Setzt an die Stelle der Live-Werte den zuletzt gestellten Stand. + /// + /// Nur auf dem Hauptthread aufrufen: greift auf den Speicher zu. + private func showLastKnownFridgeSettings(for deviceID: UUID) { + guard let settings = store.lastFridgeSettings(for: deviceID) else { + snapshots[deviceID] = nil + return + } + snapshots[deviceID] = settings.snapshot(deviceID: deviceID) + // Der Zustand bleibt stehen, damit sich die Box beim nächsten Öffnen + // sofort bedienen lässt – die Schalter zeigen dann den letzten Stand. + } + // MARK: - Kühlbox steuern (vom Hauptthread aufgerufen) func updateFridgeZoneMode(for device: ConfiguredDevice) { @@ -431,8 +495,10 @@ final class BluetoothManager: NSObject { private func applyConfiguration(_ devices: [ManagedDevice]) { managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) }) - // Verbindungen zu Geräten lösen, die nicht mehr dazugehören. - let wanted = Set(devices.filter { $0.transport == .connect }.map(\.peripheralID)) + // Verbindungen zu Geräten lösen, die nicht mehr dazugehören – und zu + // denen, die nur auf Anforderung verbunden werden und gerade niemand + // anschaut. + let wanted = Set(devices.filter { shouldStayConnected($0) }.map(\.peripheralID)) for (peripheralID, session) in bmsSessions where !wanted.contains(peripheralID) { session.stop() bmsSessions[peripheralID] = nil @@ -509,11 +575,22 @@ final class BluetoothManager: NSObject { private func connectManagedPeripherals() { guard central?.state == .poweredOn else { return } - for device in managed.values where device.transport == .connect { + for device in managed.values where shouldStayConnected(device) { connectIfNeeded(device) } } + /// Ob dieses Gerät von sich aus verbunden gehalten wird. + /// + /// Geräte auf Anforderung – die Kühlbox – nur dann, wenn ihre Ansicht + /// gerade offen ist oder ein Stellbefehl darauf wartet. + private func shouldStayConnected(_ device: ManagedDevice) -> Bool { + guard device.transport == .connect else { return false } + guard device.role.connectsOnDemand else { return true } + return requestedSessions.contains(device.id) + || pendingControls[device.peripheralID]?.isEmpty == false + } + private func connectIfNeeded(_ device: ManagedDevice) { if let existing = connectedPeripherals[device.peripheralID], existing.state == .connected || existing.state == .connecting { @@ -736,8 +813,11 @@ extension BluetoothManager: CBCentralManagerDelegate { handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi) case .connect: - // Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt aufbauen. - if connectedPeripherals[identifier] == nil { + // Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt + // aufbauen. Ausser bei Geräten auf Anforderung: Die Kühlbox piept + // bei jeder Verbindung, und dass sie in Reichweite ist, ist kein + // Grund, sie anzufassen. + if connectedPeripherals[identifier] == nil, shouldStayConnected(device) { connectIfNeeded(device) } } @@ -786,7 +866,14 @@ extension BluetoothManager: CBCentralManagerDelegate { ) session.onFridgeState = { [weak self] state in self?.fridgeStateCache[peripheral.identifier] = state - self?.publish { self?.fridgeStates[device.id] = state } + self?.publish { + self?.fridgeStates[device.id] = state + // Für die Übersicht aufheben: Sie zeigt den Stand auch dann, + // wenn längst niemand mehr verbunden ist. + if let settings = state.settings { + self?.store.setLastFridgeSettings(settings, for: device.id) + } + } } session.fridgeZoneMode = device.fridgeZoneMode bmsSessions[peripheral.identifier] = session @@ -817,7 +904,15 @@ extension BluetoothManager: CBCentralManagerDelegate { let lifetime = connectedSince.removeValue(forKey: peripheral.identifier) .map { Date().timeIntervalSince($0) } ?? 0 - if let device = managed[peripheral.identifier] { + if let device = managed[peripheral.identifier], !shouldStayConnected(device) { + // Auf Anforderung verbunden und niemand schaut mehr hin: nicht + // wieder anklopfen, sondern den letzten Stand stehenlassen. + let deviceID = device.id + publish { + self.linkStates[deviceID] = .idle + self.showLastKnownFridgeSettings(for: deviceID) + } + } else if let device = managed[peripheral.identifier] { publish { self.linkStates[device.id] = .searching } if lifetime >= stableConnection { // Die Verbindung stand und ist weggefallen - im Fahrzeug der diff --git a/CamperMonitor/Store/DeviceStore.swift b/CamperMonitor/Store/DeviceStore.swift index 4f6c93e..a0a0cb2 100644 --- a/CamperMonitor/Store/DeviceStore.swift +++ b/CamperMonitor/Store/DeviceStore.swift @@ -11,6 +11,7 @@ final class DeviceStore { private static let activeProfileKey = "activeProfileID" /// Hierhin wird gerettet, was sich nicht lesen liess – siehe `loadDevices`. private static let unreadableDevicesKey = "configuredDevices.unreadable" + private static let fridgeSettingsKey = "lastFridgeSettings" /// Alle Geräte über alle Profile hinweg. private(set) var devices: [ConfiguredDevice] = [] @@ -144,6 +145,36 @@ final class DeviceStore { return bytes } + // MARK: - Zuletzt bekannter Stand der Kühlbox + + /// Die Kühlbox wird nur verbunden, während ihre Ansicht offen ist. Damit + /// die Übersicht trotzdem etwas zeigt, wird der zuletzt gemeldete Stand + /// aufgehoben – die Einstellungen, nicht die Messwerte. + func lastFridgeSettings(for deviceID: UUID) -> FridgeSettings? { + allFridgeSettings()[deviceID.uuidString] + } + + /// Der Zeitstempel bedeutet „zuletzt **geändert**“, nicht „zuletzt + /// gesehen“: Sonst wanderte er im Sekundentakt und die Übersicht behauptete + /// Frische, wo sich nichts getan hat. + func setLastFridgeSettings(_ settings: FridgeSettings, for deviceID: UUID) { + var all = allFridgeSettings() + if var existing = all[deviceID.uuidString] { + existing.updated = settings.updated + guard existing != settings else { return } + } + all[deviceID.uuidString] = settings + guard let data = try? JSONEncoder().encode(all) else { return } + UserDefaults.standard.set(data, forKey: Self.fridgeSettingsKey) + } + + private func allFridgeSettings() -> [String: FridgeSettings] { + guard let data = UserDefaults.standard.data(forKey: Self.fridgeSettingsKey), + let decoded = try? JSONDecoder().decode([String: FridgeSettings].self, from: data) + else { return [:] } + return decoded + } + // MARK: - Persistenz private func load() { diff --git a/CamperMonitor/Views/DeviceCard.swift b/CamperMonitor/Views/DeviceCard.swift index fb45539..fdb8e69 100644 --- a/CamperMonitor/Views/DeviceCard.swift +++ b/CamperMonitor/Views/DeviceCard.swift @@ -76,7 +76,14 @@ struct DeviceCard: View { @ViewBuilder private var footer: some View { - if let fault = snapshot?.fault { + if device.role.connectsOnDemand, linkState != .live, let snapshot { + // Kein Messwert, sondern der zuletzt gestellte Stand. Das gehört + // dazugeschrieben, sonst liest man ihn als aktuelle Temperatur. + Text(lastSetText(snapshot)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } else if let fault = snapshot?.fault { Label(fault, systemImage: "exclamationmark.triangle.fill") .font(.caption) .foregroundStyle(.red) @@ -96,11 +103,29 @@ struct DeviceCard: View { } } + /// Was unter einem Gerät steht, das nur beim Öffnen verbunden wird. + private func lastSetText(_ snapshot: DeviceSnapshot) -> String { + let age = relativeUpdate(snapshot.timestamp) + guard let state = snapshot.state else { return "Zuletzt gestellt \(age)" } + return "\(state) · zuletzt gestellt \(age)" + } + + /// „vor 3 Minuten“ statt einer Uhrzeit – auf der Kachel zählt das Alter. + private func relativeUpdate(_ date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.locale = Locale(identifier: "de_DE") + formatter.unitsStyle = .full + return formatter.localizedString(for: date, relativeTo: Date()) + } + private var placeholderText: String { switch linkState { case .needsKey: return "Verschlüsselungsschlüssel fehlt – im Detail eintragen." case .failed(let message): return message - default: return "Warte auf Daten…" + default: + return device.role.connectsOnDemand + ? "Verbindet erst beim Öffnen." + : "Warte auf Daten…" } } } diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift index 2fb2b33..d9f98ae 100644 --- a/CamperMonitor/Views/DeviceDetailView.swift +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -114,10 +114,16 @@ struct DeviceDetailView: View { } .navigationTitle(currentDevice.name) .navigationBarTitleDisplayMode(.inline) - .onDisappear(perform: saveName) + .onDisappear { + saveName() + // Die Kühlbox wird nur verbunden, solange man sie ansieht – jede + // Verbindung meldet sich an ihrem Display an. + bluetooth.endSession(for: currentDevice) + } .onAppear { editedName = currentDevice.name keyInput = store.victronKeyText(for: device.id) ?? "" + bluetooth.beginSession(for: currentDevice) } .confirmationDialog("Gerät entfernen?", isPresented: $showDeleteConfirmation, diff --git a/CamperMonitor/Watch/PhoneWatchLink.swift b/CamperMonitor/Watch/PhoneWatchLink.swift index a178ff8..dd4fc2a 100644 --- a/CamperMonitor/Watch/PhoneWatchLink.swift +++ b/CamperMonitor/Watch/PhoneWatchLink.swift @@ -135,8 +135,7 @@ final class PhoneWatchLink: NSObject { snapshot: bluetooth.snapshots[device.id], level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil, orientation: device.role == .leveling ? device.sensorOrientation : nil, - fridge: device.role == .fridge - ? bluetooth.fridgeStates[device.id].map(WatchFridge.init) : nil) + fridge: device.role == .fridge ? fridgeState(for: device) : nil) } return WatchPayload(generatedAt: Date(), profile: profile, @@ -145,6 +144,19 @@ final class PhoneWatchLink: NSObject { devices: devices) } + /// Live, solange die Box verbunden ist – sonst der zuletzt gestellte Stand + /// aus dem Speicher, ohne Messwerte. + private func fridgeState(for device: ConfiguredDevice) -> WatchFridge? { + if linkStateIsLive(device), let state = bluetooth.fridgeStates[device.id] { + return WatchFridge(state) + } + return store.lastFridgeSettings(for: device.id).map(WatchFridge.init) + } + + private func linkStateIsLive(_ device: ConfiguredDevice) -> Bool { + bluetooth.linkStates[device.id] == .live + } + // MARK: - Befehle von der Uhr private func handle(_ command: WatchCommand) { @@ -168,6 +180,13 @@ final class PhoneWatchLink: NSObject { bluetooth.setFridgeLock(locked, for: device) case .fridgeTarget(let device, let zone, let value): bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device) + case .fridgeSession(let deviceID, let wanted): + guard let device = store.devices.first(where: { $0.id == deviceID }) else { break } + if wanted { + bluetooth.beginSession(for: device) + } else { + bluetooth.endSession(for: device) + } } } } @@ -250,7 +269,9 @@ extension WatchFridge { /// stellt – der Rohdatensatz bleibt auf dem iPhone. init(_ state: AlpicoolState) { let range = state.targetRange - self.init(isPoweredOn: state.isPoweredOn, + self.init(isLive: true, + updated: Date(), + isPoweredOn: state.isPoweredOn, isEco: state.isEco, isLocked: state.isLocked, isDualZone: state.isDualZone, diff --git a/CamperMonitorWatch/Views/WatchDeviceDetailView.swift b/CamperMonitorWatch/Views/WatchDeviceDetailView.swift index baa7ccc..faeef04 100644 --- a/CamperMonitorWatch/Views/WatchDeviceDetailView.swift +++ b/CamperMonitorWatch/Views/WatchDeviceDetailView.swift @@ -55,5 +55,15 @@ struct WatchDeviceDetailView: View { } } .navigationTitle(device?.name ?? "Gerät") + // Die Kühlbox wird nur verbunden, solange jemand sie ansieht. Das gilt + // auch von hier aus – das iPhone verbindet auf Zuruf. + .onAppear { + guard device?.fridge != nil else { return } + link.send(.fridgeSession(device: deviceID, wanted: true)) + } + .onDisappear { + guard device?.fridge != nil else { return } + link.send(.fridgeSession(device: deviceID, wanted: false)) + } } } diff --git a/CamperMonitorWatch/Views/WatchFridgeControls.swift b/CamperMonitorWatch/Views/WatchFridgeControls.swift index b276cc5..d4916e0 100644 --- a/CamperMonitorWatch/Views/WatchFridgeControls.swift +++ b/CamperMonitorWatch/Views/WatchFridgeControls.swift @@ -40,6 +40,12 @@ struct WatchFridgeControls: View { Text("iPhone gerade nicht erreichbar – Stellbefehle gehen erst " + "wieder durch, wenn es antwortet.") .font(.caption2) + } else if !fridge.isLive { + // Kein Messwert, sondern der zuletzt gestellte Stand: Die Box + // wird erst verbunden, während man diese Ansicht offen hat. + Text("Zuletzt gestellter Stand. Die Box verbindet sich gerade – " + + "die Werte kommen gleich.") + .font(.caption2) } } } diff --git a/CamperMonitorWatch/WatchDemo.swift b/CamperMonitorWatch/WatchDemo.swift index 124354e..4394dc3 100644 --- a/CamperMonitorWatch/WatchDemo.swift +++ b/CamperMonitorWatch/WatchDemo.swift @@ -72,7 +72,8 @@ enum WatchDemo { orientation: nil, fridge: nil), WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge, link: .live, snapshot: fridge, level: nil, orientation: nil, - fridge: WatchFridge(isPoweredOn: true, isEco: true, isLocked: false, + fridge: WatchFridge(isLive: true, updated: now, + isPoweredOn: true, isEco: true, isLocked: false, isDualZone: false, unitSymbol: "°C", leftTarget: 4, leftCurrent: 6, rightTarget: nil, rightCurrent: nil, diff --git a/README.md b/README.md index c4b56d3..cab0050 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,23 @@ Beim Einrichten Art auf **Kühlbox** stellen. Die App meldet sich beim Verbinden selbst an; steht **APP** im Display der Box, verlangt sie dabei einen Tastendruck am Gerät. +**Verbunden wird nur, während die Kühlbox geöffnet ist.** Jede Verbindung +meldet sich an ihrem Display an und stört, wer gerade davorsteht; und ihre Werte +ändern sich langsam. Auf der Übersicht steht deshalb nicht die gemessene +Innentemperatur, sondern der **zuletzt gestellte Stand** – Sollwert, Ein/Aus, +Eco oder Max, dazu wann er gestellt wurde. Der bleibt richtig, auch wenn er von +gestern ist: Ein Sollwert ändert sich nur, wenn jemand ihn ändert. Eine +Innentemperatur von gestern sähe dagegen aus wie eine von jetzt. + +Sobald du die Box öffnest, verbindet die App sich und zeigt alles live; beim +Verlassen der Ansicht trennt sie wieder. Dasselbe gilt für die Uhr: Auch von +dort wird die Verbindung nur angefordert, solange die Kühlbox-Ansicht offen ist. +Ein Stellbefehl geht ebenfalls immer durch – ist die Box gerade nicht verbunden, +wird er gemerkt und geht raus, sobald sie antwortet. + +BMS und Neigungsmesser bleiben dagegen dauerhaft verbunden. Sie stören dabei +nicht, und ihre Werte will man laufend sehen. + Neben den Messwerten gibt es hier als einzigem Gerät auch Bedienelemente: Ein/Aus, Eco oder Max, Solltemperatur je Zone und die Bedienfeldsperre. Alle Schalter zeigen den Stand, den die Box zurückmeldet – nach jedem Stellbefehl @@ -263,19 +280,27 @@ den Arm hebt: * **Übersicht** – je Gerät der Hauptwert und ein Punkt für den Zustand der Verbindung. Ganz unten steht, wie alt der Stand ist. -* **Nivellierung** – wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und - Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über Starts - hinweg gemerkt. Dazu, was zu tun ist, und die Keilhöhe je Rad, falls im - Fahrzeugprofil Spurweite und Radstand stehen. Kalibriert wird nur am iPhone: - Das gehört einmalig auf ebenen Boden, und ein Knopf dafür an der Uhr wäre vor - allem eine Gelegenheit, die Nullage aus Versehen zu verstellen. +* **Nivellierung** – drei Seiten zum Wischen, weil auf diesem Bildschirm ein + Wisch besser zu treffen ist als eine Scrollposition: + + 1. *Neigung* – wahlweise als Fahrzeugansicht wie am iPhone (Seiten- und + Heckansicht, dreifach überhöht) oder als Libelle; umschaltbar und über + Starts hinweg gemerkt. Dazu, was zu tun ist, und woher die Werte kommen. + 2. *Keile* – die Höhe je Rad als Draufsicht. Fehlen Spurweite und Radstand im + Fahrzeugprofil, steht das dort statt einer leeren Seite. + 3. *Ausrichten* – der Assistent fürs Rangieren. + + Kalibriert wird nur am iPhone: Das gehört einmalig auf ebenen Boden, und ein + Knopf dafür an der Uhr wäre vor allem eine Gelegenheit, die Nullage aus + Versehen zu verstellen. * **Ausrichten** – derselbe Assistent wie am iPhone: Tendenz, Rat und der Hinweis auf den flachsten Punkt der letzten anderthalb Minuten. **Steht das Fahrzeug in der Toleranz, vibriert die Uhr.** Das ist der eigentliche Gewinn gegenüber dem iPhone – beim Rangieren schaut niemand aufs Display, aber die Vibration am Handgelenk kommt an. * **Kühlbox** – ein/aus, Eco oder Max und die Solltemperatur, über die Krone - gestellt. + gestellt. Verbunden wird die Box erst beim Öffnen dieser Ansicht, davor steht + dort der zuletzt gestellte Stand. ### Wer mit wem funkt @@ -427,7 +452,7 @@ CamperMonitorWatch/ watchOS-App ├── WatchRootView.swift Übersicht und Altersangabe ├── WatchBubble.swift Libelle für den kleinen Bildschirm ├── WatchVehicleView.swift Fahrzeugansicht auf Uhrgrösse - ├── WatchLevelView.swift Neigung, Keilhöhe, Herkunft der Werte + ├── WatchLevelView.swift Die drei Seiten: Neigung, Keile, Ausrichten ├── WatchAlignView.swift Ausrichten mit Vibration ├── WatchDeviceDetailView.swift Alle Werte eines Geräts └── WatchFridgeControls.swift Kühlbox stellen diff --git a/Shared/Models/ConfiguredDevice.swift b/Shared/Models/ConfiguredDevice.swift index b52b1c9..84349fb 100644 --- a/Shared/Models/ConfiguredDevice.swift +++ b/Shared/Models/ConfiguredDevice.swift @@ -34,6 +34,18 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable { } } + /// Ob nur verbunden wird, während die Ansicht des Geräts offen ist. + /// + /// Die Kühlbox meldet jede Verbindung an ihrem Display an und verlangt je + /// nach Modell sogar einen Tastendruck; dauerhaft verbunden zu sein ist + /// dort also nicht unsichtbar, sondern lästig. Ihre Werte ändern sich + /// ausserdem langsam, und was auf der Übersicht zählt – der Sollwert – + /// ändert sich überhaupt nur, wenn jemand ihn ändert. + /// + /// BMS und Neigungsmesser bleiben dagegen verbunden: Sie stören nicht, und + /// ihre Werte will man laufend sehen. + var connectsOnDemand: Bool { self == .fridge } + /// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS /// braucht eine echte GATT-Verbindung. var transport: DeviceTransport { diff --git a/Shared/Models/FridgeSettings.swift b/Shared/Models/FridgeSettings.swift new file mode 100644 index 0000000..ead68e1 --- /dev/null +++ b/Shared/Models/FridgeSettings.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Der zuletzt bekannte Stand einer Kühlbox – ohne Messwerte. +/// +/// Die Box wird nur noch verbunden, während man ihre Ansicht offen hat: Jede +/// Verbindung meldet sich an ihrem Display an und stört, wer sie gerade +/// bedient. Auf der Übersicht steht deshalb, was zuletzt **eingestellt** war. +/// +/// Messwerte gehören bewusst nicht dazu. Eine Innentemperatur von vorgestern +/// sähe aus wie eine von jetzt, und man würde ihr glauben – eine Solltemperatur +/// dagegen ändert sich nur, wenn jemand sie ändert. +struct FridgeSettings: Codable, Equatable, Sendable { + var isPoweredOn: Bool + var isEco: Bool + var isLocked: Bool + var isDualZone: Bool + var usesFahrenheit: Bool + var leftTarget: Int? + var rightTarget: Int? + /// Wann dieser Stand von der Box kam. + var updated: Date + + var unitSymbol: String { usesFahrenheit ? "°F" : "°C" } + + /// In Worten, wie es auf der Kachel steht. + var stateText: String { + guard isPoweredOn else { return "Aus" } + return isEco ? "Eco" : "Max" + } + + /// Was die Übersicht zeigt: die Sollwerte, sonst nichts. + func snapshot(deviceID: UUID) -> DeviceSnapshot { + var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: updated, rssi: nil) + var metrics = [ + Metric("target_left", isDualZone ? "Soll links" : "Solltemperatur", + leftTarget.map(Double.init), unit: unitSymbol, precision: 0, primary: true), + ] + if isDualZone { + metrics.append(Metric("target_right", "Soll rechts", + rightTarget.map(Double.init), unit: unitSymbol, precision: 0)) + } + snapshot.metrics = metrics + snapshot.state = stateText + if isLocked { snapshot.offReasons = ["Bedienfeld gesperrt"] } + return snapshot + } +} diff --git a/Shared/WatchLink/WatchLink.swift b/Shared/WatchLink/WatchLink.swift index 58f7001..f84dcf2 100644 --- a/Shared/WatchLink/WatchLink.swift +++ b/Shared/WatchLink/WatchLink.swift @@ -103,6 +103,14 @@ struct WatchDevice: Codable, Equatable, Identifiable, Sendable { /// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit /// drin, den auf die Uhr zu schicken nichts brächte. struct WatchFridge: Codable, Equatable, Sendable { + /// Ob die Box gerade wirklich verbunden ist. + /// + /// Sie wird nur verbunden, während jemand ihre Ansicht offen hat – jede + /// Verbindung meldet sich an ihrem Display an. Sonst stehen hier die + /// zuletzt gestellten Werte, und die Messwerte fehlen. + var isLive: Bool + /// Wann der Stand zuletzt von der Box kam. + var updated: Date var isPoweredOn: Bool var isEco: Bool var isLocked: Bool @@ -133,6 +141,9 @@ enum WatchCommand: Codable, Equatable, Sendable { case fridgeEco(device: UUID, eco: Bool) case fridgeLock(device: UUID, locked: Bool) case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int) + /// Die Kühlbox-Ansicht auf der Uhr wurde geöffnet oder geschlossen. Das + /// iPhone verbindet die Box daraufhin – oder gibt sie wieder frei. + case fridgeSession(device: UUID, wanted: Bool) /// Ob der Befehl den schnellen Sendetakt verlängert. var wantsLiveUpdates: Bool { @@ -149,6 +160,26 @@ enum WatchFridgeZone: String, Codable, Equatable, Sendable { // MARK: - Verpacken +extension WatchFridge { + /// Aus dem zuletzt gestellten Stand, ohne Messwerte. + init(_ settings: FridgeSettings) { + self.init(isLive: false, + updated: settings.updated, + isPoweredOn: settings.isPoweredOn, + isEco: settings.isEco, + isLocked: settings.isLocked, + isDualZone: settings.isDualZone, + unitSymbol: settings.unitSymbol, + leftTarget: settings.leftTarget, + leftCurrent: nil, + rightTarget: settings.rightTarget, + rightCurrent: nil, + minTarget: settings.usesFahrenheit ? -22 : -30, + maxTarget: settings.usesFahrenheit ? 68 : 20, + batteryVolts: nil) + } +} + extension WatchPayload { func encoded() throws -> Data { try WatchCoder.encoder.encode(self) } diff --git a/Tests/main.swift b/Tests/main.swift index 5ee4b74..ca211a9 100644 --- a/Tests/main.swift +++ b/Tests/main.swift @@ -698,6 +698,44 @@ checkEqual("falsche Prüfsumme wird verworfen", checkEqual("angefangenes Paket wird aufgehoben", AlpicoolProtocol.extractFrames(from: Array(fridgeStream.prefix(10))).frames.count, 0) +// MARK: 9b – Kühlbox: der Stand, der die Verbindung überdauert + +print("\nKühlbox – zuletzt gestellter Stand") + +// Derselbe Datensatz wie oben, nur diesmal auf das eingedampft, was ohne +// Verbindung noch gilt. +var boxState = AlpicoolState() +boxState.apply(AlpicoolProtocol.Frame(command: 0x01, payload: [ + 0x00, 0x01, 0x01, 0x00, 0x04, 0x14, 0xE2, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x57, 0x0C, 0x07, +])) + +guard let boxSettings = boxState.settings else { + check("Stand lässt sich aus dem Datensatz ableiten", false) + exit(1) +} +checkEqual("eingeschaltet", boxSettings.isPoweredOn, true) +checkEqual("Eco", boxSettings.isEco, true) +checkEqual("Solltemperatur", boxSettings.leftTarget, 4) +checkEqual("Einheit", boxSettings.unitSymbol, "°C") +checkEqual("in Worten", boxSettings.stateText, "Eco") + +// Die Übersicht zeigt den Sollwert – und ausdrücklich keine Innentemperatur. +let boxSnapshot = boxSettings.snapshot(deviceID: UUID()) +checkEqual("Hauptwert ist der Sollwert", boxSnapshot.primaryMetric?.key, "target_left") +checkEqual("Sollwert steht drin", value(boxSnapshot, "target_left"), 4) +checkEqual("keine gemessene Temperatur", boxSnapshot.metrics.contains { $0.key == "temp_left" }, false) +checkEqual("keine Bordspannung", boxSnapshot.metrics.contains { $0.key == "supply_voltage" }, false) + +// Ohne Antwort der Box gibt es auch nichts aufzuheben. +checkEqual("leerer Zustand ergibt keinen Stand", AlpicoolState().settings == nil, true) + +// Auf der Uhr dasselbe Bild: Sollwerte ja, Messwerte nein. +let watchBox = WatchFridge(boxSettings) +checkEqual("auf der Uhr als nicht live gekennzeichnet", watchBox.isLive, false) +checkEqual("Sollwert kommt mit", watchBox.leftTarget, 4) +checkEqual("Messwert bleibt leer", watchBox.leftCurrent == nil, true) + // MARK: 10 – VanAlign Neigungsmesser print("\nVanAlign-Neigungsmesser") @@ -1136,7 +1174,9 @@ var watchOrientation = SensorOrientation() watchOrientation.longitudinalSource = .roll watchOrientation.invertLateral = true -let watchFridge = WatchFridge(isPoweredOn: true, isEco: true, isLocked: false, +let watchFridge = WatchFridge(isLive: true, + updated: Date(timeIntervalSince1970: 1_699_999_000), + isPoweredOn: true, isEco: true, isLocked: false, isDualZone: false, unitSymbol: "°C", leftTarget: 4, leftCurrent: 6, rightTarget: nil, rightCurrent: nil, @@ -1229,6 +1269,8 @@ let commands: [WatchCommand] = [ .fridgeEco(device: watchFridgeID, eco: true), .fridgeLock(device: watchFridgeID, locked: true), .fridgeTarget(device: watchFridgeID, zone: .right, value: -3), + .fridgeSession(device: watchFridgeID, wanted: true), + .fridgeSession(device: watchFridgeID, wanted: false), ] for command in commands { guard let message = try? command.message(), diff --git a/run-tests.sh b/run-tests.sh index 4452217..00611a5 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -15,6 +15,7 @@ swiftc -O -o "$OUT/tests" \ CamperMonitor/Bluetooth/AlpicoolProtocol.swift \ Shared/Bluetooth/VanAlignProtocol.swift \ Shared/Models/DeviceSnapshot.swift \ + Shared/Models/FridgeSettings.swift \ Shared/Models/ConfiguredDevice.swift \ Shared/Models/LevelState.swift \ Shared/Models/Profile.swift \