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