forked from fritob/Camper-Monitor
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
83ea85f3b8
commit
303a9735d0
@@ -0,0 +1,287 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user