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, orientation: device.role == .leveling ? device.sensorOrientation : nil, fridge: device.role == .fridge ? fridgeState(for: device) : nil) } return WatchPayload(generatedAt: Date(), profile: profile, isRadioReady: bluetooth.isBluetoothReady, radioStatus: bluetooth.bluetoothStatusText, 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) { 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 .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) } } } } // 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(isLive: true, updated: Date(), 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) } }