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:
co-authored by
Claude Sonnet 5
parent
9a3b5cb472
commit
6d7b32d622
@@ -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)
|
||||
|
||||
@@ -59,8 +59,47 @@ enum DemoData {
|
||||
name: "Nivellierung", role: .leveling, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000E1")!)
|
||||
|
||||
static let solar = ConfiguredDevice(
|
||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000D0")!,
|
||||
name: "Solar Dach", role: .solar, profileID: Profile.defaultID,
|
||||
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000D1")!)
|
||||
|
||||
static var devices: [ConfiguredDevice] {
|
||||
[/*solar, booster, battery, fridge, */level/*, caravanSolar*/]
|
||||
[/*booster, battery, fridge, */level, solar/*, caravanSolar*/]
|
||||
}
|
||||
|
||||
/// Mittags, gute Sonne.
|
||||
static var solarState: SolarState {
|
||||
var state = SolarState()
|
||||
state.batteryVoltage = 13.9
|
||||
state.pvVoltage = 19.4
|
||||
state.pvCurrent = 6.2
|
||||
state.pvPower = 120
|
||||
state.controllerTemperature = 34
|
||||
state.isBatteryCharging = true
|
||||
state.isControllerActive = true
|
||||
state.isCurrentLimited = false
|
||||
return state
|
||||
}
|
||||
|
||||
/// Ein paar Stunden Verlauf je Kanal, damit die Grafik im Demo-Modus
|
||||
/// sofort etwas zeigt statt erst nach ein paar Minuten Laufzeit.
|
||||
static func solarHistory() -> DeviceHistory {
|
||||
let now = Date()
|
||||
func series(around value: Double, noise: Double) -> [HistorySample] {
|
||||
(0..<120).reversed().map { step in
|
||||
let t = Double(step)
|
||||
let wave = sin(t / 14) * value * noise + cos(t / 31) * value * (noise / 2)
|
||||
return HistorySample(time: now.addingTimeInterval(-t * 60),
|
||||
value: max(0, value + wave))
|
||||
}
|
||||
}
|
||||
return [
|
||||
"pv_power": series(around: 120, noise: 0.35),
|
||||
"pv_voltage": series(around: 19.4, noise: 0.08),
|
||||
"pv_current": series(around: 6.2, noise: 0.3),
|
||||
"battery_voltage": series(around: 13.9, noise: 0.03),
|
||||
]
|
||||
}
|
||||
|
||||
/// Leicht schräg stehend, damit die Libelle etwas zu zeigen hat.
|
||||
|
||||
@@ -195,6 +195,8 @@ private struct ConfigureDeviceView: View {
|
||||
}
|
||||
} else*/ if discovery.isLevelSensor {
|
||||
role = .leveling
|
||||
} else if discovery.isSolarSensor {
|
||||
role = .solar
|
||||
} /*else if let name = discovery.name?.lowercased(),
|
||||
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
|
||||
role = .fridge
|
||||
@@ -206,6 +208,8 @@ private struct ConfigureDeviceView: View {
|
||||
// ohnehin darüber unter "Gefunden als".
|
||||
if discovery.isLevelSensor {
|
||||
name = "Nivellierung"
|
||||
} else if discovery.isSolarSensor {
|
||||
name = "Solarladeregler"
|
||||
} else if let advertised = discovery.name, advertised.count <= 20,
|
||||
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
|
||||
name = advertised
|
||||
|
||||
@@ -35,9 +35,31 @@ struct DeviceDetailView: View {
|
||||
store.devices.first { $0.id == device.id } ?? device
|
||||
}
|
||||
|
||||
/// Nur solange die App läuft: siehe `DeviceHistory`.
|
||||
@State private var selectedHistoryMetricKey: String?
|
||||
|
||||
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
|
||||
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
|
||||
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
|
||||
private var deviceHistory: DeviceHistory { bluetooth.history[device.id] ?? [:] }
|
||||
|
||||
/// Nur Metriken, zu denen sich bereits ein Verlauf mit mehr als einem
|
||||
/// Punkt angesammelt hat – sonst gäbe es nichts zu zeichnen.
|
||||
private var chartableMetrics: [Metric] {
|
||||
(snapshot?.metrics ?? []).filter { (deviceHistory[$0.key]?.count ?? 0) > 1 }
|
||||
}
|
||||
|
||||
private var selectedMetric: Metric? {
|
||||
if let key = selectedHistoryMetricKey,
|
||||
let metric = chartableMetrics.first(where: { $0.key == key }) {
|
||||
return metric
|
||||
}
|
||||
return chartableMetrics.first(where: \.isPrimary) ?? chartableMetrics.first
|
||||
}
|
||||
|
||||
private var samples: [HistorySample] {
|
||||
guard let selectedMetric else { return [] }
|
||||
return deviceHistory[selectedMetric.key] ?? []
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -65,20 +87,38 @@ struct DeviceDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if samples.count > 1, let primary = snapshot?.primaryMetric {
|
||||
Section("Verlauf – \(primary.label)") {
|
||||
if let metric = selectedMetric, samples.count > 1 {
|
||||
Section {
|
||||
if chartableMetrics.count > 1 {
|
||||
Picker("Messgrösse", selection: Binding(
|
||||
get: { metric.key },
|
||||
set: { selectedHistoryMetricKey = $0 }
|
||||
)) {
|
||||
ForEach(chartableMetrics) { candidate in
|
||||
Text(candidate.label).tag(candidate.key)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
Chart(samples) { sample in
|
||||
AreaMark(x: .value("Zeit", sample.time),
|
||||
y: .value(primary.label, sample.value))
|
||||
y: .value(metric.label, sample.value))
|
||||
.foregroundStyle(.tint.opacity(0.15))
|
||||
LineMark(x: .value("Zeit", sample.time),
|
||||
y: .value(primary.label, sample.value))
|
||||
y: .value(metric.label, sample.value))
|
||||
.foregroundStyle(.tint)
|
||||
.interpolationMethod(.monotone)
|
||||
}
|
||||
.chartYAxisLabel(primary.unit)
|
||||
.chartYAxisLabel(metric.unit)
|
||||
.frame(height: 180)
|
||||
.padding(.vertical, 8)
|
||||
} header: {
|
||||
Text("Verlauf – \(metric.label)")
|
||||
} footer: {
|
||||
Text("Nur für die laufende Sitzung – wird beim Neustart der App verworfen.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user