Author SHA1 Message Date
fototeddyandClaude Sonnet 5 2638438494 Add landscape layouts for level display, vehicle view, and alignment assistant
Rotating the phone into landscape while leveling used to leave a
portrait-shaped column of controls that needed scrolling to take in.
All three level-integration screens now branch on verticalSizeClass:
the bubble/vehicle status in the device screen and the full alignment
assistant (advice, readings, and wedge heights) lay out side-by-side
instead of stacked, sized to fit without scrolling.

The device screen also measures its actual available height via a
GeometryReader and feeds it to the level display so the bubble/vehicle
graphic scales to fill the landscape screen rather than being capped at
a fixed size, and the connection-status section moves to the bottom of
the list in landscape so it doesn't compete with the display for space.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:33:35 +02:00
fototeddyandClaude Sonnet 5 0fdef337fc Simplify CarPlay Live Activity to letter+arrow glance indicator
Replaces the full sentence ("Heck steht höher, links steht höher") in
the .small activity family with a compact "H ↑ L" style readout: a
direction letter per axis (only shown when outside tolerance) around a
single up arrow, since only the higher side is ever named anyway. Text
now shares the same green/orange/red scale as the bubble glyph (factored
into one levelColour helper instead of two duplicated thresholds), and
uses a scalable font style with minimumScaleFactor so it fits whatever
size CarPlay actually grants the card - there's no API to query the
physical display size from Live Activity content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:08:40 +02:00
fototeddyandClaude Sonnet 5 1b06ac36d1 Debounce Live Activity ending on BLE disconnect, auto-resume on reconnect
A dropped BLE connection ended the Live Activity immediately, which
flickered off and on during the brief, routine disconnects that happen
constantly while driving. It now waits 12s before actually ending, and
if the sensor reconnects within that window the pending end is simply
cancelled. If the disconnect does outlast the grace period and the
activity really ends, reconnecting afterwards restarts it automatically
with the last known reading, since that wasn't a deliberate user stop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:00:49 +02:00
fototeddyandClaude Sonnet 5 46a4da5e37 Fix Live Activity in CarPlay: show real bubble and text via .small family
CarPlay renders Live Activities through the .small activity family
(shared with the Watch Smart Stack), not the lock-screen content view.
Without it, CarPlay fell back to the Dynamic Island's compactLeading/
compactTrailing, which only had the generic "level" SF Symbol and an
empty trailing view — hence the wrong icon and missing text.

Adds .supplementalActivityFamilies([.small]) and a compact layout that
reuses the real LevelBubbleGlyph plus a one-line instruction for that
family, while the lock screen keeps its existing two-line view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:37:50 +02:00
fototeddyandClaude Sonnet 5 45aa7cf984 Add auswählbare Fahrzeuggrafik am Neigungsmesser, neues California-Set
Jedes Grafikset (Seiten-/Heckansicht) ist jetzt ein VehicleGraphicStyle
mit eigenem Seitenverhältnis, wählbar in der Neigungsmesser-Einrichtung
und angewendet in Fahrzeug-Ansicht und Ausrichtungs-Assistent. Die
bisherigen VehicleSide/VehicleRear-Assets heißen jetzt VansterSide/
VansterRear, damit sie eindeutig ihrem Set zuzuordnen sind; California
kommt als zweites Set mit eigener Seitenansicht dazu (Heckansicht
vorerst von Vanster übernommen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:06:35 +02:00
fototeddyandClaude Sonnet 5 f175372318 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:04:48 +02:00
16 changed files with 31 additions and 1304 deletions
+10 -67
View File
@@ -17,8 +17,6 @@ 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 isVotronicSolarESPSensor = false
//var isVictron: Bool { victronRecordType != nil } //var isVictron: Bool { victronRecordType != nil }
@@ -33,7 +31,6 @@ struct Discovery: Identifiable, Hashable {
var subtitle: String { var subtitle: String {
if isLevelSensor { return "VanAlign Neigungsmesser" } if isLevelSensor { return "VanAlign Neigungsmesser" }
if isVotronicSolarESPSensor { return "VotronicSolarESP" }
return "Bluetooth-Gerät" return "Bluetooth-Gerät"
} }
/*var subtitle: String { /*var subtitle: String {
@@ -128,12 +125,6 @@ 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.
/// ///
@@ -159,12 +150,11 @@ 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: DeviceHistory] = [:] private(set) var history: [UUID: [HistorySample]] = [:]
//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 votronicSolarESPStates: [UUID: VotronicSolarESPState] = [:]
private(set) var isBluetoothReady = false private(set) var isBluetoothReady = false
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…" private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
@@ -201,7 +191,6 @@ 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 votronicSolarESPSessions: [UUID: VotronicSolarESPSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:] private var connectedPeripherals: [UUID: CBPeripheral] = [:]
private var reconnectTimer: DispatchSourceTimer? private var reconnectTimer: DispatchSourceTimer?
@@ -280,15 +269,6 @@ 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
// Ohne "live" bleibt der Ausrichtungs-Assistent (und die Live
// Activity) im Demo-Modus deaktiviert, weil beide echte Messwerte
// voraussetzen.
linkStates[DemoData.level.id] = .live
votronicSolarESPStates[DemoData.solar.id] = DemoData.votronicSolarESPState
linkStates[DemoData.solar.id] = .live
record(DemoData.votronicSolarESPState.snapshot(deviceID: DemoData.solar.id, rssi: -58))
history[DemoData.solar.id] = DemoData.votronicSolarESPHistory()
/* 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
@@ -346,7 +326,6 @@ 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) }
votronicSolarESPStates = votronicSolarESPStates.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.
@@ -556,11 +535,6 @@ final class BluetoothManager: NSObject {
levelSessions[peripheralID] = nil levelSessions[peripheralID] = nil
disconnect(peripheralID) disconnect(peripheralID)
} }
for (peripheralID, session) in votronicSolarESPSessions where !wanted.contains(peripheralID) {
session.stop()
votronicSolarESPSessions[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
@@ -606,11 +580,9 @@ 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 votronicSolarESPSessions { session.stop() }
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) } for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
//bmsSessions.removeAll() //bmsSessions.removeAll()
levelSessions.removeAll() levelSessions.removeAll()
votronicSolarESPSessions.removeAll()
connectedPeripherals.removeAll() connectedPeripherals.removeAll()
connectedSince.removeAll() connectedSince.removeAll()
//pendingControls.removeAll() //pendingControls.removeAll()
@@ -720,20 +692,15 @@ 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
var deviceHistory = self.history[snapshot.deviceID] ?? [:] guard let primary = snapshot.primaryMetric, let value = primary.value else { return }
for metric in snapshot.metrics { var samples = self.history[snapshot.deviceID] ?? []
guard let value = metric.value else { continue } // Höchstens alle fünf Sekunden einen Punkt aufnehmen.
var samples = deviceHistory[metric.key] ?? [] if let last = samples.last, snapshot.timestamp.timeIntervalSince(last.time) < 5 { return }
// Höchstens alle fünf Sekunden einen Punkt aufnehmen. samples.append(HistorySample(time: snapshot.timestamp, value: value))
if let last = samples.last, if samples.count > self.historyLimit {
snapshot.timestamp.timeIntervalSince(last.time) < 5 { continue } samples.removeFirst(samples.count - self.historyLimit)
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] = deviceHistory self.history[snapshot.deviceID] = samples
} }
} }
@@ -806,8 +773,7 @@ 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.isVotronicSolarESPSensor = services.contains(VotronicSolarESPSession.serviceUUID) entry.looksLikeSupported = entry.isLevelSensor
entry.looksLikeSupported = entry.isLevelSensor || entry.isVotronicSolarESPSensor
pendingDiscoveries[peripheral.identifier] = entry pendingDiscoveries[peripheral.identifier] = entry
} }
/*private func updateDiscovery(peripheral: CBPeripheral, /*private func updateDiscovery(peripheral: CBPeripheral,
@@ -961,27 +927,6 @@ 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 = VotronicSolarESPSession(
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 }
},
onVotronicSolarESPState: { [weak self] state in
self?.publish { self?.votronicSolarESPStates[device.id] = state }
}
)
votronicSolarESPSessions[peripheral.identifier] = session
session.start()
return
}
/*let session = BMSSession( /*let session = BMSSession(
deviceID: device.id, deviceID: device.id,
peripheral: peripheral, peripheral: peripheral,
@@ -1029,8 +974,6 @@ 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
votronicSolarESPSessions[peripheral.identifier]?.handleDisconnect()
votronicSolarESPSessions[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)
+6 -57
View File
@@ -3,30 +3,18 @@ import Foundation
/// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne /// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne
/// Fahrzeug und ohne Bluetooth prüfen lassen. /// Fahrzeug und ohne Bluetooth prüfen lassen.
/// ///
/// Nur in Debug-Builds, auf zwei Wegen einzuschalten: /// Nur in Debug-Builds und nur, wenn beim Start `CAMPER_DEMO=1` gesetzt ist:
/// ///
/// * Umgebungsvariable `CAMPER_DEMO=1` beim Start praktisch im Simulator: /// xcrun simctl launch --terminate-running-process \
/// /// booted de.fritob.CamperMonitor
/// xcrun simctl launch --terminate-running-process \ /// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
/// booted de.fritob.CamperMonitor
/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor
///
/// * Schalter in den Einstellungen (`SettingsView`) der einzige Weg auf
/// einem lokal installierten Build ohne Xcode-Verbindung, da sich dort
/// keine Umgebungsvariable setzen lässt. Wirkt erst nach einem Neustart der
/// App, weil `DeviceStore` und `BluetoothManager` den Stand nur beim Start
/// lesen.
/// ///
/// Im normalen Betrieb wird hiervon nichts ausgeführt. /// Im normalen Betrieb wird hiervon nichts ausgeführt.
enum DemoData { enum DemoData {
/// Schlüssel für den Einstellungen-Schalter, siehe `SettingsView`.
static let enabledKey = "demoModeEnabled"
static var isEnabled: Bool { static var isEnabled: Bool {
#if DEBUG #if DEBUG
if ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1" { return true } return ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1"
return UserDefaults.standard.bool(forKey: enabledKey)
#else #else
return false return false
#endif #endif
@@ -71,47 +59,8 @@ 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] {
[/*booster, battery, fridge, */level, solar/*, caravanSolar*/] [/*solar, booster, battery, fridge, */level/*, caravanSolar*/]
}
/// Mittags, gute Sonne.
static var votronicSolarESPState: VotronicSolarESPState {
var state = VotronicSolarESPState()
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 votronicSolarESPHistory() -> 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,8 +195,6 @@ private struct ConfigureDeviceView: View {
} }
} else*/ if discovery.isLevelSensor { } else*/ if discovery.isLevelSensor {
role = .leveling role = .leveling
} else if discovery.isVotronicSolarESPSensor {
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
@@ -208,8 +206,6 @@ 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.isVotronicSolarESPSensor {
name = role.title
} 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
+1 -1
View File
@@ -106,7 +106,7 @@ struct DashboardView: View {
Label("Noch keine Geräte", systemImage: "antenna.radiowaves.left.and.right") Label("Noch keine Geräte", systemImage: "antenna.radiowaves.left.and.right")
} description: { } description: {
Text("Füge \(store.activeProfile.map { "\($0.name)" } ?? "diesem Fahrzeug") " Text("Füge \(store.activeProfile.map { "\($0.name)" } ?? "diesem Fahrzeug") "
+ "den Ladebooster, den VotronicSolarESP und das BMS hinzu.") + "den Ladebooster, den Solarladeregler und das BMS hinzu.")
} actions: { } actions: {
Button("Gerät suchen") { isAddingDevice = true } Button("Gerät suchen") { isAddingDevice = true }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
+9 -55
View File
@@ -36,40 +36,13 @@ 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?
/// iPhone im Querformat meldet eine kompakte Höhe das ist das /// iPhone im Querformat meldet eine kompakte Höhe das ist das
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst. /// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
private var isLandscape: Bool { verticalSizeClass == .compact } private var isLandscape: Bool { verticalSizeClass == .compact }
/// Nur der Neigungsmesser bekommt die Querformat-Sonderbehandlung
/// (Verbindungsstatus ans Ende, Anzeige auf Bildschirmhöhe) andere
/// Sensoren behalten ihre bisherige Reihenfolge und Grösse.
private var showsCompactLevelLayout: Bool { isLandscape && currentDevice.role == .leveling }
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 deviceHistory: DeviceHistory { bluetooth.history[device.id] ?? [:] } private var samples: [HistorySample] { 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 {
// Die Höhe der Libelle/Fahrzeug-Anzeige im Querformat richtet sich // Die Höhe der Libelle/Fahrzeug-Anzeige im Querformat richtet sich
@@ -79,9 +52,8 @@ struct DeviceDetailView: View {
List { List {
// Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort // Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort
// sichtbar sein, ohne erst am Verbindungsstatus // sichtbar sein, ohne erst am Verbindungsstatus
// vorbeizuscrollen der rutscht dort ganz ans Ende. Gilt // vorbeizuscrollen der rutscht dort ganz ans Ende.
// nur für den Neigungsmesser, siehe `showsCompactLevelLayout`. if !isLandscape { statusSection }
if !showsCompactLevelLayout { statusSection }
// if needsKeyAttention { keyPrompt } // if needsKeyAttention { keyPrompt }
// if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus { // if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
@@ -106,38 +78,20 @@ struct DeviceDetailView: View {
} }
} }
if let metric = selectedMetric, samples.count > 1 { if samples.count > 1, let primary = snapshot?.primaryMetric {
Section { Section("Verlauf \(primary.label)") {
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(metric.label, sample.value)) y: .value(primary.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(metric.label, sample.value)) y: .value(primary.label, sample.value))
.foregroundStyle(.tint) .foregroundStyle(.tint)
.interpolationMethod(.monotone) .interpolationMethod(.monotone)
} }
.chartYAxisLabel(metric.unit) .chartYAxisLabel(primary.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.")
} }
} }
@@ -153,7 +107,7 @@ struct DeviceDetailView: View {
} }
} }
if showsCompactLevelLayout { statusSection } if isLandscape { statusSection }
/* if let snapshot, snapshot.temperatures.count > 1 { /* if let snapshot, snapshot.temperatures.count > 1 {
Section("Temperaturen") { Section("Temperaturen") {
-16
View File
@@ -4,28 +4,12 @@ import SwiftUI
/// betrifft, steht bei diesem Gerät. /// betrifft, steht bei diesem Gerät.
struct SettingsView: View { struct SettingsView: View {
@AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false @AppStorage(AppSettings.showDiagnosticsKey) private var showDiagnostics = false
#if DEBUG
@AppStorage(DemoData.enabledKey) private var demoModeEnabled = false
#endif
@Environment(PhoneWatchLink.self) private var watch @Environment(PhoneWatchLink.self) private var watch
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
#if DEBUG
Section {
Toggle("Demo-Modus", isOn: $demoModeEnabled)
} header: {
Text("Entwicklung")
} footer: {
Text("Füllt die App mit erfundenen Fahrzeugen und Messwerten, um "
+ "Ansichten ohne echte Sensoren zu prüfen. Wirkt erst nach "
+ "einem Neustart der App einmal beenden (im App-Umschalter "
+ "nach oben wischen) und wieder öffnen.")
}
#endif
Section { Section {
Toggle("Diagnose anzeigen", isOn: $showDiagnostics) Toggle("Diagnose anzeigen", isOn: $showDiagnostics)
} header: { } header: {
@@ -1,55 +0,0 @@
import Foundation
/// VotronicSolarESP 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 VotronicSolarESPProtocol {
/// 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
}
}
@@ -1,160 +0,0 @@
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 VotronicSolarESPSession: NSObject {
static let serviceUUID = CBUUID(string: VotronicSolarESPProtocol.serviceUUID)
private static let batteryVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.batteryVoltageUUID)
private static let pvVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.pvVoltageUUID)
private static let pvCurrentUUID = CBUUID(string: VotronicSolarESPProtocol.pvCurrentUUID)
private static let pvPowerUUID = CBUUID(string: VotronicSolarESPProtocol.pvPowerUUID)
private static let controllerTempUUID = CBUUID(string: VotronicSolarESPProtocol.controllerTempUUID)
private static let statusFlagsUUID = CBUUID(string: VotronicSolarESPProtocol.statusFlagsUUID)
let deviceID: UUID
private let queue: DispatchQueue
private let peripheral: CBPeripheral
private let onUpdate: (DeviceSnapshot) -> Void
private let onStateChange: (DeviceLinkState) -> Void
private let onVotronicSolarESPState: (VotronicSolarESPState) -> Void
private var characteristics: [CBUUID: CBCharacteristic] = [:]
private var state = VotronicSolarESPState()
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,
onVotronicSolarESPState: @escaping (VotronicSolarESPState) -> Void) {
self.deviceID = deviceID
self.queue = queue
self.peripheral = peripheral
self.onUpdate = onUpdate
self.onStateChange = onStateChange
self.onVotronicSolarESPState = onVotronicSolarESPState
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)
onVotronicSolarESPState(state)
onUpdate(state.snapshot(deviceID: deviceID, rssi: nil))
}
}
// MARK: - CBPeripheralDelegate
extension VotronicSolarESPSession: 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 = VotronicSolarESPProtocol.float(from: value)
case Self.pvVoltageUUID:
state.pvVoltage = VotronicSolarESPProtocol.float(from: value)
case Self.pvCurrentUUID:
state.pvCurrent = VotronicSolarESPProtocol.float(from: value)
case Self.pvPowerUUID:
state.pvPower = VotronicSolarESPProtocol.float(from: value)
case Self.controllerTempUUID:
state.controllerTemperature = VotronicSolarESPProtocol.float(from: value)
case Self.statusFlagsUUID:
guard let flags = VotronicSolarESPProtocol.statusFlags(from: value) else { return }
state.isBatteryCharging = flags.isBatteryCharging
state.isBatteryDischarging = flags.isBatteryDischarging
state.isControllerActive = flags.isControllerActive
state.isCurrentLimited = flags.isCurrentLimited
default:
return
}
publish()
}
}
+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 "VotronicSolarESP"
} }
} }
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"
} }
} }
-48
View File
@@ -1,48 +0,0 @@
import Foundation
/// Zustand des Solarladereglers.
struct VotronicSolarESPState: 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>3</integer> <integer>2</integer>
</dict> </dict>
<key>VanAligneiOSWidgetExtension.xcscheme_^#shared#^_</key> <key>VanAligneiOSWidgetExtension.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>2</integer> <integer>3</integer>
</dict> </dict>
</dict> </dict>
</dict> </dict>
-6
View File
@@ -22,15 +22,9 @@ 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
@@ -1,332 +0,0 @@
# 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
@@ -1,233 +0,0 @@
# 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
@@ -1,265 +0,0 @@
# 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"