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>
268 lines
10 KiB
Swift
268 lines
10 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,
|
||
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)
|
||
}
|
||
}
|