Apple Watch: Übersicht, Ausrichten mit Vibration, Kühlbox am Handgelenk

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>
This commit is contained in:
BiasF
2026-08-31 15:20:16 +02:00
co-authored by Claude Opus 5
parent 32659ff0b9
commit 6612b07fd5
31 changed files with 1904 additions and 101 deletions
@@ -0,0 +1,4 @@
{
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.400", "green" : "0.620", "red" : "0.110" } }, "idiom" : "universal" } ],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,11 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : { "author" : "xcode", "version" : 1 }
}
@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }
+26
View File
@@ -0,0 +1,26 @@
import SwiftUI
/// Camper Monitor am Handgelenk.
///
/// Die Uhr ist Anzeige und Fernbedienung; gefunkt wird über das iPhone. Was
/// hier steht, kam von dort deshalb steht auf jedem Bildschirm auch, wie alt
/// es ist.
@main
struct CamperWatchApp: App {
@State private var link = PhoneLink()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
WatchRootView()
.environment(link)
.task { link.activate() }
}
.onChange(of: scenePhase) { _, phase in
// Der schnelle Takt kostet auf beiden Seiten Strom und lohnt nur,
// solange jemand hinschaut. Genau das sagt die Bildschirmphase aus:
// im Vordergrund schaut jemand hin, sonst nicht.
link.setLive(phase == .active)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
import Foundation
import Observation
import WatchConnectivity
/// Die Gegenstelle zum iPhone auf der Uhr.
///
/// Hält den zuletzt empfangenen Stand und schickt Stellbefehle zurück. Gefunkt
/// wird nur zum iPhone zu den Geräten im Fahrzeug nie, siehe `WatchLink`.
///
/// Zwei Quellen laufen zusammen:
///
/// * Der **Anwendungskontext** liegt schon da, wenn die App startet. Deshalb
/// steht sofort etwas auf dem Bildschirm, auch wenn das iPhone in der Tasche
/// steckt und gerade nicht antwortet mit Altersangabe, versteht sich.
/// * **Nachrichten** kommen im halben Sekundentakt, solange wir gemeldet haben,
/// dass jemand hinschaut. Das Melden wird regelmässig erneuert: Schläft die
/// Uhr ein, bleibt die Erneuerung aus und das iPhone hört von selbst auf.
@Observable
final class PhoneLink: NSObject {
private(set) var payload: WatchPayload?
/// Wann der Stand hier ankam nicht, wann er entstand. Beides zusammen
/// unterscheidet iPhone weg von Gerät im Fahrzeug antwortet nicht.
private(set) var receivedAt: Date?
private(set) var isReachable = false
private(set) var isActivated = false
private var session: WCSession?
/// Erneuert die Meldung hier schaut jemand hin im Vorführbetrieb
/// stattdessen der Taktgeber der erfundenen Werte.
private var timer: Timer?
private var isLive = false
/// Ohne iPhone lässt sich auf der Uhr sonst nichts vorführen.
private let isDemo = ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
func activate() {
if isDemo {
startDemo()
return
}
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self
self.session = session
session.activate()
}
/// Meldet dem iPhone, ob gerade jemand hinschaut. Beim Ausrichten zählt
/// jeder halbe Sekundentakt, beim Nachsehen des Ladezustands nicht.
func setLive(_ live: Bool) {
guard !isDemo else { return }
isLive = live
timer?.invalidate()
timer = nil
guard live else {
send(.hello(live: false))
return
}
send(.hello(live: true))
// Vor Ablauf der Frist erneuern, damit zwischendurch keine Lücke
// entsteht. Bleibt die Erneuerung aus, endet der schnelle Takt von
// selbst niemand muss ihn abbestellen.
let renew = Timer(timeInterval: WatchLink.liveLease / 3, repeats: true) { [weak self] _ in
self?.send(.hello(live: true))
}
RunLoop.main.add(renew, forMode: .common)
timer = renew
}
/// Schickt einen Befehl und übernimmt die Antwort, falls eine kommt.
///
/// Die Antwort ist der Stand *vor* der Wirkung die Kühlbox antwortet dem
/// iPhone erst ein paar Zehntel später. Sie zeigt also nicht, dass der
/// Befehl gewirkt hat, sondern nur, dass er angekommen ist. Der gestellte
/// Wert erscheint, wenn das Gerät ihn zurückmeldet, so wie am iPhone auch.
func send(_ command: WatchCommand) {
guard !isDemo, let session, session.activationState == .activated,
let message = try? command.message() else { return }
guard session.isReachable else { return }
session.sendMessage(message, replyHandler: { [weak self] reply in
guard let payload = WatchPayload.decode(from: reply) else { return }
DispatchQueue.main.async { self?.accept(payload) }
}, errorHandler: nil)
}
private func accept(_ payload: WatchPayload) {
// Ein Nachzügler darf einen neueren Stand nicht überschreiben.
if let current = self.payload, current.generatedAt > payload.generatedAt { return }
self.payload = payload
receivedAt = Date()
}
// MARK: - Vorführbetrieb
private func startDemo() {
payload = WatchDemo.payload(at: 0)
receivedAt = Date()
isReachable = true
isActivated = true
var step = 0.0
let tick = Timer(timeInterval: 0.5, repeats: true) { [weak self] _ in
step += 0.5
self?.payload = WatchDemo.payload(at: step)
self?.receivedAt = Date()
}
RunLoop.main.add(tick, forMode: .common)
timer = tick
}
}
// MARK: - WCSessionDelegate
extension PhoneLink: WCSessionDelegate {
func session(_ session: WCSession,
activationDidCompleteWith state: WCSessionActivationState,
error: Error?) {
DispatchQueue.main.async {
self.isActivated = state == .activated
self.isReachable = session.isReachable
// Was schon dalag, gilt sofort sonst bliebe der Bildschirm leer,
// bis das iPhone das nächste Mal von sich aus sendet.
if let payload = WatchPayload.decode(from: session.receivedApplicationContext) {
self.accept(payload)
}
if self.isLive { self.send(.hello(live: true)) }
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
guard let payload = WatchPayload.decode(from: message) else { return }
DispatchQueue.main.async { self.accept(payload) }
}
func session(_ session: WCSession, didReceiveApplicationContext context: [String: Any]) {
guard let payload = WatchPayload.decode(from: context) else { return }
DispatchQueue.main.async { self.accept(payload) }
}
func sessionReachabilityDidChange(_ session: WCSession) {
DispatchQueue.main.async {
self.isReachable = session.isReachable
if session.isReachable, self.isLive { self.send(.hello(live: true)) }
}
}
}
@@ -0,0 +1,111 @@
import SwiftUI
import WatchKit
/// Der Ausrichtungs-Assistent am Handgelenk der eigentliche Grund für die
/// Uhr: Beim Rangieren hat man das iPhone nicht in der Hand, aber den Arm am
/// Lenkrad. Ein Blick nach unten genügt, und die Rückmeldung steht eben kommt
/// zusätzlich als Vibration, ganz ohne Blick.
///
/// Gerechnet wird mit demselben `AlignmentAssistant` wie am iPhone: kein
/// Ortsbezug, nur der zeitliche Verlauf der Neigung. Was besser und was
/// schlechter wird, steckt vollständig darin.
struct WatchAlignView: View {
let deviceID: UUID
@Environment(PhoneLink.self) private var link
@State private var assistant = AlignmentAssistant()
/// Damit die Vibration einmal kommt und nicht im Sekundentakt.
@State private var hasAnnouncedTarget = false
private var state: LevelState? {
link.payload?.devices.first { $0.id == deviceID }?.level
}
var body: some View {
ScrollView {
VStack(spacing: 8) {
if let current = assistant.current {
Text(String(format: "%.1f°", current.deviation))
.font(.system(size: 40, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(assistant.hasReachedTarget ? .green : .primary)
.contentTransition(.numericText())
} else {
ProgressView()
.padding(.vertical, 12)
}
Label(assistant.trend.text, systemImage: assistant.trend.symbol)
.font(.footnote.weight(.medium))
.foregroundStyle(trendColor)
Text(assistant.advice)
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
if let state {
HStack(spacing: 12) {
WatchBubble(pitch: state.pitch, roll: state.roll)
.frame(width: 56, height: 56)
VStack(alignment: .leading, spacing: 2) {
Text(String(format: "Längs %.1f°", state.pitch ?? 0))
Text(String(format: "Quer %.1f°", state.roll ?? 0))
}
.font(.caption2)
.monospacedDigit()
.foregroundStyle(.secondary)
}
.padding(.top, 2)
}
Button("Von vorn messen", systemImage: "arrow.counterclockwise") {
assistant.reset()
hasAnnouncedTarget = false
}
.font(.caption2)
.padding(.top, 4)
}
.padding(.horizontal, 4)
}
.navigationTitle("Ausrichten")
.onChange(of: reading, initial: true) { _, reading in
guard let reading else { return }
assistant.add(pitch: reading.pitch, roll: reading.roll)
announceIfLevel()
}
}
/// Ein Wertepaar als Auslöser: nur wenn sich wirklich eine Neigung ändert,
/// wird gerechnet nicht bei jedem Datensatz des iPhones.
private var reading: Reading? {
guard let state, let pitch = state.pitch, let roll = state.roll else { return nil }
return Reading(pitch: pitch, roll: roll)
}
private struct Reading: Equatable {
let pitch: Double
let roll: Double
}
private var trendColor: Color {
switch assistant.trend {
case .improving: return .green
case .worsening: return .orange
default: return .secondary
}
}
/// Die Vibration ist der Punkt: Sie sagt anhalten, ohne dass jemand
/// hinschauen muss. Erneut gemeldet wird erst, wenn das Fahrzeug die
/// Toleranz zwischendurch wieder verlassen hat.
private func announceIfLevel() {
if assistant.hasReachedTarget {
guard !hasAnnouncedTarget else { return }
hasAnnouncedTarget = true
WKInterfaceDevice.current().play(.success)
} else {
hasAnnouncedTarget = false
}
}
}
@@ -0,0 +1,77 @@
import SwiftUI
/// Libelle für die Uhr: eine Blase, die dorthin wandert, wo das Fahrzeug
/// **höher** steht wie bei einer echten Wasserwaage.
///
/// Bewusst schlichter als am iPhone: Auf 40 mm Bildschirm ist der innere Ring
/// (die Toleranz) die einzige Hilfslinie, die sich noch ablesen lässt.
struct WatchBubble: View {
let pitch: Double?
let roll: Double?
/// Bis zu welcher Neigung die Anzeige ausschlägt.
var range: Double = 4
var body: some View {
GeometryReader { geometry in
let side = min(geometry.size.width, geometry.size.height)
let radius = side / 2
let bubble = side * 0.22
let travel = radius - bubble / 2 - 2
let tolerance = max(LevelState.levelTolerance / range * travel, bubble * 0.55)
ZStack {
Circle().fill(Color.gray.opacity(0.25))
Circle().strokeBorder(Color.gray.opacity(0.5), lineWidth: 1)
Circle()
.strokeBorder(isLevel ? Color.green : Color.gray.opacity(0.6),
lineWidth: isLevel ? 2 : 1)
.frame(width: tolerance * 2, height: tolerance * 2)
Circle()
.fill(color)
.frame(width: bubble, height: bubble)
// Positiver Pitch heisst: das Heck steht höher, die Blase
// wandert nach oben in der Ansicht also nach hinten.
.offset(x: clamped(roll) / range * travel,
y: -clamped(pitch) / range * travel)
.animation(.spring(duration: 0.3), value: pitch)
.animation(.spring(duration: 0.3), value: roll)
.opacity(pitch == nil && roll == nil ? 0.25 : 1)
}
.frame(width: side, height: side)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.aspectRatio(1, contentMode: .fit)
.accessibilityLabel(accessibilityText)
}
/// Hält die Blase im Ring, auch wenn das Fahrzeug stark schräg steht.
private func clamped(_ value: Double?) -> Double {
guard let value else { return 0 }
return min(max(value, -range), range)
}
private var deviation: Double? {
switch (pitch, roll) {
case let (p?, r?): return max(abs(p), abs(r))
case let (p?, nil): return abs(p)
case let (nil, r?): return abs(r)
default: return nil
}
}
private var isLevel: Bool { (deviation ?? .infinity) <= LevelState.levelTolerance }
/// Grün nur, wenn es wirklich eben ist die Akzentfarbe der App ist selbst
/// grün, sonst sähe schief aus wie eben.
private var color: Color {
guard let deviation else { return .gray }
if deviation <= LevelState.levelTolerance { return .green }
return deviation <= 2 ? .orange : .red
}
private var accessibilityText: String {
guard let pitch, let roll else { return "Keine Messwerte" }
return String(format: "Längsneigung %.1f Grad, Querneigung %.1f Grad", pitch, roll)
}
}
@@ -0,0 +1,59 @@
import SwiftUI
/// Alle Werte eines Geräts. Am iPhone stehen hier zusätzlich Verlauf und
/// Diagnose auf der Uhr wäre beides unlesbar und ist deshalb weggelassen.
struct WatchDeviceDetailView: View {
let deviceID: UUID
@Environment(PhoneLink.self) private var link
private var device: WatchDevice? {
link.payload?.devices.first { $0.id == deviceID }
}
var body: some View {
List {
if let device {
if let fridge = device.fridge {
WatchFridgeControls(deviceID: device.id, fridge: fridge)
}
Section {
ForEach(device.snapshot?.metrics.filter { $0.value != nil } ?? []) { metric in
LabeledContent(metric.label) {
Text(metric.formattedWithUnit)
.monospacedDigit()
}
.font(.footnote)
}
} header: {
Text("Messwerte")
}
Section {
if let fault = device.snapshot?.fault {
Label(fault, systemImage: "exclamationmark.triangle.fill")
.font(.caption2)
.foregroundStyle(.red)
}
if let state = device.snapshot?.state {
Text(state).font(.caption2)
}
ForEach(device.snapshot?.offReasons ?? [], id: \.self) { reason in
Text(reason)
.font(.caption2)
.foregroundStyle(.secondary)
}
Label(device.link.label, systemImage: "antenna.radiowaves.left.and.right")
.font(.caption2)
.foregroundStyle(.secondary)
}
} else {
Text("Gerät ist nicht mehr im Fahrzeugprofil.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle(device?.name ?? "Gerät")
}
}
@@ -0,0 +1,63 @@
import SwiftUI
/// Die Kühlbox von der Uhr aus stellen das einzige Gerät, das sich stellen
/// lässt.
///
/// Wie am iPhone folgen die Schalter dem Gerät, nicht der Vermutung: Getippt
/// wird ein Befehl, angezeigt wird, was die Box zurückmeldet. Über die Uhr
/// dauert das einen Wimpernschlag länger der Weg geht über das iPhone ,
/// dafür kann kein Schalter etwas behaupten, das die Box nie bestätigt hat.
struct WatchFridgeControls: View {
let deviceID: UUID
let fridge: WatchFridge
@Environment(PhoneLink.self) private var link
var body: some View {
Section {
Toggle("Ein", isOn: Binding(
get: { fridge.isPoweredOn },
set: { link.send(.fridgePower(device: deviceID, on: $0)) }
))
Toggle("Eco", isOn: Binding(
get: { fridge.isEco },
set: { link.send(.fridgeEco(device: deviceID, eco: $0)) }
))
.disabled(!fridge.isPoweredOn)
target(zone: .left,
title: fridge.isDualZone ? "Soll links" : "Soll",
value: fridge.leftTarget)
if fridge.isDualZone {
target(zone: .right, title: "Soll rechts", value: fridge.rightTarget)
}
} header: {
Text("Kühlbox")
} footer: {
if !link.isReachable {
Text("iPhone gerade nicht erreichbar Stellbefehle gehen erst "
+ "wieder durch, wenn es antwortet.")
.font(.caption2)
}
}
}
private func target(zone: WatchFridgeZone, title: String, value: Int?) -> some View {
Stepper(value: Binding(
get: { value ?? fridge.targetRange.lowerBound },
set: { link.send(.fridgeTarget(device: deviceID, zone: zone, value: $0)) }
), in: fridge.targetRange) {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
Text(value.map { "\($0) \(fridge.unitSymbol)" } ?? "")
.font(.headline)
.monospacedDigit()
}
}
.disabled(!fridge.isPoweredOn || value == nil)
}
}
@@ -0,0 +1,94 @@
import SwiftUI
/// Nivellierung auf der Uhr: Libelle, Zahlen, und was zu tun ist.
struct WatchLevelView: View {
/// Nur für den Einstieg angezeigt wird immer der Stand aus dem letzten
/// Datensatz, sonst bliebe die Ansicht auf dem Wert vom Öffnen stehen.
let device: WatchDevice
@Environment(PhoneLink.self) private var link
@State private var isCalibrating = false
private var current: WatchDevice { link.payload?.levelDevice ?? device }
private var state: LevelState { current.level ?? LevelState() }
var body: some View {
ScrollView {
VStack(spacing: 10) {
WatchBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: 110)
HStack(spacing: 14) {
angle("Längs", state.pitch)
angle("Quer", state.roll)
}
Text(state.instruction ?? "Keine Werte")
.font(.footnote.weight(.medium))
.multilineTextAlignment(.center)
.foregroundStyle(state.isLevel ? .green : .primary)
if let wedges = wedgeText {
Text(wedges)
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
NavigationLink {
WatchAlignView(deviceID: current.id)
} label: {
Label("Ausrichten", systemImage: "car.side.rear.and.collision.and.car.side.front")
}
.buttonStyle(.borderedProminent)
Button("Kalibrieren", systemImage: "scope") { isCalibrating = true }
.font(.footnote)
if state.isKnownUncalibrated {
Text("Noch nicht kalibriert die Anzeige zeigt die Lage des "
+ "Sensors, nicht die des Fahrzeugs.")
.font(.caption2)
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
}
}
.padding(.horizontal, 4)
}
.navigationTitle("Nivellierung")
.confirmationDialog("Auf aktuelle Lage kalibrieren?",
isPresented: $isCalibrating, titleVisibility: .visible) {
Button("Kalibrieren") { link.send(.calibrateLevel(device: current.id)) }
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Nur sinnvoll, wenn das Fahrzeug jetzt eben steht.")
}
}
private func angle(_ title: String, _ value: Double?) -> some View {
VStack(spacing: 0) {
Text(value.map { String(format: "%.1f°", $0) } ?? "")
.font(.title3.weight(.semibold))
.monospacedDigit()
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
/// Wie hoch die Auffahrkeile sein müssen reine Geometrie, und nur zu
/// haben, wenn im Fahrzeugprofil Spurweite und Radstand stehen.
private var wedgeText: String? {
guard let profile = link.payload?.profile else { return nil }
var parts: [String] = []
if let roll = state.roll, let width = profile.trackWidth,
let wedge = LevelingWedge.across(roll: roll, trackWidth: width) {
parts.append(String(format: "%.0f cm %@", wedge.heightInCentimetres, wedge.side.text))
}
if let pitch = state.pitch, let base = profile.wheelbase,
let wedge = LevelingWedge.along(pitch: pitch, wheelbase: base) {
parts.append(String(format: "%.0f cm %@", wedge.heightInCentimetres, wedge.side.text))
}
return parts.isEmpty ? nil : "Keile: " + parts.joined(separator: ", ")
}
}
@@ -0,0 +1,164 @@
import SwiftUI
/// Der Einstieg: das Wichtigste untereinander, in der Reihenfolge, in der man
/// im Wohnmobil danach schaut.
struct WatchRootView: View {
@Environment(PhoneLink.self) private var link
var body: some View {
NavigationStack {
Group {
if let payload = link.payload {
content(for: payload)
} else {
waiting
}
}
.navigationTitle(link.payload?.profile.name ?? "Camper")
}
}
private func content(for payload: WatchPayload) -> some View {
List {
if let level = payload.levelDevice {
Section {
NavigationLink {
WatchLevelView(device: level)
} label: {
LevelSummaryRow(state: level.level, link: level.link)
}
}
}
Section {
ForEach(payload.devices.filter { $0.role != .leveling }) { device in
NavigationLink {
WatchDeviceDetailView(deviceID: device.id)
} label: {
DeviceRow(device: device)
}
}
}
Section {
FreshnessNote(payload: payload, receivedAt: link.receivedAt)
}
}
}
private var waiting: some View {
VStack(spacing: 8) {
Image(systemName: "iphone.gen3.radiowaves.left.and.right")
.font(.title2)
.foregroundStyle(.tint)
Text("Warte auf das iPhone")
.font(.headline)
Text("Die Werte kommen vom iPhone. Dort muss Camper Monitor "
+ "geöffnet sein.")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 4)
}
}
/// Eine Gerätezeile: Name klein, Hauptwert gross auf dem kleinen Bildschirm
/// zählt nur der eine Wert, wegen dem man den Arm hebt.
struct DeviceRow: View {
let device: WatchDevice
var body: some View {
HStack(spacing: 8) {
Image(systemName: device.role.symbol)
.foregroundStyle(.tint)
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(device.name)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
if let metric = device.snapshot?.primaryMetric, metric.value != nil {
Text(metric.formattedWithUnit)
.font(.title3.weight(.semibold))
.monospacedDigit()
.foregroundStyle(device.isLive ? .primary : .secondary)
} else {
Text(device.link.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 0)
Circle()
.fill(device.statusColor)
.frame(width: 7, height: 7)
}
}
}
/// Die Nivellierung bekommt eine eigene, grössere Zeile: sie ist der Grund,
/// aus dem man die Uhr im Fahrzeug überhaupt anschaut.
struct LevelSummaryRow: View {
let state: LevelState?
let link: DeviceLinkState
var body: some View {
HStack(spacing: 10) {
WatchBubble(pitch: state?.pitch, roll: state?.roll)
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text("Nivellierung")
.font(.caption2)
.foregroundStyle(.secondary)
// Ohne Messwerte steht hier, woran es liegt, statt eines
// nichtssagenden Strichs.
Text(state?.instruction ?? link.label)
.font(.footnote.weight(.medium))
.lineLimit(2)
}
Spacer(minLength: 0)
}
.padding(.vertical, 2)
}
}
/// Wie alt der Stand ist auf der Uhr die wichtigste Nebenangabe. Alles hier
/// ist weitergereicht; eine abgerissene Strecke zum iPhone sieht sonst genauso
/// aus wie ein Fahrzeug, an dem sich nichts tut.
struct FreshnessNote: View {
let payload: WatchPayload
let receivedAt: Date?
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { context in
let age = context.date.timeIntervalSince(receivedAt ?? payload.generatedAt)
Label {
Text(text(age: age))
.font(.caption2)
} icon: {
Image(systemName: age > 10 ? "exclamationmark.triangle.fill" : "iphone.gen3")
}
.foregroundStyle(age > 10 ? .orange : .secondary)
}
}
private func text(age: TimeInterval) -> String {
if !payload.isRadioReady { return payload.radioStatus }
if age < 3 { return "Stand: eben" }
if age < 90 { return String(format: "Stand: vor %.0f s", age) }
return String(format: "Stand: vor %.0f min iPhone erreichbar?", age / 60)
}
}
extension WatchDevice {
/// Grün heisst: der Wert ist frisch und kommt vom Gerät selbst.
var statusColor: Color {
switch link {
case .live: return (snapshot?.isStale ?? true) ? .orange : .green
case .needsKey: return .orange
case .failed: return .red
default: return .gray
}
}
}
+81
View File
@@ -0,0 +1,81 @@
import Foundation
/// Erfundene Werte für den Simulator, damit sich die Ansichten ohne iPhone und
/// ohne Fahrzeug prüfen lassen. Eingeschaltet über `CAMPER_DEMO=1`:
///
/// xcrun simctl launch --terminate-running-process booted \
/// de.fritob.CamperMonitor.watchkitapp
/// # mit SIMCTL_CHILD_CAMPER_DEMO=1 davor
///
/// Im normalen Betrieb wird hiervon nichts ausgeführt.
enum WatchDemo {
private static let profile = Profile(name: "Kastenwagen", symbol: "box.truck",
trackWidth: 1.85, wheelbase: 3.50)
private static let solarID = UUID(uuidString: "00000000-0000-0000-0000-000000000050")!
private static let batteryID = UUID(uuidString: "00000000-0000-0000-0000-0000000000A0")!
private static let fridgeID = UUID(uuidString: "00000000-0000-0000-0000-0000000000F0")!
private static let levelID = UUID(uuidString: "00000000-0000-0000-0000-0000000000E0")!
/// `seconds` läuft weiter, damit sich die Neigung bewegt sonst zeigt der
/// Ausrichtungs-Assistent nie einen Trend.
static func payload(at seconds: Double) -> WatchPayload {
let now = Date()
let swing = sin(seconds / 8)
var level = LevelState()
level.pitch = 1.4 * swing + 0.3
level.roll = -0.9 * cos(seconds / 11)
level.pitchOffset = 0.4
level.rollOffset = -0.2
var solar = DeviceSnapshot(deviceID: solarID, timestamp: now, rssi: -58)
solar.state = "Konstantspannung (Absorption)"
solar.metrics = [
Metric("pv_power", "PV-Leistung", 284 + 40 * swing, unit: "W", precision: 0, primary: true),
Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2),
Metric("battery_current", "Ladestrom", 18.6, unit: "A", precision: 1),
]
var battery = DeviceSnapshot(deviceID: batteryID, timestamp: now, rssi: -64)
battery.state = "Lädt"
battery.metrics = [
Metric("soc", "Ladezustand", 78.4, unit: "%", precision: 1, primary: true),
Metric("voltage", "Spannung", 13.66, unit: "V", precision: 2),
Metric("current", "Strom", 18.6, unit: "A", precision: 1),
Metric("capacity", "Restkapazität", 156.8, unit: "Ah", precision: 1),
]
var fridge = DeviceSnapshot(deviceID: fridgeID, timestamp: now, rssi: -66)
fridge.state = "Kompressor läuft"
fridge.metrics = [
Metric("temperature", "Innentemperatur", 6, unit: "°C", precision: 0, primary: true),
Metric("target", "Solltemperatur", 4, unit: "°C", precision: 0),
Metric("battery_voltage", "Bordspannung", 12.7, unit: "V", precision: 1),
]
return WatchPayload(
generatedAt: now,
profile: profile,
isRadioReady: true,
radioStatus: "Demo-Modus",
devices: [
WatchDevice(id: levelID, name: "Nivellierung", role: .leveling,
link: .live, snapshot: level.snapshot(deviceID: levelID, rssi: -70),
level: level, fridge: nil),
WatchDevice(id: batteryID, name: "Bulltron 200 Ah", role: .bms,
link: .live, snapshot: battery, level: nil, fridge: nil),
WatchDevice(id: solarID, name: "Solar Dach", role: .solarCharger,
link: .live, snapshot: solar, level: nil, fridge: nil),
WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge,
link: .live, snapshot: fridge, level: nil,
fridge: WatchFridge(isPoweredOn: true, isEco: true, isLocked: false,
isDualZone: false, unitSymbol: "°C",
leftTarget: 4, leftCurrent: 6,
rightTarget: nil, rightCurrent: nil,
minTarget: -20, maxTarget: 20,
batteryVolts: 12.7)),
])
}
}