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:
fototeddy
2026-09-06 21:25:28 +02:00
co-authored by Claude Sonnet 5
parent 83ea85f3b8
commit 303a9735d0
79 changed files with 190 additions and 190 deletions
+287
View File
@@ -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)
}
}