224 lines
9.0 KiB
Swift
224 lines
9.0 KiB
Swift
import Foundation
|
||
|
||
/// Was zwischen iPhone und Uhr hin- und hergeht.
|
||
///
|
||
/// Die Aufteilung folgt dem, was jedes Gerät hergibt:
|
||
///
|
||
/// * **Den Neigungsmesser funkt die Uhr selbst an** (`WatchLevelRadio`). Er
|
||
/// bewirbt seinen Dienst, ist also ohne Einrichtung auffindbar, und er ist
|
||
/// unverschlüsselt. Damit steht die Nivellierung am Handgelenk auch dann,
|
||
/// wenn das iPhone in der Tasche schläft – und das ist der Fall, für den man
|
||
/// die Uhr überhaupt anschaut.
|
||
/// * **Alles andere kommt über das iPhone.** BMS und Kühlbox lassen nur eine
|
||
/// Verbindung zu; eine mitlesende Uhr nähme dem iPhone die Verbindung weg,
|
||
/// statt sie zu ergänzen. Und die Victron-Schlüssel liegen in der Keychain
|
||
/// des iPhones – sie auf die Uhr zu kopieren hiesse, sie ein zweites Mal
|
||
/// aufzubewahren, ohne dass der zweite Ort etwas brächte.
|
||
///
|
||
/// Übertragen werden deshalb die Messwerte der übrigen Geräte, dazu Fahrzeug
|
||
/// und Einbaulage des Sensors: Beides braucht die Uhr, um aus den Rohwinkeln
|
||
/// des Neigungsmessers dasselbe zu machen wie das iPhone.
|
||
enum WatchLink {
|
||
|
||
/// Schlüssel im WatchConnectivity-Wörterbuch. Übertragen wird jeweils ein
|
||
/// JSON-`Data`, nicht ein aufgedröseltes Wörterbuch: die Typen unten sind
|
||
/// damit die einzige Stelle, an der das Format steht.
|
||
static let payloadKey = "payload"
|
||
static let commandKey = "command"
|
||
|
||
/// Wie lange ein „ich schaue gerade hin“ der Uhr gilt, ohne erneuert zu
|
||
/// werden. Bricht die Verbindung ab oder wandert die App auf der Uhr in den
|
||
/// Hintergrund, hört das iPhone von selbst wieder auf, im Sekundentakt zu
|
||
/// senden.
|
||
static let liveLease: TimeInterval = 12
|
||
|
||
/// Sendetakt, solange die Uhr hinschaut. Der Neigungsmesser wird zweimal je
|
||
/// Sekunde abgefragt – schneller zu senden brächte nichts.
|
||
static let liveInterval: TimeInterval = 0.5
|
||
|
||
/// Sendetakt sonst. Geht als Anwendungskontext raus, den watchOS auch dann
|
||
/// noch zustellt, wenn die App auf der Uhr gerade nicht läuft.
|
||
static let idleInterval: TimeInterval = 2
|
||
}
|
||
|
||
/// Der gesamte Stand eines Fahrzeugs, so wie ihn die Uhr anzeigt.
|
||
struct WatchPayload: Codable, Equatable, Sendable {
|
||
/// Steigt, wenn sich das Format ändert. Eine Uhr mit älterer App bekommt
|
||
/// sonst Werte, die sie falsch versteht – lieber sagt sie „App aktualisieren“.
|
||
static let currentVersion = 1
|
||
|
||
var version = WatchPayload.currentVersion
|
||
var generatedAt: Date
|
||
/// Das am iPhone gewählte Fahrzeug.
|
||
var profile: Profile
|
||
/// Ob das iPhone gerade überhaupt funken kann.
|
||
var isRadioReady: Bool
|
||
/// Klartext dazu, falls nicht.
|
||
var radioStatus: String
|
||
var devices: [WatchDevice]
|
||
|
||
/// Nach der Rolle gesucht, nicht nach vorhandenen Werten: Ein
|
||
/// Neigungsmesser, der gerade nicht antwortet, gehört trotzdem an seinen
|
||
/// Platz – sonst rutschte er beim Verbindungsabriss in die Geräteliste.
|
||
var levelDevice: WatchDevice? { devices.first { $0.role == .leveling } }
|
||
// var fridgeDevice: WatchDevice? { devices.first { $0.role == .fridge } }
|
||
|
||
/// Ob sich inhaltlich nichts geändert hat. Der Zeitstempel zählt dabei
|
||
/// nicht mit: sonst gälte jeder Datensatz als neu und die Uhr bekäme rund
|
||
/// um die Uhr Funkverkehr, auch wenn das Fahrzeug still steht.
|
||
func hasSameContent(as other: WatchPayload) -> Bool {
|
||
var mine = self, theirs = other
|
||
mine.generatedAt = .distantPast
|
||
theirs.generatedAt = .distantPast
|
||
return mine == theirs
|
||
}
|
||
|
||
/// Wie alt der Stand ist. Auf der Uhr die wichtigste Angabe überhaupt:
|
||
/// alles hier ist Weitergereichtes, und eine abgerissene Strecke zum
|
||
/// iPhone sieht sonst aus wie ein stillstehendes Fahrzeug.
|
||
func age(now: Date = Date()) -> TimeInterval { now.timeIntervalSince(generatedAt) }
|
||
}
|
||
|
||
/// Ein Gerät mitsamt seinem letzten Messwertsatz.
|
||
struct WatchDevice: Codable, Equatable, Identifiable, Sendable {
|
||
var id: UUID
|
||
var name: String
|
||
var role: DeviceRole
|
||
var link: DeviceLinkState
|
||
var snapshot: DeviceSnapshot?
|
||
/// Nur beim Neigungsmesser belegt.
|
||
var level: LevelState?
|
||
/// Ebenfalls nur dort: wie der Sensor im Fahrzeug sitzt. Die Uhr funkt
|
||
/// den Neigungsmesser selbst an und braucht die Zuordnung deshalb auch –
|
||
/// eingestellt wird sie weiterhin nur am iPhone.
|
||
var orientation: SensorOrientation?
|
||
/// Nur bei der Kühlbox belegt.
|
||
//var fridge: WatchFridge?
|
||
|
||
var isLive: Bool { link == .live && !(snapshot?.isStale ?? true) }
|
||
}
|
||
|
||
/// Zustand und Grenzen der Kühlbox, so weit die Uhr sie braucht.
|
||
///
|
||
/// Eine eigene Struktur statt `AlpicoolState`: dort steckt der Rohdatensatz mit
|
||
/// drin, den auf die Uhr zu schicken nichts brächte.
|
||
/*struct WatchFridge: Codable, Equatable, Sendable {
|
||
/// Ob die Box gerade wirklich verbunden ist.
|
||
///
|
||
/// Sie wird nur verbunden, während jemand ihre Ansicht offen hat – jede
|
||
/// Verbindung meldet sich an ihrem Display an. Sonst stehen hier die
|
||
/// zuletzt gestellten Werte, und die Messwerte fehlen.
|
||
var isLive: Bool
|
||
/// Wann der Stand zuletzt von der Box kam.
|
||
var updated: Date
|
||
var isPoweredOn: Bool
|
||
var isEco: Bool
|
||
var isLocked: Bool
|
||
var isDualZone: Bool
|
||
var unitSymbol: String
|
||
var leftTarget: Int?
|
||
var leftCurrent: Int?
|
||
var rightTarget: Int?
|
||
var rightCurrent: Int?
|
||
var minTarget: Int
|
||
var maxTarget: Int
|
||
var batteryVolts: Double?
|
||
|
||
var targetRange: ClosedRange<Int> {
|
||
minTarget < maxTarget ? minTarget...maxTarget : -30...20
|
||
}
|
||
}*/
|
||
|
||
/// Was die Uhr das iPhone tun lässt.
|
||
///
|
||
/// Gestellt wird nur, was auch am iPhone gestellt werden kann; die Uhr bekommt
|
||
/// keine eigenen Fähigkeiten. Das Ergebnis liest sie wie jeder andere Wert aus
|
||
/// dem nächsten Datensatz – bestätigt wird also das Gerät, nicht der Tastendruck.
|
||
enum WatchCommand: Codable, Equatable, Sendable {
|
||
/// Die Uhr schaut gerade hin und möchte den schnellen Takt.
|
||
case hello(live: Bool)
|
||
//case fridgePower(device: UUID, on: Bool)
|
||
//case fridgeEco(device: UUID, eco: Bool)
|
||
//case fridgeLock(device: UUID, locked: Bool)
|
||
//case fridgeTarget(device: UUID, zone: WatchFridgeZone, value: Int)
|
||
/// Die Kühlbox-Ansicht auf der Uhr wurde geöffnet oder geschlossen. Das
|
||
/// iPhone verbindet die Box daraufhin – oder gibt sie wieder frei.
|
||
//case fridgeSession(device: UUID, wanted: Bool)
|
||
|
||
/// Ob der Befehl den schnellen Sendetakt verlängert.
|
||
var wantsLiveUpdates: Bool {
|
||
if case .hello(let live) = self { return live }
|
||
// Wer stellt, schaut auch hin.
|
||
return true
|
||
}
|
||
}
|
||
/*
|
||
/// Spiegelt `AlpicoolState.Zone`, ohne das Protokoll auf die Uhr zu ziehen.
|
||
enum WatchFridgeZone: String, Codable, Equatable, Sendable {
|
||
case left, right
|
||
}
|
||
|
||
// MARK: - Verpacken
|
||
|
||
extension WatchFridge {
|
||
/// Aus dem zuletzt gestellten Stand, ohne Messwerte.
|
||
init(_ settings: FridgeSettings) {
|
||
self.init(isLive: false,
|
||
updated: settings.updated,
|
||
isPoweredOn: settings.isPoweredOn,
|
||
isEco: settings.isEco,
|
||
isLocked: settings.isLocked,
|
||
isDualZone: settings.isDualZone,
|
||
unitSymbol: settings.unitSymbol,
|
||
leftTarget: settings.leftTarget,
|
||
leftCurrent: nil,
|
||
rightTarget: settings.rightTarget,
|
||
rightCurrent: nil,
|
||
minTarget: settings.usesFahrenheit ? -22 : -30,
|
||
maxTarget: settings.usesFahrenheit ? 68 : 20,
|
||
batteryVolts: nil)
|
||
}
|
||
}*/
|
||
|
||
extension WatchPayload {
|
||
func encoded() throws -> Data { try WatchCoder.encoder.encode(self) }
|
||
|
||
/// Aus einem WatchConnectivity-Wörterbuch zurücklesen. Fremde oder zu neue
|
||
/// Datensätze ergeben nil statt eines halb gefüllten Zustands.
|
||
static func decode(from message: [String: Any]) -> WatchPayload? {
|
||
guard let data = message[WatchLink.payloadKey] as? Data,
|
||
let payload = try? WatchCoder.decoder.decode(WatchPayload.self, from: data),
|
||
payload.version == WatchPayload.currentVersion else { return nil }
|
||
return payload
|
||
}
|
||
|
||
func message() throws -> [String: Any] { [WatchLink.payloadKey: try encoded()] }
|
||
}
|
||
|
||
extension WatchCommand {
|
||
func message() throws -> [String: Any] {
|
||
[WatchLink.commandKey: try WatchCoder.encoder.encode(self)]
|
||
}
|
||
|
||
static func decode(from message: [String: Any]) -> WatchCommand? {
|
||
guard let data = message[WatchLink.commandKey] as? Data else { return nil }
|
||
return try? WatchCoder.decoder.decode(WatchCommand.self, from: data)
|
||
}
|
||
}
|
||
|
||
/// Feste Einstellungen für beide Richtungen. Ohne das würde ein Datum auf der
|
||
/// einen Seite anders geschrieben als auf der anderen gelesen.
|
||
enum WatchCoder {
|
||
static let encoder: JSONEncoder = {
|
||
let encoder = JSONEncoder()
|
||
encoder.dateEncodingStrategy = .millisecondsSince1970
|
||
return encoder
|
||
}()
|
||
|
||
static let decoder: JSONDecoder = {
|
||
let decoder = JSONDecoder()
|
||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||
return decoder
|
||
}()
|
||
}
|