init
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
|
||||
/// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne
|
||||
/// Fahrzeug und ohne Bluetooth prüfen lassen.
|
||||
///
|
||||
/// Nur in Debug-Builds und nur, wenn beim Start `CAMPER_DEMO=1` gesetzt ist:
|
||||
///
|
||||
/// xcrun simctl launch --terminate-running-process \
|
||||
/// booted de.fritob.CamperMonitor
|
||||
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
|
||||
///
|
||||
/// Im normalen Betrieb wird hiervon nichts ausgeführt.
|
||||
enum DemoData {
|
||||
|
||||
static var isEnabled: Bool {
|
||||
#if DEBUG
|
||||
return ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
static let mainProfile = Profile(id: Profile.defaultID, name: "Kastenwagen", symbol: "box.truck")
|
||||
static let secondProfile = Profile(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C2")!,
|
||||
name: "Wohnwagen", symbol: "caravan")
|
||||
|
||||
static var profiles: [Profile] { [mainProfile, secondProfile] }
|
||||
|
||||
static let booster = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000B0")!,
|
||||
name: "Ladebooster", role: .chargeBooster, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000B1")!)
|
||||
|
||||
static let solar = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-000000000050")!,
|
||||
name: "Solar Dach", role: .solarCharger, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-000000000051")!)
|
||||
|
||||
static let battery = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000A0")!,
|
||||
name: "Bulltron 200 Ah", role: .bms, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000A1")!)
|
||||
|
||||
static let caravanSolar = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C3")!,
|
||||
name: "Solar Wohnwagen", role: .solarCharger, profileID: secondProfile.id,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000C4")!)
|
||||
|
||||
static var devices: [ConfiguredDevice] { [solar, booster, battery, caravanSolar] }
|
||||
|
||||
static func snapshots() -> [DeviceSnapshot] {
|
||||
var solarSnapshot = DeviceSnapshot(deviceID: solar.id, timestamp: Date(), rssi: -58)
|
||||
solarSnapshot.state = "Konstantspannung (Absorption)"
|
||||
solarSnapshot.metrics = [
|
||||
Metric("pv_power", "PV-Leistung", 284, unit: "W", precision: 0, primary: true),
|
||||
Metric("battery_power", "Ladeleistung", 262, unit: "W", precision: 0),
|
||||
Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2),
|
||||
Metric("battery_current", "Ladestrom", 18.6, unit: "A", precision: 1),
|
||||
Metric("yield_today", "Ertrag heute", 1.34, unit: "kWh", precision: 2),
|
||||
]
|
||||
|
||||
var boosterSnapshot = DeviceSnapshot(deviceID: booster.id, timestamp: Date(), rssi: -71)
|
||||
boosterSnapshot.state = "Aus"
|
||||
boosterSnapshot.offReasons = ["Keine Eingangsspannung"]
|
||||
boosterSnapshot.metrics = [
|
||||
Metric("output_voltage", "Ausgang (Aufbaubatterie)", 14.09, unit: "V", precision: 2, primary: true),
|
||||
Metric("input_voltage", "Eingang (Starterbatterie)", 12.42, unit: "V", precision: 2),
|
||||
]
|
||||
|
||||
let cells = [3.412, 3.418, 3.409, 3.421]
|
||||
var batterySnapshot = DeviceSnapshot(deviceID: battery.id, timestamp: Date(), rssi: -64)
|
||||
batterySnapshot.state = "Lädt"
|
||||
batterySnapshot.cellVoltages = cells
|
||||
batterySnapshot.temperatures = [21, 22]
|
||||
batterySnapshot.metrics = [
|
||||
Metric("soc", "Ladezustand", 78.4, unit: "%", precision: 1, primary: true),
|
||||
Metric("voltage", "Spannung", 13.66, unit: "V", precision: 2),
|
||||
Metric("current", "Strom", 18.6, unit: "A", precision: 1),
|
||||
Metric("power", "Leistung", 254, unit: "W", precision: 0),
|
||||
Metric("capacity", "Restkapazität", 156.8, unit: "Ah", precision: 1),
|
||||
Metric("cell_delta", "Zell-Differenz", 12, unit: "mV", precision: 0),
|
||||
Metric("cell_max", "Höchste Zelle (Zelle 4)", 3.421, unit: "V", precision: 3),
|
||||
Metric("cell_min", "Niedrigste Zelle (Zelle 3)", 3.409, unit: "V", precision: 3),
|
||||
Metric("temp_max", "Temperatur", 22, unit: "°C", precision: 0),
|
||||
Metric("cycles", "Ladezyklen", 143, unit: "", precision: 0),
|
||||
]
|
||||
|
||||
var caravanSnapshot = DeviceSnapshot(deviceID: caravanSolar.id, timestamp: Date(), rssi: -77)
|
||||
caravanSnapshot.state = "Erhaltung (Float)"
|
||||
caravanSnapshot.metrics = [
|
||||
Metric("pv_power", "PV-Leistung", 62, unit: "W", precision: 0, primary: true),
|
||||
Metric("battery_voltage", "Batteriespannung", 13.62, unit: "V", precision: 2),
|
||||
Metric("battery_current", "Ladestrom", 4.4, unit: "A", precision: 1),
|
||||
]
|
||||
|
||||
return [solarSnapshot, boosterSnapshot, batterySnapshot, caravanSnapshot]
|
||||
}
|
||||
|
||||
/// Ein paar Stunden Verlauf, damit die Grafik etwas zeigt.
|
||||
static func history(for deviceID: UUID, around value: Double) -> [HistorySample] {
|
||||
let now = Date()
|
||||
return (0..<80).reversed().map { step in
|
||||
let t = Double(step)
|
||||
let wave = sin(t / 9) * value * 0.18 + cos(t / 23) * value * 0.07
|
||||
return HistorySample(time: now.addingTimeInterval(-t * 60),
|
||||
value: max(0, value + wave))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Ablage für die Victron-Verschlüsselungsschlüssel. Die gehören nicht in die
|
||||
/// UserDefaults, deshalb Keychain.
|
||||
enum KeychainStore {
|
||||
|
||||
private static let service = "de.fritob.CamperMonitor.victronKeys"
|
||||
|
||||
static func setKey(_ hex: String?, for deviceID: UUID) {
|
||||
let account = deviceID.uuidString
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
|
||||
guard let hex, let data = hex.data(using: .utf8) else { return }
|
||||
query[kSecValueData as String] = data
|
||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func key(for deviceID: UUID) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: deviceID.uuidString,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: AnyObject?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
/// Wandelt einen Hex-String in Bytes. Leerzeichen, Doppelpunkte und ein
|
||||
/// führendes "0x" werden ignoriert, damit sich der Schlüssel aus
|
||||
/// VictronConnect einfach einfügen lässt.
|
||||
var hexBytes: [UInt8]? {
|
||||
var cleaned = self.lowercased()
|
||||
.replacingOccurrences(of: "0x", with: "")
|
||||
.filter { $0.isHexDigit }
|
||||
guard !cleaned.isEmpty, cleaned.count % 2 == 0 else { return nil }
|
||||
var bytes: [UInt8] = []
|
||||
bytes.reserveCapacity(cleaned.count / 2)
|
||||
while !cleaned.isEmpty {
|
||||
let pair = String(cleaned.prefix(2))
|
||||
cleaned.removeFirst(2)
|
||||
guard let byte = UInt8(pair, radix: 16) else { return nil }
|
||||
bytes.append(byte)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user