init
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user