forked from fritob/Camper-Monitor
CamperMonitor (Haupt-Repo) und VanAligneiOS (aus dem gemergten solar-integration-Branch) liefen unter zwei verschiedenen internen Namen, obwohl die App nach aussen längst einheitlich "VanControl Pro" heisst. Jetzt durchgängig VanControl: - Ordner: CamperMonitor/, CamperMonitorWatch/, CamperMonitorComplication/, VanAligneiOSWidget/ → VanControl/, VanControlWatch/, VanControlComplication/, VanControlWidget/ - Xcode-Projekt: CamperMonitor.xcodeproj → VanControl.xcodeproj, alle Targets/Schemes/Produktnamen entsprechend umbenannt - Bundle-Identifier auf Wunsch mitgeändert: de.s0.fototeddy.VanControl* (App noch nicht veröffentlicht); dabei auch die WKCompanionAppBundleIdentifier-Werte korrigiert, die noch das alte de.fritob-Präfix statt des tatsächlichen de.s0.fototeddy-Präfixes trugen - Swift-Dateien/Typen: CamperMonitorApp → VanControlApp, VanAligneiOSWidget* → VanControlWidget* - Config/*-Info.plist umbenannt, README.md/Tools/README.md/run-tests.sh auf die neuen Pfade angepasst Bewusst unverändert: firmware/vanalign und alle Bezüge auf "VanAlign" als Namen der Neigungsmesser-Hardware (eigenständiges Produkt, kein App-Name) sowie der komplette Android/-Ordner. Build (App, Watch, Debug) und Protokoll-Testlauf grün. Co-Authored-By: Claude Sonnet 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)
|
||
}
|
||
}
|