Files
VanAligneiOS/CamperMonitor/Store/DeviceStore.swift
fototeddyandClaude Sonnet 5 9a3b5cb472 Fix device signing/companion ID, duplicate-device crash, background updates, and add level bubble to Live Activity
- Assign the shared development team to the widget extension and watch
  complication targets, and correct the watch app's companion bundle
  identifier so on-device installs succeed after the bundle ID rename
  to de.s0.fototeddy.VanAligneiOS.
- Guard against two ConfiguredDevices sharing a peripheralID (e.g. a
  double-tapped "Sichern"), which crashed BluetoothManager's
  Dictionary(uniqueKeysWithValues:) on launch.
- Keep Bluetooth running in the background while a Live Activity is
  active, so lock screen/CarPlay keep receiving fresh readings instead
  of freezing at the last value before backgrounding.
- Render the actual bubble-level graphic (not just a static icon) in
  the Live Activity's lock screen view and expanded Dynamic Island.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:50:08 +02:00

265 lines
9.7 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}