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"