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. /// /// Dasselbe Peripheral doppelt im selben Profil anzulegen, würde später /// beim Aufbau der Funk-Konfiguration abstürzen (die dortige Zuordnung /// nach Peripheral-ID verlangt Eindeutigkeit) – etwa bei einem /// Doppel-Tipp auf „Sichern“. Ein zweiter Eintrag ersetzt darum den /// ersten, statt sich danebenzustellen. func add(_ device: ConfiguredDevice, victronKey: String? = nil) { devices.removeAll { $0.profileID == device.profileID && $0.peripheralID == device.peripheralID } 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) } }