forked from fritob/Camper-Monitor
Die Uhr funkt den Neigungsmesser jetzt selbst an, statt die Werte vom iPhone weiterreichen zu lassen. Bei diesem einen Gerät geht das, und nur bei ihm: Es bewirbt seinen Dienst, ist also ohne Einrichtung auffindbar, und es ist unverschlüsselt – es gibt keinen Schlüssel, der auf der Uhr ein zweites Mal lagern müsste. Damit steht die Nivellierung am Handgelenk ohne geöffnetes iPhone, und genau dafür hebt man beim Rangieren den Arm. Batterie, Solar und Kühlbox bleiben beim Weg über das iPhone: Sie lassen nur eine Verbindung zu, und die Victron-Schlüssel liegen in dessen Keychain. Die Einbaulage des Sensors reist einmal mit dem Datensatz herüber und bleibt auf der Uhr gespeichert – ohne sie stünden längs und quer je nach Einbau vertauscht. LevelSession und VanAlignProtocol wandern nach Shared/Bluetooth; beide sind reines CoreBluetooth und laufen auf watchOS unverändert. Die Fahrzeugansicht gibt es jetzt auch auf der Uhr, umschaltbar zur Libelle; Überhöhung und Farbregeln stehen in Shared/VehicleTilt.swift, damit iPhone und Uhr nicht auseinanderlaufen. Kalibrieren fällt auf der Uhr weg, samt Befehl. Das gehört einmalig ans iPhone mit ebenem Fahrzeug; ein Knopf dafür am Handgelenk wäre vor allem eine Gelegenheit, die Nullage aus Versehen zu verstellen. Zwei Fehler dabei behoben: * Beim Start meldete die Uhr dem iPhone nie, dass jemand hinschaut – onChange(of: scenePhase) feuert beim ersten Erscheinen nicht. Der schnelle Sendetakt blieb aus, bis die App einmal im Hintergrund war. * Ein einmal direkt gemessener Wert hatte für immer Vorrang. Blieb der Sensor stehen, ohne die Verbindung zu trennen, klebte die Anzeige daran. Jetzt gilt er drei Sekunden als frisch, danach übernimmt der Stand des iPhones – mit Alter und Grund daneben. Ausserdem WATCHOS_DEPLOYMENT_TARGET auf 10.0 (Xcode hatte 11.6 gesetzt) und die Bluetooth-Begründung für das Watch-Target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
10 KiB
Swift
267 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,
|
||
orientation: device.role == .leveling ? device.sensorOrientation : 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|