Files
Camper-Monitor/VanControl/Watch/PhoneWatchLink.swift
T
fototeddyandClaude Sonnet 5 303a9735d0 App-Namen im iOS-Projekt auf VanControl vereinheitlichen
CamperMonitor (Haupt-Repo) und VanAligneiOS (aus dem gemergten
solar-integration-Branch) liefen unter zwei verschiedenen internen
Namen, obwohl die App nach aussen längst einheitlich "VanControl Pro"
heisst. Jetzt durchgängig VanControl:

- Ordner: CamperMonitor/, CamperMonitorWatch/, CamperMonitorComplication/,
  VanAligneiOSWidget/ → VanControl/, VanControlWatch/,
  VanControlComplication/, VanControlWidget/
- Xcode-Projekt: CamperMonitor.xcodeproj → VanControl.xcodeproj, alle
  Targets/Schemes/Produktnamen entsprechend umbenannt
- Bundle-Identifier auf Wunsch mitgeändert: de.s0.fototeddy.VanControl*
  (App noch nicht veröffentlicht); dabei auch die
  WKCompanionAppBundleIdentifier-Werte korrigiert, die noch das alte
  de.fritob-Präfix statt des tatsächlichen de.s0.fototeddy-Präfixes
  trugen
- Swift-Dateien/Typen: CamperMonitorApp → VanControlApp,
  VanAligneiOSWidget* → VanControlWidget*
- Config/*-Info.plist umbenannt, README.md/Tools/README.md/run-tests.sh
  auf die neuen Pfade angepasst

Bewusst unverändert: firmware/vanalign und alle Bezüge auf "VanAlign"
als Namen der Neigungsmesser-Hardware (eigenständiges Produkt, kein
App-Name) sowie der komplette Android/-Ordner.

Build (App, Watch, Debug) und Protokoll-Testlauf grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:25:28 +02:00

288 lines
11 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,
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)
}
}