App: - New DeviceRole.solar with its own BLE session/protocol/state (SolarSession, VanAlignSolarProtocol, SolarState), mirroring the leveling sensor but deliberately not wired into the Live Activity. - BluetoothManager now keeps an in-memory history per metric (voltage, current, power) instead of a single primary-metric series; cleared on app restart by design, not persisted to disk. - DeviceDetailView shows a channel picker above the history chart when a device has more than one chartable metric. - AddDeviceView recognizes the solar service UUID during setup. - DemoData gets a simulated solar device so the integration can be checked in the simulator without hardware. Firmware: - Add esp32_ble_solar.yaml (Votronic solar charge controller over BLE) and simulated variants (esp32_ble_sim.yaml, esp32_ble_solar_sim.yaml) for testing without a vehicle. - esp32_ble.yaml: set flash_size/psram for the ESP32-S3 N16R8 module. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
140 lines
5.4 KiB
Swift
140 lines
5.4 KiB
Swift
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 batteryMonitor
|
||
//case bms
|
||
//case fridge
|
||
case leveling
|
||
case solar
|
||
|
||
var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
//case .chargeBooster: return "Ladebooster"
|
||
//case .batteryMonitor: return "Batteriemonitor"
|
||
//case .bms: return "Batterie / BMS"
|
||
//case .fridge: return "Kühlbox"
|
||
case .leveling: return "Nivellierung"
|
||
case .solar: return "Solarladeregler"
|
||
}
|
||
}
|
||
|
||
var symbol: String {
|
||
switch self {
|
||
//case .chargeBooster: return "bolt.car"
|
||
//case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
|
||
//case .bms: return "battery.100percent.bolt"
|
||
//case .fridge: return "refrigerator"
|
||
case .leveling: return "level"
|
||
case .solar: return "sun.max"
|
||
}
|
||
}
|
||
|
||
/// Es gibt keine Geräte mehr, die nur auf Anforderung verbunden werden –
|
||
/// der Neigungsmesser bleibt immer verbunden.
|
||
var connectsOnDemand: Bool { false }
|
||
/*/// Ob nur verbunden wird, während die Ansicht des Geräts offen ist.
|
||
///
|
||
/// Die Kühlbox meldet jede Verbindung an ihrem Display an und verlangt je
|
||
/// nach Modell sogar einen Tastendruck; dauerhaft verbunden zu sein ist
|
||
/// dort also nicht unsichtbar, sondern lästig. Ihre Werte ändern sich
|
||
/// ausserdem langsam, und was auf der Übersicht zählt – der Sollwert –
|
||
/// ändert sich überhaupt nur, wenn jemand ihn ändert.
|
||
///
|
||
/// BMS und Neigungsmesser bleiben dagegen verbunden: Sie stören nicht, und
|
||
/// ihre Werte will man laufend sehen.
|
||
var connectsOnDemand: Bool { self == .fridge }*/
|
||
|
||
/// Der Neigungsmesser braucht eine echte GATT-Verbindung, um Werte zu liefern.
|
||
var transport: DeviceTransport {
|
||
return .connect
|
||
}
|
||
/*/// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS
|
||
/// braucht eine echte GATT-Verbindung.
|
||
var transport: DeviceTransport {
|
||
switch self {
|
||
case .bms, .fridge, .leveling: return .connect
|
||
default: return .advertisement
|
||
}
|
||
}*/
|
||
}
|
||
|
||
enum DeviceTransport: Sendable {
|
||
/// Passives Mitlesen der BLE-Werbedaten (Victron Instant Readout).
|
||
case advertisement
|
||
/// Verbindungsaufbau, Kommando schreiben, Antwort per Notify lesen (Daly).
|
||
case connect
|
||
}
|
||
|
||
/*/// Wie viele Kühlzonen eine Box hat. Manche Einzonen-Boxen senden den langen
|
||
/// Datensatz einer Doppelzonen-Box mit, weshalb die Erkennung übersteuerbar ist.
|
||
enum FridgeZoneMode: String, Codable, CaseIterable, Identifiable, Sendable {
|
||
case automatic
|
||
case single
|
||
case dual
|
||
|
||
var id: String { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .automatic: return "Automatisch"
|
||
case .single: return "Eine Zone"
|
||
case .dual: return "Zwei Zonen"
|
||
}
|
||
}
|
||
}*/
|
||
|
||
/// 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?
|
||
/// Nur für Kühlboxen.
|
||
//var fridgeZoneMode: FridgeZoneMode = .automatic
|
||
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
|
||
var sensorOrientation = SensorOrientation.identity
|
||
|
||
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)
|
||
//fridgeZoneMode = try container.decodeIfPresent(FridgeZoneMode.self, forKey: .fridgeZoneMode)
|
||
// ?? .automatic
|
||
sensorOrientation = try container.decodeIfPresent(SensorOrientation.self,
|
||
forKey: .sensorOrientation) ?? .identity
|
||
}
|
||
}
|