Files
BiasFandClaude Opus 5 5fec4a55dd Kühlbox nur verbinden, während man sie ansieht
Jede Verbindung meldet sich am Display der Box an – sie piept, und wer
gerade davorsteht, wird gestört. Dauerhaft verbunden zu sein ist dort
also nicht unsichtbar wie beim BMS, sondern lästig.

Die Kühlbox wird deshalb nur noch verbunden, solange ihre Ansicht offen
ist: am iPhone über die Detailansicht, an der Uhr über einen eigenen
Befehl, den sie beim Öffnen und Schliessen schickt. Ein Stellbefehl geht
weiterhin immer durch – ist die Box nicht verbunden, wird er gemerkt und
löst den Verbindungsaufbau aus.

Abgeriegelt sind alle drei Wege, über die bisher verbunden wurde: beim
Start, im Takt des Wiederverbindens, und – das war das eigentliche Loch –
sobald das Gerät in den Werbedaten auftaucht. Da die App dauerhaft
scannt, hätte allein dieser Weg die Box weiter angefunkt. Dass sie in
Reichweite ist, ist kein Grund, sie anzufassen.

Auf der Übersicht steht dafür der zuletzt gestellte Stand statt der
Messwerte: Sollwert, Ein/Aus, Eco oder Max, dazu wann er gestellt wurde.
Das bleibt richtig, auch wenn es von gestern ist – ein Sollwert ändert
sich nur, wenn jemand ihn ändert. Eine Innentemperatur von gestern sähe
dagegen aus wie eine von jetzt, und man würde ihr glauben. Genau das
sichern die neuen Prüfungen ab: Hauptwert ist der Sollwert, gemessene
Temperatur und Bordspannung kommen nicht vor.

Der Stand liegt in den Einstellungen (FridgeSettings) mit einem
Zeitstempel, der „zuletzt geändert" bedeutet und nicht „zuletzt gesehen" –
sonst behauptete die Kachel Frische, wo sich nichts getan hat. Die Uhr
bekommt dasselbe Bild: WatchFridge trägt jetzt ein Kennzeichen isLive.

BMS und Neigungsmesser bleiben dauerhaft verbunden. Sie stören nicht, und
ihre Werte will man laufend sehen.

Nicht nachgezogen ist die Android-App – dort verbindet der
BluetoothManager weiterhin dauerhaft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:00:10 +02:00

224 lines
9.0 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}()
}