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
@@ -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