Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46a4da5e37 | ||
|
|
45aa7cf984 | ||
|
|
f175372318 |
@@ -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,
|
||||||
@@ -931,17 +897,7 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
queue: queue,
|
queue: queue,
|
||||||
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
|
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
|
||||||
onStateChange: { [weak self] state in
|
onStateChange: { [weak self] state in
|
||||||
self?.publish {
|
self?.publish { self?.linkStates[device.id] = state }
|
||||||
self?.linkStates[device.id] = state
|
|
||||||
if state == .live {
|
|
||||||
// Verbindung wieder da: ein anstehendes Ende
|
|
||||||
// verwerfen oder eine zwischenzeitlich doch schon
|
|
||||||
// beendete Live Activity wieder aufnehmen.
|
|
||||||
self?.activityManager?.handleReconnect(
|
|
||||||
deviceID: device.id,
|
|
||||||
state: self?.levelStates[device.id] ?? LevelState())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onLevelState: { [weak self] state in
|
onLevelState: { [weak self] state in
|
||||||
self?.publish {
|
self?.publish {
|
||||||
@@ -961,27 +917,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,21 +964,13 @@ 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)
|
||||||
.map { Date().timeIntervalSince($0) } ?? 0
|
.map { Date().timeIntervalSince($0) } ?? 0
|
||||||
|
|
||||||
if let device = managed[peripheral.identifier] {
|
if let device = managed[peripheral.identifier] {
|
||||||
let deviceName = store.devices.first { $0.id == device.id }?.name ?? ""
|
publish { self.linkStates[device.id] = .searching }
|
||||||
publish {
|
|
||||||
self.linkStates[device.id] = .searching
|
|
||||||
// Erst nach einer Gnadenfrist wirklich beenden – sonst
|
|
||||||
// flackerte die Live Activity bei jedem kurzen Funkloch.
|
|
||||||
self.activityManager?.handleDisconnect(deviceID: device.id, deviceName: deviceName)
|
|
||||||
}
|
|
||||||
if lifetime >= stableConnection {
|
if lifetime >= stableConnection {
|
||||||
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
|
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
|
||||||
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
|
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
|
||||||
|
|||||||
@@ -15,18 +15,6 @@ final class LevelActivityManager {
|
|||||||
private(set) var trackedDeviceID: UUID?
|
private(set) var trackedDeviceID: UUID?
|
||||||
@ObservationIgnored private var activity: Activity<LevelActivityAttributes>?
|
@ObservationIgnored private var activity: Activity<LevelActivityAttributes>?
|
||||||
|
|
||||||
/// Wie lange nach einem Verbindungsabbruch gewartet wird, bevor die
|
|
||||||
/// Aktivität wirklich endet. Verbindungen fallen im Fahrzeug regelmässig
|
|
||||||
/// kurz weg – ohne diese Gnadenfrist flackerte die Anzeige bei jedem
|
|
||||||
/// kurzen Funkloch aus und wieder ein.
|
|
||||||
static let disconnectGrace: TimeInterval = 12
|
|
||||||
|
|
||||||
@ObservationIgnored private var pendingEnd: Task<Void, Never>?
|
|
||||||
/// Gerät, dessen Aktivität wegen einer länger anhaltenden Trennung
|
|
||||||
/// tatsächlich beendet wurde – bei der nächsten Wiederverbindung wird sie
|
|
||||||
/// automatisch neu gestartet, damit das kein bewusster Nutzer-Stop war.
|
|
||||||
@ObservationIgnored private var deviceToResume: (id: UUID, name: String)?
|
|
||||||
|
|
||||||
/// Ob gerade irgendeine Aktivität läuft – die App muss dafür im
|
/// Ob gerade irgendeine Aktivität läuft – die App muss dafür im
|
||||||
/// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die
|
/// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die
|
||||||
/// Anzeige beim ersten Sperren des Bildschirms ein.
|
/// Anzeige beim ersten Sperren des Bildschirms ein.
|
||||||
@@ -37,11 +25,8 @@ final class LevelActivityManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func start(deviceID: UUID, deviceName: String, state: LevelState) {
|
func start(deviceID: UUID, deviceName: String, state: LevelState) {
|
||||||
pendingEnd?.cancel()
|
|
||||||
pendingEnd = nil
|
|
||||||
deviceToResume = nil
|
|
||||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||||
endActivity()
|
end()
|
||||||
let attributes = LevelActivityAttributes(deviceName: deviceName)
|
let attributes = LevelActivityAttributes(deviceName: deviceName)
|
||||||
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
|
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
|
||||||
do {
|
do {
|
||||||
@@ -61,45 +46,7 @@ final class LevelActivityManager {
|
|||||||
Task { await activity.update(content) }
|
Task { await activity.update(content) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bewusstes Beenden durch den Nutzer – anders als bei einem
|
|
||||||
/// Verbindungsabbruch soll das bei der nächsten Verbindung nicht
|
|
||||||
/// automatisch wieder aufleben.
|
|
||||||
func end() {
|
func end() {
|
||||||
pendingEnd?.cancel()
|
|
||||||
pendingEnd = nil
|
|
||||||
deviceToResume = nil
|
|
||||||
endActivity()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// BLE-Verbindung weg: nicht sofort beenden, sondern erst nach einer
|
|
||||||
/// kurzen Gnadenfrist – meldet sich das Gerät vorher zurück
|
|
||||||
/// (`handleReconnect`), passiert gar nichts.
|
|
||||||
func handleDisconnect(deviceID: UUID, deviceName: String) {
|
|
||||||
guard trackedDeviceID == deviceID, activity != nil, pendingEnd == nil else { return }
|
|
||||||
pendingEnd = Task { [weak self] in
|
|
||||||
try? await Task.sleep(for: .seconds(Self.disconnectGrace))
|
|
||||||
guard let self, !Task.isCancelled, self.trackedDeviceID == deviceID else { return }
|
|
||||||
self.deviceToResume = (deviceID, deviceName)
|
|
||||||
self.endActivity()
|
|
||||||
self.pendingEnd = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// BLE-Verbindung wieder da: ein noch anstehendes Ende verwerfen, oder –
|
|
||||||
/// falls die Gnadenfrist schon abgelaufen und die Aktivität wirklich
|
|
||||||
/// beendet war – sie mit dem letzten bekannten Stand neu starten.
|
|
||||||
func handleReconnect(deviceID: UUID, state: LevelState) {
|
|
||||||
if pendingEnd != nil, trackedDeviceID == deviceID {
|
|
||||||
pendingEnd?.cancel()
|
|
||||||
pendingEnd = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let resume = deviceToResume, resume.id == deviceID else { return }
|
|
||||||
deviceToResume = nil
|
|
||||||
start(deviceID: deviceID, deviceName: resume.name, state: state)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func endActivity() {
|
|
||||||
guard let activity else { return }
|
guard let activity else { return }
|
||||||
trackedDeviceID = nil
|
trackedDeviceID = nil
|
||||||
self.activity = nil
|
self.activity = nil
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ struct AlignmentAssistantView: View {
|
|||||||
|
|
||||||
@Environment(BluetoothManager.self) private var bluetooth
|
@Environment(BluetoothManager.self) private var bluetooth
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
|
||||||
|
|
||||||
@State private var assistant = AlignmentAssistant()
|
@State private var assistant = AlignmentAssistant()
|
||||||
@State private var didAnnounceTarget = false
|
@State private var didAnnounceTarget = false
|
||||||
@@ -19,18 +18,49 @@ struct AlignmentAssistantView: View {
|
|||||||
|
|
||||||
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
|
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
|
||||||
|
|
||||||
/// iPhone im Querformat meldet eine kompakte Höhe – das ist das
|
|
||||||
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
|
|
||||||
private var isLandscape: Bool { verticalSizeClass == .compact }
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
ScrollView {
|
||||||
if isLandscape {
|
VStack(spacing: 24) {
|
||||||
landscapeLayout
|
Picker("Darstellung", selection: $displayStyle) {
|
||||||
} else {
|
ForEach(LevelDisplayStyle.allCases) { style in
|
||||||
portraitLayout
|
Text(style.title).tag(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
switch displayStyle {
|
||||||
|
case .bubble:
|
||||||
|
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||||
|
.frame(maxWidth: 320)
|
||||||
|
case .vehicle:
|
||||||
|
VehicleTiltView(pitch: state.pitch, roll: state.roll,
|
||||||
|
style: device.vehicleGraphicStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isLive { disconnectedBanner }
|
||||||
|
|
||||||
|
adviceBanner
|
||||||
|
|
||||||
|
readings
|
||||||
|
|
||||||
|
if let best = assistant.best, let gain = assistant.improvementAtBest,
|
||||||
|
let seconds = assistant.timeSinceBest {
|
||||||
|
bestPointCard(best: best, gain: gain, seconds: seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
wedgeSection
|
||||||
|
|
||||||
|
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
|
||||||
|
assistant.reset()
|
||||||
|
didAnnounceTarget = false
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.padding(.bottom, 24)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(Color(.systemGroupedBackground))
|
||||||
.navigationTitle("Ausrichtungs-Assistent")
|
.navigationTitle("Ausrichtungs-Assistent")
|
||||||
@@ -66,94 +96,10 @@ struct AlignmentAssistantView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Layouts
|
|
||||||
|
|
||||||
private var portraitLayout: some View {
|
|
||||||
ScrollView {
|
|
||||||
VStack(spacing: 24) {
|
|
||||||
picker.padding(.top, 8)
|
|
||||||
|
|
||||||
display
|
|
||||||
|
|
||||||
if !isLive { disconnectedBanner }
|
|
||||||
|
|
||||||
adviceBanner
|
|
||||||
|
|
||||||
readings
|
|
||||||
|
|
||||||
if let best = assistant.best, let gain = assistant.improvementAtBest,
|
|
||||||
let seconds = assistant.timeSinceBest {
|
|
||||||
bestPointCard(best: best, gain: gain, seconds: seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
wedgeSection
|
|
||||||
|
|
||||||
resetButton
|
|
||||||
.padding(.bottom, 24)
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Anzeige links, alles zum Rangieren Nötige rechts – ohne Scrollen, denn
|
|
||||||
/// im Querformat schaut man beiläufig hin, nicht in Ruhe. Die
|
|
||||||
/// Bestpunkt-Karte und der ausführliche Verbindungs-Hinweis bleiben dafür
|
|
||||||
/// dem Hochformat vorbehalten; die Keilhöhen bleiben in jedem Fall sichtbar.
|
|
||||||
private var landscapeLayout: some View {
|
|
||||||
HStack(alignment: .top, spacing: 16) {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
picker
|
|
||||||
display
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
if !isLive { compactDisconnectedBanner }
|
|
||||||
adviceBanner
|
|
||||||
readings
|
|
||||||
wedgeSection
|
|
||||||
resetButton
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.padding(12)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Bausteine
|
// MARK: - Bausteine
|
||||||
|
|
||||||
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
||||||
|
|
||||||
private var picker: some View {
|
|
||||||
Picker("Darstellung", selection: $displayStyle) {
|
|
||||||
ForEach(LevelDisplayStyle.allCases) { style in
|
|
||||||
Text(style.title).tag(style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var display: some View {
|
|
||||||
switch displayStyle {
|
|
||||||
case .bubble:
|
|
||||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
|
||||||
.frame(maxWidth: isLandscape ? 220 : 320, maxHeight: isLandscape ? 160 : .infinity)
|
|
||||||
case .vehicle:
|
|
||||||
VehicleTiltView(pitch: state.pitch, roll: state.roll,
|
|
||||||
style: device.vehicleGraphicStyle, compact: isLandscape)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var resetButton: some View {
|
|
||||||
Button("Neu beginnen", systemImage: "arrow.counterclockwise") {
|
|
||||||
assistant.reset()
|
|
||||||
didAnnounceTarget = false
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne
|
/// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne
|
||||||
/// Hinweis sähe das aus, als hinge die App – man rangiert dann nach einem
|
/// Hinweis sähe das aus, als hinge die App – man rangiert dann nach einem
|
||||||
/// Wert, der längst nicht mehr gilt.
|
/// Wert, der längst nicht mehr gilt.
|
||||||
@@ -175,54 +121,40 @@ struct AlignmentAssistantView: View {
|
|||||||
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16))
|
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kurzform für Querformat: derselbe Hinweis in einer Zeile.
|
|
||||||
private var compactDisconnectedBanner: some View {
|
|
||||||
Label("Nicht verbunden – Anzeige steht still", systemImage: "antenna.radiowaves.left.and.right.slash")
|
|
||||||
.font(.caption.weight(.medium))
|
|
||||||
.foregroundStyle(.orange)
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.8)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(8)
|
|
||||||
.background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
private var adviceBanner: some View {
|
private var adviceBanner: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: assistant.hasReachedTarget
|
Image(systemName: assistant.hasReachedTarget
|
||||||
? "checkmark.circle.fill" : assistant.trend.symbol)
|
? "checkmark.circle.fill" : assistant.trend.symbol)
|
||||||
.font(isLandscape ? .title2 : .title)
|
.font(.title)
|
||||||
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
|
.foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor)
|
||||||
Text(assistant.advice)
|
Text(assistant.advice)
|
||||||
.font(isLandscape ? .subheadline.weight(.medium) : .title3.weight(.medium))
|
.font(.title3.weight(.medium))
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.padding(isLandscape ? 10 : 16)
|
.padding()
|
||||||
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
|
.background(assistant.hasReachedTarget ? Color.green.opacity(0.15)
|
||||||
: Color(.secondarySystemGroupedBackground),
|
: Color(.secondarySystemGroupedBackground),
|
||||||
in: .rect(cornerRadius: 16))
|
in: .rect(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var readings: some View {
|
private var readings: some View {
|
||||||
HStack(spacing: isLandscape ? 8 : 12) {
|
HStack(spacing: 12) {
|
||||||
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||||||
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||||||
if !isLandscape {
|
reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation))
|
||||||
reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reading(_ title: String, _ text: String) -> some View {
|
private func reading(_ title: String, _ text: String) -> some View {
|
||||||
VStack(spacing: isLandscape ? 1 : 4) {
|
VStack(spacing: 4) {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font((isLandscape ? Font.callout : .title2).weight(.semibold).monospacedDigit())
|
.font(.title2.weight(.semibold).monospacedDigit())
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.caption2)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, isLandscape ? 6 : 12)
|
.padding(.vertical, 12)
|
||||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
|
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,25 +188,23 @@ struct AlignmentAssistantView: View {
|
|||||||
trackWidth: width, wheelbase: base)
|
trackWidth: width, wheelbase: base)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: isLandscape ? 6 : 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Label("Auffahrkeile", systemImage: "triangle.fill")
|
Label("Auffahrkeile", systemImage: "triangle.fill")
|
||||||
.font(isLandscape ? .subheadline.weight(.semibold) : .headline)
|
.font(.headline)
|
||||||
|
|
||||||
if profile?.trackWidth == nil || profile?.wheelbase == nil {
|
if profile?.trackWidth == nil || profile?.wheelbase == nil {
|
||||||
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
|
Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt "
|
||||||
+ "sich beim Fahrzeug hinterlegen.")
|
+ "sich beim Fahrzeug hinterlegen.")
|
||||||
.font(.caption)
|
.font(.callout)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else if let lift, !lift.isNegligible {
|
} else if let lift, !lift.isNegligible {
|
||||||
WheelLiftPlan(lift: lift, isCompact: isLandscape)
|
WheelLiftPlan(lift: lift)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
if !isLandscape {
|
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
|
||||||
Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad "
|
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
|
||||||
+ "bleibt liegen, die übrigen werden auf seine Höhe gebracht.")
|
.font(.caption)
|
||||||
.font(.caption)
|
.foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Text("Keine Keile nötig.")
|
Text("Keine Keile nötig.")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
@@ -282,7 +212,7 @@ struct AlignmentAssistantView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(isLandscape ? 10 : 16)
|
.padding()
|
||||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,3 +230,4 @@ struct AlignmentAssistantView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ struct DeviceDetailView: View {
|
|||||||
@Environment(DeviceStore.self) private var store
|
@Environment(DeviceStore.self) private var store
|
||||||
@Environment(BluetoothManager.self) private var bluetooth
|
@Environment(BluetoothManager.self) private var bluetooth
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
|
||||||
|
|
||||||
@State private var editedName = ""
|
@State private var editedName = ""
|
||||||
/// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der
|
/// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der
|
||||||
@@ -36,167 +35,106 @@ 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
|
|
||||||
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
|
|
||||||
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
|
List {
|
||||||
// nach der tatsächlich verfügbaren Bildschirmhöhe, nicht nach einem
|
statusSection
|
||||||
// festen Wert – deshalb misst ein GeometryReader die Liste von aussen.
|
// if needsKeyAttention { keyPrompt }
|
||||||
GeometryReader { geometry in
|
|
||||||
List {
|
|
||||||
// Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort
|
|
||||||
// sichtbar sein, ohne erst am Verbindungsstatus
|
|
||||||
// vorbeizuscrollen – der rutscht dort ganz ans Ende. Gilt
|
|
||||||
// nur für den Neigungsmesser, siehe `showsCompactLevelLayout`.
|
|
||||||
if !showsCompactLevelLayout { statusSection }
|
|
||||||
// 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 {
|
||||||
// FridgeControls(device: currentDevice, state: fridge)
|
// FridgeControls(device: currentDevice, state: fridge)
|
||||||
// }
|
// }
|
||||||
|
|
||||||
if currentDevice.role == .leveling {
|
if currentDevice.role == .leveling {
|
||||||
LevelControls(device: currentDevice,
|
LevelControls(device: currentDevice,
|
||||||
state: bluetooth.levelStates[device.id] ?? LevelState(),
|
state: bluetooth.levelStates[device.id] ?? LevelState())
|
||||||
availableHeight: geometry.size.height)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let snapshot, !snapshot.metrics.isEmpty {
|
if let snapshot, !snapshot.metrics.isEmpty {
|
||||||
Section("Messwerte") {
|
Section("Messwerte") {
|
||||||
ForEach(snapshot.metrics) { metric in
|
ForEach(snapshot.metrics) { metric in
|
||||||
LabeledContent(metric.label) {
|
LabeledContent(metric.label) {
|
||||||
Text(metric.formattedWithUnit)
|
Text(metric.formattedWithUnit)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(metric.value == nil ? .secondary : .primary)
|
.foregroundStyle(metric.value == nil ? .secondary : .primary)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let metric = selectedMetric, samples.count > 1 {
|
if samples.count > 1, let primary = snapshot?.primaryMetric {
|
||||||
Section {
|
Section("Verlauf – \(primary.label)") {
|
||||||
if chartableMetrics.count > 1 {
|
Chart(samples) { sample in
|
||||||
Picker("Messgrösse", selection: Binding(
|
AreaMark(x: .value("Zeit", sample.time),
|
||||||
get: { metric.key },
|
y: .value(primary.label, sample.value))
|
||||||
set: { selectedHistoryMetricKey = $0 }
|
.foregroundStyle(.tint.opacity(0.15))
|
||||||
)) {
|
LineMark(x: .value("Zeit", sample.time),
|
||||||
ForEach(chartableMetrics) { candidate in
|
y: .value(primary.label, sample.value))
|
||||||
Text(candidate.label).tag(candidate.key)
|
.foregroundStyle(.tint)
|
||||||
}
|
.interpolationMethod(.monotone)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.chartYAxisLabel(primary.unit)
|
||||||
.listRowInsets(EdgeInsets())
|
.frame(height: 180)
|
||||||
.padding(.horizontal)
|
.padding(.vertical, 8)
|
||||||
.padding(.top, 4)
|
}
|
||||||
}
|
}
|
||||||
Chart(samples) { sample in
|
|
||||||
AreaMark(x: .value("Zeit", sample.time),
|
if let snapshot, !snapshot.cellVoltages.isEmpty {
|
||||||
y: .value(metric.label, sample.value))
|
cellSection(snapshot.cellVoltages)
|
||||||
.foregroundStyle(.tint.opacity(0.15))
|
}
|
||||||
LineMark(x: .value("Zeit", sample.time),
|
|
||||||
y: .value(metric.label, sample.value))
|
if let snapshot, !snapshot.info.isEmpty {
|
||||||
.foregroundStyle(.tint)
|
Section("Gerät") {
|
||||||
.interpolationMethod(.monotone)
|
ForEach(snapshot.info) { item in
|
||||||
}
|
LabeledContent(item.label, value: item.value)
|
||||||
.chartYAxisLabel(metric.unit)
|
|
||||||
.frame(height: 180)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
} header: {
|
|
||||||
Text("Verlauf – \(metric.label)")
|
|
||||||
} footer: {
|
|
||||||
Text("Nur für die laufende Sitzung – wird beim Neustart der App verworfen.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let snapshot, !snapshot.cellVoltages.isEmpty {
|
/* if let snapshot, snapshot.temperatures.count > 1 {
|
||||||
cellSection(snapshot.cellVoltages)
|
Section("Temperaturen") {
|
||||||
}
|
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
||||||
|
LabeledContent("Fühler \(index + 1)") {
|
||||||
if let snapshot, !snapshot.info.isEmpty {
|
Text(String(format: "%.0f °C", value)).monospacedDigit()
|
||||||
Section("Gerät") {
|
|
||||||
ForEach(snapshot.info) { item in
|
|
||||||
LabeledContent(item.label, value: item.value)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if showsCompactLevelLayout { statusSection }
|
|
||||||
|
|
||||||
/* if let snapshot, snapshot.temperatures.count > 1 {
|
|
||||||
Section("Temperaturen") {
|
|
||||||
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
|
|
||||||
LabeledContent("Fühler \(index + 1)") {
|
|
||||||
Text(String(format: "%.0f °C", value)).monospacedDigit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentDevice.role.transport == .advertisement {
|
|
||||||
if showsTechnicalDetails { diagnosticsSection }
|
|
||||||
} else if showsTechnicalDetails {
|
|
||||||
bmsDiagnosticsSection
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsSection*/
|
|
||||||
}
|
}
|
||||||
.navigationTitle(currentDevice.name)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
if currentDevice.role.transport == .advertisement {
|
||||||
.onDisappear {
|
if showsTechnicalDetails { diagnosticsSection }
|
||||||
saveName()
|
} else if showsTechnicalDetails {
|
||||||
// Die Kühlbox wird nur verbunden, solange man sie ansieht – jede
|
bmsDiagnosticsSection
|
||||||
// Verbindung meldet sich an ihrem Display an.
|
|
||||||
//bluetooth.endSession(for: currentDevice)
|
|
||||||
}
|
}
|
||||||
.onAppear {
|
|
||||||
editedName = currentDevice.name
|
settingsSection*/
|
||||||
keyInput = store.victronKeyText(for: device.id) ?? ""
|
}
|
||||||
//bluetooth.beginSession(for: currentDevice)
|
.navigationTitle(currentDevice.name)
|
||||||
}
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.confirmationDialog("Gerät entfernen?",
|
.onDisappear {
|
||||||
isPresented: $showDeleteConfirmation,
|
saveName()
|
||||||
titleVisibility: .visible) {
|
// Die Kühlbox wird nur verbunden, solange man sie ansieht – jede
|
||||||
Button("Entfernen", role: .destructive) {
|
// Verbindung meldet sich an ihrem Display an.
|
||||||
store.remove(device)
|
//bluetooth.endSession(for: currentDevice)
|
||||||
bluetooth.refreshConfiguration()
|
}
|
||||||
dismiss()
|
.onAppear {
|
||||||
}
|
editedName = currentDevice.name
|
||||||
} message: {
|
keyInput = store.victronKeyText(for: device.id) ?? ""
|
||||||
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
|
//bluetooth.beginSession(for: currentDevice)
|
||||||
|
}
|
||||||
|
.confirmationDialog("Gerät entfernen?",
|
||||||
|
isPresented: $showDeleteConfirmation,
|
||||||
|
titleVisibility: .visible) {
|
||||||
|
Button("Entfernen", role: .destructive) {
|
||||||
|
store.remove(device)
|
||||||
|
bluetooth.refreshConfiguration()
|
||||||
|
dismiss()
|
||||||
}
|
}
|
||||||
|
} message: {
|
||||||
|
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,76 +105,47 @@ struct LevelBubble: View {
|
|||||||
struct LevelControls: View {
|
struct LevelControls: View {
|
||||||
let device: ConfiguredDevice
|
let device: ConfiguredDevice
|
||||||
let state: LevelState
|
let state: LevelState
|
||||||
/// Höhe der umgebenden Liste, von `DeviceDetailView` per `GeometryReader`
|
|
||||||
/// gemessen. Nur im Querformat gebraucht, um die Anzeige auf
|
|
||||||
/// Bildschirmhöhe zu bringen statt sie auf einen festen Wert zu kappen.
|
|
||||||
var availableHeight: CGFloat?
|
|
||||||
|
|
||||||
@Environment(BluetoothManager.self) private var bluetooth
|
@Environment(BluetoothManager.self) private var bluetooth
|
||||||
@Environment(DeviceStore.self) private var store
|
@Environment(DeviceStore.self) private var store
|
||||||
@Environment(LevelActivityManager.self) private var levelActivity
|
@Environment(LevelActivityManager.self) private var levelActivity
|
||||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
|
||||||
@State private var showAssistant = false
|
@State private var showAssistant = false
|
||||||
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
|
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
|
||||||
|
|
||||||
/// iPhone im Querformat meldet eine kompakte Höhe – das ist das
|
|
||||||
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst.
|
|
||||||
private var isLandscape: Bool { verticalSizeClass == .compact }
|
|
||||||
|
|
||||||
/// Verfügbare Höhe abzüglich grober Reserve für Listenränder und die
|
|
||||||
/// eigene vertikale Auffüllung – genug, um praktisch den ganzen
|
|
||||||
/// Bildschirm zu nutzen, ohne über den unteren Rand hinauszuschiessen.
|
|
||||||
private var landscapeContentHeight: CGFloat {
|
|
||||||
max(150, (availableHeight ?? 350) - 56)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
if isLandscape {
|
VStack(spacing: 16) {
|
||||||
HStack(alignment: .center, spacing: 20) {
|
Picker("Darstellung", selection: $displayStyle) {
|
||||||
display
|
ForEach(LevelDisplayStyle.allCases) { style in
|
||||||
.frame(maxWidth: .infinity)
|
Text(style.title).tag(style)
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
picker
|
|
||||||
if let instruction = state.instruction {
|
|
||||||
Label(instruction,
|
|
||||||
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
HStack(spacing: 16) {
|
|
||||||
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
|
||||||
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.frame(height: landscapeContentHeight)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
} else {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
picker
|
|
||||||
|
|
||||||
display
|
|
||||||
|
|
||||||
if let instruction = state.instruction {
|
|
||||||
Label(instruction,
|
|
||||||
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 24) {
|
|
||||||
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
|
||||||
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.pickerStyle(.segmented)
|
||||||
.padding(.vertical, 8)
|
|
||||||
|
switch displayStyle {
|
||||||
|
case .bubble:
|
||||||
|
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||||
|
.frame(maxHeight: 220)
|
||||||
|
case .vehicle:
|
||||||
|
VehicleTiltView(pitch: state.pitch, roll: state.roll,
|
||||||
|
style: device.vehicleGraphicStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let instruction = state.instruction {
|
||||||
|
Label(instruction,
|
||||||
|
systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(state.isLevel ? Color.green : Color.primary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 24) {
|
||||||
|
reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
|
||||||
|
reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -249,28 +220,6 @@ struct LevelControls: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var picker: some View {
|
|
||||||
Picker("Darstellung", selection: $displayStyle) {
|
|
||||||
ForEach(LevelDisplayStyle.allCases) { style in
|
|
||||||
Text(style.title).tag(style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var display: some View {
|
|
||||||
switch displayStyle {
|
|
||||||
case .bubble:
|
|
||||||
LevelBubble(pitch: state.pitch, roll: state.roll)
|
|
||||||
.frame(maxHeight: isLandscape ? landscapeContentHeight : 220)
|
|
||||||
case .vehicle:
|
|
||||||
VehicleTiltView(pitch: state.pitch, roll: state.roll,
|
|
||||||
style: device.vehicleGraphicStyle, compact: isLandscape,
|
|
||||||
compactPanelHeight: max(60, landscapeContentHeight - 40))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
||||||
|
|
||||||
private var liveActivityBinding: Binding<Bool> {
|
private var liveActivityBinding: Binding<Bool> {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -16,38 +16,31 @@ struct VehicleTiltView: View {
|
|||||||
let pitch: Double?
|
let pitch: Double?
|
||||||
let roll: Double?
|
let roll: Double?
|
||||||
var style: VehicleGraphicStyle = .vanster
|
var style: VehicleGraphicStyle = .vanster
|
||||||
/// Für Querformat: beide Ansichten nebeneinander statt untereinander,
|
|
||||||
/// kleinere Schrift, ohne Überhöhungs-Hinweis – muss ohne Scrollen in die
|
|
||||||
/// Bildschirmhöhe passen.
|
|
||||||
var compact = false
|
|
||||||
/// Bildhöhe je Panel im Querformat – vom Aufrufer an die tatsächlich
|
|
||||||
/// verfügbare Bildschirmhöhe angepasst, statt fest verdrahtet.
|
|
||||||
var compactPanelHeight: CGFloat = 62
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if compact {
|
VStack(spacing: 20) {
|
||||||
HStack(alignment: .top, spacing: 16) {
|
tiltPanel(image: style.sideImageName,
|
||||||
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
|
angle: pitch,
|
||||||
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
|
title: "Längs",
|
||||||
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
|
lowerLabel: "Front",
|
||||||
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
|
upperLabel: "Heck",
|
||||||
}
|
aspect: style.sideAspect)
|
||||||
} else {
|
|
||||||
VStack(spacing: 20) {
|
|
||||||
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs",
|
|
||||||
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect)
|
|
||||||
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer",
|
|
||||||
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect)
|
|
||||||
|
|
||||||
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
|
tiltPanel(image: style.rearImageName,
|
||||||
Text(String(format: "Neigung %.0f-fach überhöht dargestellt – "
|
angle: roll,
|
||||||
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
|
title: "Quer",
|
||||||
VehicleTilt.exaggeration))
|
lowerLabel: "links",
|
||||||
.font(.caption2)
|
upperLabel: "rechts",
|
||||||
.foregroundStyle(.secondary)
|
aspect: style.rearAspect)
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.frame(maxWidth: .infinity)
|
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
|
||||||
}
|
Text(String(format: "Neigung %.0f-fach überhöht dargestellt – "
|
||||||
|
+ "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.",
|
||||||
|
VehicleTilt.exaggeration))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,13 +50,13 @@ struct VehicleTiltView: View {
|
|||||||
lowerLabel: String,
|
lowerLabel: String,
|
||||||
upperLabel: String,
|
upperLabel: String,
|
||||||
aspect: Double) -> some View {
|
aspect: Double) -> some View {
|
||||||
VStack(spacing: compact ? 4 : 8) {
|
VStack(spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(compact ? .caption2.weight(.medium) : .subheadline.weight(.medium))
|
.font(.subheadline.weight(.medium))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(angle.map { String(format: "%.1f°", $0) } ?? "–")
|
Text(angle.map { String(format: "%.1f°", $0) } ?? "–")
|
||||||
.font((compact ? Font.caption2 : .subheadline).weight(.semibold).monospacedDigit())
|
.font(.subheadline.weight(.semibold).monospacedDigit())
|
||||||
.foregroundStyle(VehicleTilt.colour(for: angle))
|
.foregroundStyle(VehicleTilt.colour(for: angle))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,14 +76,14 @@ struct VehicleTiltView: View {
|
|||||||
.animation(.spring(duration: 0.4), value: angle)
|
.animation(.spring(duration: 0.4), value: angle)
|
||||||
.opacity(angle == nil ? 0.3 : 1)
|
.opacity(angle == nil ? 0.3 : 1)
|
||||||
}
|
}
|
||||||
.frame(height: compact ? compactPanelHeight : 110)
|
.frame(height: 110)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(lowerLabel)
|
Text(lowerLabel)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(upperLabel)
|
Text(upperLabel)
|
||||||
}
|
}
|
||||||
.font(compact ? .system(size: 9) : .caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
+2
-2
@@ -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>
|
||||||
|
|||||||
@@ -19,20 +19,6 @@ private struct LevelIcon: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
|
|
||||||
private let levelTolerance: Double = 0.5
|
|
||||||
|
|
||||||
/// Grün bis Toleranz, orange bis 2°, sonst rot – dieselbe Skala für Blase
|
|
||||||
/// und Text, wie in der App (`VehicleTilt.colour`). Hier dupliziert, weil
|
|
||||||
/// die Extension `Shared/` nicht mitkompiliert (siehe
|
|
||||||
/// `LevelActivityAttributes.swift`).
|
|
||||||
private func levelColour(forDeviation deviation: Double?) -> Color {
|
|
||||||
guard let deviation else { return .white.opacity(0.4) }
|
|
||||||
if deviation <= levelTolerance { return .green }
|
|
||||||
if deviation <= 2 { return .orange }
|
|
||||||
return .red
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte
|
/// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte
|
||||||
/// Dynamic Island – dieselbe Optik wie `LevelBubble` in der App, aber ohne
|
/// Dynamic Island – dieselbe Optik wie `LevelBubble` in der App, aber ohne
|
||||||
/// deren Abhängigkeit auf `Shared/Models/LevelState.swift`, das in der
|
/// deren Abhängigkeit auf `Shared/Models/LevelState.swift`, das in der
|
||||||
@@ -45,12 +31,17 @@ private struct LevelBubbleGlyph: View {
|
|||||||
|
|
||||||
/// Bis zu welcher Neigung die Blase ausschlägt, wie in der App-Libelle.
|
/// Bis zu welcher Neigung die Blase ausschlägt, wie in der App-Libelle.
|
||||||
private let range: Double = 6
|
private let range: Double = 6
|
||||||
|
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
|
||||||
|
private let tolerance: Double = 0.5
|
||||||
|
|
||||||
private var hasReading: Bool { pitch != nil || roll != nil }
|
private var hasReading: Bool { pitch != nil || roll != nil }
|
||||||
|
|
||||||
private var bubbleColor: Color {
|
private var bubbleColor: Color {
|
||||||
guard let pitch, let roll else { return .white.opacity(0.4) }
|
guard let pitch, let roll else { return .white.opacity(0.4) }
|
||||||
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
|
let deviation = max(abs(pitch), abs(roll))
|
||||||
|
if deviation <= tolerance { return .green }
|
||||||
|
if deviation <= 2 { return .orange }
|
||||||
|
return .red
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -59,7 +50,7 @@ private struct LevelBubbleGlyph: View {
|
|||||||
let travel = radius - bubble / 2 - 3
|
let travel = radius - bubble / 2 - 3
|
||||||
let offsetX = clamped(roll) / range * travel
|
let offsetX = clamped(roll) / range * travel
|
||||||
let offsetY = clamped(pitch) / range * travel
|
let offsetY = clamped(pitch) / range * travel
|
||||||
let toleranceRadius = max(levelTolerance / range * travel, bubble * 0.55)
|
let toleranceRadius = max(tolerance / range * travel, bubble * 0.55)
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -146,67 +137,28 @@ private struct LevelActivityContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus
|
/// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus
|
||||||
/// ein Blick-Symbol statt eines ganzen Satzes – zum Lesen im Vorbeifahren.
|
/// eine Textzeile, ohne die längere Zwei-Zeilen-Aufschlüsselung des
|
||||||
///
|
/// Sperrbildschirms.
|
||||||
/// Gezeigt wird nur, welche Seite höher steht: Buchstabe (Längsneigung),
|
|
||||||
/// Pfeil, Buchstabe (Querneigung) – z.B. "H ↑ L" für "Heck und links stehen
|
|
||||||
/// höher". Ein einzelner Pfeil in der Mitte reicht, weil ohnehin nur die
|
|
||||||
/// höher stehende Seite benannt wird ("höher" ist implizit "nach oben").
|
|
||||||
private struct CompactLevelView: View {
|
private struct CompactLevelView: View {
|
||||||
let attributes: LevelActivityAttributes
|
let attributes: LevelActivityAttributes
|
||||||
let state: LevelActivityAttributes.ContentState
|
let state: LevelActivityAttributes.ContentState
|
||||||
|
|
||||||
private var hasReading: Bool { state.pitch != nil || state.roll != nil }
|
|
||||||
|
|
||||||
/// "H"/"F", nur wenn ausserhalb der Toleranz.
|
|
||||||
private var pitchLetter: String? {
|
|
||||||
guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
|
|
||||||
return pitch >= 0 ? "H" : "F"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "R"/"L", nur wenn ausserhalb der Toleranz.
|
|
||||||
private var rollLetter: String? {
|
|
||||||
guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
|
|
||||||
return roll >= 0 ? "R" : "L"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dieselbe Skala wie die Blase, über die grössere der beiden Neigungen.
|
|
||||||
private var statusColor: Color {
|
|
||||||
guard let pitch = state.pitch, let roll = state.roll else { return .white.opacity(0.4) }
|
|
||||||
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 12) {
|
||||||
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40)
|
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40)
|
||||||
|
|
||||||
Group {
|
Text(state.instruction
|
||||||
if !hasReading {
|
?? (state.pitch == nil && state.roll == nil ? "Keine Messwerte" : attributes.deviceName))
|
||||||
Text("–")
|
.font(.callout.weight(.semibold))
|
||||||
} else if state.isLevel {
|
.foregroundStyle(state.isLevel ? .green : .white)
|
||||||
Image(systemName: "checkmark.circle.fill")
|
.lineLimit(1)
|
||||||
} else {
|
.minimumScaleFactor(0.8)
|
||||||
HStack(spacing: 6) {
|
|
||||||
if let pitchLetter { Text(pitchLetter) }
|
|
||||||
Image(systemName: "arrow.up")
|
|
||||||
if let rollLetter { Text(rollLetter) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Skalierbarer Textstil statt fester Punktgrösse: CarPlay-
|
|
||||||
// Bildschirme unterscheiden sich in Grösse, die exakten Masse
|
|
||||||
// eines Displays sind aber nicht abfragbar. `minimumScaleFactor`
|
|
||||||
// lässt die Schrift so gross wie möglich, aber so klein wie
|
|
||||||
// nötig werden, um in den vom System zugeteilten Platz zu passen.
|
|
||||||
.font(.title2.weight(.bold))
|
|
||||||
.minimumScaleFactor(0.5)
|
|
||||||
.lineLimit(1)
|
|
||||||
.foregroundStyle(statusColor)
|
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,17 +167,20 @@ private struct LockScreenLevelView: View {
|
|||||||
let attributes: LevelActivityAttributes
|
let attributes: LevelActivityAttributes
|
||||||
let state: LevelActivityAttributes.ContentState
|
let state: LevelActivityAttributes.ContentState
|
||||||
|
|
||||||
|
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
|
||||||
|
private let tolerance: Double = 0.5
|
||||||
|
|
||||||
/// Längszeile, z.B. "Heck steht höher 1.8°" – nur wenn ausserhalb der
|
/// Längszeile, z.B. "Heck steht höher 1.8°" – nur wenn ausserhalb der
|
||||||
/// Toleranz, genau wie bei `LevelState.instruction` in der App.
|
/// Toleranz, genau wie bei `LevelState.instruction` in der App.
|
||||||
private var pitchLine: String? {
|
private var pitchLine: String? {
|
||||||
guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
|
guard let pitch = state.pitch, abs(pitch) > tolerance else { return nil }
|
||||||
let label = pitch >= 0 ? "Heck steht höher" : "Front steht höher"
|
let label = pitch >= 0 ? "Heck steht höher" : "Front steht höher"
|
||||||
return "\(label) \(LevelDirectionFormatting.magnitude(pitch))"
|
return "\(label) \(LevelDirectionFormatting.magnitude(pitch))"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Querzeile, z.B. "links steht höher 0.9°".
|
/// Querzeile, z.B. "links steht höher 0.9°".
|
||||||
private var rollLine: String? {
|
private var rollLine: String? {
|
||||||
guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
|
guard let roll = state.roll, abs(roll) > tolerance else { return nil }
|
||||||
let label = roll >= 0 ? "rechts steht höher" : "links steht höher"
|
let label = roll >= 0 ? "rechts steht höher" : "links steht höher"
|
||||||
return "\(label) \(LevelDirectionFormatting.magnitude(roll))"
|
return "\(label) \(LevelDirectionFormatting.magnitude(roll))"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"};
|
|
||||||
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
Reference in New Issue
Block a user