diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift
index f27a945..813a825 100644
--- a/CamperMonitor/Bluetooth/BluetoothManager.swift
+++ b/CamperMonitor/Bluetooth/BluetoothManager.swift
@@ -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)
diff --git a/CamperMonitor/Store/DemoData.swift b/CamperMonitor/Store/DemoData.swift
index e595f27..a39807f 100644
--- a/CamperMonitor/Store/DemoData.swift
+++ b/CamperMonitor/Store/DemoData.swift
@@ -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.
diff --git a/CamperMonitor/Views/AddDeviceView.swift b/CamperMonitor/Views/AddDeviceView.swift
index 5a75518..4570be3 100644
--- a/CamperMonitor/Views/AddDeviceView.swift
+++ b/CamperMonitor/Views/AddDeviceView.swift
@@ -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
diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift
index ad9d236..a755c75 100644
--- a/CamperMonitor/Views/DeviceDetailView.swift
+++ b/CamperMonitor/Views/DeviceDetailView.swift
@@ -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.")
}
}
diff --git a/Shared/Bluetooth/SolarSession.swift b/Shared/Bluetooth/SolarSession.swift
new file mode 100644
index 0000000..d138e92
--- /dev/null
+++ b/Shared/Bluetooth/SolarSession.swift
@@ -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()
+ }
+}
diff --git a/Shared/Bluetooth/VanAlignSolarProtocol.swift b/Shared/Bluetooth/VanAlignSolarProtocol.swift
new file mode 100644
index 0000000..98f9ac2
--- /dev/null
+++ b/Shared/Bluetooth/VanAlignSolarProtocol.swift
@@ -0,0 +1,55 @@
+import Foundation
+
+/// Solarladeregler „VanAlign Solar" – zweiter ESP32 im Fahrzeug, liest einen
+/// Votronic-Solarladeregler aus und stellt die Werte über einen eigenen
+/// BLE-Dienst bereit. Siehe `firmware/vanalign/esp32_ble_solar.yaml`.
+///
+/// Wie beim Neigungsmesser: kein Rahmenprotokoll, jede Messgrösse liegt in
+/// einer eigenen Charakteristik, alle sind reine Lesewerte, der Client fragt
+/// sie im Takt ab.
+enum VanAlignSolarProtocol {
+
+ /// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
+ static let serviceUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A001"
+
+ static let batteryVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A101"
+ static let pvVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A102"
+ static let pvCurrentUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A103"
+ static let pvPowerUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A104"
+ static let controllerTempUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A105"
+ /// Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
+ /// Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
+ static let statusFlagsUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A106"
+
+ /// Liest einen Messwert aus vier Bytes, little-endian – wie beim
+ /// Neigungsmesser legt die Firmware den Float per `memcpy` ab.
+ static func float(from data: Data) -> Double? {
+ guard data.count >= 4 else { return nil }
+ var raw: UInt32 = 0
+ for (index, byte) in data.prefix(4).enumerated() {
+ raw |= UInt32(byte) << UInt32(8 * index)
+ }
+ let value = Float(bitPattern: raw)
+ guard value.isFinite else { return nil }
+ return Double(value)
+ }
+
+ struct StatusFlags {
+ var isBatteryCharging = false
+ var isBatteryDischarging = false
+ var isControllerActive = false
+ var isCurrentLimited = false
+ var isAESActive = false
+ }
+
+ static func statusFlags(from data: Data) -> StatusFlags? {
+ guard let byte = data.first else { return nil }
+ var flags = StatusFlags()
+ flags.isBatteryCharging = byte & (1 << 0) != 0
+ flags.isBatteryDischarging = byte & (1 << 1) != 0
+ flags.isControllerActive = byte & (1 << 2) != 0
+ flags.isCurrentLimited = byte & (1 << 3) != 0
+ flags.isAESActive = byte & (1 << 4) != 0
+ return flags
+ }
+}
diff --git a/Shared/Models/ConfiguredDevice.swift b/Shared/Models/ConfiguredDevice.swift
index d84579f..65c050d 100644
--- a/Shared/Models/ConfiguredDevice.swift
+++ b/Shared/Models/ConfiguredDevice.swift
@@ -4,33 +4,33 @@ import Foundation
/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
//case chargeBooster
- //case solarCharger
//case batteryMonitor
//case bms
//case fridge
case leveling
+ case solar
var id: String { rawValue }
var title: String {
switch self {
//case .chargeBooster: return "Ladebooster"
- //case .solarCharger: return "Solarladeregler"
//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 .solarCharger: return "sun.max"
//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"
}
}
diff --git a/Shared/Models/SolarState.swift b/Shared/Models/SolarState.swift
new file mode 100644
index 0000000..e864cf9
--- /dev/null
+++ b/Shared/Models/SolarState.swift
@@ -0,0 +1,48 @@
+import Foundation
+
+/// Zustand des Solarladereglers.
+struct SolarState: Equatable, Codable, Sendable {
+ var batteryVoltage: Double?
+ var pvVoltage: Double?
+ var pvCurrent: Double?
+ var pvPower: Double?
+ var controllerTemperature: Double?
+
+ var isBatteryCharging: Bool?
+ var isBatteryDischarging: Bool?
+ var isControllerActive: Bool?
+ var isCurrentLimited: Bool?
+
+ var hasReading: Bool {
+ batteryVoltage != nil || pvVoltage != nil || pvCurrent != nil || pvPower != nil
+ }
+
+ /// Kurzer Klartext, wie bei den übrigen Geräten als "Zustand" angezeigt.
+ var stateText: String? {
+ guard isControllerActive != nil else { return nil }
+ if isBatteryCharging == true { return "Lädt" }
+ if isControllerActive == true { return "Aktiv" }
+ return "Inaktiv"
+ }
+
+ func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
+ var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
+ snapshot.metrics = [
+ Metric("pv_power", "Solarleistung", pvPower, unit: "W", precision: 0, primary: true),
+ Metric("pv_voltage", "PV-Spannung", pvVoltage, unit: "V", precision: 1),
+ Metric("pv_current", "PV-Strom", pvCurrent, unit: "A", precision: 1),
+ Metric("battery_voltage", "Batteriespannung", batteryVoltage, unit: "V", precision: 2),
+ ]
+ if let controllerTemperature {
+ snapshot.metrics.append(
+ Metric("controller_temperature", "Reglertemperatur", controllerTemperature,
+ unit: "°C", precision: 0)
+ )
+ }
+ snapshot.state = stateText
+ if isCurrentLimited == true {
+ snapshot.offReasons = ["PV-Strombegrenzung aktiv"]
+ }
+ return snapshot
+ }
+}
diff --git a/VanAligneiOS.xcodeproj/project.xcworkspace/xcuserdata/christopher.xcuserdatad/UserInterfaceState.xcuserstate b/VanAligneiOS.xcodeproj/project.xcworkspace/xcuserdata/christopher.xcuserdatad/UserInterfaceState.xcuserstate
index 26962c3..b25ab53 100644
Binary files a/VanAligneiOS.xcodeproj/project.xcworkspace/xcuserdata/christopher.xcuserdatad/UserInterfaceState.xcuserstate and b/VanAligneiOS.xcodeproj/project.xcworkspace/xcuserdata/christopher.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/VanAligneiOS.xcodeproj/xcuserdata/christopher.xcuserdatad/xcschemes/xcschememanagement.plist b/VanAligneiOS.xcodeproj/xcuserdata/christopher.xcuserdatad/xcschemes/xcschememanagement.plist
index b92e75e..108b3c8 100644
--- a/VanAligneiOS.xcodeproj/xcuserdata/christopher.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/VanAligneiOS.xcodeproj/xcuserdata/christopher.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -22,12 +22,12 @@
VanAligneComplication.xcscheme_^#shared#^_
orderHint
- 2
+ 3
VanAligneiOSWidgetExtension.xcscheme_^#shared#^_
orderHint
- 3
+ 2
diff --git a/firmware/vanalign/esp32_ble.yaml b/firmware/vanalign/esp32_ble.yaml
index fc9165b..9d2572f 100644
--- a/firmware/vanalign/esp32_ble.yaml
+++ b/firmware/vanalign/esp32_ble.yaml
@@ -22,9 +22,15 @@ esphome:
esp32:
board: esp32-s3-devkitc-1
+ flash_size: 16MB
framework:
type: esp-idf
+# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
+psram:
+ mode: octal
+ speed: 80MHz
+
logger:
level: WARN
diff --git a/firmware/vanalign/esp32_ble_sim.yaml b/firmware/vanalign/esp32_ble_sim.yaml
new file mode 100644
index 0000000..c7c92cd
--- /dev/null
+++ b/firmware/vanalign/esp32_ble_sim.yaml
@@ -0,0 +1,332 @@
+# VanAlign Pro - Neigungsmessung über BLE (SIMULATION)
+#
+# Kopie von esp32_ble.yaml für den Fall, dass gerade kein MPU6050 zum
+# Anschliessen vorhanden ist. Der `platform: mpu6050`-Sensor sowie der
+# i2c-Bus wurden entfernt und durch Template-Sensoren ersetzt, die
+# plausible, sich langsam ändernde Beschleunigungswerte erzeugen (ein
+# gedachter Sensor, der gemütlich hin- und herschaukelt). Pitch/Roll,
+# Kalibrierung und die BLE-Charakteristiken funktionieren dadurch exakt wie
+# im Original - nur eben ohne angeschlossene Hardware.
+#
+# Name und Friendly Name sind bewusst auf "-sim" abgeändert, damit dieses
+# Gerät im Netzwerk/BLE nicht mit einem echten VanAlign-Gerät kollidiert.
+#
+# Sobald wieder ein echter MPU6050 verfügbar ist, einfach esp32_ble.yaml
+# weiterverwenden - diese Datei ist nur zum Testen der App/BLE-Anbindung.
+
+esphome:
+ name: vanalign-sim
+ friendly_name: "VanAlign Pro (Sim)"
+
+
+esp32:
+ board: esp32-s3-devkitc-1
+ flash_size: 16MB
+ framework:
+ type: esp-idf
+
+# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
+psram:
+ mode: octal
+ speed: 80MHz
+
+
+logger:
+ level: WARN
+
+espnow:
+ channel: 1
+
+sensor:
+ # Simulierte Rohwerte anstelle des physischen MPU6050. Die Sensor-Lage
+ # (Pitch/Roll) wandert langsam und stetig, wie es ein tatsächlich leicht
+ # schaukelndes Fahrzeug/Werkstück tun würde (Perioden ~75s/~113s).
+ - platform: template
+ name: "MPU6050 Accel X (Sim)"
+ id: accel_x
+ internal: true
+ update_interval: 0.1s
+ lambda: |-
+ float t = millis() / 1000.0f;
+ float pitch_rad = (15.0f * sin(t / 12.0f)) * 3.14159265f / 180.0f;
+ float roll_rad = (10.0f * sin(t / 18.0f + 1.0f)) * 3.14159265f / 180.0f;
+ return -9.80665f * sin(roll_rad) * cos(pitch_rad);
+
+ - platform: template
+ name: "MPU6050 Accel Y (Sim)"
+ id: accel_y
+ internal: true
+ update_interval: 0.1s
+ lambda: |-
+ float t = millis() / 1000.0f;
+ float pitch_rad = (15.0f * sin(t / 12.0f)) * 3.14159265f / 180.0f;
+ return 9.80665f * sin(pitch_rad);
+
+ - platform: template
+ name: "MPU6050 Accel Z (Sim)"
+ id: accel_z
+ internal: true
+ update_interval: 0.1s
+ lambda: |-
+ float t = millis() / 1000.0f;
+ float pitch_rad = (15.0f * sin(t / 12.0f)) * 3.14159265f / 180.0f;
+ float roll_rad = (10.0f * sin(t / 18.0f + 1.0f)) * 3.14159265f / 180.0f;
+ return 9.80665f * cos(roll_rad) * cos(pitch_rad);
+
+ # Gyro-Werte werden nur zur Anzeige simuliert (kleine Winkelgeschwindigkeit
+ # passend zur Schaukelbewegung oben, kein realer Bezug nötig).
+ - platform: template
+ name: "MPU6050 Gyro X-Achse"
+ id: mpu_gyro_x
+ update_interval: 0.1s
+ lambda: |-
+ float t = millis() / 1000.0f;
+ return (10.0f / 18.0f) * cos(t / 18.0f + 1.0f) * 3.14159265f / 180.0f;
+
+ - platform: template
+ name: "MPU6050 Gyro Y-Achse"
+ id: mpu_gyro_y
+ update_interval: 0.1s
+ lambda: |-
+ float t = millis() / 1000.0f;
+ return (15.0f / 12.0f) * cos(t / 12.0f) * 3.14159265f / 180.0f;
+
+ - platform: template
+ name: "MPU6050 Gyro Z-Achse"
+ id: mpu_gyro_z
+ update_interval: 0.1s
+ lambda: |-
+ return 0.0f;
+
+ - platform: template
+ name: "Neigung Pitch"
+ id: pitch
+ icon: mdi:caravan
+ unit_of_measurement: "°"
+ accuracy_decimals: 1
+ update_interval: 0.1s
+ lambda: |-
+ if (isnan(id(accel_x).state) || isnan(id(accel_y).state) || isnan(id(accel_z).state)) {
+ return NAN;
+ }
+ float raw = atan2(id(accel_y).state, sqrt(pow(id(accel_x).state, 2) + pow(id(accel_z).state, 2))) * (180.0 / 3.14159265);
+ return raw - id(pitch_offset); // Offset wird hier subtrahiert
+ filters:
+ - sliding_window_moving_average:
+ window_size: 8
+ send_every: 1
+ - exponential_moving_average:
+ alpha: 0.2
+
+ - platform: template
+ name: "Neigung Roll"
+ id: roll
+ icon: mdi:axis-x-rotate-clockwise
+ unit_of_measurement: "°"
+ accuracy_decimals: 1
+ update_interval: 0.1s
+ lambda: |-
+ if (isnan(id(accel_x).state) || isnan(id(accel_z).state)) {
+ return NAN;
+ }
+ float raw = atan2(-id(accel_x).state, id(accel_z).state) * (180.0 / 3.14159265);
+ return raw - id(roll_offset); // Offset wird hier subtrahiert
+ filters:
+ - sliding_window_moving_average:
+ window_size: 8
+ send_every: 1
+ - exponential_moving_average:
+ alpha: 0.2
+
+globals:
+ - id: pitch_offset
+ type: float
+ restore_value: yes
+ initial_value: '0.0'
+ - id: roll_offset
+ type: float
+ restore_value: yes
+ initial_value: '0.0'
+ # Die Einbaulage liegt im Gerät, nicht in den Apps: Sie beschreibt, wie der
+ # Sensor im Fahrzeug sitzt – eine Eigenschaft des Einbaus, nicht des Telefons.
+ # Damit sehen iPhone, Uhr und Android dasselbe, ohne sie je einzeln zu
+ # bestimmen. Angewandt wird sie weiterhin in den Apps; das Gerät verwahrt sie
+ # nur, sonst rechneten ältere Clients die Korrektur ein zweites Mal.
+ - id: orientation_version
+ type: uint8_t
+ restore_value: yes
+ initial_value: '0'
+ - id: orientation_source
+ type: uint8_t
+ restore_value: yes
+ initial_value: '0'
+ - id: orientation_invert_long
+ type: bool
+ restore_value: yes
+ initial_value: 'false'
+ - id: orientation_invert_lat
+ type: bool
+ restore_value: yes
+ initial_value: 'false'
+ - id: orientation_twist
+ type: float
+ restore_value: yes
+ initial_value: '0.0'
+
+ - id: enable_captive
+ type: bool
+ restore_value: yes
+ initial_value: 'false'
+
+esp32_ble_server:
+ services:
+ - uuid: 2a24b789-7aab-4535-af3e-ee76a35cc42d
+ advertise: true
+ characteristics:
+ - id: pitch_ble
+ uuid: cad48e28-7fbe-41cf-bae9-d77a6c233424
+ description: "Pitch"
+ read: true
+ value: !lambda |-
+ std::vector v(sizeof(float));
+ float val = id(pitch).state;
+ memcpy(v.data(), &val, sizeof(float));
+ return v;
+ - id: roll_ble
+ uuid: cad48e28-7fbe-41cf-bae9-d77a6c233425
+ description: "Roll"
+ read: true
+ value: !lambda |-
+ std::vector v(sizeof(float));
+ float val = id(roll).state;
+ memcpy(v.data(), &val, sizeof(float));
+ return v;
+ # Die gespeicherten Nullpunkte, zwei Floats. Daran erkennen die Apps,
+ # ob überhaupt schon kalibriert wurde.
+ - id: offsets_ble
+ uuid: cad48e28-7fbe-41cf-bae9-d77a6c233426
+ description: "Kalibrier-Offsets"
+ read: true
+ value: !lambda |-
+ std::vector v(2 * sizeof(float));
+ float p = id(pitch_offset);
+ float r = id(roll_offset);
+ memcpy(v.data(), &p, sizeof(float));
+ memcpy(v.data() + sizeof(float), &r, sizeof(float));
+ return v;
+
+ # Die Einbaulage, acht Byte:
+ #
+ # 0 Version, 1 = gültig gesetzt, 0 = nie geschrieben
+ # 1 Längsachse: 0 = Pitch des Sensors, 1 = Roll des Sensors
+ # 2 längs umgekehrt (0/1)
+ # 3 quer umgekehrt (0/1)
+ # 4..7 Verdrehung um die Hochachse, float32, Grad
+ - id: orientation_ble
+ uuid: cad48e28-7fbe-41cf-bae9-d77a6c233428
+ description: "Einbaulage"
+ read: true
+ write: true
+ value: !lambda |-
+ std::vector v(8, 0);
+ v[0] = id(orientation_version);
+ v[1] = id(orientation_source);
+ v[2] = id(orientation_invert_long) ? 1 : 0;
+ v[3] = id(orientation_invert_lat) ? 1 : 0;
+ float t = id(orientation_twist);
+ memcpy(v.data() + 4, &t, sizeof(float));
+ return v;
+ on_write:
+ then:
+ - lambda: |-
+ if (x.size() < 8 || x[0] != 1) {
+ ESP_LOGW("vanalign", "Einbaulage verworfen: %d Byte, Version %d",
+ (int) x.size(), x.empty() ? -1 : (int) x[0]);
+ return;
+ }
+ float t;
+ memcpy(&t, x.data() + 4, sizeof(float));
+ if (!std::isfinite(t) || fabsf(t) > 180.0f) {
+ ESP_LOGW("vanalign", "Einbaulage verworfen: Verdrehung %.1f", t);
+ return;
+ }
+ id(orientation_version) = 1;
+ id(orientation_source) = x[1];
+ id(orientation_invert_long) = x[2] != 0;
+ id(orientation_invert_lat) = x[3] != 0;
+ id(orientation_twist) = t;
+ ESP_LOGI("vanalign", "Einbaulage gespeichert: Quelle=%d laengs=%d quer=%d verdreht=%.1f",
+ (int) x[1], (int) x[2], (int) x[3], t);
+
+ - id: calib_ble
+ uuid: cad48e28-7fbe-41cf-bae9-d77a6c233427
+ description: "Kalibriere Neigung"
+ write: true
+ on_write:
+ then:
+ - lambda: |-
+ bool reset = !x.empty() && (x[0] == 0x00 || x[0] == '0');
+ if (reset) {
+ id(reset_calibration).execute();
+ } else {
+ id(calibrate_level).execute();
+ }
+
+#web_server:
+# port: 80
+
+
+#ota:
+# platform: web_server
+
+
+#wifi:
+# ap:
+# ssid: "VanAlign-Setup"
+# password: "kalibrierung"
+
+
+
+script:
+ # Die aktuelle Lage wird zur neuen Null. Knopf und Bluetooth laufen hier
+ # zusammen, damit sie nicht auseinanderdriften.
+ - id: calibrate_level
+ then:
+ - lambda: |-
+ if (isnan(id(accel_x).state) || isnan(id(accel_y).state) || isnan(id(accel_z).state)) {
+ ESP_LOGW("vanalign", "Kalibrierung abgebrochen: keine Sensorwerte");
+ return;
+ }
+ id(pitch_offset) = atan2(id(accel_y).state, sqrt(pow(id(accel_x).state, 2) + pow(id(accel_z).state, 2))) * (180.0 / 3.14159265);
+ id(roll_offset) = atan2(-id(accel_x).state, id(accel_z).state) * (180.0 / 3.14159265);
+ ESP_LOGI("vanalign", "Kalibriert: pitch_offset=%.2f roll_offset=%.2f", id(pitch_offset), id(roll_offset));
+
+ - id: reset_calibration
+ then:
+ - lambda: |-
+ id(pitch_offset) = 0.0f;
+ id(roll_offset) = 0.0f;
+ ESP_LOGI("vanalign", "Kalibrierung zurückgesetzt");
+
+button:
+ - platform: template
+ name: "Kalibriere Neigung"
+ id: calib_button
+ on_press:
+ - script.execute: calibrate_level
+
+ - platform: template
+ name: "Kalibrierung zurücksetzen"
+ id: calib_reset_button
+ on_press:
+ - script.execute: reset_calibration
+ - platform: restart
+ name: "ESP Restart"
+
+text_sensor:
+ - platform: template
+ name: "Firmware Version"
+ id: firmware_version
+ icon: mdi:tag
+ lambda: |-
+ return {"v1.0.2-sim"};
diff --git a/firmware/vanalign/esp32_ble_solar.yaml b/firmware/vanalign/esp32_ble_solar.yaml
new file mode 100644
index 0000000..16ca5c9
--- /dev/null
+++ b/firmware/vanalign/esp32_ble_solar.yaml
@@ -0,0 +1,233 @@
+# VanAlign Solar - Votronic Solarladeregler über BLE
+#
+# Zweiter ESP32 im Fahrzeug, unabhängig vom Neigungssensor (esp32_ble.yaml).
+# Liest den Votronic-Solarladeregler über den Displaylink-Port (UART) mit der
+# externen Komponente github://syssi/esphome-votronic aus und stellt die
+# Werte - genau wie beim Neigungssensor - über einen eigenen BLE-Service
+# bereit. Zusätzlich (für Debug-Zwecke) läuft WLAN mit und die Werte werden
+# auch auf dem eingebauten Webserver (Port 80) angezeigt - MQTT und die API
+# sind weiterhin nicht enthalten. WLAN-Zugangsdaten liegen in secrets.yaml
+# (lokal anzulegen, ist per .gitignore ausgeschlossen).
+#
+# Verkabelung: UART TX=GPIO4, RX=GPIO5 an den Displaylink-Port des Reglers,
+# Baudrate 1000 (kein Tippfehler - das Votronic-Protokoll nutzt diese
+# ungewöhnlich niedrige Rate). Board/Pins ggf. an die tatsächlich verbaute
+# Hardware anpassen, hier als ESP32-S3-DevKitC-1 wie beim Neigungssensor
+# angenommen.
+#
+# BLE-Service 05c9a349-2b8e-4b1d-9c9d-c247e9a6a001 (wird beworben):
+#
+# Charakteristik UUID (Ende) Inhalt
+# Batteriespg. ...a101 Float32 LE, Volt
+# PV-Spannung ...a102 Float32 LE, Volt
+# PV-Strom ...a103 Float32 LE, Ampere
+# PV-Leistung ...a104 Float32 LE, Watt
+# Reglertemp. ...a105 Float32 LE, °C
+# Statusflags ...a106 1 Byte, Bitmaske: Bit0 Batterie lädt,
+# Bit1 Batterie entlädt, Bit2 PV-Regler
+# aktiv, Bit3 PV-Strombegrenzung, Bit4 AES
+# PV-Modus-ID ...a107 1 Byte, roher Wert aus pv_mode_setting_id
+# Batteriestatus ...a108 1 Byte, rohe Bitmaske aus dem Regler
+# Reglerstatus ...a109 1 Byte, rohe Bitmaske aus dem Regler
+#
+# Alle Charakteristiken sind reine Lesewerte, wie beim Neigungssensor fragt
+# der Client sie im Takt ab.
+
+esphome:
+ name: vanalign-solar
+ friendly_name: "VanAlign Solar"
+
+esp32:
+ board: esp32-s3-devkitc-1
+ flash_size: 16MB
+ framework:
+ type: esp-idf
+
+# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
+psram:
+ mode: octal
+ speed: 80MHz
+
+logger:
+ level: WARN
+
+wifi:
+ ssid: !secret wifi_ssid
+ password: !secret wifi_password
+
+web_server:
+ port: 80
+
+external_components:
+ - source: github://syssi/esphome-votronic@main
+ refresh: 0s
+
+uart:
+ - id: uart_0
+ baud_rate: 1000
+ tx_pin: GPIO4
+ rx_pin: GPIO5
+
+votronic:
+ - id: votronic0
+ uart_id: uart_0
+ rx_timeout: 150ms
+ throttle: 2s
+
+sensor:
+ - platform: votronic
+ votronic_id: votronic0
+ battery_computer_battery_voltage:
+ name: "Batteriespannung"
+ id: battery_voltage
+ pv_voltage:
+ name: "PV Spannung"
+ id: pv_voltage
+ pv_current:
+ name: "PV Strom"
+ id: pv_current
+ pv_power:
+ name: "PV Leistung"
+ id: pv_power
+ pv_controller_temperature:
+ name: "Reglertemperatur"
+ id: pv_temperature
+ pv_mode_setting_id:
+ name: "PV Modus-ID"
+ id: pv_mode_id
+ pv_battery_status_bitmask:
+ name: "PV Batteriestatus (Bitmaske)"
+ id: pv_battery_status_bitmask
+ pv_controller_status_bitmask:
+ name: "PV Reglerstatus (Bitmaske)"
+ id: pv_controller_status_bitmask
+
+binary_sensor:
+ - platform: votronic
+ votronic_id: votronic0
+ battery_computer_charging:
+ name: "Batterie lädt"
+ id: battery_charging
+ battery_computer_discharging:
+ name: "Batterie entlädt"
+ id: battery_discharging
+ pv_controller_active:
+ name: "PV Regler aktiv"
+ id: pv_active
+ pv_current_reduction:
+ name: "PV Strombegrenzung"
+ id: pv_current_reduction
+ pv_aes_active:
+ name: "PV AES aktiv"
+ id: pv_aes_active
+
+text_sensor:
+ - platform: votronic
+ votronic_id: votronic0
+ pv_mode_setting:
+ name: "PV Modus"
+ pv_battery_status:
+ name: "PV Batteriestatus"
+ pv_controller_status:
+ name: "PV Reglerstatus"
+ - platform: template
+ name: "Firmware Version"
+ id: firmware_version
+ icon: mdi:tag
+ lambda: |-
+ return {"v1.0.0"};
+
+esp32_ble_server:
+ services:
+ - uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a001
+ advertise: true
+ characteristics:
+ - id: battery_voltage_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a101
+ description: "Batteriespannung"
+ read: true
+ value: !lambda |-
+ std::vector v(sizeof(float));
+ float val = id(battery_voltage).state;
+ memcpy(v.data(), &val, sizeof(float));
+ return v;
+ - id: pv_voltage_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a102
+ description: "PV Spannung"
+ read: true
+ value: !lambda |-
+ std::vector v(sizeof(float));
+ float val = id(pv_voltage).state;
+ memcpy(v.data(), &val, sizeof(float));
+ return v;
+ - id: pv_current_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a103
+ description: "PV Strom"
+ read: true
+ value: !lambda |-
+ std::vector v(sizeof(float));
+ float val = id(pv_current).state;
+ memcpy(v.data(), &val, sizeof(float));
+ return v;
+ - id: pv_power_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a104
+ description: "PV Leistung"
+ read: true
+ value: !lambda |-
+ std::vector v(sizeof(float));
+ float val = id(pv_power).state;
+ memcpy(v.data(), &val, sizeof(float));
+ return v;
+ - id: pv_temperature_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a105
+ description: "Reglertemperatur"
+ read: true
+ value: !lambda |-
+ std::vector v(sizeof(float));
+ float val = id(pv_temperature).state;
+ memcpy(v.data(), &val, sizeof(float));
+ return v;
+ # Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
+ # Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
+ - id: status_flags_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a106
+ description: "Statusflags"
+ read: true
+ value: !lambda |-
+ std::vector v(1, 0);
+ uint8_t flags = 0;
+ if (id(battery_charging).state) flags |= (1 << 0);
+ if (id(battery_discharging).state) flags |= (1 << 1);
+ if (id(pv_active).state) flags |= (1 << 2);
+ if (id(pv_current_reduction).state) flags |= (1 << 3);
+ if (id(pv_aes_active).state) flags |= (1 << 4);
+ v[0] = flags;
+ return v;
+ - id: pv_mode_id_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a107
+ description: "PV Modus-ID"
+ read: true
+ value: !lambda |-
+ std::vector v(1, 0);
+ v[0] = (uint8_t) id(pv_mode_id).state;
+ return v;
+ - id: pv_battery_status_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a108
+ description: "PV Batteriestatus (Bitmaske)"
+ read: true
+ value: !lambda |-
+ std::vector v(1, 0);
+ v[0] = (uint8_t) id(pv_battery_status_bitmask).state;
+ return v;
+ - id: pv_controller_status_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a109
+ description: "PV Reglerstatus (Bitmaske)"
+ read: true
+ value: !lambda |-
+ std::vector v(1, 0);
+ v[0] = (uint8_t) id(pv_controller_status_bitmask).state;
+ return v;
+
+button:
+ - platform: restart
+ name: "ESP Restart"
diff --git a/firmware/vanalign/esp32_ble_solar_sim.yaml b/firmware/vanalign/esp32_ble_solar_sim.yaml
new file mode 100644
index 0000000..7c2a90a
--- /dev/null
+++ b/firmware/vanalign/esp32_ble_solar_sim.yaml
@@ -0,0 +1,265 @@
+# VanAlign Solar - Votronic Solarladeregler über BLE (SIMULATION)
+#
+# Kopie von esp32_ble_solar.yaml für den Fall, dass gerade kein Votronic-
+# Regler zum Anschliessen vorhanden ist. Die `platform: votronic`-Sensoren
+# sowie UART/external_components wurden entfernt und durch Template-Sensoren
+# ersetzt, die einen plausiblen Tagesverlauf simulieren: PV-Spannung/-Strom
+# folgen einem "Sonnenstand" (ein gedachter, ca. 6 Minuten langer Tag), die
+# Batteriespannung schwankt gemütlich mit, und die Statusflags/Bitmasken
+# leiten sich aus diesen Werten ab. Die BLE-Charakteristiken funktionieren
+# dadurch exakt wie im Original - nur eben ohne angeschlossene Hardware.
+#
+# Name und Friendly Name sind bewusst auf "-sim" abgeändert, damit dieses
+# Gerät im BLE-Umfeld nicht mit einem echten VanAlign-Solar-Gerät kollidiert.
+#
+# Für Debug-Zwecke läuft WLAN mit und die Werte werden zusätzlich auf dem
+# eingebauten Webserver (Port 80) angezeigt; WLAN-Zugangsdaten liegen in
+# secrets.yaml (lokal anzulegen, ist per .gitignore ausgeschlossen). Alle
+# simulierten Werte aktualisieren sich höchstens jede Sekunde, damit sich
+# Änderungen beim Debuggen zügig zeigen.
+#
+# Sobald wieder ein echter Votronic-Regler verfügbar ist, einfach
+# esp32_ble_solar.yaml weiterverwenden - diese Datei ist nur zum Testen der
+# App/BLE-Anbindung.
+
+esphome:
+ name: vanalign-solar-sim
+ friendly_name: "VanAlign Solar (Sim)"
+
+esp32:
+ board: esp32-s3-devkitc-1
+ flash_size: 16MB
+ framework:
+ type: esp-idf
+
+# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
+psram:
+ mode: octal
+ speed: 80MHz
+
+logger:
+ level: WARN
+
+wifi:
+ ssid: !secret wifi_ssid
+ password: !secret wifi_password
+
+web_server:
+ port: 80
+
+sensor:
+ # Simulierter "Sonnenstand": 0 nachts, sanfter Buckel tagsüber, Periode
+ # ca. 6 Minuten - reicht zum Beobachten eines vollen Auf/Ab in der App.
+ - platform: template
+ name: "Sonnenstand (Sim)"
+ id: sun_level
+ internal: true
+ update_interval: 1s
+ lambda: |-
+ float t = millis() / 1000.0f;
+ return std::max(0.0f, sinf(t / 180.0f));
+
+ - platform: template
+ name: "Batteriespannung (Sim)"
+ id: battery_voltage
+ unit_of_measurement: "V"
+ accuracy_decimals: 2
+ update_interval: 1s
+ lambda: |-
+ float t = millis() / 1000.0f;
+ return 12.6f + 0.8f * id(sun_level).state + 0.05f * sin(t / 20.0f);
+
+ - platform: template
+ name: "PV Spannung (Sim)"
+ id: pv_voltage
+ unit_of_measurement: "V"
+ accuracy_decimals: 2
+ update_interval: 1s
+ lambda: |-
+ float t = millis() / 1000.0f;
+ return id(sun_level).state > 0.01f ? (19.0f + 1.0f * sin(t / 25.0f)) : 0.0f;
+
+ - platform: template
+ name: "PV Strom (Sim)"
+ id: pv_current
+ unit_of_measurement: "A"
+ accuracy_decimals: 2
+ update_interval: 1s
+ lambda: |-
+ float t = millis() / 1000.0f;
+ return id(sun_level).state * (6.0f + 1.5f * sin(t / 17.0f));
+
+ - platform: template
+ name: "PV Leistung (Sim)"
+ id: pv_power
+ unit_of_measurement: "W"
+ accuracy_decimals: 1
+ update_interval: 1s
+ lambda: |-
+ return id(pv_voltage).state * id(pv_current).state;
+
+ - platform: template
+ name: "Reglertemperatur (Sim)"
+ id: pv_temperature
+ unit_of_measurement: "°C"
+ accuracy_decimals: 1
+ update_interval: 1s
+ lambda: |-
+ float t = millis() / 1000.0f;
+ return 22.0f + 10.0f * id(sun_level).state + 1.0f * sin(t / 11.0f);
+
+ - platform: template
+ name: "PV Modus-ID (Sim)"
+ id: pv_mode_id
+ update_interval: 1s
+ lambda: |-
+ return 3.0f; // fester simulierter Modus (MPPT)
+
+ - platform: template
+ name: "PV Batteriestatus (Bitmaske, Sim)"
+ id: pv_battery_status_bitmask
+ update_interval: 1s
+ lambda: |-
+ return id(sun_level).state > 0.01f ? 1.0f : 0.0f;
+
+ - platform: template
+ name: "PV Reglerstatus (Bitmaske, Sim)"
+ id: pv_controller_status_bitmask
+ update_interval: 1s
+ lambda: |-
+ return id(sun_level).state > 0.01f ? 2.0f : 0.0f;
+
+binary_sensor:
+ - platform: template
+ name: "Batterie lädt (Sim)"
+ id: battery_charging
+ lambda: |-
+ return id(pv_current).state > 0.2f;
+
+ - platform: template
+ name: "Batterie entlädt (Sim)"
+ id: battery_discharging
+ lambda: |-
+ return id(pv_current).state <= 0.2f;
+
+ - platform: template
+ name: "PV Regler aktiv (Sim)"
+ id: pv_active
+ lambda: |-
+ return id(sun_level).state > 0.01f;
+
+ - platform: template
+ name: "PV Strombegrenzung (Sim)"
+ id: pv_current_reduction
+ lambda: |-
+ float t = millis() / 1000.0f;
+ return id(pv_active).state && sin(t / 60.0f) > 0.9f;
+
+ - platform: template
+ name: "PV AES aktiv (Sim)"
+ id: pv_aes_active
+ lambda: |-
+ return id(battery_voltage).state > 13.3f;
+
+text_sensor:
+ - platform: template
+ name: "Firmware Version"
+ id: firmware_version
+ icon: mdi:tag
+ lambda: |-
+ return {"v1.0.0-sim"};
+
+esp32_ble_server:
+ services:
+ - uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a001
+ advertise: true
+ characteristics:
+ - id: battery_voltage_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a101
+ description: "Batteriespannung"
+ read: true
+ value: !lambda |-
+ std::vector v(sizeof(float));
+ float val = id(battery_voltage).state;
+ memcpy(v.data(), &val, sizeof(float));
+ return v;
+ - id: pv_voltage_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a102
+ description: "PV Spannung"
+ read: true
+ value: !lambda |-
+ std::vector v(sizeof(float));
+ float val = id(pv_voltage).state;
+ memcpy(v.data(), &val, sizeof(float));
+ return v;
+ - id: pv_current_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a103
+ description: "PV Strom"
+ read: true
+ value: !lambda |-
+ std::vector v(sizeof(float));
+ float val = id(pv_current).state;
+ memcpy(v.data(), &val, sizeof(float));
+ return v;
+ - id: pv_power_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a104
+ description: "PV Leistung"
+ read: true
+ value: !lambda |-
+ std::vector v(sizeof(float));
+ float val = id(pv_power).state;
+ memcpy(v.data(), &val, sizeof(float));
+ return v;
+ - id: pv_temperature_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a105
+ description: "Reglertemperatur"
+ read: true
+ value: !lambda |-
+ std::vector v(sizeof(float));
+ float val = id(pv_temperature).state;
+ memcpy(v.data(), &val, sizeof(float));
+ return v;
+ # Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
+ # Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
+ - id: status_flags_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a106
+ description: "Statusflags"
+ read: true
+ value: !lambda |-
+ std::vector v(1, 0);
+ uint8_t flags = 0;
+ if (id(battery_charging).state) flags |= (1 << 0);
+ if (id(battery_discharging).state) flags |= (1 << 1);
+ if (id(pv_active).state) flags |= (1 << 2);
+ if (id(pv_current_reduction).state) flags |= (1 << 3);
+ if (id(pv_aes_active).state) flags |= (1 << 4);
+ v[0] = flags;
+ return v;
+ - id: pv_mode_id_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a107
+ description: "PV Modus-ID"
+ read: true
+ value: !lambda |-
+ std::vector v(1, 0);
+ v[0] = (uint8_t) id(pv_mode_id).state;
+ return v;
+ - id: pv_battery_status_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a108
+ description: "PV Batteriestatus (Bitmaske)"
+ read: true
+ value: !lambda |-
+ std::vector v(1, 0);
+ v[0] = (uint8_t) id(pv_battery_status_bitmask).state;
+ return v;
+ - id: pv_controller_status_ble
+ uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a109
+ description: "PV Reglerstatus (Bitmaske)"
+ read: true
+ value: !lambda |-
+ std::vector v(1, 0);
+ v[0] = (uint8_t) id(pv_controller_status_bitmask).state;
+ return v;
+
+button:
+ - platform: restart
+ name: "ESP Restart"