Add solar charge controller integration and firmware

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>
This commit is contained in:
fototeddy
2026-09-05 00:04:20 +02:00
co-authored by Claude Sonnet 5
parent 9a3b5cb472
commit 6d7b32d622
14 changed files with 1257 additions and 22 deletions
+63 -10
View File
@@ -17,6 +17,8 @@ struct Discovery: Identifiable, Hashable {
var looksLikeSupported: Bool
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
var isLevelSensor = false
/// Der Solarladeregler bewirbt seinen Dienst ebenso.
var isSolarSensor = false
//var isVictron: Bool { victronRecordType != nil }
@@ -31,6 +33,7 @@ struct Discovery: Identifiable, Hashable {
var subtitle: String {
if isLevelSensor { return "VanAlign Neigungsmesser" }
if isSolarSensor { return "VanAlign Solarladeregler" }
return "Bluetooth-Gerät"
}
/*var subtitle: String {
@@ -125,6 +128,12 @@ struct HistorySample: Identifiable, Hashable {
let value: Double
}
/// Verlauf mehrerer Messgrössen eines Geräts, je Metrik-Schlüssel (siehe
/// `Metric.key`). Nur im Arbeitsspeicher: Sinn ist die Grafik während der
/// laufenden App-Sitzung, nicht ein dauerhaftes Log ein Neustart der App
/// oder das Beenden im Hintergrund darf den Verlauf also verwerfen.
typealias DeviceHistory = [String: [HistorySample]]
/// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Geräten und hält
/// parallel die Verbindung zum Neigungsmesser.
///
@@ -150,11 +159,12 @@ final class BluetoothManager: NSObject {
private(set) var snapshots: [UUID: DeviceSnapshot] = [:]
private(set) var linkStates: [UUID: DeviceLinkState] = [:]
private(set) var discoveries: [UUID: Discovery] = [:]
private(set) var history: [UUID: [HistorySample]] = [:]
private(set) var history: [UUID: DeviceHistory] = [:]
//private(set) var diagnostics: [UUID: VictronDiagnostics] = [:]
//private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
//private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
private(set) var levelStates: [UUID: LevelState] = [:]
private(set) var solarStates: [UUID: SolarState] = [:]
private(set) var isBluetoothReady = false
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
@@ -191,6 +201,7 @@ final class BluetoothManager: NSObject {
//private var bmsSessions: [UUID: BMSSession] = [:]
private var levelSessions: [UUID: LevelSession] = [:]
private var solarSessions: [UUID: SolarSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
private var reconnectTimer: DispatchSourceTimer?
@@ -269,6 +280,11 @@ final class BluetoothManager: NSObject {
bluetoothStatusText = "Demo-Modus"
//fridgeStates[DemoData.fridge.id] = DemoData.fridgeState
levelStates[DemoData.level.id] = DemoData.levelState
solarStates[DemoData.solar.id] = DemoData.solarState
linkStates[DemoData.solar.id] = .live
record(DemoData.solarState.snapshot(deviceID: DemoData.solar.id, rssi: -58))
history[DemoData.solar.id] = DemoData.solarHistory()
/* for snapshot in DemoData.snapshots() {
snapshots[snapshot.deviceID] = snapshot
linkStates[snapshot.deviceID] = .live
@@ -326,6 +342,7 @@ final class BluetoothManager: NSObject {
//bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
//fridgeStates = fridgeStates.filter { known.contains($0.key) }
levelStates = levelStates.filter { known.contains($0.key) }
solarStates = solarStates.filter { known.contains($0.key) }
// Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin
// ihren zuletzt gestellten Stand.
@@ -535,6 +552,11 @@ final class BluetoothManager: NSObject {
levelSessions[peripheralID] = nil
disconnect(peripheralID)
}
for (peripheralID, session) in solarSessions where !wanted.contains(peripheralID) {
session.stop()
solarSessions[peripheralID] = nil
disconnect(peripheralID)
}
// Einstellungen an bestehende Sitzungen weiterreichen.
for device in devices {
//bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode
@@ -580,9 +602,11 @@ final class BluetoothManager: NSObject {
discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
//for (_, session) in bmsSessions { session.stop() }
for (_, session) in levelSessions { session.stop() }
for (_, session) in solarSessions { session.stop() }
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
//bmsSessions.removeAll()
levelSessions.removeAll()
solarSessions.removeAll()
connectedPeripherals.removeAll()
connectedSince.removeAll()
//pendingControls.removeAll()
@@ -692,15 +716,20 @@ final class BluetoothManager: NSObject {
private func record(_ snapshot: DeviceSnapshot) {
publish {
self.snapshots[snapshot.deviceID] = snapshot
guard let primary = snapshot.primaryMetric, let value = primary.value else { return }
var samples = self.history[snapshot.deviceID] ?? []
// Höchstens alle fünf Sekunden einen Punkt aufnehmen.
if let last = samples.last, snapshot.timestamp.timeIntervalSince(last.time) < 5 { return }
samples.append(HistorySample(time: snapshot.timestamp, value: value))
if samples.count > self.historyLimit {
samples.removeFirst(samples.count - self.historyLimit)
var deviceHistory = self.history[snapshot.deviceID] ?? [:]
for metric in snapshot.metrics {
guard let value = metric.value else { continue }
var samples = deviceHistory[metric.key] ?? []
// Höchstens alle fünf Sekunden einen Punkt aufnehmen.
if let last = samples.last,
snapshot.timestamp.timeIntervalSince(last.time) < 5 { continue }
samples.append(HistorySample(time: snapshot.timestamp, value: value))
if samples.count > self.historyLimit {
samples.removeFirst(samples.count - self.historyLimit)
}
deviceHistory[metric.key] = samples
}
self.history[snapshot.deviceID] = samples
self.history[snapshot.deviceID] = deviceHistory
}
}
@@ -773,7 +802,8 @@ final class BluetoothManager: NSObject {
if let name, !name.isEmpty { entry.name = name }
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
entry.isLevelSensor = services.contains(LevelSession.serviceUUID)
entry.looksLikeSupported = entry.isLevelSensor
entry.isSolarSensor = services.contains(SolarSession.serviceUUID)
entry.looksLikeSupported = entry.isLevelSensor || entry.isSolarSensor
pendingDiscoveries[peripheral.identifier] = entry
}
/*private func updateDiscovery(peripheral: CBPeripheral,
@@ -917,6 +947,27 @@ extension BluetoothManager: CBCentralManagerDelegate {
return
}
if device.role == .solar {
// Bewusst kein Draht zu `activityManager`: Der Solarertrag soll
// nicht in der Live Activity/CarPlay auftauchen, die ist dem
// Neigungsmesser vorbehalten.
let session = SolarSession(
deviceID: device.id,
peripheral: peripheral,
queue: queue,
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
onStateChange: { [weak self] state in
self?.publish { self?.linkStates[device.id] = state }
},
onSolarState: { [weak self] state in
self?.publish { self?.solarStates[device.id] = state }
}
)
solarSessions[peripheral.identifier] = session
session.start()
return
}
/*let session = BMSSession(
deviceID: device.id,
peripheral: peripheral,
@@ -964,6 +1015,8 @@ extension BluetoothManager: CBCentralManagerDelegate {
//bmsSessions[peripheral.identifier] = nil
levelSessions[peripheral.identifier]?.handleDisconnect()
levelSessions[peripheral.identifier] = nil
solarSessions[peripheral.identifier]?.handleDisconnect()
solarSessions[peripheral.identifier] = nil
connectedPeripherals[peripheral.identifier] = nil
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)