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
+160
View File
@@ -0,0 +1,160 @@
import CoreBluetooth
import Foundation
/// Hält die Verbindung zum Solarladeregler.
///
/// Einfacher noch als `LevelSession`: die Firmware bietet für keine
/// Charakteristik `notify` an (siehe `esp32_ble_solar.yaml`), es wird also
/// immer im Takt abgefragt statt abonniert.
final class SolarSession: NSObject {
static let serviceUUID = CBUUID(string: VanAlignSolarProtocol.serviceUUID)
private static let batteryVoltageUUID = CBUUID(string: VanAlignSolarProtocol.batteryVoltageUUID)
private static let pvVoltageUUID = CBUUID(string: VanAlignSolarProtocol.pvVoltageUUID)
private static let pvCurrentUUID = CBUUID(string: VanAlignSolarProtocol.pvCurrentUUID)
private static let pvPowerUUID = CBUUID(string: VanAlignSolarProtocol.pvPowerUUID)
private static let controllerTempUUID = CBUUID(string: VanAlignSolarProtocol.controllerTempUUID)
private static let statusFlagsUUID = CBUUID(string: VanAlignSolarProtocol.statusFlagsUUID)
let deviceID: UUID
private let queue: DispatchQueue
private let peripheral: CBPeripheral
private let onUpdate: (DeviceSnapshot) -> Void
private let onStateChange: (DeviceLinkState) -> Void
private let onSolarState: (SolarState) -> Void
private var characteristics: [CBUUID: CBCharacteristic] = [:]
private var state = SolarState()
private var pollTimer: DispatchSourceTimer?
/// Reicht für einen Solarregler, dessen Werte sich über Sekunden ändern
/// deutlich seltener als beim Ausrichten mit dem Neigungsmesser.
var pollInterval: TimeInterval = 5
init(deviceID: UUID,
peripheral: CBPeripheral,
queue: DispatchQueue,
onUpdate: @escaping (DeviceSnapshot) -> Void,
onStateChange: @escaping (DeviceLinkState) -> Void,
onSolarState: @escaping (SolarState) -> Void) {
self.deviceID = deviceID
self.queue = queue
self.peripheral = peripheral
self.onUpdate = onUpdate
self.onStateChange = onStateChange
self.onSolarState = onSolarState
super.init()
peripheral.delegate = self
}
// MARK: - Lebenszyklus
func start() {
onStateChange(.connecting)
peripheral.discoverServices([Self.serviceUUID])
}
func stop() {
pollTimer?.cancel()
pollTimer = nil
characteristics.removeAll()
}
func handleDisconnect() {
pollTimer?.cancel()
pollTimer = nil
characteristics.removeAll()
}
// MARK: - Abfrage
private func startPolling() {
guard pollTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now(), repeating: pollInterval)
timer.setEventHandler { [weak self] in self?.readAll() }
timer.resume()
pollTimer = timer
}
private func readAll() {
guard peripheral.state == .connected else { return }
for characteristic in characteristics.values where characteristic.properties.contains(.read) {
peripheral.readValue(for: characteristic)
}
}
private func publish() {
guard state.hasReading else { return }
onStateChange(.live)
onSolarState(state)
onUpdate(state.snapshot(deviceID: deviceID, rssi: nil))
}
}
// MARK: - CBPeripheralDelegate
extension SolarSession: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
onStateChange(.failed(error.localizedDescription))
return
}
guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
onStateChange(.failed("Solarladeregler-Dienst nicht gefunden"))
return
}
peripheral.discoverCharacteristics(
[Self.batteryVoltageUUID, Self.pvVoltageUUID, Self.pvCurrentUUID,
Self.pvPowerUUID, Self.controllerTempUUID, Self.statusFlagsUUID],
for: service
)
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
guard error == nil, let found = service.characteristics else {
onStateChange(.failed(error?.localizedDescription ?? "Keine Merkmale gefunden"))
return
}
for characteristic in found {
characteristics[characteristic.uuid] = characteristic
}
guard !characteristics.isEmpty else {
onStateChange(.failed("Solarwerte nicht gefunden"))
return
}
startPolling()
readAll()
}
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard error == nil, let value = characteristic.value else { return }
switch characteristic.uuid {
case Self.batteryVoltageUUID:
state.batteryVoltage = VanAlignSolarProtocol.float(from: value)
case Self.pvVoltageUUID:
state.pvVoltage = VanAlignSolarProtocol.float(from: value)
case Self.pvCurrentUUID:
state.pvCurrent = VanAlignSolarProtocol.float(from: value)
case Self.pvPowerUUID:
state.pvPower = VanAlignSolarProtocol.float(from: value)
case Self.controllerTempUUID:
state.controllerTemperature = VanAlignSolarProtocol.float(from: value)
case Self.statusFlagsUUID:
guard let flags = VanAlignSolarProtocol.statusFlags(from: value) else { return }
state.isBatteryCharging = flags.isBatteryCharging
state.isBatteryDischarging = flags.isBatteryDischarging
state.isControllerActive = flags.isControllerActive
state.isCurrentLimited = flags.isCurrentLimited
default:
return
}
publish()
}
}