App-Namen im iOS-Projekt auf VanControl vereinheitlichen

CamperMonitor (Haupt-Repo) und VanAligneiOS (aus dem gemergten
solar-integration-Branch) liefen unter zwei verschiedenen internen
Namen, obwohl die App nach aussen längst einheitlich "VanControl Pro"
heisst. Jetzt durchgängig VanControl:

- Ordner: CamperMonitor/, CamperMonitorWatch/, CamperMonitorComplication/,
  VanAligneiOSWidget/ → VanControl/, VanControlWatch/,
  VanControlComplication/, VanControlWidget/
- Xcode-Projekt: CamperMonitor.xcodeproj → VanControl.xcodeproj, alle
  Targets/Schemes/Produktnamen entsprechend umbenannt
- Bundle-Identifier auf Wunsch mitgeändert: de.s0.fototeddy.VanControl*
  (App noch nicht veröffentlicht); dabei auch die
  WKCompanionAppBundleIdentifier-Werte korrigiert, die noch das alte
  de.fritob-Präfix statt des tatsächlichen de.s0.fototeddy-Präfixes
  trugen
- Swift-Dateien/Typen: CamperMonitorApp → VanControlApp,
  VanAligneiOSWidget* → VanControlWidget*
- Config/*-Info.plist umbenannt, README.md/Tools/README.md/run-tests.sh
  auf die neuen Pfade angepasst

Bewusst unverändert: firmware/vanalign und alle Bezüge auf "VanAlign"
als Namen der Neigungsmesser-Hardware (eigenständiges Produkt, kein
App-Name) sowie der komplette Android/-Ordner.

Build (App, Watch, Debug) und Protokoll-Testlauf grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
fototeddy
2026-09-06 21:25:28 +02:00
co-authored by Claude Sonnet 5
parent 83ea85f3b8
commit 303a9735d0
79 changed files with 190 additions and 190 deletions
+167
View File
@@ -0,0 +1,167 @@
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.VanControl
/// # 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",
trackWidth: 1.85, wheelbase: 3.50)
static let secondProfile = Profile(
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C2")!,
name: "Wohnwagen", symbol: "car.side")
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 victronSolar = ConfiguredDevice(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000050")!,
name: "Solar Dach", role: .victronSolarCharger, 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 caravanVictronSolar = ConfiguredDevice(
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C3")!,
name: "Solar Wohnwagen", role: .victronSolarCharger, profileID: secondProfile.id,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000C4")!)
static let fridge = ConfiguredDevice(
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000F0")!,
name: "Kühlbox", role: .fridge, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000F1")!)
static let level = ConfiguredDevice(
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000E0")!,
name: "Nivellierung", role: .leveling, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000E1")!)
static let votronicSolar = ConfiguredDevice(
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000D0")!,
name: "VotronicSolarESP", role: .votronicSolar, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000D1")!)
static var devices: [ConfiguredDevice] {
[victronSolar, booster, battery, fridge, level, votronicSolar, caravanVictronSolar]
}
/// Leicht schräg stehend, damit die Libelle etwas zu zeigen hat.
static var levelState: LevelState {
var state = LevelState()
state.pitch = 1.8
state.roll = -0.9
state.pitchOffset = 0.4
state.rollOffset = -0.2
return state
}
/// Zustand der Demo-Kühlbox: eingeschaltet, Eco, Soll 4 °C, ist 6 °C.
static var fridgeState: AlpicoolState {
var state = AlpicoolState()
state.apply(AlpicoolProtocol.Frame(command: 0x01, payload: [
0x00, 0x01, 0x01, 0x00, 0x04, 0x14, 0xE2, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x06, 0x57, 0x0C, 0x07,
]))
return state
}
/// Mittags, gute Sonne.
static var votronicSolarESPState: VotronicSolarESPState {
var state = VotronicSolarESPState()
state.batteryVoltage = 13.9
state.pvVoltage = 19.4
state.pvCurrent = 6.2
state.pvPower = 120
state.controllerTemperature = 34
state.isBatteryCharging = true
state.isControllerActive = true
state.isCurrentLimited = false
return state
}
static func snapshots() -> [DeviceSnapshot] {
var victronSolarSnapshot = DeviceSnapshot(deviceID: victronSolar.id, timestamp: Date(), rssi: -58)
victronSolarSnapshot.state = "Konstantspannung (Absorption)"
victronSolarSnapshot.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: caravanVictronSolar.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 [victronSolarSnapshot, boosterSnapshot, batterySnapshot,
fridgeState.snapshot(deviceID: fridge.id, rssi: -66),
levelState.snapshot(deviceID: level.id, rssi: -70),
votronicSolarESPState.snapshot(deviceID: votronicSolar.id, rssi: -58),
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))
}
}
}
+257
View File
@@ -0,0 +1,257 @@
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.
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: - 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)
}
}
+59
View File
@@ -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.VanControl.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
}
}