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)) } } } }