forked from fritob/Camper-Monitor
185 lines
6.0 KiB
Swift
185 lines
6.0 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"
|
||
|
||
/// Alle Geräte über alle Profile hinweg.
|
||
private(set) var devices: [ConfiguredDevice] = []
|
||
private(set) var profiles: [Profile] = []
|
||
|
||
private(set) var activeProfileID: UUID = Profile.defaultID
|
||
|
||
/// 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: - 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),
|
||
let decoded = try? JSONDecoder().decode([ConfiguredDevice].self, from: data) {
|
||
devices = decoded
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|