Compare commits

4 Commits
Author SHA1 Message Date
fototeddyandClaude Sonnet 5 97e3d040a9 Vereinfache Fahrzeug-Symbolauswahl auf sechs Icons, Default suv.side
Ersetzt die zehn wählbaren Symbole durch eine kürzere, konsistentere
Auswahl (car, car.side, suv.side, truck.pickup.side, box.truck, bus)
und setzt suv.side als neuen Standard für neu angelegte Fahrzeuge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 10:05:13 +02:00
fototeddyandClaude Sonnet 5 26e088e48b Merge main: Live Activity CarPlay polish, direction-letter formatting, bubble pitch fix
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:05:38 +02:00
fototeddyandClaude Sonnet 5 77b8cd3f18 Update Xcode workspace UI state
No code changes; Xcode's own session state (open tabs/scroll position)
after building and running the solar integration in the simulator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:05:49 +02:00
fototeddyandClaude Sonnet 5 6d7b32d622 Add solar charge controller integration and firmware
App:
- New DeviceRole.solar with its own BLE session/protocol/state
  (SolarSession, VanAlignSolarProtocol, SolarState), mirroring the
  leveling sensor but deliberately not wired into the Live Activity.
- BluetoothManager now keeps an in-memory history per metric (voltage,
  current, power) instead of a single primary-metric series; cleared on
  app restart by design, not persisted to disk.
- DeviceDetailView shows a channel picker above the history chart when a
  device has more than one chartable metric.
- AddDeviceView recognizes the solar service UUID during setup.
- DemoData gets a simulated solar device so the integration can be
  checked in the simulator without hardware.

Firmware:
- Add esp32_ble_solar.yaml (Votronic solar charge controller over BLE)
  and simulated variants (esp32_ble_sim.yaml, esp32_ble_solar_sim.yaml)
  for testing without a vehicle.
- esp32_ble.yaml: set flash_size/psram for the ESP32-S3 N16R8 module.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:04:20 +02:00
16 changed files with 1260 additions and 26 deletions
+63 -10
View File
@@ -17,6 +17,8 @@ struct Discovery: Identifiable, Hashable {
var looksLikeSupported: Bool var looksLikeSupported: Bool
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar. /// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
var isLevelSensor = false var isLevelSensor = false
/// Der Solarladeregler bewirbt seinen Dienst ebenso.
var isSolarSensor = false
//var isVictron: Bool { victronRecordType != nil } //var isVictron: Bool { victronRecordType != nil }
@@ -31,6 +33,7 @@ struct Discovery: Identifiable, Hashable {
var subtitle: String { var subtitle: String {
if isLevelSensor { return "VanAlign Neigungsmesser" } if isLevelSensor { return "VanAlign Neigungsmesser" }
if isSolarSensor { return "VanAlign Solarladeregler" }
return "Bluetooth-Gerät" return "Bluetooth-Gerät"
} }
/*var subtitle: String { /*var subtitle: String {
@@ -125,6 +128,12 @@ struct HistorySample: Identifiable, Hashable {
let value: Double 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 /// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Geräten und hält
/// parallel die Verbindung zum Neigungsmesser. /// parallel die Verbindung zum Neigungsmesser.
/// ///
@@ -150,11 +159,12 @@ final class BluetoothManager: NSObject {
private(set) var snapshots: [UUID: DeviceSnapshot] = [:] private(set) var snapshots: [UUID: DeviceSnapshot] = [:]
private(set) var linkStates: [UUID: DeviceLinkState] = [:] private(set) var linkStates: [UUID: DeviceLinkState] = [:]
private(set) var discoveries: [UUID: Discovery] = [:] 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 diagnostics: [UUID: VictronDiagnostics] = [:]
//private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:] //private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
//private(set) var fridgeStates: [UUID: AlpicoolState] = [:] //private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
private(set) var levelStates: [UUID: LevelState] = [:] private(set) var levelStates: [UUID: LevelState] = [:]
private(set) var solarStates: [UUID: SolarState] = [:]
private(set) var isBluetoothReady = false private(set) var isBluetoothReady = false
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…" private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
@@ -191,6 +201,7 @@ final class BluetoothManager: NSObject {
//private var bmsSessions: [UUID: BMSSession] = [:] //private var bmsSessions: [UUID: BMSSession] = [:]
private var levelSessions: [UUID: LevelSession] = [:] private var levelSessions: [UUID: LevelSession] = [:]
private var solarSessions: [UUID: SolarSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:] private var connectedPeripherals: [UUID: CBPeripheral] = [:]
private var reconnectTimer: DispatchSourceTimer? private var reconnectTimer: DispatchSourceTimer?
@@ -269,6 +280,11 @@ final class BluetoothManager: NSObject {
bluetoothStatusText = "Demo-Modus" bluetoothStatusText = "Demo-Modus"
//fridgeStates[DemoData.fridge.id] = DemoData.fridgeState //fridgeStates[DemoData.fridge.id] = DemoData.fridgeState
levelStates[DemoData.level.id] = DemoData.levelState 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() { /* for snapshot in DemoData.snapshots() {
snapshots[snapshot.deviceID] = snapshot snapshots[snapshot.deviceID] = snapshot
linkStates[snapshot.deviceID] = .live linkStates[snapshot.deviceID] = .live
@@ -326,6 +342,7 @@ final class BluetoothManager: NSObject {
//bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) } //bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
//fridgeStates = fridgeStates.filter { known.contains($0.key) } //fridgeStates = fridgeStates.filter { known.contains($0.key) }
levelStates = levelStates.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 // Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin
// ihren zuletzt gestellten Stand. // ihren zuletzt gestellten Stand.
@@ -535,6 +552,11 @@ final class BluetoothManager: NSObject {
levelSessions[peripheralID] = nil levelSessions[peripheralID] = nil
disconnect(peripheralID) disconnect(peripheralID)
} }
for (peripheralID, session) in solarSessions where !wanted.contains(peripheralID) {
session.stop()
solarSessions[peripheralID] = nil
disconnect(peripheralID)
}
// Einstellungen an bestehende Sitzungen weiterreichen. // Einstellungen an bestehende Sitzungen weiterreichen.
for device in devices { for device in devices {
//bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode //bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode
@@ -580,9 +602,11 @@ final class BluetoothManager: NSObject {
discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
//for (_, session) in bmsSessions { session.stop() } //for (_, session) in bmsSessions { session.stop() }
for (_, session) in levelSessions { session.stop() } for (_, session) in levelSessions { session.stop() }
for (_, session) in solarSessions { session.stop() }
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) } for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
//bmsSessions.removeAll() //bmsSessions.removeAll()
levelSessions.removeAll() levelSessions.removeAll()
solarSessions.removeAll()
connectedPeripherals.removeAll() connectedPeripherals.removeAll()
connectedSince.removeAll() connectedSince.removeAll()
//pendingControls.removeAll() //pendingControls.removeAll()
@@ -692,15 +716,20 @@ final class BluetoothManager: NSObject {
private func record(_ snapshot: DeviceSnapshot) { private func record(_ snapshot: DeviceSnapshot) {
publish { publish {
self.snapshots[snapshot.deviceID] = snapshot self.snapshots[snapshot.deviceID] = snapshot
guard let primary = snapshot.primaryMetric, let value = primary.value else { return } var deviceHistory = self.history[snapshot.deviceID] ?? [:]
var samples = self.history[snapshot.deviceID] ?? [] for metric in snapshot.metrics {
// Höchstens alle fünf Sekunden einen Punkt aufnehmen. guard let value = metric.value else { continue }
if let last = samples.last, snapshot.timestamp.timeIntervalSince(last.time) < 5 { return } var samples = deviceHistory[metric.key] ?? []
samples.append(HistorySample(time: snapshot.timestamp, value: value)) // Höchstens alle fünf Sekunden einen Punkt aufnehmen.
if samples.count > self.historyLimit { if let last = samples.last,
samples.removeFirst(samples.count - self.historyLimit) 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 } if let name, !name.isEmpty { entry.name = name }
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [] let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
entry.isLevelSensor = services.contains(LevelSession.serviceUUID) 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 pendingDiscoveries[peripheral.identifier] = entry
} }
/*private func updateDiscovery(peripheral: CBPeripheral, /*private func updateDiscovery(peripheral: CBPeripheral,
@@ -917,6 +947,27 @@ extension BluetoothManager: CBCentralManagerDelegate {
return 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( /*let session = BMSSession(
deviceID: device.id, deviceID: device.id,
peripheral: peripheral, peripheral: peripheral,
@@ -964,6 +1015,8 @@ extension BluetoothManager: CBCentralManagerDelegate {
//bmsSessions[peripheral.identifier] = nil //bmsSessions[peripheral.identifier] = nil
levelSessions[peripheral.identifier]?.handleDisconnect() levelSessions[peripheral.identifier]?.handleDisconnect()
levelSessions[peripheral.identifier] = nil levelSessions[peripheral.identifier] = nil
solarSessions[peripheral.identifier]?.handleDisconnect()
solarSessions[peripheral.identifier] = nil
connectedPeripherals[peripheral.identifier] = nil connectedPeripherals[peripheral.identifier] = nil
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier) let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
+40 -1
View File
@@ -59,8 +59,47 @@ enum DemoData {
name: "Nivellierung", role: .leveling, profileID: Profile.defaultID, name: "Nivellierung", role: .leveling, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000E1")!) 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] { 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. /// Leicht schräg stehend, damit die Libelle etwas zu zeigen hat.
+4
View File
@@ -195,6 +195,8 @@ private struct ConfigureDeviceView: View {
} }
} else*/ if discovery.isLevelSensor { } else*/ if discovery.isLevelSensor {
role = .leveling role = .leveling
} else if discovery.isSolarSensor {
role = .solar
} /*else if let name = discovery.name?.lowercased(), } /*else if let name = discovery.name?.lowercased(),
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) { ["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
role = .fridge role = .fridge
@@ -206,6 +208,8 @@ private struct ConfigureDeviceView: View {
// ohnehin darüber unter "Gefunden als". // ohnehin darüber unter "Gefunden als".
if discovery.isLevelSensor { if discovery.isLevelSensor {
name = "Nivellierung" name = "Nivellierung"
} else if discovery.isSolarSensor {
name = "Solarladeregler"
} else if let advertised = discovery.name, advertised.count <= 20, } else if let advertised = discovery.name, advertised.count <= 20,
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil { advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
name = advertised name = advertised
+46 -6
View File
@@ -35,9 +35,31 @@ struct DeviceDetailView: View {
store.devices.first { $0.id == device.id } ?? device 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 snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching } 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 { var body: some View {
List { List {
@@ -65,20 +87,38 @@ struct DeviceDetailView: View {
} }
} }
if samples.count > 1, let primary = snapshot?.primaryMetric { if let metric = selectedMetric, samples.count > 1 {
Section("Verlauf \(primary.label)") { 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 Chart(samples) { sample in
AreaMark(x: .value("Zeit", sample.time), AreaMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value)) y: .value(metric.label, sample.value))
.foregroundStyle(.tint.opacity(0.15)) .foregroundStyle(.tint.opacity(0.15))
LineMark(x: .value("Zeit", sample.time), LineMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value)) y: .value(metric.label, sample.value))
.foregroundStyle(.tint) .foregroundStyle(.tint)
.interpolationMethod(.monotone) .interpolationMethod(.monotone)
} }
.chartYAxisLabel(primary.unit) .chartYAxisLabel(metric.unit)
.frame(height: 180) .frame(height: 180)
.padding(.vertical, 8) .padding(.vertical, 8)
} header: {
Text("Verlauf \(metric.label)")
} footer: {
Text("Nur für die laufende Sitzung wird beim Neustart der App verworfen.")
} }
} }
+1 -1
View File
@@ -145,7 +145,7 @@ private struct ProfileEditView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var name = "" @State private var name = ""
@State private var symbol = "box.truck" @State private var symbol = "suv.side"
@State private var trackWidth = "" @State private var trackWidth = ""
@State private var wheelbase = "" @State private var wheelbase = ""
+160
View File
@@ -0,0 +1,160 @@
import CoreBluetooth
import Foundation
/// Hält die Verbindung zum Solarladeregler.
///
/// Einfacher noch als `LevelSession`: die Firmware bietet für keine
/// Charakteristik `notify` an (siehe `esp32_ble_solar.yaml`), es wird also
/// immer im Takt abgefragt statt abonniert.
final class SolarSession: NSObject {
static let serviceUUID = CBUUID(string: VanAlignSolarProtocol.serviceUUID)
private static let batteryVoltageUUID = CBUUID(string: VanAlignSolarProtocol.batteryVoltageUUID)
private static let pvVoltageUUID = CBUUID(string: VanAlignSolarProtocol.pvVoltageUUID)
private static let pvCurrentUUID = CBUUID(string: VanAlignSolarProtocol.pvCurrentUUID)
private static let pvPowerUUID = CBUUID(string: VanAlignSolarProtocol.pvPowerUUID)
private static let controllerTempUUID = CBUUID(string: VanAlignSolarProtocol.controllerTempUUID)
private static let statusFlagsUUID = CBUUID(string: VanAlignSolarProtocol.statusFlagsUUID)
let deviceID: UUID
private let queue: DispatchQueue
private let peripheral: CBPeripheral
private let onUpdate: (DeviceSnapshot) -> Void
private let onStateChange: (DeviceLinkState) -> Void
private let onSolarState: (SolarState) -> Void
private var characteristics: [CBUUID: CBCharacteristic] = [:]
private var state = SolarState()
private var pollTimer: DispatchSourceTimer?
/// Reicht für einen Solarregler, dessen Werte sich über Sekunden ändern
/// deutlich seltener als beim Ausrichten mit dem Neigungsmesser.
var pollInterval: TimeInterval = 5
init(deviceID: UUID,
peripheral: CBPeripheral,
queue: DispatchQueue,
onUpdate: @escaping (DeviceSnapshot) -> Void,
onStateChange: @escaping (DeviceLinkState) -> Void,
onSolarState: @escaping (SolarState) -> Void) {
self.deviceID = deviceID
self.queue = queue
self.peripheral = peripheral
self.onUpdate = onUpdate
self.onStateChange = onStateChange
self.onSolarState = onSolarState
super.init()
peripheral.delegate = self
}
// MARK: - Lebenszyklus
func start() {
onStateChange(.connecting)
peripheral.discoverServices([Self.serviceUUID])
}
func stop() {
pollTimer?.cancel()
pollTimer = nil
characteristics.removeAll()
}
func handleDisconnect() {
pollTimer?.cancel()
pollTimer = nil
characteristics.removeAll()
}
// MARK: - Abfrage
private func startPolling() {
guard pollTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now(), repeating: pollInterval)
timer.setEventHandler { [weak self] in self?.readAll() }
timer.resume()
pollTimer = timer
}
private func readAll() {
guard peripheral.state == .connected else { return }
for characteristic in characteristics.values where characteristic.properties.contains(.read) {
peripheral.readValue(for: characteristic)
}
}
private func publish() {
guard state.hasReading else { return }
onStateChange(.live)
onSolarState(state)
onUpdate(state.snapshot(deviceID: deviceID, rssi: nil))
}
}
// MARK: - CBPeripheralDelegate
extension SolarSession: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
onStateChange(.failed(error.localizedDescription))
return
}
guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
onStateChange(.failed("Solarladeregler-Dienst nicht gefunden"))
return
}
peripheral.discoverCharacteristics(
[Self.batteryVoltageUUID, Self.pvVoltageUUID, Self.pvCurrentUUID,
Self.pvPowerUUID, Self.controllerTempUUID, Self.statusFlagsUUID],
for: service
)
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
guard error == nil, let found = service.characteristics else {
onStateChange(.failed(error?.localizedDescription ?? "Keine Merkmale gefunden"))
return
}
for characteristic in found {
characteristics[characteristic.uuid] = characteristic
}
guard !characteristics.isEmpty else {
onStateChange(.failed("Solarwerte nicht gefunden"))
return
}
startPolling()
readAll()
}
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard error == nil, let value = characteristic.value else { return }
switch characteristic.uuid {
case Self.batteryVoltageUUID:
state.batteryVoltage = VanAlignSolarProtocol.float(from: value)
case Self.pvVoltageUUID:
state.pvVoltage = VanAlignSolarProtocol.float(from: value)
case Self.pvCurrentUUID:
state.pvCurrent = VanAlignSolarProtocol.float(from: value)
case Self.pvPowerUUID:
state.pvPower = VanAlignSolarProtocol.float(from: value)
case Self.controllerTempUUID:
state.controllerTemperature = VanAlignSolarProtocol.float(from: value)
case Self.statusFlagsUUID:
guard let flags = VanAlignSolarProtocol.statusFlags(from: value) else { return }
state.isBatteryCharging = flags.isBatteryCharging
state.isBatteryDischarging = flags.isBatteryDischarging
state.isControllerActive = flags.isControllerActive
state.isCurrentLimited = flags.isCurrentLimited
default:
return
}
publish()
}
}
@@ -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
}
}
+3 -3
View File
@@ -4,33 +4,33 @@ import Foundation
/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird. /// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable { enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
//case chargeBooster //case chargeBooster
//case solarCharger
//case batteryMonitor //case batteryMonitor
//case bms //case bms
//case fridge //case fridge
case leveling case leveling
case solar
var id: String { rawValue } var id: String { rawValue }
var title: String { var title: String {
switch self { switch self {
//case .chargeBooster: return "Ladebooster" //case .chargeBooster: return "Ladebooster"
//case .solarCharger: return "Solarladeregler"
//case .batteryMonitor: return "Batteriemonitor" //case .batteryMonitor: return "Batteriemonitor"
//case .bms: return "Batterie / BMS" //case .bms: return "Batterie / BMS"
//case .fridge: return "Kühlbox" //case .fridge: return "Kühlbox"
case .leveling: return "Nivellierung" case .leveling: return "Nivellierung"
case .solar: return "Solarladeregler"
} }
} }
var symbol: String { var symbol: String {
switch self { switch self {
//case .chargeBooster: return "bolt.car" //case .chargeBooster: return "bolt.car"
//case .solarCharger: return "sun.max"
//case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent" //case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
//case .bms: return "battery.100percent.bolt" //case .bms: return "battery.100percent.bolt"
//case .fridge: return "refrigerator" //case .fridge: return "refrigerator"
case .leveling: return "level" case .leveling: return "level"
case .solar: return "sun.max"
} }
} }
+2 -3
View File
@@ -15,7 +15,7 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
init(id: UUID = UUID(), init(id: UUID = UUID(),
name: String, name: String,
symbol: String = "box.truck", symbol: String = "suv.side",
trackWidth: Double? = nil, trackWidth: Double? = nil,
wheelbase: Double? = nil) { wheelbase: Double? = nil) {
self.id = id self.id = id
@@ -47,7 +47,6 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft ein nicht /// Alle Namen gegen NSImage(systemSymbolName:) geprüft ein nicht
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen. /// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
static let symbols = [ static let symbols = [
"box.truck", "truck.pickup.side", "bus", "bus.doubledecker", "car", "car.side", "suv.side", "truck.pickup.side", "box.truck", "bus",
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
] ]
} }
+48
View File
@@ -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
}
}
@@ -22,12 +22,12 @@
<key>VanAligneComplication.xcscheme_^#shared#^_</key> <key>VanAligneComplication.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>2</integer> <integer>3</integer>
</dict> </dict>
<key>VanAligneiOSWidgetExtension.xcscheme_^#shared#^_</key> <key>VanAligneiOSWidgetExtension.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>3</integer> <integer>2</integer>
</dict> </dict>
</dict> </dict>
</dict> </dict>
+6
View File
@@ -22,9 +22,15 @@ esphome:
esp32: esp32:
board: esp32-s3-devkitc-1 board: esp32-s3-devkitc-1
flash_size: 16MB
framework: framework:
type: esp-idf type: esp-idf
# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
psram:
mode: octal
speed: 80MHz
logger: logger:
level: WARN level: WARN
+332
View File
@@ -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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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"};
+233
View File
@@ -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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_controller_status_bitmask).state;
return v;
button:
- platform: restart
name: "ESP Restart"
+265
View File
@@ -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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_controller_status_bitmask).state;
return v;
button:
- platform: restart
name: "ESP Restart"