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:
@@ -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 } }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user