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>
98 lines
2.8 KiB
Swift
98 lines
2.8 KiB
Swift
import Foundation
|
||
|
||
/// Eine einzelne Messgrösse in einer bereits formatierten Form.
|
||
struct Metric: Identifiable, Hashable, Codable, Sendable {
|
||
let key: String
|
||
let label: String
|
||
let value: Double?
|
||
let unit: String
|
||
/// Nachkommastellen für die Anzeige.
|
||
let precision: Int
|
||
/// Wird auf der Kachel gross dargestellt.
|
||
let isPrimary: Bool
|
||
|
||
var id: String { key }
|
||
|
||
init(_ key: String,
|
||
_ label: String,
|
||
_ value: Double?,
|
||
unit: String,
|
||
precision: Int = 2,
|
||
primary: Bool = false) {
|
||
self.key = key
|
||
self.label = label
|
||
self.value = value
|
||
self.unit = unit
|
||
self.precision = precision
|
||
self.isPrimary = primary
|
||
}
|
||
|
||
var formatted: String {
|
||
guard let value else { return "–" }
|
||
return String(format: "%.\(precision)f", value)
|
||
}
|
||
|
||
var formattedWithUnit: String {
|
||
guard value != nil else { return "–" }
|
||
return unit.isEmpty ? formatted : "\(formatted) \(unit)"
|
||
}
|
||
}
|
||
|
||
/// Der komplette, zuletzt empfangene Zustand eines Geräts.
|
||
struct DeviceSnapshot: Identifiable, Codable, Equatable, Sendable {
|
||
var id: UUID { deviceID }
|
||
var deviceID: UUID
|
||
var timestamp: Date
|
||
var metrics: [Metric] = []
|
||
/// z.B. "Bulk", "Float", "Aus"
|
||
var state: String?
|
||
/// Klartext einer aktiven Störung, sonst nil.
|
||
var fault: String?
|
||
/// Grund, warum das Gerät gerade nicht lädt (Victron Off-Reason).
|
||
var offReasons: [String] = []
|
||
var rssi: Int?
|
||
/// Einzelzellspannungen in Volt (nur BMS).
|
||
var cellVoltages: [Double] = []
|
||
/// Temperaturfühler in °C (nur BMS).
|
||
var temperatures: [Double] = []
|
||
/// Feste Angaben des Geräts, etwa Modell oder Seriennummer.
|
||
var info: [InfoItem] = []
|
||
|
||
struct InfoItem: Identifiable, Hashable, Codable, Sendable {
|
||
let label: String
|
||
let value: String
|
||
var id: String { label }
|
||
}
|
||
|
||
var primaryMetric: Metric? {
|
||
metrics.first(where: \.isPrimary) ?? metrics.first
|
||
}
|
||
|
||
var age: TimeInterval { Date().timeIntervalSince(timestamp) }
|
||
|
||
/// Werte älter als eine Minute gelten als veraltet – Victron sendet etwa
|
||
/// jede Sekunde, das Daly wird alle paar Sekunden gepollt.
|
||
var isStale: Bool { age > 60 }
|
||
}
|
||
|
||
/// Verbindungszustand für die UI.
|
||
enum DeviceLinkState: Equatable, Codable, Sendable {
|
||
case idle
|
||
case searching
|
||
case connecting
|
||
case live
|
||
case needsKey
|
||
case failed(String)
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .idle: return "Inaktiv"
|
||
case .searching: return "Suche…"
|
||
case .connecting: return "Verbinde…"
|
||
case .live: return "Live"
|
||
case .needsKey: return "Schlüssel fehlt"
|
||
case .failed(let m): return m
|
||
}
|
||
}
|
||
}
|