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
@@ -0,0 +1,85 @@
import Foundation
/// Welche Rolle ein Gerät im Camper spielt. Bestimmt Icon, Sortierung und
/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
case chargeBooster
case solarCharger
case batteryMonitor
case bms
var id: String { rawValue }
var title: String {
switch self {
case .chargeBooster: return "Ladebooster"
case .solarCharger: return "Solarladeregler"
case .batteryMonitor: return "Batteriemonitor"
case .bms: return "Batterie / BMS"
}
}
var symbol: String {
switch self {
case .chargeBooster: return "bolt.car"
case .solarCharger: return "sun.max"
case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
case .bms: return "battery.100percent.bolt"
}
}
/// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS
/// braucht eine echte GATT-Verbindung.
var transport: DeviceTransport {
self == .bms ? .connect : .advertisement
}
}
enum DeviceTransport: Sendable {
/// Passives Mitlesen der BLE-Werbedaten (Victron Instant Readout).
case advertisement
/// Verbindungsaufbau, Kommando schreiben, Antwort per Notify lesen (Daly).
case connect
}
/// Ein vom Nutzer eingerichtetes Gerät. Der Victron-Schlüssel liegt nicht hier,
/// sondern in der Keychain (siehe `KeychainStore`).
struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
var id: UUID
var name: String
var role: DeviceRole
/// Zu welchem Fahrzeug das Gerät gehört.
var profileID: UUID
/// CoreBluetooth-Identifier des Peripherals. Pro iPhone stabil, aber nicht
/// geräteübergreifend deshalb wird beim Einrichten neu gescannt.
var peripheralID: UUID
/// Zuletzt gesehener Advertised Name, nur zur Wiedererkennung in der UI.
var advertisedName: String?
init(id: UUID = UUID(),
name: String,
role: DeviceRole,
profileID: UUID,
peripheralID: UUID,
advertisedName: String? = nil) {
self.id = id
self.name = name
self.role = role
self.profileID = profileID
self.peripheralID = peripheralID
self.advertisedName = advertisedName
}
/// Geräte, die vor der Profilverwaltung angelegt wurden, haben noch keine
/// Zuordnung die landen im Standardprofil.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
role = try container.decode(DeviceRole.self, forKey: .role)
profileID = try container.decodeIfPresent(UUID.self, forKey: .profileID)
?? Profile.defaultID
peripheralID = try container.decode(UUID.self, forKey: .peripheralID)
advertisedName = try container.decodeIfPresent(String.self, forKey: .advertisedName)
}
}
+89
View File
@@ -0,0 +1,89 @@
import Foundation
/// Eine einzelne Messgrösse in einer bereits formatierten Form.
struct Metric: Identifiable, Hashable, Sendable {
let key: String
let label: String
let value: Double?
let unit: String
/// Nachkommastellen für die Anzeige.
let precision: Int
/// Wird auf der Kachel gross dargestellt.
let isPrimary: Bool
var id: String { key }
init(_ key: String,
_ label: String,
_ value: Double?,
unit: String,
precision: Int = 2,
primary: Bool = false) {
self.key = key
self.label = label
self.value = value
self.unit = unit
self.precision = precision
self.isPrimary = primary
}
var formatted: String {
guard let value else { return "" }
return String(format: "%.\(precision)f", value)
}
var formattedWithUnit: String {
guard value != nil else { return "" }
return unit.isEmpty ? formatted : "\(formatted) \(unit)"
}
}
/// Der komplette, zuletzt empfangene Zustand eines Geräts.
struct DeviceSnapshot: Identifiable, Sendable {
var id: UUID { deviceID }
var deviceID: UUID
var timestamp: Date
var metrics: [Metric] = []
/// z.B. "Bulk", "Float", "Aus"
var state: String?
/// Klartext einer aktiven Störung, sonst nil.
var fault: String?
/// Grund, warum das Gerät gerade nicht lädt (Victron Off-Reason).
var offReasons: [String] = []
var rssi: Int?
/// Einzelzellspannungen in Volt (nur BMS).
var cellVoltages: [Double] = []
/// Temperaturfühler in °C (nur BMS).
var temperatures: [Double] = []
var primaryMetric: Metric? {
metrics.first(where: \.isPrimary) ?? metrics.first
}
var age: TimeInterval { Date().timeIntervalSince(timestamp) }
/// Werte älter als eine Minute gelten als veraltet Victron sendet etwa
/// jede Sekunde, das Daly wird alle paar Sekunden gepollt.
var isStale: Bool { age > 60 }
}
/// Verbindungszustand für die UI.
enum DeviceLinkState: Equatable, Sendable {
case idle
case searching
case connecting
case live
case needsKey
case failed(String)
var label: String {
switch self {
case .idle: return "Inaktiv"
case .searching: return "Suche…"
case .connecting: return "Verbinde…"
case .live: return "Live"
case .needsKey: return "Schlüssel fehlt"
case .failed(let m): return m
}
}
}
+32
View File
@@ -0,0 +1,32 @@
import Foundation
/// Ein Fahrzeug. Jedes Profil hat seinen eigenen Satz Geräte; die App zeigt und
/// funkt immer nur für das gerade gewählte.
struct Profile: Identifiable, Codable, Hashable, Sendable {
var id: UUID
var name: String
/// SF-Symbol für die Auswahl im Dashboard.
var symbol: String
init(id: UUID = UUID(), name: String, symbol: String = "box.truck") {
self.id = id
self.name = name
self.symbol = symbol
}
/// Profil, dem Geräte aus der Zeit vor der Profilverwaltung zugeordnet
/// werden. Feste Kennung, damit die Zuordnung beim Update erhalten bleibt.
static let defaultID = UUID(uuidString: "00000000-0000-0000-0000-00000000C001")!
static var initial: Profile {
Profile(id: defaultID, name: "Mein Camper")
}
/// Auswahl für die Profilbearbeitung.
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft ein nicht
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
static let symbols = [
"box.truck", "truck.pickup.side", "bus", "bus.doubledecker",
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
]
}
+101
View File
@@ -0,0 +1,101 @@
import Foundation
/// Klartexte für die Zustands- und Fehlercodes aus den Victron-Werbedaten.
enum VictronCodes {
/// VE.Reg 0x0201 Betriebszustand des Laders.
static func deviceState(_ code: UInt32?) -> String? {
guard let code else { return nil }
switch code {
case 0: return "Aus"
case 1: return "Stromsparmodus"
case 2: return "Störung"
case 3: return "Konstantstrom (Bulk)"
case 4: return "Konstantspannung (Absorption)"
case 5: return "Erhaltung (Float)"
case 6: return "Lagerung"
case 7: return "Ausgleichsladung"
case 9: return "Wechselrichten"
case 11: return "Netzteilbetrieb"
case 245: return "Startet"
case 246: return "Wiederholte Absorption"
case 247: return "Auto-Ausgleich"
case 248: return "Battery Safe"
case 252: return "Externe Steuerung"
default: return "Zustand \(code)"
}
}
/// VE.Reg 0xEDDA Ladefehler. 0 bedeutet "kein Fehler".
static func chargerError(_ code: UInt32?) -> String? {
guard let code, code != 0 else { return nil }
switch code {
case 1: return "Batterietemperatur zu hoch"
case 2: return "Batteriespannung zu hoch"
case 3: return "Temperatursensor defekt"
case 4: return "Temperatursensor Kurzschluss"
case 5: return "Temperatursensor unplausibel"
case 6: return "Spannungsmessung defekt"
case 7: return "Spannungsmessung Kurzschluss"
case 8: return "Spannungsmessung unplausibel"
case 11: return "Zu hohe Restwelligkeit"
case 14: return "Batterietemperatur zu niedrig"
case 17: return "Lader überhitzt"
case 18: return "Lader Überstrom"
case 19: return "Stromrichtung verkehrt"
case 20: return "Bulk-Zeit überschritten"
case 21: return "Stromsensor defekt"
case 22: return "Interner Temperatursensor defekt"
case 26: return "Anschlussklemme überhitzt"
case 27: return "Kurzschluss im Lader"
case 28: return "Endstufenfehler"
case 29: return "Überladeschutz"
case 33: return "Eingangsspannung zu hoch (PV)"
case 34: return "Eingangsstrom zu hoch (PV)"
case 38: return "Eingang abgeschaltet (Batteriespannung)"
case 39: return "Eingang abgeschaltet (Stromfluss)"
case 65: return "Kommunikation verloren"
case 66: return "Konfiguration synchronisierter Lader fehlerhaft"
case 67: return "BMS-Verbindung verloren"
case 68: return "Netzwerk fehlkonfiguriert"
case 116: return "Kalibrierdaten verloren"
case 117: return "Inkompatible Firmware"
case 119: return "Einstellungen ungültig"
default: return "Fehler \(code)"
}
}
/// VE.Reg 0x0207 Bitmaske, warum das Gerät gerade nicht arbeitet.
static func offReasons(_ mask: UInt32?) -> [String] {
guard let mask, mask != 0 else { return [] }
let table: [(UInt32, String)] = [
(0x0000_0001, "Keine Eingangsspannung"),
(0x0000_0002, "Per Schalter ausgeschaltet"),
(0x0000_0004, "Per Einstellung ausgeschaltet"),
(0x0000_0008, "Remote-Eingang"),
(0x0000_0010, "Schutzfunktion aktiv"),
(0x0000_0020, "Paygo"),
(0x0000_0040, "BMS"),
(0x0000_0080, "Motor-Abschalterkennung"),
(0x0000_0100, "Eingangsspannung wird geprüft"),
]
return table.filter { mask & $0.0 != 0 }.map(\.1)
}
/// VE.Reg 0xEEB8 Alarmgründe des Batteriewächters.
static func alarmReasons(_ mask: UInt32?) -> [String] {
guard let mask, mask != 0 else { return [] }
let table: [(UInt32, String)] = [
(0x0001, "Unterspannung"),
(0x0002, "Überspannung"),
(0x0004, "Niedriger Ladezustand"),
(0x0008, "Starterbatterie Unterspannung"),
(0x0010, "Starterbatterie Überspannung"),
(0x0020, "Temperatur zu niedrig"),
(0x0040, "Temperatur zu hoch"),
(0x0080, "Mittenspannung"),
(0x0100, "Ladung überfällig"),
]
return table.filter { mask & $0.0 != 0 }.map(\.1)
}
}