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,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,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "VansterRear.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : { "template-rendering-intent" : "template" }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "VansterSide.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 },
|
||||
"properties" : { "template-rendering-intent" : "template" }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
/// VanControl Pro am Handgelenk.
|
||||
///
|
||||
/// Zwei Quellen, jede dort, wo sie hingehört:
|
||||
///
|
||||
/// * Der **Neigungsmesser** hängt direkt an der Uhr (`WatchLevelRadio`). Die
|
||||
/// Nivellierung braucht damit kein geöffnetes iPhone – und genau dafür hebt
|
||||
/// man beim Rangieren den Arm.
|
||||
/// * **Batterie, Solar und Kühlbox** kommen über das iPhone (`PhoneLink`),
|
||||
/// weil dort die Schlüssel liegen und die Geräte nur eine Verbindung
|
||||
/// zulassen.
|
||||
@main
|
||||
struct CamperWatchApp: App {
|
||||
@State private var link = PhoneLink()
|
||||
@State private var radio = WatchLevelRadio()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchRootView()
|
||||
.environment(link)
|
||||
.environment(radio)
|
||||
.task {
|
||||
link.activate()
|
||||
radio.start()
|
||||
// Beim Start meldet `onChange` nichts: Die Bildschirmphase
|
||||
// steht schon auf „aktiv“ und ändert sich nicht mehr. Ohne
|
||||
// diese Zeile bliebe der schnelle Takt des iPhones aus, bis
|
||||
// die App einmal im Hintergrund war.
|
||||
link.setLive(true)
|
||||
}
|
||||
// Die Einbaulage kommt bevorzugt aus dem Sensor selbst. Nur
|
||||
// falls dort noch nichts steht – ältere Firmware, oder nie
|
||||
// bestimmt –, gilt, was das iPhone meldet.
|
||||
.onChange(of: link.payload?.levelDevice?.orientation) { _, orientation in
|
||||
guard let orientation, !radio.hasDeviceOrientation else { return }
|
||||
radio.orientation = orientation
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
// Beides kostet Strom und lohnt nur, solange jemand hinschaut.
|
||||
// Genau das sagt die Bildschirmphase aus.
|
||||
link.setLive(phase == .active)
|
||||
if phase == .active { radio.start() } else { radio.stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,115 @@
|
||||
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 {
|
||||
@Environment(PhoneLink.self) private var link
|
||||
@Environment(WatchLevelRadio.self) private var radio
|
||||
@State private var assistant = AlignmentAssistant()
|
||||
/// Damit die Vibration einmal kommt und nicht im Sekundentakt.
|
||||
@State private var hasAnnouncedTarget = false
|
||||
|
||||
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
|
||||
private var state: LevelState? { reading.hasReading ? reading.state : nil }
|
||||
|
||||
var body: some View {
|
||||
// Ohne ScrollView: Beim Rangieren schaut man kurz hin, da darf nichts
|
||||
// ausserhalb des Bildes liegen. Alles ist deshalb so bemessen, dass es
|
||||
// auch auf der kleinsten Uhr auf einen Blick passt.
|
||||
VStack(spacing: 3) {
|
||||
if let current = assistant.current {
|
||||
Text(String(format: "%.1f°", current.deviation))
|
||||
.font(.system(size: 30, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(assistant.hasReachedTarget ? .green : .primary)
|
||||
.contentTransition(.numericText())
|
||||
} else {
|
||||
ProgressView()
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
Label(assistant.trend.text, systemImage: assistant.trend.symbol)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(trendColor)
|
||||
|
||||
Text(assistant.advice)
|
||||
.font(.system(size: 11))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
|
||||
if let state {
|
||||
HStack(spacing: 8) {
|
||||
WatchBubble(pitch: state.pitch, roll: state.roll)
|
||||
.frame(width: 40, height: 40)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(String(format: "Längs %.1f°", state.pitch ?? 0))
|
||||
Text(String(format: "Quer %.1f°", state.roll ?? 0))
|
||||
}
|
||||
.font(.system(size: 11))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
assistant.reset()
|
||||
hasAnnouncedTarget = false
|
||||
} label: {
|
||||
Label("Von vorn", systemImage: "arrow.counterclockwise")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.horizontal, 2)
|
||||
.navigationTitle("Ausrichten")
|
||||
.onChange(of: tilt, initial: true) { _, tilt in
|
||||
guard let tilt else { return }
|
||||
assistant.add(pitch: tilt.pitch, roll: tilt.roll)
|
||||
announceIfLevel()
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Wertepaar als Auslöser: gerechnet wird nur, wenn sich die Neigung
|
||||
/// wirklich ändert – nicht bei jeder Aktualisierung der Anzeige.
|
||||
private var tilt: Tilt? {
|
||||
guard let state, let pitch = state.pitch, let roll = state.roll else { return nil }
|
||||
return Tilt(pitch: pitch, roll: roll)
|
||||
}
|
||||
|
||||
private struct Tilt: 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,69 @@
|
||||
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")
|
||||
// Die Kühlbox wird nur verbunden, solange jemand sie ansieht. Das gilt
|
||||
// auch von hier aus – das iPhone verbindet auf Zuruf.
|
||||
.onAppear {
|
||||
guard device?.fridge != nil else { return }
|
||||
link.send(.fridgeSession(device: deviceID, wanted: true))
|
||||
}
|
||||
.onDisappear {
|
||||
guard device?.fridge != nil else { return }
|
||||
link.send(.fridgeSession(device: deviceID, wanted: false))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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)
|
||||
} else if !fridge.isLive {
|
||||
// Kein Messwert, sondern der zuletzt gestellte Stand: Die Box
|
||||
// wird erst verbunden, während man diese Ansicht offen hat.
|
||||
Text("Zuletzt gestellter Stand. Die Box verbindet sich gerade – "
|
||||
+ "die Werte kommen gleich.")
|
||||
.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,230 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Nivellierung auf der Uhr, aufgeteilt auf drei Seiten zum Wischen.
|
||||
///
|
||||
/// Auf einem Bildschirm dieser Grösse ist Wischen besser als Scrollen: Beim
|
||||
/// Rangieren trifft man einen Wisch auch ohne hinzusehen, eine Scrollposition
|
||||
/// dagegen nicht – und was gerade wichtig ist, hängt vom Arbeitsschritt ab.
|
||||
///
|
||||
/// 1. **Neigung** – wie das Fahrzeug steht, als Fahrzeugansicht oder Libelle.
|
||||
/// 2. **Keile** – wie hoch jedes Rad unterlegt werden muss.
|
||||
/// 3. **Ausrichten** – der Assistent fürs Rangieren, mit Vibration.
|
||||
///
|
||||
/// Die Werte kommen bevorzugt **direkt vom Sensor** (`WatchLevelRadio`); nur
|
||||
/// wenn der gerade nicht erreichbar ist, tritt der zuletzt über das iPhone
|
||||
/// gemeldete Stand an seine Stelle. Woher sie stammen, steht auf der ersten
|
||||
/// Seite – ein alter Wert sieht sonst aus wie ein stillstehendes Fahrzeug.
|
||||
///
|
||||
/// Kalibriert wird hier nichts. Das gehört einmalig ans iPhone, mit ebenem
|
||||
/// Fahrzeug und Ruhe dafür; auf der Uhr wäre der Knopf nur eine Gelegenheit,
|
||||
/// die Nullage aus Versehen zu verstellen.
|
||||
struct WatchLevelView: View {
|
||||
@State private var page = Page.reading
|
||||
|
||||
enum Page: Hashable {
|
||||
case reading, wedges, align
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $page) {
|
||||
LevelReadingPage()
|
||||
.tag(Page.reading)
|
||||
WedgePage()
|
||||
.tag(Page.wedges)
|
||||
WatchAlignView()
|
||||
.tag(Page.align)
|
||||
}
|
||||
.tabViewStyle(.page)
|
||||
}
|
||||
}
|
||||
|
||||
/// Erste Seite: wie das Fahrzeug steht.
|
||||
struct LevelReadingPage: View {
|
||||
@Environment(PhoneLink.self) private var link
|
||||
@Environment(WatchLevelRadio.self) private var radio
|
||||
@AppStorage("levelDisplayStyle") private var showsVehicle = true
|
||||
|
||||
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
if showsVehicle {
|
||||
WatchVehicleView(pitch: reading.state.pitch, roll: reading.state.roll)
|
||||
} else {
|
||||
WatchBubble(pitch: reading.state.pitch, roll: reading.state.roll)
|
||||
.frame(maxWidth: 110)
|
||||
HStack(spacing: 14) {
|
||||
angle("Längs", reading.state.pitch)
|
||||
angle("Quer", reading.state.roll)
|
||||
}
|
||||
}
|
||||
|
||||
Text(reading.state.instruction ?? "Keine Werte")
|
||||
.font(.footnote.weight(.medium))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(reading.state.isLevel ? .green : .primary)
|
||||
|
||||
Button {
|
||||
showsVehicle.toggle()
|
||||
} label: {
|
||||
Label(showsVehicle ? "Libelle" : "Fahrzeug",
|
||||
systemImage: showsVehicle ? "circle.circle" : "box.truck")
|
||||
}
|
||||
.font(.caption2)
|
||||
|
||||
SourceNote(reading: reading)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.navigationTitle("Neigung")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zweite Seite: die Keilhöhen, als Draufsicht auf die vier Räder.
|
||||
struct WedgePage: View {
|
||||
@Environment(PhoneLink.self) private var link
|
||||
@Environment(WatchLevelRadio.self) private var radio
|
||||
|
||||
private var reading: WatchLevelReading { WatchLevelReading(radio: radio, phone: link) }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
if let lift, !lift.isNegligible {
|
||||
WheelLiftPlan(lift: lift, isCompact: true)
|
||||
Text("Zentimeter unter das jeweilige Rad. Das höchste bleibt liegen.")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else if hasMeasurements {
|
||||
Label("Keine Keile nötig", systemImage: "checkmark.circle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.green)
|
||||
.padding(.top, 20)
|
||||
} else {
|
||||
// Ohne Fahrzeugmasse lässt sich nichts rechnen. Das gehört
|
||||
// gesagt, sonst wirkt die Seite kaputt.
|
||||
Label("Fahrzeugmasse fehlen", systemImage: "ruler")
|
||||
.font(.footnote.weight(.medium))
|
||||
Text("Spurweite und Radstand am iPhone im Fahrzeugprofil "
|
||||
+ "hinterlegen, dann steht hier die Keilhöhe je Rad.")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
.navigationTitle("Keile")
|
||||
}
|
||||
|
||||
private var hasMeasurements: Bool {
|
||||
link.payload?.profile.trackWidth != nil && link.payload?.profile.wheelbase != nil
|
||||
}
|
||||
|
||||
/// Wie hoch jedes Rad unterlegt werden muss – reine Geometrie, und nur zu
|
||||
/// haben, wenn im Fahrzeugprofil Spurweite und Radstand stehen.
|
||||
private var lift: LevelingLift? {
|
||||
guard let profile = link.payload?.profile,
|
||||
let width = profile.trackWidth, let base = profile.wheelbase,
|
||||
let pitch = reading.state.pitch, let roll = reading.state.roll
|
||||
else { return nil }
|
||||
return LevelingLift.compute(pitch: pitch, roll: roll,
|
||||
trackWidth: width, wheelbase: base)
|
||||
}
|
||||
}
|
||||
|
||||
/// Woher die angezeigte Neigung kommt und wie alt sie ist.
|
||||
struct SourceNote: View {
|
||||
let reading: WatchLevelReading
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 1)) { context in
|
||||
let age = reading.age(now: context.date)
|
||||
Label {
|
||||
Text(text(age: age))
|
||||
} icon: {
|
||||
Image(systemName: reading.isDirect
|
||||
? "antenna.radiowaves.left.and.right" : "iphone.gen3")
|
||||
}
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(age > 5 ? .orange : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func text(age: TimeInterval) -> String {
|
||||
guard reading.hasReading else { return reading.statusText }
|
||||
let source = reading.isDirect ? "Sensor" : "iPhone"
|
||||
if age < 3 { return "\(source), eben gemessen" }
|
||||
// Steht der Wert, gehört der Grund dazu und nicht nur das Alter.
|
||||
if age < 90 { return String(format: "%@, vor %.0f s · %@", source, age, reading.statusText) }
|
||||
return String(format: "%@, vor %.0f min · %@", source, age / 60, reading.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die angezeigte Neigung samt Herkunft.
|
||||
///
|
||||
/// Die eigene Verbindung hat Vorrang, solange sie **frisch** ist. Das „solange“
|
||||
/// ist der Punkt: Bleibt der Sensor stehen, ohne die Verbindung zu trennen,
|
||||
/// würde ein einmal gelesener Wert sonst für immer stehenbleiben – und eine
|
||||
/// eingefrorene Anzeige ist beim Ausrichten schlimmer als gar keine. Dann tritt
|
||||
/// der Stand des iPhones an seine Stelle, und wenn auch der fehlt, bleibt der
|
||||
/// alte Wert stehen, aber mit seinem Alter daneben.
|
||||
struct WatchLevelReading {
|
||||
let state: LevelState
|
||||
let isDirect: Bool
|
||||
let measuredAt: Date?
|
||||
let statusText: String
|
||||
|
||||
/// Ab hier gilt ein direkt gemessener Wert als überholt. Gefragt wird
|
||||
/// zweimal je Sekunde ab; drei Sekunden Stille sind schon viel.
|
||||
static let directLifetime: TimeInterval = 3
|
||||
|
||||
init(radio: WatchLevelRadio, phone: PhoneLink, now: Date = Date()) {
|
||||
let directAge = radio.updatedAt.map { now.timeIntervalSince($0) }
|
||||
let hasFreshDirect = radio.state.hasReading
|
||||
&& (directAge ?? .infinity) <= Self.directLifetime
|
||||
let relayed = phone.payload?.levelDevice
|
||||
|
||||
if hasFreshDirect {
|
||||
state = radio.state
|
||||
isDirect = true
|
||||
measuredAt = radio.updatedAt
|
||||
statusText = radio.statusText
|
||||
} else if let relayed, let level = relayed.level {
|
||||
state = level
|
||||
isDirect = false
|
||||
measuredAt = relayed.snapshot?.timestamp ?? phone.payload?.generatedAt
|
||||
statusText = relayed.link.label
|
||||
} else if radio.state.hasReading {
|
||||
state = radio.state
|
||||
isDirect = true
|
||||
measuredAt = radio.updatedAt
|
||||
statusText = radio.statusText
|
||||
} else {
|
||||
state = LevelState()
|
||||
isDirect = false
|
||||
measuredAt = nil
|
||||
statusText = radio.statusText
|
||||
}
|
||||
}
|
||||
|
||||
var hasReading: Bool { state.hasReading }
|
||||
|
||||
func age(now: Date = Date()) -> TimeInterval {
|
||||
guard let measuredAt else { return 0 }
|
||||
return now.timeIntervalSince(measuredAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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
|
||||
@Environment(WatchLevelRadio.self) private var radio
|
||||
|
||||
/// Der Weg durch die Ansichten liegt hier, damit ihn auch die Komplikation
|
||||
/// setzen kann: Ein Tipp aufs Zifferblatt springt direkt zur Nivellierung,
|
||||
/// statt in der Geräteliste zu landen.
|
||||
@State private var path: [WatchRoute] = []
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
content
|
||||
.navigationTitle(link.payload?.profile.name ?? "Camper")
|
||||
.navigationDestination(for: WatchRoute.self) { route in
|
||||
switch route {
|
||||
case .level: WatchLevelView()
|
||||
case .device(let id): WatchDeviceDetailView(deviceID: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
guard WatchDeepLink.isLevel(url) else { return }
|
||||
// Nicht anhängen, sondern ersetzen: Wer vom Zifferblatt kommt, will
|
||||
// die Nivellierung sehen und nicht einen Stapel von vorhin.
|
||||
path = [.level]
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
List {
|
||||
// Die Nivellierung steht immer oben und immer da: Sie kommt vom
|
||||
// Sensor selbst und braucht das iPhone nicht.
|
||||
Section {
|
||||
NavigationLink(value: WatchRoute.level) {
|
||||
LevelSummaryRow(reading: WatchLevelReading(radio: radio, phone: link))
|
||||
}
|
||||
}
|
||||
|
||||
if let payload = link.payload {
|
||||
Section {
|
||||
ForEach(payload.devices.filter { $0.role != .leveling }) { device in
|
||||
NavigationLink(value: WatchRoute.device(device.id)) {
|
||||
DeviceRow(device: device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
FreshnessNote(payload: payload, receivedAt: link.receivedAt)
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
waiting
|
||||
} footer: {
|
||||
Text("Batterie, Solar und Kühlbox kommen vom iPhone. Die "
|
||||
+ "Nivellierung oben nicht – die geht direkt.")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var waiting: some View {
|
||||
VStack(spacing: 4) {
|
||||
Label("Warte auf das iPhone", systemImage: "iphone.gen3.radiowaves.left.and.right")
|
||||
.font(.footnote.weight(.medium))
|
||||
Text("Dort muss VanControl Pro geöffnet sein.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wohin die Uhr springen kann. Als eigener Typ, weil der Weg von aussen
|
||||
/// gesetzt wird – von der Komplikation.
|
||||
enum WatchRoute: Hashable {
|
||||
case level
|
||||
case device(UUID)
|
||||
}
|
||||
|
||||
/// 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 reading: WatchLevelReading
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
WatchBubble(pitch: reading.state.pitch, roll: reading.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(reading.hasReading
|
||||
? (reading.state.instruction ?? "Keine Werte")
|
||||
: reading.statusText)
|
||||
.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,67 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Neigung am Fahrzeug selbst, wie am iPhone – nur auf Uhrgrösse gebracht.
|
||||
///
|
||||
/// Zwei Ansichten, jede um ihre Achse gekippt: die Seitenansicht für längs
|
||||
/// (Front links, Heck rechts) und die Heckansicht für quer. Die Heckansicht
|
||||
/// teilt die Blickrichtung des Fahrers, links im Bild ist also links am
|
||||
/// Fahrzeug.
|
||||
struct WatchVehicleView: View {
|
||||
let pitch: Double?
|
||||
let roll: Double?
|
||||
|
||||
/// Wie am iPhone dreifach überhöht – zwei Grad wären sonst kaum zu sehen.
|
||||
/// Die angeschriebenen Gradzahlen bleiben echt.
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
panel(image: "VansterSide", angle: pitch, title: "Längs",
|
||||
lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect)
|
||||
panel(image: "VansterRear", angle: roll, title: "Quer",
|
||||
lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect)
|
||||
}
|
||||
}
|
||||
|
||||
private func panel(image: String,
|
||||
angle: Double?,
|
||||
title: String,
|
||||
lower: String,
|
||||
upper: String,
|
||||
aspect: Double) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
Text(angle.map { String(format: "%.1f°", $0) } ?? "–")
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(VehicleTilt.colour(for: angle))
|
||||
}
|
||||
.font(.caption2)
|
||||
|
||||
ZStack {
|
||||
// Die Waagerechte als Bezug – ohne sie ist eine kleine Neigung
|
||||
// nicht einzuschätzen.
|
||||
Rectangle()
|
||||
.fill(Color.secondary.opacity(0.35))
|
||||
.frame(height: 1)
|
||||
|
||||
Image(image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.foregroundStyle(VehicleTilt.colour(for: angle))
|
||||
.aspectRatio(aspect, contentMode: .fit)
|
||||
.rotationEffect(.degrees(-(angle ?? 0) * VehicleTilt.exaggeration))
|
||||
.animation(.spring(duration: 0.4), value: angle)
|
||||
.opacity(angle == nil ? 0.3 : 1)
|
||||
}
|
||||
.frame(height: 52)
|
||||
|
||||
HStack {
|
||||
Text(lower)
|
||||
Spacer()
|
||||
Text(upper)
|
||||
}
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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.VanControl.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 victronSolarID = 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 victronSolar = DeviceSnapshot(deviceID: victronSolarID, timestamp: now, rssi: -58)
|
||||
victronSolar.state = "Konstantspannung (Absorption)"
|
||||
victronSolar.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, orientation: .identity, fridge: nil),
|
||||
WatchDevice(id: batteryID, name: "Bulltron 200 Ah", role: .bms,
|
||||
link: .live, snapshot: battery, level: nil,
|
||||
orientation: nil, fridge: nil),
|
||||
WatchDevice(id: victronSolarID, name: "Solar Dach", role: .victronSolarCharger,
|
||||
link: .live, snapshot: victronSolar, level: nil,
|
||||
orientation: nil, fridge: nil),
|
||||
WatchDevice(id: fridgeID, name: "Kühlbox", role: .fridge,
|
||||
link: .live, snapshot: fridge, level: nil, orientation: nil,
|
||||
fridge: WatchFridge(isLive: true, updated: now,
|
||||
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)),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Die eigene Funkverbindung der Uhr zum Neigungsmesser.
|
||||
///
|
||||
/// Für dieses eine Gerät geht die Uhr direkt hin, statt über das iPhone. Das
|
||||
/// ist genau dort möglich, wo es beim Rest nicht geht:
|
||||
///
|
||||
/// * Der Neigungsmesser **bewirbt seinen Dienst**. Die Uhr findet ihn also von
|
||||
/// selbst und muss nichts eingerichtet bekommen – anders als BMS und
|
||||
/// Kühlbox, die über die Kennung des Peripheriegeräts gesucht werden.
|
||||
/// * Er ist **unverschlüsselt**. Es braucht keinen Victron-Schlüssel, der auf
|
||||
/// der Uhr ein zweites Mal lagern müsste.
|
||||
///
|
||||
/// Damit ist die Nivellierung auf der Uhr eigenständig: kein geöffnetes iPhone,
|
||||
/// keine Reichweite zum Telefon. Für alles andere bleibt der Weg über das
|
||||
/// iPhone, siehe `PhoneLink`.
|
||||
///
|
||||
/// Gefunkt wird nur im Vordergrund. watchOS lässt eine App im Hintergrund
|
||||
/// ohnehin kaum scannen, und beim Ausrichten schaut man auf die Uhr.
|
||||
@Observable
|
||||
final class WatchLevelRadio: NSObject {
|
||||
|
||||
private(set) var state = LevelState()
|
||||
private(set) var link: DeviceLinkState = .idle
|
||||
/// Wann zuletzt ein Wert vom Sensor kam.
|
||||
private(set) var updatedAt: Date?
|
||||
private(set) var statusText = "Bluetooth wird gestartet…"
|
||||
|
||||
/// Ob die Einbaulage aus dem Gerät selbst stammt. Dann hat sie Vorrang vor
|
||||
/// dem, was das iPhone meldet – im Gerät steht, wie der Sensor eingebaut
|
||||
/// ist, und das ist für alle Clients dasselbe.
|
||||
private(set) var hasDeviceOrientation = false
|
||||
|
||||
/// Die Einbaulage des Sensors. Kommt vom Gerät oder vom iPhone und wird
|
||||
/// hier behalten,
|
||||
/// damit die Uhr auch ohne Telefon richtig rechnet – ohne sie stünden
|
||||
/// längs und quer je nach Einbau vertauscht oder mit falschem Vorzeichen.
|
||||
var orientation = SensorOrientation.identity {
|
||||
didSet {
|
||||
guard orientation != oldValue else { return }
|
||||
Self.store(orientation)
|
||||
let value = orientation
|
||||
queue.async { self.session?.orientation = value }
|
||||
}
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "de.fritob.VanControl.watch.ble")
|
||||
private var central: CBCentralManager?
|
||||
private var peripheral: CBPeripheral?
|
||||
private var session: LevelSession?
|
||||
private var isRunning = false
|
||||
|
||||
/// Wie lange nach einem Fehlschlag gewartet wird, bevor neu gesucht wird.
|
||||
///
|
||||
/// Ohne diese Bremse dreht es sich im Kreis: Ist der Sensor gerade nicht zu
|
||||
/// haben – etwa weil er schon mit dem iPhone spricht –, folgt auf jeden
|
||||
/// Verbindungsversuch sofort der Abbruch, auf den Abbruch sofort die
|
||||
/// nächste Suche, und das so schnell, wie das Funkmodul es zulässt. Die
|
||||
/// Uhr ist dann mit Verbindungsaufbau beschäftigt statt mit Anzeigen, und
|
||||
/// watchOS schiesst die App irgendwann wegen Speicher ab.
|
||||
private var retryDelay: TimeInterval = 1
|
||||
private static let maximumRetryDelay: TimeInterval = 8
|
||||
private var isRetryScheduled = false
|
||||
|
||||
/// Anzeigewerte höchstens fünfmal je Sekunde weiterreichen. Der Sensor
|
||||
/// meldet je nach Firmware deutlich öfter; jede Meldung ist sonst ein
|
||||
/// Neuzeichnen der Oberfläche, und dafür ist die Uhr zu klein.
|
||||
private static let publishInterval: TimeInterval = 0.2
|
||||
private var lastPublished = Date.distantPast
|
||||
|
||||
/// Kennung für die Momentaufnahmen dieser Sitzung. Auf der Uhr gibt es
|
||||
/// keine Geräteliste, in der sie nachgeschlagen würde – sie muss nur
|
||||
/// innerhalb eines Laufs gleich bleiben.
|
||||
private let deviceID = UUID()
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
orientation = Self.storedOrientation()
|
||||
}
|
||||
|
||||
// MARK: - Steuerung
|
||||
|
||||
func start() {
|
||||
isRunning = true
|
||||
if central == nil {
|
||||
central = CBCentralManager(delegate: self, queue: queue)
|
||||
} else {
|
||||
queue.async { self.scanIfPossible() }
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isRunning = false
|
||||
queue.async {
|
||||
self.central?.stopScan()
|
||||
self.session?.stop()
|
||||
if let peripheral = self.peripheral, peripheral.state != .disconnected {
|
||||
self.central?.cancelPeripheralConnection(peripheral)
|
||||
}
|
||||
}
|
||||
publish { self.link = .idle }
|
||||
}
|
||||
|
||||
private func scanIfPossible() {
|
||||
guard isRunning, let central, central.state == .poweredOn else { return }
|
||||
if let peripheral, peripheral.state == .connected { return }
|
||||
central.scanForPeripherals(withServices: [LevelSession.serviceUUID])
|
||||
publish {
|
||||
self.link = .searching
|
||||
self.statusText = "Suche Neigungsmesser…"
|
||||
}
|
||||
}
|
||||
|
||||
/// Nach einem Fehlschlag: erst warten, dann wieder suchen. Die Wartezeit
|
||||
/// wächst, bis der Sensor wieder antwortet.
|
||||
private func scanAfterBackOff() {
|
||||
guard isRunning, !isRetryScheduled else { return }
|
||||
isRetryScheduled = true
|
||||
let delay = retryDelay
|
||||
retryDelay = min(retryDelay * 2, Self.maximumRetryDelay)
|
||||
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.isRetryScheduled = false
|
||||
self?.scanIfPossible()
|
||||
}
|
||||
}
|
||||
|
||||
private func publish(_ work: @escaping () -> Void) {
|
||||
if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) }
|
||||
}
|
||||
|
||||
// MARK: - Einbaulage merken
|
||||
|
||||
private static let orientationKey = "sensorOrientation"
|
||||
|
||||
private static func storedOrientation() -> SensorOrientation {
|
||||
guard let data = UserDefaults.standard.data(forKey: orientationKey),
|
||||
let value = try? JSONDecoder().decode(SensorOrientation.self, from: data)
|
||||
else { return .identity }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func store(_ orientation: SensorOrientation) {
|
||||
guard let data = try? JSONEncoder().encode(orientation) else { return }
|
||||
UserDefaults.standard.set(data, forKey: orientationKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CBCentralManagerDelegate
|
||||
|
||||
extension WatchLevelRadio: CBCentralManagerDelegate {
|
||||
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
switch central.state {
|
||||
case .poweredOn:
|
||||
scanIfPossible()
|
||||
case .unauthorized:
|
||||
publish {
|
||||
self.link = .failed("Bluetooth nicht erlaubt")
|
||||
self.statusText = "Bluetooth für die App erlauben"
|
||||
}
|
||||
case .poweredOff:
|
||||
publish {
|
||||
self.link = .failed("Bluetooth aus")
|
||||
self.statusText = "Bluetooth ist ausgeschaltet"
|
||||
}
|
||||
default:
|
||||
publish { self.statusText = "Bluetooth nicht verfügbar" }
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager,
|
||||
didDiscover peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any],
|
||||
rssi RSSI: NSNumber) {
|
||||
// Nach dem Abschalten der Suche können noch Meldungen nachkommen. Ein
|
||||
// zweiter Verbindungsversuch auf dasselbe Gerät bringt nichts und
|
||||
// erzeugt nur eine weitere Trennung, auf die wieder eine Suche folgt.
|
||||
if let existing = self.peripheral, existing.state != .disconnected { return }
|
||||
|
||||
// Gesucht wird nach dem Dienst, nicht nach dem Namen: der lässt sich in
|
||||
// der Firmware ändern, die Dienstkennung nicht.
|
||||
central.stopScan()
|
||||
self.peripheral = peripheral
|
||||
central.connect(peripheral)
|
||||
publish {
|
||||
self.link = .connecting
|
||||
self.statusText = "Verbinde…"
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
let session = LevelSession(
|
||||
deviceID: deviceID,
|
||||
peripheral: peripheral,
|
||||
queue: queue,
|
||||
onUpdate: { _ in },
|
||||
onStateChange: { [weak self] state in
|
||||
self?.publish { self?.link = state }
|
||||
},
|
||||
onLevelState: { [weak self] state in
|
||||
guard let self else { return }
|
||||
// Es kommen Werte: Der Sensor ist zu haben, die Wartezeit darf
|
||||
// wieder von vorn beginnen.
|
||||
self.retryDelay = 1
|
||||
let now = Date()
|
||||
guard now.timeIntervalSince(self.lastPublished) >= Self.publishInterval
|
||||
else { return }
|
||||
self.lastPublished = now
|
||||
self.publish {
|
||||
self.state = state
|
||||
self.updatedAt = now
|
||||
self.statusText = "Direkt am Sensor"
|
||||
}
|
||||
},
|
||||
onDeviceOrientation: { [weak self] orientation in
|
||||
guard let self else { return }
|
||||
Self.store(orientation)
|
||||
self.publish {
|
||||
self.hasDeviceOrientation = true
|
||||
self.orientation = orientation
|
||||
}
|
||||
})
|
||||
session.orientation = orientation
|
||||
self.session = session
|
||||
session.start()
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager,
|
||||
didFailToConnect peripheral: CBPeripheral,
|
||||
error: Error?) {
|
||||
publish { self.link = .failed(error?.localizedDescription ?? "Verbindung gescheitert") }
|
||||
scanAfterBackOff()
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager,
|
||||
didDisconnectPeripheral peripheral: CBPeripheral,
|
||||
error: Error?) {
|
||||
session?.handleDisconnect()
|
||||
session = nil
|
||||
publish {
|
||||
// Die zuletzt gemessene Lage bleibt stehen; wie alt sie ist, steht
|
||||
// daneben. Sie wegzuwerfen hiesse, beim Rangieren mitten in der
|
||||
// Bewegung einen leeren Bildschirm zu zeigen.
|
||||
self.link = .searching
|
||||
self.statusText = "Verbindung verloren – suche erneut"
|
||||
}
|
||||
scanAfterBackOff()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user