forked from fritob/Camper-Monitor
Jede Verbindung meldet sich am Display der Box an – sie piept, und wer gerade davorsteht, wird gestört. Dauerhaft verbunden zu sein ist dort also nicht unsichtbar wie beim BMS, sondern lästig. Die Kühlbox wird deshalb nur noch verbunden, solange ihre Ansicht offen ist: am iPhone über die Detailansicht, an der Uhr über einen eigenen Befehl, den sie beim Öffnen und Schliessen schickt. Ein Stellbefehl geht weiterhin immer durch – ist die Box nicht verbunden, wird er gemerkt und löst den Verbindungsaufbau aus. Abgeriegelt sind alle drei Wege, über die bisher verbunden wurde: beim Start, im Takt des Wiederverbindens, und – das war das eigentliche Loch – sobald das Gerät in den Werbedaten auftaucht. Da die App dauerhaft scannt, hätte allein dieser Weg die Box weiter angefunkt. Dass sie in Reichweite ist, ist kein Grund, sie anzufassen. Auf der Übersicht steht dafür der zuletzt gestellte Stand statt der Messwerte: Sollwert, Ein/Aus, Eco oder Max, dazu wann er gestellt wurde. Das bleibt richtig, auch wenn es von gestern ist – ein Sollwert ändert sich nur, wenn jemand ihn ändert. Eine Innentemperatur von gestern sähe dagegen aus wie eine von jetzt, und man würde ihr glauben. Genau das sichern die neuen Prüfungen ab: Hauptwert ist der Sollwert, gemessene Temperatur und Bordspannung kommen nicht vor. Der Stand liegt in den Einstellungen (FridgeSettings) mit einem Zeitstempel, der „zuletzt geändert" bedeutet und nicht „zuletzt gesehen" – sonst behauptete die Kachel Frische, wo sich nichts getan hat. Die Uhr bekommt dasselbe Bild: WatchFridge trägt jetzt ein Kennzeichen isLive. BMS und Neigungsmesser bleiben dauerhaft verbunden. Sie stören nicht, und ihre Werte will man laufend sehen. Nicht nachgezogen ist die Android-App – dort verbindet der BluetoothManager weiterhin dauerhaft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
288 lines
11 KiB
Swift
288 lines
11 KiB
Swift
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)
|
||
}
|
||
}
|