Files
Camper-Monitor/CamperMonitor/Watch/PhoneWatchLink.swift
T
BiasFandClaude Opus 5 6612b07fd5 Apple Watch: Übersicht, Ausrichten mit Vibration, Kühlbox am Handgelenk
Neues Target CamperMonitorWatch, eingebettet in die iPhone-App. Die Uhr
zeigt je Gerät den Hauptwert, die Nivellierung mit Libelle und Keilhöhen,
den Ausrichtungs-Assistenten und stellt die Kühlbox.

Die Uhr funkt nicht selbst, obwohl watchOS das könnte. 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. Und die
Victron-Schlüssel liegen in der Keychain des iPhones; sie ein zweites Mal
auf der Uhr aufzubewahren brächte nichts. Das iPhone bleibt also das
Funkgerät und reicht fertige Messwerte über WatchConnectivity weiter.

Gesendet wird in zwei Takten: ein halber Sekundentakt als Nachricht,
solange die Watch-App im Vordergrund ist und das über eine Frist meldet,
sonst alle zwei Sekunden als Anwendungskontext und nur bei Änderungen.
Läuft die Frist ab, hört das iPhone von selbst wieder auf.

Solange die Uhr hinschaut, hält die App das Funkgerät auch im Hintergrund
am Leben (UIBackgroundModes in Config/CamperMonitor-Info.plist) – beim
Rangieren liegt das iPhone sonst mit dunklem Bildschirm in der Halterung
und die Anzeige am Handgelenk wäre genau dann tot. Die verbundenen Geräte
liefern dabei weiter, die Victron-Werbedaten nicht: ungefiltertes Suchen
lässt iOS im Hintergrund nicht zu.

Der plattformneutrale Modellcode wandert nach Shared/ und wird in beide
Targets übersetzt; LevelState ist dafür aus VanAlignProtocol.swift
herausgelöst. Datensatz und Befehle stehen in Shared/WatchLink und sind
in run-tests.sh abgesichert: Rundlauf, Schutz gegen fremde Formatversion,
Vergleich ohne Zeitstempel und die Auflösung der Zeitangaben.

Geprüft sind der Bau beider Targets samt Einbetten und der Testlauf; auf
echter Hardware noch nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:20:16 +02:00

268 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}