forked from fritob/Camper-Monitor
Sitzt der Sensor schräg statt längs im Fahrzeug, verteilt sich eine reine
Querneigung auf beide Achsen: Das Fahrzeug kippt zur Seite, und die
Längsanzeige kippt sichtbar mit – bei 20° Verdrehung mit gut einem
Drittel des Werts. Achsentausch und Vorzeichen halfen dagegen nicht, die
springen in 90°-Schritten.
SensorOrientation bekommt deshalb "twist", einen stufenlosen Winkel. Der
Einbaulage-Assistent misst ihn ohne zusätzlichen Handgriff mit: Beim
Kippen der Front nach unten dürfte sich nur die Längsneigung ändern;
wandert die Querneigung mit, ist das der gesuchte Winkel. Unter zwei Grad
gilt es als Wackeln der Hand. Angezeigt wird er in der Zusammenfassung
("um 20° verdreht"), herausgerechnet bei jeder Anzeige.
Beim Kalibrieren liesse sich das grundsätzlich nicht ermitteln: Es misst
eine einzige Lage und zieht sie als Nullpunkt ab. Eine Drehung um die
Hochachse steckt darin nicht – eben sieht in jeder Verdrehung gleich aus.
Das neue Feld hat beim Aufspielen sämtliche eingerichteten Geräte
gekostet, und der Grund gehört hierher: Swifts erzeugte Codable-Umsetzung
verlangt beim Lesen jedes Feld, Standardwerte im Code zählen nicht.
Gespeicherte Einbaulagen hatten kein "twist", also scheiterte das Lesen
der Einbaulage, damit des Geräts, damit der ganzen Liste – und das
nächste Speichern schrieb die leere Liste über den Bestand.
Zwei Vorkehrungen dagegen:
* SensorOrientation liest jetzt von Hand und nachsichtig, mit
decodeIfPresent und Rückfallwerten, wie Profile und ConfiguredDevice es
längst tun. Jedes künftige Feld gehört dort hinein.
* DeviceStore liest die Liste zweistufig: scheitert sie als Ganzes, wird
jedes Gerät einzeln versucht und behalten, was lesbar ist. Die
Rohdaten wandern zusätzlich in einen eigenen Schlüssel, statt beim
nächsten Speichern überschrieben zu werden.
Beides in run-tests.sh abgesichert: die Erkennung und Rückrechnung der
Verdrehung samt Gegenprobe ohne Korrektur, und das Lesen gespeicherter
Geräte ohne "twist", ganz ohne Einbaulage und mit unbrauchbarer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
227 lines
7.8 KiB
Swift
227 lines
7.8 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"
|
||
|
||
/// 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: - 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)
|
||
}
|
||
}
|