first commit
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user