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>
258 lines
9.3 KiB
Swift
258 lines
9.3 KiB
Swift
import Foundation
|
||
import Observation
|
||
|
||
/// Verwaltet die Fahrzeugprofile, die darin eingerichteten Geräte und deren
|
||
/// Schlüssel.
|
||
@Observable
|
||
final class DeviceStore {
|
||
|
||
private static let devicesKey = "configuredDevices"
|
||
private static let profilesKey = "profiles"
|
||
private static let activeProfileKey = "activeProfileID"
|
||
/// Hierhin wird gerettet, was sich nicht lesen liess – siehe `loadDevices`.
|
||
private static let unreadableDevicesKey = "configuredDevices.unreadable"
|
||
private static let fridgeSettingsKey = "lastFridgeSettings"
|
||
|
||
/// Alle Geräte über alle Profile hinweg.
|
||
private(set) var devices: [ConfiguredDevice] = []
|
||
private(set) var profiles: [Profile] = []
|
||
|
||
private(set) var activeProfileID: UUID = Profile.defaultID
|
||
|
||
/// Ob beim Start Geräte im Speicher lagen, die sich nicht lesen liessen.
|
||
/// Ihre Rohdaten sind aufgehoben, statt beim nächsten Speichern
|
||
/// überschrieben zu werden.
|
||
private(set) var hasUnreadableDevices = false
|
||
|
||
/// Zwischenspeicher, damit nicht bei jedem Advertisement die Keychain
|
||
/// befragt wird – das passiert bis zu mehrmals pro Sekunde.
|
||
@ObservationIgnored private var keyCache: [UUID: [UInt8]] = [:]
|
||
|
||
init() {
|
||
if DemoData.isEnabled {
|
||
profiles = DemoData.profiles
|
||
devices = DemoData.devices
|
||
activeProfileID = DemoData.profiles[0].id
|
||
return
|
||
}
|
||
load()
|
||
}
|
||
|
||
// MARK: - Profile
|
||
|
||
var activeProfile: Profile? {
|
||
profiles.first { $0.id == activeProfileID }
|
||
}
|
||
|
||
/// Die Geräte des gewählten Fahrzeugs.
|
||
var activeDevices: [ConfiguredDevice] {
|
||
devices.filter { $0.profileID == activeProfileID }
|
||
}
|
||
|
||
var hasMultipleProfiles: Bool { profiles.count > 1 }
|
||
|
||
func selectProfile(_ profile: Profile) {
|
||
guard profile.id != activeProfileID else { return }
|
||
activeProfileID = profile.id
|
||
save()
|
||
}
|
||
|
||
@discardableResult
|
||
func addProfile(named name: String, symbol: String = "box.truck") -> Profile {
|
||
let profile = Profile(name: name, symbol: symbol)
|
||
profiles.append(profile)
|
||
save()
|
||
return profile
|
||
}
|
||
|
||
func update(_ profile: Profile) {
|
||
guard let index = profiles.firstIndex(where: { $0.id == profile.id }) else { return }
|
||
profiles[index] = profile
|
||
save()
|
||
}
|
||
|
||
/// Entfernt ein Profil samt seiner Geräte und Schlüssel. Das letzte Profil
|
||
/// bleibt bestehen – ohne Profil hätte die App keinen Ort für Geräte.
|
||
func removeProfile(_ profile: Profile) {
|
||
guard profiles.count > 1 else { return }
|
||
for device in devices where device.profileID == profile.id {
|
||
KeychainStore.setKey(nil, for: device.id)
|
||
keyCache[device.id] = nil
|
||
}
|
||
devices.removeAll { $0.profileID == profile.id }
|
||
profiles.removeAll { $0.id == profile.id }
|
||
if activeProfileID == profile.id, let first = profiles.first {
|
||
activeProfileID = first.id
|
||
}
|
||
save()
|
||
}
|
||
|
||
func deviceCount(in profile: Profile) -> Int {
|
||
devices.count { $0.profileID == profile.id }
|
||
}
|
||
|
||
// MARK: - Geräte
|
||
|
||
/// Legt ein Gerät im gerade gewählten Profil an.
|
||
func add(_ device: ConfiguredDevice, victronKey: String? = nil) {
|
||
devices.append(device)
|
||
if let victronKey {
|
||
setVictronKey(victronKey, for: device.id)
|
||
}
|
||
save()
|
||
}
|
||
|
||
func update(_ device: ConfiguredDevice) {
|
||
guard let index = devices.firstIndex(where: { $0.id == device.id }) else { return }
|
||
devices[index] = device
|
||
save()
|
||
}
|
||
|
||
func remove(_ device: ConfiguredDevice) {
|
||
devices.removeAll { $0.id == device.id }
|
||
KeychainStore.setKey(nil, for: device.id)
|
||
keyCache[device.id] = nil
|
||
save()
|
||
}
|
||
|
||
/// Nur im aktiven Profil suchen: die Geräte des anderen Fahrzeugs sollen
|
||
/// weder gelesen noch verbunden werden.
|
||
func device(withPeripheralID id: UUID) -> ConfiguredDevice? {
|
||
activeDevices.first { $0.peripheralID == id }
|
||
}
|
||
|
||
// MARK: - Victron-Schlüssel
|
||
|
||
func victronKeyText(for deviceID: UUID) -> String? {
|
||
KeychainStore.key(for: deviceID)
|
||
}
|
||
|
||
func setVictronKey(_ hex: String?, for deviceID: UUID) {
|
||
let trimmed = hex?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let value = (trimmed?.isEmpty ?? true) ? nil : trimmed
|
||
KeychainStore.setKey(value, for: deviceID)
|
||
keyCache[deviceID] = value?.hexBytes
|
||
}
|
||
|
||
/// Der Schlüssel als Bytes, oder nil wenn keiner hinterlegt bzw. der
|
||
/// hinterlegte keine 16 Byte lang ist.
|
||
func victronKeyBytes(for deviceID: UUID) -> [UInt8]? {
|
||
if let cached = keyCache[deviceID] { return cached }
|
||
guard let bytes = KeychainStore.key(for: deviceID)?.hexBytes, bytes.count == 16 else {
|
||
return nil
|
||
}
|
||
keyCache[deviceID] = bytes
|
||
return bytes
|
||
}
|
||
|
||
// MARK: - Zuletzt bekannter Stand der Kühlbox
|
||
|
||
/// Die Kühlbox wird nur verbunden, während ihre Ansicht offen ist. Damit
|
||
/// die Übersicht trotzdem etwas zeigt, wird der zuletzt gemeldete Stand
|
||
/// aufgehoben – die Einstellungen, nicht die Messwerte.
|
||
func lastFridgeSettings(for deviceID: UUID) -> FridgeSettings? {
|
||
allFridgeSettings()[deviceID.uuidString]
|
||
}
|
||
|
||
/// Der Zeitstempel bedeutet „zuletzt **geändert**“, nicht „zuletzt
|
||
/// gesehen“: Sonst wanderte er im Sekundentakt und die Übersicht behauptete
|
||
/// Frische, wo sich nichts getan hat.
|
||
func setLastFridgeSettings(_ settings: FridgeSettings, for deviceID: UUID) {
|
||
var all = allFridgeSettings()
|
||
if var existing = all[deviceID.uuidString] {
|
||
existing.updated = settings.updated
|
||
guard existing != settings else { return }
|
||
}
|
||
all[deviceID.uuidString] = settings
|
||
guard let data = try? JSONEncoder().encode(all) else { return }
|
||
UserDefaults.standard.set(data, forKey: Self.fridgeSettingsKey)
|
||
}
|
||
|
||
private func allFridgeSettings() -> [String: FridgeSettings] {
|
||
guard let data = UserDefaults.standard.data(forKey: Self.fridgeSettingsKey),
|
||
let decoded = try? JSONDecoder().decode([String: FridgeSettings].self, from: data)
|
||
else { return [:] }
|
||
return decoded
|
||
}
|
||
|
||
// MARK: - Persistenz
|
||
|
||
private func load() {
|
||
let defaults = UserDefaults.standard
|
||
|
||
if let data = defaults.data(forKey: Self.profilesKey),
|
||
let decoded = try? JSONDecoder().decode([Profile].self, from: data),
|
||
!decoded.isEmpty {
|
||
profiles = decoded
|
||
} else {
|
||
// Erster Start oder Update von einer Version ohne Profile.
|
||
profiles = [.initial]
|
||
}
|
||
|
||
if let data = defaults.data(forKey: Self.devicesKey) {
|
||
devices = loadDevices(from: data)
|
||
}
|
||
|
||
if let raw = defaults.string(forKey: Self.activeProfileKey),
|
||
let id = UUID(uuidString: raw),
|
||
profiles.contains(where: { $0.id == id }) {
|
||
activeProfileID = id
|
||
} else {
|
||
activeProfileID = profiles[0].id
|
||
}
|
||
|
||
// Geräte, deren Profil nicht mehr existiert, wären sonst unerreichbar.
|
||
let known = Set(profiles.map(\.id))
|
||
for index in devices.indices where !known.contains(devices[index].profileID) {
|
||
devices[index].profileID = profiles[0].id
|
||
}
|
||
}
|
||
|
||
/// Liest die Geräteliste so verlustarm wie möglich.
|
||
///
|
||
/// Der einfache Weg – die ganze Liste auf einmal – scheitert vollständig,
|
||
/// sobald ein einziges Gerät nicht lesbar ist: etwa weil eine neuere
|
||
/// Fassung der App ein Feld hinzugefügt hat, das die ältere nicht kennt,
|
||
/// oder umgekehrt. Die Liste wäre dann leer, und das nächste Speichern
|
||
/// schriebe diese Leere über den Bestand. Genau so gehen Einrichtungen
|
||
/// verloren, ohne dass jemand etwas löscht.
|
||
///
|
||
/// Deshalb zweistufig: erst die ganze Liste, und wenn das misslingt, jedes
|
||
/// Gerät für sich. Was dabei übrig bleibt, wird behalten; die Rohdaten
|
||
/// wandern zusätzlich in einen eigenen Schlüssel, damit sich der Bestand
|
||
/// später noch untersuchen lässt.
|
||
private func loadDevices(from data: Data) -> [ConfiguredDevice] {
|
||
let decoder = JSONDecoder()
|
||
if let decoded = try? decoder.decode([ConfiguredDevice].self, from: data) {
|
||
return decoded
|
||
}
|
||
|
||
let salvaged = (try? decoder.decode([Salvage].self, from: data))?
|
||
.compactMap(\.device) ?? []
|
||
UserDefaults.standard.set(data, forKey: Self.unreadableDevicesKey)
|
||
hasUnreadableDevices = true
|
||
return salvaged
|
||
}
|
||
|
||
/// Hülle, die ein einzelnes unlesbares Gerät verschluckt, statt die ganze
|
||
/// Liste scheitern zu lassen.
|
||
private struct Salvage: Decodable {
|
||
let device: ConfiguredDevice?
|
||
|
||
init(from decoder: Decoder) throws {
|
||
device = try? ConfiguredDevice(from: decoder)
|
||
}
|
||
}
|
||
|
||
private func save() {
|
||
let defaults = UserDefaults.standard
|
||
if let data = try? JSONEncoder().encode(devices) {
|
||
defaults.set(data, forKey: Self.devicesKey)
|
||
}
|
||
if let data = try? JSONEncoder().encode(profiles) {
|
||
defaults.set(data, forKey: Self.profilesKey)
|
||
}
|
||
defaults.set(activeProfileID.uuidString, forKey: Self.activeProfileKey)
|
||
}
|
||
}
|