forked from fritob/Camper-Monitor
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:
@@ -49,82 +49,3 @@ enum VanAlignProtocol {
|
||||
return (pitch, roll)
|
||||
}
|
||||
}
|
||||
|
||||
/// Zustand des Neigungsmessers.
|
||||
struct LevelState: Equatable {
|
||||
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
|
||||
/// Bereits auf die Einbaulage des Sensors umgerechnet.
|
||||
var pitch: Double?
|
||||
/// Querneigung: positiv bedeutet, die rechte Seite steht höher.
|
||||
var roll: Double?
|
||||
/// Wie der Sensor selbst meldet – nötig, um die Einbaulage zu bestimmen.
|
||||
var rawPitch: Double?
|
||||
var rawRoll: Double?
|
||||
var pitchOffset: Double?
|
||||
var rollOffset: Double?
|
||||
|
||||
var hasReading: Bool { pitch != nil || roll != nil }
|
||||
|
||||
/// Ob kalibriert wurde – oder nil, wenn das Gerät es nicht verrät.
|
||||
///
|
||||
/// Nicht jede Firmware stellt die Offsets bereit. Fehlen sie, heisst das
|
||||
/// „unbekannt“ und nicht „nicht kalibriert“: eine Warnung, die sich nie
|
||||
/// abstellen lässt, ist schlimmer als keine.
|
||||
var calibrationState: Bool? {
|
||||
guard let pitchOffset, let rollOffset else { return nil }
|
||||
return abs(pitchOffset) > 0.001 || abs(rollOffset) > 0.001
|
||||
}
|
||||
|
||||
/// Nur wahr, wenn es auch belegt ist.
|
||||
var isCalibrated: Bool { calibrationState == true }
|
||||
|
||||
/// Nur wahr, wenn das Gerät ausdrücklich Nulloffsets meldet.
|
||||
var isKnownUncalibrated: Bool { calibrationState == false }
|
||||
|
||||
/// Bis hierhin gilt das Fahrzeug als eben genug.
|
||||
static let levelTolerance = 0.5
|
||||
|
||||
var isLevel: Bool {
|
||||
guard let pitch, let roll else { return false }
|
||||
return abs(pitch) <= Self.levelTolerance && abs(roll) <= Self.levelTolerance
|
||||
}
|
||||
|
||||
/// Die grössere der beiden Abweichungen – das ist die, die man zuerst
|
||||
/// ausgleichen will.
|
||||
var largestDeviation: 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Was zu tun ist, in Worten. Beim Ausrichten schaut man aufs Handy,
|
||||
/// nicht auf Vorzeichen.
|
||||
var instruction: String? {
|
||||
guard let pitch, let roll else { return nil }
|
||||
if isLevel { return "Steht eben" }
|
||||
var parts: [String] = []
|
||||
if abs(pitch) > Self.levelTolerance {
|
||||
parts.append(pitch > 0 ? "Heck steht höher" : "Front steht höher")
|
||||
}
|
||||
if abs(roll) > Self.levelTolerance {
|
||||
parts.append(roll > 0 ? "rechts steht höher" : "links steht höher")
|
||||
}
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
|
||||
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
|
||||
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
|
||||
snapshot.metrics = [
|
||||
Metric("pitch", "Längsneigung", pitch, unit: "°", precision: 1, primary: true),
|
||||
Metric("roll", "Querneigung", roll, unit: "°", precision: 1),
|
||||
]
|
||||
snapshot.state = instruction
|
||||
if isKnownUncalibrated {
|
||||
snapshot.offReasons = ["Nicht kalibriert"]
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ import SwiftUI
|
||||
struct CamperMonitorApp: App {
|
||||
@State private var store: DeviceStore
|
||||
@State private var bluetooth: BluetoothManager
|
||||
@State private var watch: PhoneWatchLink
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
init() {
|
||||
let store = DeviceStore()
|
||||
let bluetooth = BluetoothManager(store: store)
|
||||
_store = State(initialValue: store)
|
||||
_bluetooth = State(initialValue: BluetoothManager(store: store))
|
||||
_bluetooth = State(initialValue: bluetooth)
|
||||
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
@@ -17,14 +20,27 @@ struct CamperMonitorApp: App {
|
||||
DashboardView()
|
||||
.environment(store)
|
||||
.environment(bluetooth)
|
||||
.environment(watch)
|
||||
.task { watch.activate() }
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
// Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt
|
||||
// werden, also Funk sparen und beim Zurückkommen neu starten.
|
||||
//
|
||||
// Ausnahme: Solange jemand auf die Uhr schaut, läuft es weiter. Beim
|
||||
// Rangieren liegt das iPhone in der Halterung mit dunklem Bildschirm
|
||||
// – hörte das Funkgerät dann auf, wäre die Anzeige am Handgelenk
|
||||
// genau dann tot, wenn sie gebraucht wird. Die verbundenen Geräte
|
||||
// (Neigungsmesser, BMS, Kühlbox) liefern im Hintergrund weiter; die
|
||||
// Victron-Werbedaten nicht, dafür müsste ohne Filter gesucht werden.
|
||||
watch.isAppInBackground = phase != .active
|
||||
switch phase {
|
||||
case .active: bluetooth.start()
|
||||
case .background: bluetooth.stop()
|
||||
default: break
|
||||
case .active:
|
||||
bluetooth.start()
|
||||
case .background:
|
||||
if !watch.wantsLiveUpdates { bluetooth.stop() }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Hilft beim Ausrichten des Fahrzeugs während des Rangierens.
|
||||
///
|
||||
/// Bewusst **ohne** Positionsbestimmung: Aus einem MEMS-Beschleunigungssensor
|
||||
/// lässt sich keine brauchbare Strecke ableiten, weil der Fehler beim
|
||||
/// zweifachen Integrieren quadratisch mit der Zeit wächst. Beim Rangieren im
|
||||
/// Schritttempo gehen die tatsächlichen Beschleunigungen ohnehin im Rauschen
|
||||
/// unter.
|
||||
///
|
||||
/// Gebraucht wird das auch gar nicht. Die Frage beim Einparken lautet nie „wo
|
||||
/// stehe ich“, sondern „wird es besser oder schlechter, und wo war es am
|
||||
/// besten“. Beides steckt bereits im zeitlichen Verlauf der Neigung – ganz
|
||||
/// ohne Annahmen über das Gelände.
|
||||
struct AlignmentAssistant {
|
||||
|
||||
struct Sample: Equatable {
|
||||
let time: Date
|
||||
let pitch: Double
|
||||
let roll: Double
|
||||
|
||||
/// Gesamtabweichung von der Waagerechten.
|
||||
var deviation: Double { (pitch * pitch + roll * roll).squareRoot() }
|
||||
}
|
||||
|
||||
/// Wie lange zurückgeschaut wird.
|
||||
static let memory: TimeInterval = 90
|
||||
/// Ab dieser Verbesserung lohnt der Hinweis auf einen früheren Punkt.
|
||||
static let worthGoingBack = 0.2
|
||||
/// Unterhalb dieser Änderung gilt die Lage als unverändert.
|
||||
static let trendThreshold = 0.08
|
||||
|
||||
private(set) var samples: [Sample] = []
|
||||
|
||||
enum Trend: Equatable {
|
||||
case improving
|
||||
case worsening
|
||||
case steady
|
||||
case unknown
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .improving: return "Wird besser"
|
||||
case .worsening: return "Wird schlechter"
|
||||
case .steady: return "Bleibt gleich"
|
||||
case .unknown: return "Messe…"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .improving: return "arrow.down.right.circle.fill"
|
||||
case .worsening: return "arrow.up.right.circle.fill"
|
||||
case .steady: return "equal.circle.fill"
|
||||
case .unknown: return "hourglass.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutating func add(pitch: Double, roll: Double, at time: Date = Date()) {
|
||||
samples.append(Sample(time: time, pitch: pitch, roll: roll))
|
||||
let cutoff = time.addingTimeInterval(-Self.memory)
|
||||
samples.removeAll { $0.time < cutoff }
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
samples.removeAll()
|
||||
}
|
||||
|
||||
var current: Sample? { samples.last }
|
||||
|
||||
/// Der flachste Punkt, den wir gesehen haben.
|
||||
var best: Sample? {
|
||||
samples.min { $0.deviation < $1.deviation }
|
||||
}
|
||||
|
||||
/// Ob und wie stark sich die Lage gerade ändert.
|
||||
///
|
||||
/// Verglichen wird das jüngste Drittel mit dem davorliegenden. Einzelne
|
||||
/// Messwerte wären zu unruhig; das Fahrzeug wippt beim Rangieren.
|
||||
var trend: Trend {
|
||||
guard samples.count >= 6 else { return .unknown }
|
||||
let recent = samples.suffix(3)
|
||||
let previous = samples.dropLast(3).suffix(3)
|
||||
guard !previous.isEmpty else { return .unknown }
|
||||
|
||||
let now = recent.map(\.deviation).reduce(0, +) / Double(recent.count)
|
||||
let before = previous.map(\.deviation).reduce(0, +) / Double(previous.count)
|
||||
let change = now - before
|
||||
|
||||
if abs(change) < Self.trendThreshold { return .steady }
|
||||
return change < 0 ? .improving : .worsening
|
||||
}
|
||||
|
||||
/// Wieviel besser der beste Punkt gegenüber jetzt war – nil, wenn es sich
|
||||
/// nicht lohnt oder wir gerade selbst am besten Punkt stehen.
|
||||
var improvementAtBest: Double? {
|
||||
guard let current, let best, best.time < current.time else { return nil }
|
||||
let gain = current.deviation - best.deviation
|
||||
return gain >= Self.worthGoingBack ? gain : nil
|
||||
}
|
||||
|
||||
/// Wie lange der beste Punkt zurückliegt.
|
||||
var timeSinceBest: TimeInterval? {
|
||||
guard improvementAtBest != nil, let current, let best else { return nil }
|
||||
return current.time.timeIntervalSince(best.time)
|
||||
}
|
||||
|
||||
/// Was der Fahrer jetzt tun soll.
|
||||
var advice: String {
|
||||
guard let current else { return "Warte auf Messwerte…" }
|
||||
if current.deviation <= LevelState.levelTolerance {
|
||||
return "Steht eben – anhalten"
|
||||
}
|
||||
if let seconds = timeSinceBest {
|
||||
return String(format: "Vor %.0f s stand es besser – ein Stück zurück",
|
||||
seconds.rounded())
|
||||
}
|
||||
switch trend {
|
||||
case .improving: return "Wird besser – weiter so"
|
||||
case .worsening: return "Wird schlechter – andere Richtung"
|
||||
case .steady: return "Ändert sich kaum – andere Richtung versuchen"
|
||||
case .unknown: return "Langsam weiterfahren"
|
||||
}
|
||||
}
|
||||
|
||||
var hasReachedTarget: Bool {
|
||||
(current?.deviation ?? .infinity) <= LevelState.levelTolerance
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie hoch ein Auffahrkeil sein muss, um eine Neigung auszugleichen.
|
||||
///
|
||||
/// Rein geometrisch und damit exakt: Höhe = tan(Winkel) × Abstand der Achsen
|
||||
/// beziehungsweise der Räder.
|
||||
struct LevelingWedge: Equatable {
|
||||
/// Wo der Keil hin muss.
|
||||
let side: Side
|
||||
/// Höhe in Metern.
|
||||
let height: Double
|
||||
/// Der zugrundeliegende Winkel in Grad.
|
||||
let angle: Double
|
||||
|
||||
enum Side: Equatable {
|
||||
case front, rear, left, right
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .front: return "vorne"
|
||||
case .rear: return "hinten"
|
||||
case .left: return "links"
|
||||
case .right: return "rechts"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var heightInCentimetres: Double { height * 100 }
|
||||
|
||||
/// Quer: die tieferliegende Seite muss angehoben werden. Positiver Roll
|
||||
/// heisst, dass rechts höher steht – der Keil gehört also nach links.
|
||||
static func across(roll: Double, trackWidth: Double) -> LevelingWedge? {
|
||||
wedge(angle: roll, distance: trackWidth,
|
||||
whenPositive: .left, whenNegative: .right)
|
||||
}
|
||||
|
||||
/// Längs: positiver Pitch heisst, dass das Heck höher steht – der Keil
|
||||
/// gehört unter die Vorderräder.
|
||||
static func along(pitch: Double, wheelbase: Double) -> LevelingWedge? {
|
||||
wedge(angle: pitch, distance: wheelbase,
|
||||
whenPositive: .front, whenNegative: .rear)
|
||||
}
|
||||
|
||||
private static func wedge(angle: Double,
|
||||
distance: Double,
|
||||
whenPositive: Side,
|
||||
whenNegative: Side) -> LevelingWedge? {
|
||||
guard distance > 0, abs(angle) > LevelState.levelTolerance else { return nil }
|
||||
let height = tan(abs(angle) * .pi / 180) * distance
|
||||
return LevelingWedge(side: angle > 0 ? whenPositive : whenNegative,
|
||||
height: height,
|
||||
angle: abs(angle))
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Welche Rolle ein Gerät im Camper spielt. Bestimmt Icon, Sortierung und
|
||||
/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
|
||||
enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case chargeBooster
|
||||
case solarCharger
|
||||
case batteryMonitor
|
||||
case bms
|
||||
case fridge
|
||||
case leveling
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .chargeBooster: return "Ladebooster"
|
||||
case .solarCharger: return "Solarladeregler"
|
||||
case .batteryMonitor: return "Batteriemonitor"
|
||||
case .bms: return "Batterie / BMS"
|
||||
case .fridge: return "Kühlbox"
|
||||
case .leveling: return "Nivellierung"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .chargeBooster: return "bolt.car"
|
||||
case .solarCharger: return "sun.max"
|
||||
case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
|
||||
case .bms: return "battery.100percent.bolt"
|
||||
case .fridge: return "refrigerator"
|
||||
case .leveling: return "level"
|
||||
}
|
||||
}
|
||||
|
||||
/// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS
|
||||
/// braucht eine echte GATT-Verbindung.
|
||||
var transport: DeviceTransport {
|
||||
switch self {
|
||||
case .bms, .fridge, .leveling: return .connect
|
||||
default: return .advertisement
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DeviceTransport: Sendable {
|
||||
/// Passives Mitlesen der BLE-Werbedaten (Victron Instant Readout).
|
||||
case advertisement
|
||||
/// Verbindungsaufbau, Kommando schreiben, Antwort per Notify lesen (Daly).
|
||||
case connect
|
||||
}
|
||||
|
||||
/// Wie viele Kühlzonen eine Box hat. Manche Einzonen-Boxen senden den langen
|
||||
/// Datensatz einer Doppelzonen-Box mit, weshalb die Erkennung übersteuerbar ist.
|
||||
enum FridgeZoneMode: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case automatic
|
||||
case single
|
||||
case dual
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .automatic: return "Automatisch"
|
||||
case .single: return "Eine Zone"
|
||||
case .dual: return "Zwei Zonen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein vom Nutzer eingerichtetes Gerät. Der Victron-Schlüssel liegt nicht hier,
|
||||
/// sondern in der Keychain (siehe `KeychainStore`).
|
||||
struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var role: DeviceRole
|
||||
/// Zu welchem Fahrzeug das Gerät gehört.
|
||||
var profileID: UUID
|
||||
/// CoreBluetooth-Identifier des Peripherals. Pro iPhone stabil, aber nicht
|
||||
/// geräteübergreifend – deshalb wird beim Einrichten neu gescannt.
|
||||
var peripheralID: UUID
|
||||
/// Zuletzt gesehener Advertised Name, nur zur Wiedererkennung in der UI.
|
||||
var advertisedName: String?
|
||||
/// Nur für Kühlboxen.
|
||||
var fridgeZoneMode: FridgeZoneMode = .automatic
|
||||
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
|
||||
var sensorOrientation = SensorOrientation.identity
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
name: String,
|
||||
role: DeviceRole,
|
||||
profileID: UUID,
|
||||
peripheralID: UUID,
|
||||
advertisedName: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.role = role
|
||||
self.profileID = profileID
|
||||
self.peripheralID = peripheralID
|
||||
self.advertisedName = advertisedName
|
||||
}
|
||||
|
||||
/// Geräte, die vor der Profilverwaltung angelegt wurden, haben noch keine
|
||||
/// Zuordnung – die landen im Standardprofil.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
role = try container.decode(DeviceRole.self, forKey: .role)
|
||||
profileID = try container.decodeIfPresent(UUID.self, forKey: .profileID)
|
||||
?? Profile.defaultID
|
||||
peripheralID = try container.decode(UUID.self, forKey: .peripheralID)
|
||||
advertisedName = try container.decodeIfPresent(String.self, forKey: .advertisedName)
|
||||
fridgeZoneMode = try container.decodeIfPresent(FridgeZoneMode.self, forKey: .fridgeZoneMode)
|
||||
?? .automatic
|
||||
sensorOrientation = try container.decodeIfPresent(SensorOrientation.self,
|
||||
forKey: .sensorOrientation) ?? .identity
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine einzelne Messgrösse in einer bereits formatierten Form.
|
||||
struct Metric: Identifiable, Hashable, 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, 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, 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, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Ein Fahrzeug. Jedes Profil hat seinen eigenen Satz Geräte; die App zeigt und
|
||||
/// funkt immer nur für das gerade gewählte.
|
||||
struct Profile: Identifiable, Codable, Hashable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
/// SF-Symbol für die Auswahl im Dashboard.
|
||||
var symbol: String
|
||||
|
||||
/// Spurweite in Metern – für die Berechnung der Auffahrkeile quer.
|
||||
var trackWidth: Double?
|
||||
/// Radstand in Metern – dasselbe längs.
|
||||
var wheelbase: Double?
|
||||
|
||||
init(id: UUID = UUID(),
|
||||
name: String,
|
||||
symbol: String = "box.truck",
|
||||
trackWidth: Double? = nil,
|
||||
wheelbase: Double? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.symbol = symbol
|
||||
self.trackWidth = trackWidth
|
||||
self.wheelbase = wheelbase
|
||||
}
|
||||
|
||||
/// Profile aus der Zeit vor den Fahrzeugmassen haben noch keine.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
symbol = try container.decode(String.self, forKey: .symbol)
|
||||
trackWidth = try container.decodeIfPresent(Double.self, forKey: .trackWidth)
|
||||
wheelbase = try container.decodeIfPresent(Double.self, forKey: .wheelbase)
|
||||
}
|
||||
|
||||
/// Profil, dem Geräte aus der Zeit vor der Profilverwaltung zugeordnet
|
||||
/// werden. Feste Kennung, damit die Zuordnung beim Update erhalten bleibt.
|
||||
static let defaultID = UUID(uuidString: "00000000-0000-0000-0000-00000000C001")!
|
||||
|
||||
static var initial: Profile {
|
||||
Profile(id: defaultID, name: "Mein Camper")
|
||||
}
|
||||
|
||||
/// Auswahl für die Profilbearbeitung.
|
||||
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft – ein nicht
|
||||
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
|
||||
static let symbols = [
|
||||
"box.truck", "truck.pickup.side", "bus", "bus.doubledecker",
|
||||
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
|
||||
]
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Wie der Neigungsmesser im Fahrzeug sitzt.
|
||||
///
|
||||
/// Der Sensor kann quer eingebaut, gedreht oder kopfüber montiert sein. Dann
|
||||
/// stimmen seine Achsen nicht mit denen des Fahrzeugs überein: was er als
|
||||
/// Längsneigung meldet, ist womöglich die Querneigung, und Vorzeichen können
|
||||
/// vertauscht sein.
|
||||
///
|
||||
/// Statt das raten zu lassen, ermittelt der Einrichtungsassistent die Lage
|
||||
/// durch zwei definierte Kippbewegungen.
|
||||
struct SensorOrientation: Codable, Equatable, Hashable, Sendable {
|
||||
|
||||
/// Welche Achse des Sensors die Längsneigung des Fahrzeugs liefert.
|
||||
enum Source: String, Codable, Hashable, Sendable {
|
||||
case pitch
|
||||
case roll
|
||||
}
|
||||
|
||||
/// Woher die Längsneigung kommt. Die Querneigung kommt aus der jeweils
|
||||
/// anderen Achse.
|
||||
var longitudinalSource: Source = .pitch
|
||||
var invertLongitudinal = false
|
||||
var invertLateral = false
|
||||
|
||||
static let identity = SensorOrientation()
|
||||
|
||||
var isIdentity: Bool { self == .identity }
|
||||
|
||||
/// Rechnet Sensorwerte in Fahrzeugwerte um.
|
||||
func apply(pitch: Double?, roll: Double?) -> (pitch: Double?, roll: Double?) {
|
||||
let longitudinal = longitudinalSource == .pitch ? pitch : roll
|
||||
let lateral = longitudinalSource == .pitch ? roll : pitch
|
||||
return (longitudinal.map { invertLongitudinal ? -$0 : $0 },
|
||||
lateral.map { invertLateral ? -$0 : $0 })
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
if isIdentity { return "Achsen unverändert" }
|
||||
var parts: [String] = []
|
||||
if longitudinalSource == .roll { parts.append("Achsen getauscht") }
|
||||
if invertLongitudinal { parts.append("längs umgekehrt") }
|
||||
if invertLateral { parts.append("quer umgekehrt") }
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Wertet die Kippbewegungen des Einrichtungsassistenten aus.
|
||||
///
|
||||
/// Zweimal wird gekippt: einmal die Front nach unten, einmal die linke Seite
|
||||
/// nach unten. Welche Sensorachse sich dabei jeweils bewegt und in welche
|
||||
/// Richtung, ergibt die Einbaulage.
|
||||
enum OrientationDetection {
|
||||
|
||||
/// So weit muss gekippt werden, damit die Bewegung eindeutig ist.
|
||||
/// Darunter wäre nicht zu unterscheiden, ob überhaupt gekippt wurde.
|
||||
static let minimumTilt = 5.0
|
||||
|
||||
/// Soviel deutlicher muss die gewinnende Deutung sein als die andere.
|
||||
static let ambiguityMargin = 1.3
|
||||
|
||||
struct Reading: Equatable {
|
||||
let pitch: Double
|
||||
let roll: Double
|
||||
|
||||
static func - (lhs: Reading, rhs: Reading) -> Reading {
|
||||
Reading(pitch: lhs.pitch - rhs.pitch, roll: lhs.roll - rhs.roll)
|
||||
}
|
||||
}
|
||||
|
||||
enum Failure: Error, Equatable {
|
||||
/// Es wurde zu wenig oder gar nicht gekippt.
|
||||
case tooLittleMovement
|
||||
/// Beide Achsen haben sich ähnlich stark bewegt – dann lässt sich
|
||||
/// nicht sagen, welche gemeint war.
|
||||
case ambiguous
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .tooLittleMovement:
|
||||
return "Zu wenig Bewegung. Deutlicher kippen, mindestens eine "
|
||||
+ "Handbreit."
|
||||
case .ambiguous:
|
||||
return "Beide Schritte haben dieselbe Achse bewegt. Im ersten "
|
||||
+ "Schritt nach vorne kippen, im zweiten zur Seite."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ermittelt aus beiden Kippbewegungen die Einbaulage.
|
||||
///
|
||||
/// Beide Bewegungen werden gemeinsam beurteilt. Kippt man von Hand zur
|
||||
/// Seite, geht die andere Achse fast immer ein Stück mit – jede Bewegung
|
||||
/// für sich betrachtet sähe das nach schrägem Kippen aus. Im Paar ist die
|
||||
/// Zuordnung trotzdem eindeutig: es gewinnt die Deutung, die beide
|
||||
/// Bewegungen zusammen am besten erklärt.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - nose: Änderung beim Kippen der Front nach unten.
|
||||
/// - side: Änderung beim Kippen der linken Seite nach unten.
|
||||
static func orientation(nose: Reading,
|
||||
side: Reading) -> Result<SensorOrientation, Failure> {
|
||||
guard max(abs(nose.pitch), abs(nose.roll)) >= minimumTilt,
|
||||
max(abs(side.pitch), abs(side.roll)) >= minimumTilt else {
|
||||
return .failure(.tooLittleMovement)
|
||||
}
|
||||
|
||||
// Zwei mögliche Deutungen, bewertet danach, wieviel Bewegung sie
|
||||
// erklären: die erste Achse längs und die zweite quer – oder umgekehrt.
|
||||
let pitchIsLongitudinal = abs(nose.pitch) + abs(side.roll)
|
||||
let rollIsLongitudinal = abs(nose.roll) + abs(side.pitch)
|
||||
let winner = max(pitchIsLongitudinal, rollIsLongitudinal)
|
||||
let loser = min(pitchIsLongitudinal, rollIsLongitudinal)
|
||||
// Liegen beide Deutungen dicht beieinander, ist die Zuordnung wirklich
|
||||
// nicht zu treffen – etwa wenn zweimal um dieselbe Achse gekippt wurde.
|
||||
guard winner >= ambiguityMargin * loser else { return .failure(.ambiguous) }
|
||||
|
||||
var orientation = SensorOrientation()
|
||||
orientation.longitudinalSource = pitchIsLongitudinal > rollIsLongitudinal ? .pitch : .roll
|
||||
let longitudinal = orientation.longitudinalSource == .pitch ? nose.pitch : nose.roll
|
||||
let lateral = orientation.longitudinalSource == .pitch ? side.roll : side.pitch
|
||||
// Die zugeordnete Achse muss in ihrem Schritt auch deutlich gewandert
|
||||
// sein, sonst stünde das Vorzeichen auf wackligem Grund.
|
||||
guard abs(longitudinal) >= minimumTilt, abs(lateral) >= minimumTilt else {
|
||||
return .failure(.tooLittleMovement)
|
||||
}
|
||||
// Front nach unten heisst: das Heck steht höher, die Längsneigung des
|
||||
// Fahrzeugs ist also positiv.
|
||||
orientation.invertLongitudinal = longitudinal < 0
|
||||
// Linke Seite nach unten heisst: rechts steht höher, die Querneigung
|
||||
// ist positiv.
|
||||
orientation.invertLateral = lateral < 0
|
||||
return .success(orientation)
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Klartexte für die Zustands- und Fehlercodes aus den Victron-Werbedaten.
|
||||
enum VictronCodes {
|
||||
|
||||
/// VE.Reg 0x0201 – Betriebszustand des Laders.
|
||||
static func deviceState(_ code: UInt32?) -> String? {
|
||||
guard let code else { return nil }
|
||||
switch code {
|
||||
case 0: return "Aus"
|
||||
case 1: return "Stromsparmodus"
|
||||
case 2: return "Störung"
|
||||
case 3: return "Konstantstrom (Bulk)"
|
||||
case 4: return "Konstantspannung (Absorption)"
|
||||
case 5: return "Erhaltung (Float)"
|
||||
case 6: return "Lagerung"
|
||||
case 7: return "Ausgleichsladung"
|
||||
case 9: return "Wechselrichten"
|
||||
case 11: return "Netzteilbetrieb"
|
||||
case 245: return "Startet"
|
||||
case 246: return "Wiederholte Absorption"
|
||||
case 247: return "Auto-Ausgleich"
|
||||
case 248: return "Battery Safe"
|
||||
case 252: return "Externe Steuerung"
|
||||
default: return "Zustand \(code)"
|
||||
}
|
||||
}
|
||||
|
||||
/// VE.Reg 0xEDDA – Ladefehler. 0 bedeutet "kein Fehler".
|
||||
static func chargerError(_ code: UInt32?) -> String? {
|
||||
guard let code, code != 0 else { return nil }
|
||||
switch code {
|
||||
case 1: return "Batterietemperatur zu hoch"
|
||||
case 2: return "Batteriespannung zu hoch"
|
||||
case 3: return "Temperatursensor defekt"
|
||||
case 4: return "Temperatursensor Kurzschluss"
|
||||
case 5: return "Temperatursensor unplausibel"
|
||||
case 6: return "Spannungsmessung defekt"
|
||||
case 7: return "Spannungsmessung Kurzschluss"
|
||||
case 8: return "Spannungsmessung unplausibel"
|
||||
case 11: return "Zu hohe Restwelligkeit"
|
||||
case 14: return "Batterietemperatur zu niedrig"
|
||||
case 17: return "Lader überhitzt"
|
||||
case 18: return "Lader Überstrom"
|
||||
case 19: return "Stromrichtung verkehrt"
|
||||
case 20: return "Bulk-Zeit überschritten"
|
||||
case 21: return "Stromsensor defekt"
|
||||
case 22: return "Interner Temperatursensor defekt"
|
||||
case 26: return "Anschlussklemme überhitzt"
|
||||
case 27: return "Kurzschluss im Lader"
|
||||
case 28: return "Endstufenfehler"
|
||||
case 29: return "Überladeschutz"
|
||||
case 33: return "Eingangsspannung zu hoch (PV)"
|
||||
case 34: return "Eingangsstrom zu hoch (PV)"
|
||||
case 38: return "Eingang abgeschaltet (Batteriespannung)"
|
||||
case 39: return "Eingang abgeschaltet (Stromfluss)"
|
||||
case 65: return "Kommunikation verloren"
|
||||
case 66: return "Konfiguration synchronisierter Lader fehlerhaft"
|
||||
case 67: return "BMS-Verbindung verloren"
|
||||
case 68: return "Netzwerk fehlkonfiguriert"
|
||||
case 116: return "Kalibrierdaten verloren"
|
||||
case 117: return "Inkompatible Firmware"
|
||||
case 119: return "Einstellungen ungültig"
|
||||
default: return "Fehler \(code)"
|
||||
}
|
||||
}
|
||||
|
||||
/// VE.Reg 0x0207 – Bitmaske, warum das Gerät gerade nicht arbeitet.
|
||||
static func offReasons(_ mask: UInt32?) -> [String] {
|
||||
guard let mask, mask != 0 else { return [] }
|
||||
let table: [(UInt32, String)] = [
|
||||
(0x0000_0001, "Keine Eingangsspannung"),
|
||||
(0x0000_0002, "Per Schalter ausgeschaltet"),
|
||||
(0x0000_0004, "Per Einstellung ausgeschaltet"),
|
||||
(0x0000_0008, "Remote-Eingang"),
|
||||
(0x0000_0010, "Schutzfunktion aktiv"),
|
||||
(0x0000_0020, "Paygo"),
|
||||
(0x0000_0040, "BMS"),
|
||||
(0x0000_0080, "Motor-Abschalterkennung"),
|
||||
(0x0000_0100, "Eingangsspannung wird geprüft"),
|
||||
]
|
||||
return table.filter { mask & $0.0 != 0 }.map(\.1)
|
||||
}
|
||||
|
||||
/// VE.Reg 0xEEB8 – Alarmgründe des Batteriewächters.
|
||||
static func alarmReasons(_ mask: UInt32?) -> [String] {
|
||||
guard let mask, mask != 0 else { return [] }
|
||||
let table: [(UInt32, String)] = [
|
||||
(0x0001, "Unterspannung"),
|
||||
(0x0002, "Überspannung"),
|
||||
(0x0004, "Niedriger Ladezustand"),
|
||||
(0x0008, "Starterbatterie Unterspannung"),
|
||||
(0x0010, "Starterbatterie Überspannung"),
|
||||
(0x0020, "Temperatur zu niedrig"),
|
||||
(0x0040, "Temperatur zu hoch"),
|
||||
(0x0080, "Mittenspannung"),
|
||||
(0x0100, "Ladung überfällig"),
|
||||
]
|
||||
return table.filter { mask & $0.0 != 0 }.map(\.1)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import SwiftUI
|
||||
/// betrifft, steht bei diesem Gerät.
|
||||
struct SettingsView: View {
|
||||
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
|
||||
@Environment(PhoneWatchLink.self) private var watch
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -21,6 +22,16 @@ struct SettingsView: View {
|
||||
+ "Meldet ein Gerät einen Fehler, werden die Angaben ohnehin "
|
||||
+ "eingeblendet, auch wenn das hier ausgeschaltet ist.")
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("Apple Watch", value: watch.statusText)
|
||||
} footer: {
|
||||
Text("Die Uhr zeigt die Werte dieses iPhones und steuert die "
|
||||
+ "Kühlbox darüber – sie funkt nicht selbst zu den Geräten. "
|
||||
+ "Dafür muss diese App laufen; im Hintergrund liefern "
|
||||
+ "Neigungsmesser, BMS und Kühlbox weiter, die "
|
||||
+ "Victron-Werbedaten erst wieder im Vordergrund.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Einstellungen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import WatchConnectivity
|
||||
|
||||
/// Die Gegenstelle zur Apple Watch auf dem iPhone.
|
||||
///
|
||||
/// Das iPhone bleibt das Funkgerät (warum, steht bei `WatchLink`) und reicht
|
||||
/// den fertigen Stand weiter. Zwei Wege, je nachdem, ob die Uhr gerade
|
||||
/// hinschaut:
|
||||
///
|
||||
/// * **Anwendungskontext** – ein Datensatz, den watchOS aufhebt und auch dann
|
||||
/// zustellt, wenn die App auf der Uhr gerade nicht läuft. Damit steht beim
|
||||
/// Aufwecken sofort etwas da statt eines leeren Bildschirms.
|
||||
/// * **Nachricht** – nur solange die Uhr erreichbar ist und gemeldet hat, dass
|
||||
/// sie hinschaut. Dafür im halben Sekundentakt, was beim Ausrichten zählt.
|
||||
///
|
||||
/// Ohne gekoppelte Uhr läuft hier gar nichts: kein Timer, keine Sitzung.
|
||||
@Observable
|
||||
final class PhoneWatchLink: NSObject {
|
||||
|
||||
/// Ob die Uhr gerade Werte sehen will. Solange das gilt, hält die App das
|
||||
/// Bluetooth auch im Hintergrund am Leben.
|
||||
private(set) var wantsLiveUpdates = false
|
||||
/// Nur für die Anzeige in den Einstellungen.
|
||||
private(set) var statusText = "Keine Uhr gekoppelt"
|
||||
|
||||
private let store: DeviceStore
|
||||
private let bluetooth: BluetoothManager
|
||||
|
||||
private var session: WCSession?
|
||||
private var timer: Timer?
|
||||
/// Bis wann der Wunsch der Uhr nach schnellen Werten gilt.
|
||||
private var liveUntil = Date.distantPast
|
||||
private var lastSentContent: WatchPayload?
|
||||
private var lastContextSent = Date.distantPast
|
||||
|
||||
/// Setzt die App, damit beim Ablauf des Wunsches im Hintergrund das Funken
|
||||
/// wieder eingestellt wird.
|
||||
var isAppInBackground = false
|
||||
|
||||
init(store: DeviceStore, bluetooth: BluetoothManager) {
|
||||
self.store = store
|
||||
self.bluetooth = bluetooth
|
||||
super.init()
|
||||
}
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else {
|
||||
statusText = "Dieses Gerät kann keine Uhr koppeln"
|
||||
return
|
||||
}
|
||||
let session = WCSession.default
|
||||
session.delegate = self
|
||||
self.session = session
|
||||
session.activate()
|
||||
}
|
||||
|
||||
// MARK: - Takt
|
||||
|
||||
private func updateTimer() {
|
||||
let wanted = session?.activationState == .activated && session?.isPaired == true
|
||||
if wanted && timer == nil {
|
||||
// Der schnelle Takt ist der Grundtakt; ob wirklich gesendet wird,
|
||||
// entscheidet `tick` – so muss der Timer nie umgestellt werden.
|
||||
let timer = Timer(timeInterval: WatchLink.liveInterval, repeats: true) { [weak self] _ in
|
||||
self?.tick()
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
self.timer = timer
|
||||
} else if !wanted {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
let live = Date() < liveUntil
|
||||
if live != wantsLiveUpdates {
|
||||
wantsLiveUpdates = live
|
||||
// Der Wunsch ist gerade abgelaufen und niemand schaut mehr hin:
|
||||
// dann darf das Funkgerät im Hintergrund auch wieder ruhen.
|
||||
if !live && isAppInBackground { bluetooth.stop() }
|
||||
}
|
||||
|
||||
// Den Stand erst zusammenstellen, wenn er auch rausgeht: im Ruhetakt
|
||||
// ist das nur jeder vierte Aufruf, und zusammengestellt wird auf dem
|
||||
// Hauptthread.
|
||||
let isContextDue = Date().timeIntervalSince(lastContextSent) >= WatchLink.idleInterval
|
||||
guard let session, live || isContextDue else { return }
|
||||
guard let payload = buildPayload() else { return }
|
||||
|
||||
if live && session.isReachable {
|
||||
send(payload, over: session)
|
||||
return
|
||||
}
|
||||
|
||||
guard isContextDue,
|
||||
lastSentContent?.hasSameContent(as: payload) != true,
|
||||
session.isPaired, session.isWatchAppInstalled else { return }
|
||||
do {
|
||||
try session.updateApplicationContext(payload.message())
|
||||
lastSentContent = payload
|
||||
lastContextSent = Date()
|
||||
} catch {
|
||||
statusText = "Uhr: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Nachrichten werden ohne Antwort verschickt: Ein verlorener Datensatz ist
|
||||
/// in einer halben Sekunde ohnehin überholt, ein Wiederholungsversuch käme
|
||||
/// zu spät und stünde nur dem nächsten im Weg.
|
||||
private func send(_ payload: WatchPayload, over session: WCSession) {
|
||||
guard let message = try? payload.message() else { return }
|
||||
session.sendMessage(message, replyHandler: nil, errorHandler: nil)
|
||||
lastSentContent = payload
|
||||
}
|
||||
|
||||
/// Nach Änderungen an Geräten oder Profilen aufrufen, damit die Uhr nicht
|
||||
/// bis zum nächsten Takt einen überholten Fahrzeugnamen zeigt.
|
||||
func sendNow() {
|
||||
lastSentContent = nil
|
||||
lastContextSent = .distantPast
|
||||
tick()
|
||||
}
|
||||
|
||||
// MARK: - Stand zusammenstellen
|
||||
|
||||
private func buildPayload() -> WatchPayload? {
|
||||
guard let profile = store.activeProfile else { return nil }
|
||||
let devices = store.activeDevices.map { device in
|
||||
WatchDevice(id: device.id,
|
||||
name: device.name,
|
||||
role: device.role,
|
||||
link: bluetooth.linkStates[device.id] ?? .searching,
|
||||
snapshot: bluetooth.snapshots[device.id],
|
||||
level: device.role == .leveling ? bluetooth.levelStates[device.id] : nil,
|
||||
fridge: device.role == .fridge
|
||||
? bluetooth.fridgeStates[device.id].map(WatchFridge.init) : nil)
|
||||
}
|
||||
return WatchPayload(generatedAt: Date(),
|
||||
profile: profile,
|
||||
isRadioReady: bluetooth.isBluetoothReady,
|
||||
radioStatus: bluetooth.bluetoothStatusText,
|
||||
devices: devices)
|
||||
}
|
||||
|
||||
// MARK: - Befehle von der Uhr
|
||||
|
||||
private func handle(_ command: WatchCommand) {
|
||||
if command.wantsLiveUpdates {
|
||||
liveUntil = Date().addingTimeInterval(WatchLink.liveLease)
|
||||
wantsLiveUpdates = true
|
||||
// Die Uhr kann das iPhone aus dem Hintergrund wecken. Dann steht
|
||||
// das Funkgerät und muss erst wieder anlaufen, sonst geht der
|
||||
// Stellbefehl ins Leere.
|
||||
if isAppInBackground { bluetooth.start() }
|
||||
}
|
||||
|
||||
switch command {
|
||||
case .hello:
|
||||
break
|
||||
case .fridgePower(let device, let on):
|
||||
bluetooth.setFridgePower(on, for: device)
|
||||
case .fridgeEco(let device, let eco):
|
||||
bluetooth.setFridgeEco(eco, for: device)
|
||||
case .fridgeLock(let device, let locked):
|
||||
bluetooth.setFridgeLock(locked, for: device)
|
||||
case .fridgeTarget(let device, let zone, let value):
|
||||
bluetooth.setFridgeTarget(value, zone: zone == .left ? .left : .right, for: device)
|
||||
case .calibrateLevel(let device):
|
||||
bluetooth.calibrateLevel(for: device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WCSessionDelegate
|
||||
|
||||
extension PhoneWatchLink: WCSessionDelegate {
|
||||
|
||||
func session(_ session: WCSession,
|
||||
activationDidCompleteWith state: WCSessionActivationState,
|
||||
error: Error?) {
|
||||
DispatchQueue.main.async {
|
||||
if let error {
|
||||
self.statusText = "Uhr: \(error.localizedDescription)"
|
||||
} else {
|
||||
self.updateStatus(session)
|
||||
}
|
||||
self.updateTimer()
|
||||
self.sendNow()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateStatus(_ session: WCSession) {
|
||||
if !session.isPaired {
|
||||
statusText = "Keine Uhr gekoppelt"
|
||||
} else if !session.isWatchAppInstalled {
|
||||
statusText = "Uhr gekoppelt, App dort nicht installiert"
|
||||
} else {
|
||||
statusText = "Uhr bereit"
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
guard let command = WatchCommand.decode(from: message) else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.handle(command)
|
||||
// Auf einen Stellbefehl gleich den frischen Stand hinterherschicken.
|
||||
// Der zeigt zwar noch den alten Wert – die Box antwortet erst –,
|
||||
// aber die Uhr weiss dadurch sofort, dass der Befehl ankam.
|
||||
self.tick()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mit Antwort, wenn die Uhr auf eine wartet: das ist der Weg, auf dem sie
|
||||
/// beim Öffnen ohne Verzögerung an einen Stand kommt.
|
||||
func session(_ session: WCSession,
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
if let command = WatchCommand.decode(from: message) { self.handle(command) }
|
||||
replyHandler((try? self.buildPayload()?.message()) ?? [:])
|
||||
}
|
||||
}
|
||||
|
||||
func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
DispatchQueue.main.async { self.sendNow() }
|
||||
}
|
||||
|
||||
/// Die Uhr wurde gekoppelt, gewechselt oder die App dort installiert.
|
||||
func sessionWatchStateDidChange(_ session: WCSession) {
|
||||
DispatchQueue.main.async {
|
||||
self.updateStatus(session)
|
||||
self.updateTimer()
|
||||
self.sendNow()
|
||||
}
|
||||
}
|
||||
|
||||
// Beim Wechsel auf eine andere Uhr muss die Sitzung neu aktiviert werden.
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
func sessionDidDeactivate(_ session: WCSession) {
|
||||
session.activate()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Umformen
|
||||
|
||||
extension WatchFridge {
|
||||
/// Aus dem Zustand der Box wird nur übernommen, was die Uhr anzeigt oder
|
||||
/// stellt – der Rohdatensatz bleibt auf dem iPhone.
|
||||
init(_ state: AlpicoolState) {
|
||||
let range = state.targetRange
|
||||
self.init(isPoweredOn: state.isPoweredOn,
|
||||
isEco: state.isEco,
|
||||
isLocked: state.isLocked,
|
||||
isDualZone: state.isDualZone,
|
||||
unitSymbol: state.unitSymbol,
|
||||
leftTarget: state.leftTarget,
|
||||
leftCurrent: state.leftCurrent,
|
||||
rightTarget: state.isDualZone ? state.rightTarget : nil,
|
||||
rightCurrent: state.isDualZone ? state.rightCurrent : nil,
|
||||
minTarget: range.lowerBound,
|
||||
maxTarget: range.upperBound,
|
||||
batteryVolts: state.batteryVolts)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user