This commit is contained in:
BiasF
2026-08-30 10:36:52 +02:00
commit fdcc644828
30 changed files with 4152 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
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)
}
}