Compare commits

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:04:20 +02:00
35 changed files with 1476 additions and 662 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

@@ -1,10 +0,0 @@
{
"images" : [
{
"filename" : "CaliforniaSide.png",
"idiom" : "universal"
}
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "template-rendering-intent" : "template" }
}
@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VansterRear.png", "filename" : "VehicleRear.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VansterSide.png", "filename" : "VehicleSide.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

+61 -24
View File
@@ -17,6 +17,8 @@ struct Discovery: Identifiable, Hashable {
var looksLikeSupported: Bool var looksLikeSupported: Bool
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar. /// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
var isLevelSensor = false var isLevelSensor = false
/// Der Solarladeregler bewirbt seinen Dienst ebenso.
var isSolarSensor = false
//var isVictron: Bool { victronRecordType != nil } //var isVictron: Bool { victronRecordType != nil }
@@ -31,6 +33,7 @@ struct Discovery: Identifiable, Hashable {
var subtitle: String { var subtitle: String {
if isLevelSensor { return "VanAlign Neigungsmesser" } if isLevelSensor { return "VanAlign Neigungsmesser" }
if isSolarSensor { return "VanAlign Solarladeregler" }
return "Bluetooth-Gerät" return "Bluetooth-Gerät"
} }
/*var subtitle: String { /*var subtitle: String {
@@ -125,6 +128,12 @@ struct HistorySample: Identifiable, Hashable {
let value: Double let value: Double
} }
/// Verlauf mehrerer Messgrössen eines Geräts, je Metrik-Schlüssel (siehe
/// `Metric.key`). Nur im Arbeitsspeicher: Sinn ist die Grafik während der
/// laufenden App-Sitzung, nicht ein dauerhaftes Log ein Neustart der App
/// oder das Beenden im Hintergrund darf den Verlauf also verwerfen.
typealias DeviceHistory = [String: [HistorySample]]
/// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Geräten und hält /// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Geräten und hält
/// parallel die Verbindung zum Neigungsmesser. /// parallel die Verbindung zum Neigungsmesser.
/// ///
@@ -150,11 +159,12 @@ final class BluetoothManager: NSObject {
private(set) var snapshots: [UUID: DeviceSnapshot] = [:] private(set) var snapshots: [UUID: DeviceSnapshot] = [:]
private(set) var linkStates: [UUID: DeviceLinkState] = [:] private(set) var linkStates: [UUID: DeviceLinkState] = [:]
private(set) var discoveries: [UUID: Discovery] = [:] private(set) var discoveries: [UUID: Discovery] = [:]
private(set) var history: [UUID: [HistorySample]] = [:] private(set) var history: [UUID: DeviceHistory] = [:]
//private(set) var diagnostics: [UUID: VictronDiagnostics] = [:] //private(set) var diagnostics: [UUID: VictronDiagnostics] = [:]
//private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:] //private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
//private(set) var fridgeStates: [UUID: AlpicoolState] = [:] //private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
private(set) var levelStates: [UUID: LevelState] = [:] private(set) var levelStates: [UUID: LevelState] = [:]
private(set) var solarStates: [UUID: SolarState] = [:]
private(set) var isBluetoothReady = false private(set) var isBluetoothReady = false
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…" private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
@@ -191,6 +201,7 @@ final class BluetoothManager: NSObject {
//private var bmsSessions: [UUID: BMSSession] = [:] //private var bmsSessions: [UUID: BMSSession] = [:]
private var levelSessions: [UUID: LevelSession] = [:] private var levelSessions: [UUID: LevelSession] = [:]
private var solarSessions: [UUID: SolarSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:] private var connectedPeripherals: [UUID: CBPeripheral] = [:]
private var reconnectTimer: DispatchSourceTimer? private var reconnectTimer: DispatchSourceTimer?
@@ -269,6 +280,11 @@ final class BluetoothManager: NSObject {
bluetoothStatusText = "Demo-Modus" bluetoothStatusText = "Demo-Modus"
//fridgeStates[DemoData.fridge.id] = DemoData.fridgeState //fridgeStates[DemoData.fridge.id] = DemoData.fridgeState
levelStates[DemoData.level.id] = DemoData.levelState levelStates[DemoData.level.id] = DemoData.levelState
solarStates[DemoData.solar.id] = DemoData.solarState
linkStates[DemoData.solar.id] = .live
record(DemoData.solarState.snapshot(deviceID: DemoData.solar.id, rssi: -58))
history[DemoData.solar.id] = DemoData.solarHistory()
/* for snapshot in DemoData.snapshots() { /* for snapshot in DemoData.snapshots() {
snapshots[snapshot.deviceID] = snapshot snapshots[snapshot.deviceID] = snapshot
linkStates[snapshot.deviceID] = .live linkStates[snapshot.deviceID] = .live
@@ -326,6 +342,7 @@ final class BluetoothManager: NSObject {
//bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) } //bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
//fridgeStates = fridgeStates.filter { known.contains($0.key) } //fridgeStates = fridgeStates.filter { known.contains($0.key) }
levelStates = levelStates.filter { known.contains($0.key) } levelStates = levelStates.filter { known.contains($0.key) }
solarStates = solarStates.filter { known.contains($0.key) }
// Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin // Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin
// ihren zuletzt gestellten Stand. // ihren zuletzt gestellten Stand.
@@ -535,6 +552,11 @@ final class BluetoothManager: NSObject {
levelSessions[peripheralID] = nil levelSessions[peripheralID] = nil
disconnect(peripheralID) disconnect(peripheralID)
} }
for (peripheralID, session) in solarSessions where !wanted.contains(peripheralID) {
session.stop()
solarSessions[peripheralID] = nil
disconnect(peripheralID)
}
// Einstellungen an bestehende Sitzungen weiterreichen. // Einstellungen an bestehende Sitzungen weiterreichen.
for device in devices { for device in devices {
//bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode //bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode
@@ -580,9 +602,11 @@ final class BluetoothManager: NSObject {
discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil
//for (_, session) in bmsSessions { session.stop() } //for (_, session) in bmsSessions { session.stop() }
for (_, session) in levelSessions { session.stop() } for (_, session) in levelSessions { session.stop() }
for (_, session) in solarSessions { session.stop() }
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) } for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
//bmsSessions.removeAll() //bmsSessions.removeAll()
levelSessions.removeAll() levelSessions.removeAll()
solarSessions.removeAll()
connectedPeripherals.removeAll() connectedPeripherals.removeAll()
connectedSince.removeAll() connectedSince.removeAll()
//pendingControls.removeAll() //pendingControls.removeAll()
@@ -692,15 +716,20 @@ final class BluetoothManager: NSObject {
private func record(_ snapshot: DeviceSnapshot) { private func record(_ snapshot: DeviceSnapshot) {
publish { publish {
self.snapshots[snapshot.deviceID] = snapshot self.snapshots[snapshot.deviceID] = snapshot
guard let primary = snapshot.primaryMetric, let value = primary.value else { return } var deviceHistory = self.history[snapshot.deviceID] ?? [:]
var samples = self.history[snapshot.deviceID] ?? [] for metric in snapshot.metrics {
guard let value = metric.value else { continue }
var samples = deviceHistory[metric.key] ?? []
// Höchstens alle fünf Sekunden einen Punkt aufnehmen. // Höchstens alle fünf Sekunden einen Punkt aufnehmen.
if let last = samples.last, snapshot.timestamp.timeIntervalSince(last.time) < 5 { return } if let last = samples.last,
snapshot.timestamp.timeIntervalSince(last.time) < 5 { continue }
samples.append(HistorySample(time: snapshot.timestamp, value: value)) samples.append(HistorySample(time: snapshot.timestamp, value: value))
if samples.count > self.historyLimit { if samples.count > self.historyLimit {
samples.removeFirst(samples.count - self.historyLimit) samples.removeFirst(samples.count - self.historyLimit)
} }
self.history[snapshot.deviceID] = samples deviceHistory[metric.key] = samples
}
self.history[snapshot.deviceID] = deviceHistory
} }
} }
@@ -773,7 +802,8 @@ final class BluetoothManager: NSObject {
if let name, !name.isEmpty { entry.name = name } if let name, !name.isEmpty { entry.name = name }
let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [] let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []
entry.isLevelSensor = services.contains(LevelSession.serviceUUID) entry.isLevelSensor = services.contains(LevelSession.serviceUUID)
entry.looksLikeSupported = entry.isLevelSensor entry.isSolarSensor = services.contains(SolarSession.serviceUUID)
entry.looksLikeSupported = entry.isLevelSensor || entry.isSolarSensor
pendingDiscoveries[peripheral.identifier] = entry pendingDiscoveries[peripheral.identifier] = entry
} }
/*private func updateDiscovery(peripheral: CBPeripheral, /*private func updateDiscovery(peripheral: CBPeripheral,
@@ -897,17 +927,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 {
@@ -927,6 +947,27 @@ extension BluetoothManager: CBCentralManagerDelegate {
return return
} }
if device.role == .solar {
// Bewusst kein Draht zu `activityManager`: Der Solarertrag soll
// nicht in der Live Activity/CarPlay auftauchen, die ist dem
// Neigungsmesser vorbehalten.
let session = SolarSession(
deviceID: device.id,
peripheral: peripheral,
queue: queue,
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
onStateChange: { [weak self] state in
self?.publish { self?.linkStates[device.id] = state }
},
onSolarState: { [weak self] state in
self?.publish { self?.solarStates[device.id] = state }
}
)
solarSessions[peripheral.identifier] = session
session.start()
return
}
/*let session = BMSSession( /*let session = BMSSession(
deviceID: device.id, deviceID: device.id,
peripheral: peripheral, peripheral: peripheral,
@@ -974,19 +1015,15 @@ extension BluetoothManager: CBCentralManagerDelegate {
//bmsSessions[peripheral.identifier] = nil //bmsSessions[peripheral.identifier] = nil
levelSessions[peripheral.identifier]?.handleDisconnect() levelSessions[peripheral.identifier]?.handleDisconnect()
levelSessions[peripheral.identifier] = nil levelSessions[peripheral.identifier] = nil
solarSessions[peripheral.identifier]?.handleDisconnect()
solarSessions[peripheral.identifier] = nil
connectedPeripherals[peripheral.identifier] = nil connectedPeripherals[peripheral.identifier] = nil
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier) let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
.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
@@ -5,28 +5,14 @@ import Observation
/// Startet, aktualisiert und beendet die Live Activity des Neigungsmessers. /// Startet, aktualisiert und beendet die Live Activity des Neigungsmessers.
/// ///
/// Es läuft höchstens eine Aktivität gleichzeitig mehr als einen /// Es läuft höchstens eine Aktivität gleichzeitig mehr als einen
/// Neigungsmesser gibt es im aktiven Profil ohnehin nicht. Seit iOS 26 zeigt /// Neigungsmesser gibt es im aktiven Profil ohnehin nicht. Seit iOS 17 zeigt
/// CarPlay eine laufende Live Activity automatisch im Dashboard an; ein /// CarPlay eine laufende Live Activity automatisch im Dashboard an; ein
/// eigenes CarPlay-App-Target braucht es dafür nicht die Inhalte kommen /// eigenes CarPlay-App-Target braucht es dafür nicht.
/// aber nicht von der Sperrbildschirm-Ansicht, sondern von der
/// `.small`-Aktivitätsfamilie (siehe `VanAligneiOSWidgetLiveActivity`).
@Observable @Observable
final class LevelActivityManager { 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 +23,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 +44,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
+40 -1
View File
@@ -59,8 +59,47 @@ enum DemoData {
name: "Nivellierung", role: .leveling, profileID: Profile.defaultID, name: "Nivellierung", role: .leveling, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000E1")!) peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000E1")!)
static let solar = ConfiguredDevice(
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000D0")!,
name: "Solar Dach", role: .solar, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000D1")!)
static var devices: [ConfiguredDevice] { static var devices: [ConfiguredDevice] {
[/*solar, booster, battery, fridge, */level/*, caravanSolar*/] [/*booster, battery, fridge, */level, solar/*, caravanSolar*/]
}
/// Mittags, gute Sonne.
static var solarState: SolarState {
var state = SolarState()
state.batteryVoltage = 13.9
state.pvVoltage = 19.4
state.pvCurrent = 6.2
state.pvPower = 120
state.controllerTemperature = 34
state.isBatteryCharging = true
state.isControllerActive = true
state.isCurrentLimited = false
return state
}
/// Ein paar Stunden Verlauf je Kanal, damit die Grafik im Demo-Modus
/// sofort etwas zeigt statt erst nach ein paar Minuten Laufzeit.
static func solarHistory() -> DeviceHistory {
let now = Date()
func series(around value: Double, noise: Double) -> [HistorySample] {
(0..<120).reversed().map { step in
let t = Double(step)
let wave = sin(t / 14) * value * noise + cos(t / 31) * value * (noise / 2)
return HistorySample(time: now.addingTimeInterval(-t * 60),
value: max(0, value + wave))
}
}
return [
"pv_power": series(around: 120, noise: 0.35),
"pv_voltage": series(around: 19.4, noise: 0.08),
"pv_current": series(around: 6.2, noise: 0.3),
"battery_voltage": series(around: 13.9, noise: 0.03),
]
} }
/// Leicht schräg stehend, damit die Libelle etwas zu zeigen hat. /// Leicht schräg stehend, damit die Libelle etwas zu zeigen hat.
+4
View File
@@ -195,6 +195,8 @@ private struct ConfigureDeviceView: View {
} }
} else*/ if discovery.isLevelSensor { } else*/ if discovery.isLevelSensor {
role = .leveling role = .leveling
} else if discovery.isSolarSensor {
role = .solar
} /*else if let name = discovery.name?.lowercased(), } /*else if let name = discovery.name?.lowercased(),
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) { ["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
role = .fridge role = .fridge
@@ -206,6 +208,8 @@ private struct ConfigureDeviceView: View {
// ohnehin darüber unter "Gefunden als". // ohnehin darüber unter "Gefunden als".
if discovery.isLevelSensor { if discovery.isLevelSensor {
name = "Nivellierung" name = "Nivellierung"
} else if discovery.isSolarSensor {
name = "Solarladeregler"
} else if let advertised = discovery.name, advertised.count <= 20, } else if let advertised = discovery.name, advertised.count <= 20,
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil { advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
name = advertised name = advertised
+53 -123
View File
@@ -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,19 +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)
}
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")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -66,94 +95,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 +120,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 +187,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 +211,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 +229,4 @@ struct AlignmentAssistantView: View {
#endif #endif
} }
} }
+47 -23
View File
@@ -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,24 +35,35 @@ struct DeviceDetailView: View {
store.devices.first { $0.id == device.id } ?? device store.devices.first { $0.id == device.id } ?? device
} }
/// iPhone im Querformat meldet eine kompakte Höhe das ist das /// Nur solange die App läuft: siehe `DeviceHistory`.
/// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst. @State private var selectedHistoryMetricKey: String?
private var isLandscape: Bool { verticalSizeClass == .compact }
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] } private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching } private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] } private var deviceHistory: DeviceHistory { bluetooth.history[device.id] ?? [:] }
/// Nur Metriken, zu denen sich bereits ein Verlauf mit mehr als einem
/// Punkt angesammelt hat sonst gäbe es nichts zu zeichnen.
private var chartableMetrics: [Metric] {
(snapshot?.metrics ?? []).filter { (deviceHistory[$0.key]?.count ?? 0) > 1 }
}
private var selectedMetric: Metric? {
if let key = selectedHistoryMetricKey,
let metric = chartableMetrics.first(where: { $0.key == key }) {
return metric
}
return chartableMetrics.first(where: \.isPrimary) ?? chartableMetrics.first
}
private var samples: [HistorySample] {
guard let selectedMetric else { return [] }
return deviceHistory[selectedMetric.key] ?? []
}
var body: some View { var body: some View {
// Die Höhe der Libelle/Fahrzeug-Anzeige im Querformat richtet sich
// nach der tatsächlich verfügbaren Bildschirmhöhe, nicht nach einem
// festen Wert deshalb misst ein GeometryReader die Liste von aussen.
GeometryReader { geometry in
List { List {
// Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort statusSection
// sichtbar sein, ohne erst am Verbindungsstatus
// vorbeizuscrollen der rutscht dort ganz ans Ende.
if !isLandscape { statusSection }
// if needsKeyAttention { keyPrompt } // if needsKeyAttention { keyPrompt }
// if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus { // if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus {
@@ -62,8 +72,7 @@ struct DeviceDetailView: View {
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 {
@@ -78,20 +87,38 @@ struct DeviceDetailView: View {
} }
} }
if samples.count > 1, let primary = snapshot?.primaryMetric { if let metric = selectedMetric, samples.count > 1 {
Section("Verlauf \(primary.label)") { Section {
if chartableMetrics.count > 1 {
Picker("Messgrösse", selection: Binding(
get: { metric.key },
set: { selectedHistoryMetricKey = $0 }
)) {
ForEach(chartableMetrics) { candidate in
Text(candidate.label).tag(candidate.key)
}
}
.pickerStyle(.segmented)
.listRowInsets(EdgeInsets())
.padding(.horizontal)
.padding(.top, 4)
}
Chart(samples) { sample in Chart(samples) { sample in
AreaMark(x: .value("Zeit", sample.time), AreaMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value)) y: .value(metric.label, sample.value))
.foregroundStyle(.tint.opacity(0.15)) .foregroundStyle(.tint.opacity(0.15))
LineMark(x: .value("Zeit", sample.time), LineMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value)) y: .value(metric.label, sample.value))
.foregroundStyle(.tint) .foregroundStyle(.tint)
.interpolationMethod(.monotone) .interpolationMethod(.monotone)
} }
.chartYAxisLabel(primary.unit) .chartYAxisLabel(metric.unit)
.frame(height: 180) .frame(height: 180)
.padding(.vertical, 8) .padding(.vertical, 8)
} header: {
Text("Verlauf \(metric.label)")
} footer: {
Text("Nur für die laufende Sitzung wird beim Neustart der App verworfen.")
} }
} }
@@ -107,8 +134,6 @@ struct DeviceDetailView: View {
} }
} }
if isLandscape { statusSection }
/* if let snapshot, snapshot.temperatures.count > 1 { /* if let snapshot, snapshot.temperatures.count > 1 {
Section("Temperaturen") { Section("Temperaturen") {
ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in
@@ -152,7 +177,6 @@ struct DeviceDetailView: View {
Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.") Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.")
} }
} }
}
// MARK: - Abschnitte // MARK: - Abschnitte
-40
View File
@@ -25,8 +25,6 @@ struct LevelSetupView: View {
store.devices.first { $0.id == device.id } ?? device store.devices.first { $0.id == device.id } ?? device
} }
private let graphicColumns = [GridItem(.adaptive(minimum: 76), spacing: 12)]
var body: some View { var body: some View {
List { List {
Section { Section {
@@ -82,44 +80,6 @@ struct LevelSetupView: View {
} footer: { } footer: {
Text(calibrationHint) Text(calibrationHint)
} }
Section {
LazyVGrid(columns: graphicColumns, spacing: 12) {
ForEach(VehicleGraphicStyle.allCases) { style in
Button {
var updated = currentDevice
updated.vehicleGraphicStyle = style
store.update(updated)
} label: {
VStack(spacing: 4) {
Image(style.sideImageName)
.resizable()
.scaledToFit()
.padding(10)
.frame(width: 60, height: 60)
.background(currentDevice.vehicleGraphicStyle == style
? Color.accentColor.opacity(0.18)
: Color.secondary.opacity(0.08),
in: .rect(cornerRadius: 12))
.foregroundStyle(currentDevice.vehicleGraphicStyle == style
? Color.accentColor : Color.primary)
Text(style.title)
.font(.caption2)
.foregroundStyle(currentDevice.vehicleGraphicStyle == style
? Color.accentColor : Color.secondary)
}
}
.buttonStyle(.plain)
.accessibilityLabel(style.title)
}
}
.padding(.vertical, 4)
} header: {
Text("Fahrzeuggrafik")
} footer: {
Text("Wird in der Fahrzeug-Darstellung des Neigungsmessers verwendet. "
+ "Weitere Grafiken folgen.")
}
} }
.navigationTitle("Neigungsmesser einrichten") .navigationTitle("Neigungsmesser einrichten")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
+13 -65
View File
@@ -105,59 +105,30 @@ 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 {
HStack(alignment: .center, spacing: 20) {
display
.frame(maxWidth: .infinity)
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) { VStack(spacing: 16) {
picker Picker("Darstellung", selection: $displayStyle) {
ForEach(LevelDisplayStyle.allCases) { style in
Text(style.title).tag(style)
}
}
.pickerStyle(.segmented)
display switch displayStyle {
case .bubble:
LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxHeight: 220)
case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll)
}
if let instruction = state.instruction { if let instruction = state.instruction {
Label(instruction, Label(instruction,
@@ -175,7 +146,6 @@ struct LevelControls: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 8) .padding(.vertical, 8)
} }
}
Section { Section {
Button { Button {
@@ -249,28 +219,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> {
+1 -1
View File
@@ -145,7 +145,7 @@ private struct ProfileEditView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var name = "" @State private var name = ""
@State private var symbol = "suv.side" @State private var symbol = "box.truck"
@State private var trackWidth = "" @State private var trackWidth = ""
@State private var wheelbase = "" @State private var wheelbase = ""
+18 -26
View File
@@ -15,29 +15,22 @@ import SwiftUI
struct VehicleTiltView: View { struct VehicleTiltView: View {
let pitch: Double? let pitch: Double?
let roll: Double? let roll: Double?
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 {
HStack(alignment: .top, spacing: 16) {
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)
}
} else {
VStack(spacing: 20) { VStack(spacing: 20) {
tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs", tiltPanel(image: "VehicleSide",
lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect) angle: pitch,
tiltPanel(image: style.rearImageName, angle: roll, title: "Quer", title: "Längs",
lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect) lowerLabel: "Front",
upperLabel: "Heck",
aspect: VehicleTilt.sideAspect)
tiltPanel(image: "VehicleRear",
angle: roll,
title: "Quer",
lowerLabel: "links",
upperLabel: "rechts",
aspect: VehicleTilt.rearAspect)
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten. // Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
Text(String(format: "Neigung %.0f-fach überhöht dargestellt " Text(String(format: "Neigung %.0f-fach überhöht dargestellt "
@@ -49,7 +42,6 @@ struct VehicleTiltView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
}
private func tiltPanel(image: String, private func tiltPanel(image: String,
angle: Double?, angle: Double?,
@@ -57,13 +49,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 +75,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,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VansterRear.png", "filename" : "VehicleRear.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VansterSide.png", "filename" : "VehicleSide.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

@@ -14,9 +14,9 @@ struct WatchVehicleView: View {
/// Die angeschriebenen Gradzahlen bleiben echt. /// Die angeschriebenen Gradzahlen bleiben echt.
var body: some View { var body: some View {
VStack(spacing: 8) { VStack(spacing: 8) {
panel(image: "VansterSide", angle: pitch, title: "Längs", panel(image: "VehicleSide", angle: pitch, title: "Längs",
lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect) lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect)
panel(image: "VansterRear", angle: roll, title: "Quer", panel(image: "VehicleRear", angle: roll, title: "Quer",
lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect) lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect)
} }
} }
+160
View File
@@ -0,0 +1,160 @@
import CoreBluetooth
import Foundation
/// Hält die Verbindung zum Solarladeregler.
///
/// Einfacher noch als `LevelSession`: die Firmware bietet für keine
/// Charakteristik `notify` an (siehe `esp32_ble_solar.yaml`), es wird also
/// immer im Takt abgefragt statt abonniert.
final class SolarSession: NSObject {
static let serviceUUID = CBUUID(string: VanAlignSolarProtocol.serviceUUID)
private static let batteryVoltageUUID = CBUUID(string: VanAlignSolarProtocol.batteryVoltageUUID)
private static let pvVoltageUUID = CBUUID(string: VanAlignSolarProtocol.pvVoltageUUID)
private static let pvCurrentUUID = CBUUID(string: VanAlignSolarProtocol.pvCurrentUUID)
private static let pvPowerUUID = CBUUID(string: VanAlignSolarProtocol.pvPowerUUID)
private static let controllerTempUUID = CBUUID(string: VanAlignSolarProtocol.controllerTempUUID)
private static let statusFlagsUUID = CBUUID(string: VanAlignSolarProtocol.statusFlagsUUID)
let deviceID: UUID
private let queue: DispatchQueue
private let peripheral: CBPeripheral
private let onUpdate: (DeviceSnapshot) -> Void
private let onStateChange: (DeviceLinkState) -> Void
private let onSolarState: (SolarState) -> Void
private var characteristics: [CBUUID: CBCharacteristic] = [:]
private var state = SolarState()
private var pollTimer: DispatchSourceTimer?
/// Reicht für einen Solarregler, dessen Werte sich über Sekunden ändern
/// deutlich seltener als beim Ausrichten mit dem Neigungsmesser.
var pollInterval: TimeInterval = 5
init(deviceID: UUID,
peripheral: CBPeripheral,
queue: DispatchQueue,
onUpdate: @escaping (DeviceSnapshot) -> Void,
onStateChange: @escaping (DeviceLinkState) -> Void,
onSolarState: @escaping (SolarState) -> Void) {
self.deviceID = deviceID
self.queue = queue
self.peripheral = peripheral
self.onUpdate = onUpdate
self.onStateChange = onStateChange
self.onSolarState = onSolarState
super.init()
peripheral.delegate = self
}
// MARK: - Lebenszyklus
func start() {
onStateChange(.connecting)
peripheral.discoverServices([Self.serviceUUID])
}
func stop() {
pollTimer?.cancel()
pollTimer = nil
characteristics.removeAll()
}
func handleDisconnect() {
pollTimer?.cancel()
pollTimer = nil
characteristics.removeAll()
}
// MARK: - Abfrage
private func startPolling() {
guard pollTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now(), repeating: pollInterval)
timer.setEventHandler { [weak self] in self?.readAll() }
timer.resume()
pollTimer = timer
}
private func readAll() {
guard peripheral.state == .connected else { return }
for characteristic in characteristics.values where characteristic.properties.contains(.read) {
peripheral.readValue(for: characteristic)
}
}
private func publish() {
guard state.hasReading else { return }
onStateChange(.live)
onSolarState(state)
onUpdate(state.snapshot(deviceID: deviceID, rssi: nil))
}
}
// MARK: - CBPeripheralDelegate
extension SolarSession: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
onStateChange(.failed(error.localizedDescription))
return
}
guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
onStateChange(.failed("Solarladeregler-Dienst nicht gefunden"))
return
}
peripheral.discoverCharacteristics(
[Self.batteryVoltageUUID, Self.pvVoltageUUID, Self.pvCurrentUUID,
Self.pvPowerUUID, Self.controllerTempUUID, Self.statusFlagsUUID],
for: service
)
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
guard error == nil, let found = service.characteristics else {
onStateChange(.failed(error?.localizedDescription ?? "Keine Merkmale gefunden"))
return
}
for characteristic in found {
characteristics[characteristic.uuid] = characteristic
}
guard !characteristics.isEmpty else {
onStateChange(.failed("Solarwerte nicht gefunden"))
return
}
startPolling()
readAll()
}
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard error == nil, let value = characteristic.value else { return }
switch characteristic.uuid {
case Self.batteryVoltageUUID:
state.batteryVoltage = VanAlignSolarProtocol.float(from: value)
case Self.pvVoltageUUID:
state.pvVoltage = VanAlignSolarProtocol.float(from: value)
case Self.pvCurrentUUID:
state.pvCurrent = VanAlignSolarProtocol.float(from: value)
case Self.pvPowerUUID:
state.pvPower = VanAlignSolarProtocol.float(from: value)
case Self.controllerTempUUID:
state.controllerTemperature = VanAlignSolarProtocol.float(from: value)
case Self.statusFlagsUUID:
guard let flags = VanAlignSolarProtocol.statusFlags(from: value) else { return }
state.isBatteryCharging = flags.isBatteryCharging
state.isBatteryDischarging = flags.isBatteryDischarging
state.isControllerActive = flags.isControllerActive
state.isCurrentLimited = flags.isCurrentLimited
default:
return
}
publish()
}
}
@@ -0,0 +1,55 @@
import Foundation
/// Solarladeregler VanAlign Solar" zweiter ESP32 im Fahrzeug, liest einen
/// Votronic-Solarladeregler aus und stellt die Werte über einen eigenen
/// BLE-Dienst bereit. Siehe `firmware/vanalign/esp32_ble_solar.yaml`.
///
/// Wie beim Neigungsmesser: kein Rahmenprotokoll, jede Messgrösse liegt in
/// einer eigenen Charakteristik, alle sind reine Lesewerte, der Client fragt
/// sie im Takt ab.
enum VanAlignSolarProtocol {
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
static let serviceUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A001"
static let batteryVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A101"
static let pvVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A102"
static let pvCurrentUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A103"
static let pvPowerUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A104"
static let controllerTempUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A105"
/// Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
/// Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
static let statusFlagsUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A106"
/// Liest einen Messwert aus vier Bytes, little-endian wie beim
/// Neigungsmesser legt die Firmware den Float per `memcpy` ab.
static func float(from data: Data) -> Double? {
guard data.count >= 4 else { return nil }
var raw: UInt32 = 0
for (index, byte) in data.prefix(4).enumerated() {
raw |= UInt32(byte) << UInt32(8 * index)
}
let value = Float(bitPattern: raw)
guard value.isFinite else { return nil }
return Double(value)
}
struct StatusFlags {
var isBatteryCharging = false
var isBatteryDischarging = false
var isControllerActive = false
var isCurrentLimited = false
var isAESActive = false
}
static func statusFlags(from data: Data) -> StatusFlags? {
guard let byte = data.first else { return nil }
var flags = StatusFlags()
flags.isBatteryCharging = byte & (1 << 0) != 0
flags.isBatteryDischarging = byte & (1 << 1) != 0
flags.isControllerActive = byte & (1 << 2) != 0
flags.isCurrentLimited = byte & (1 << 3) != 0
flags.isAESActive = byte & (1 << 4) != 0
return flags
}
}
+3 -7
View File
@@ -4,33 +4,33 @@ import Foundation
/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird. /// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable { enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
//case chargeBooster //case chargeBooster
//case solarCharger
//case batteryMonitor //case batteryMonitor
//case bms //case bms
//case fridge //case fridge
case leveling case leveling
case solar
var id: String { rawValue } var id: String { rawValue }
var title: String { var title: String {
switch self { switch self {
//case .chargeBooster: return "Ladebooster" //case .chargeBooster: return "Ladebooster"
//case .solarCharger: return "Solarladeregler"
//case .batteryMonitor: return "Batteriemonitor" //case .batteryMonitor: return "Batteriemonitor"
//case .bms: return "Batterie / BMS" //case .bms: return "Batterie / BMS"
//case .fridge: return "Kühlbox" //case .fridge: return "Kühlbox"
case .leveling: return "Nivellierung" case .leveling: return "Nivellierung"
case .solar: return "Solarladeregler"
} }
} }
var symbol: String { var symbol: String {
switch self { switch self {
//case .chargeBooster: return "bolt.car" //case .chargeBooster: return "bolt.car"
//case .solarCharger: return "sun.max"
//case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent" //case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
//case .bms: return "battery.100percent.bolt" //case .bms: return "battery.100percent.bolt"
//case .fridge: return "refrigerator" //case .fridge: return "refrigerator"
case .leveling: return "level" case .leveling: return "level"
case .solar: return "sun.max"
} }
} }
@@ -105,8 +105,6 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
//var fridgeZoneMode: FridgeZoneMode = .automatic //var fridgeZoneMode: FridgeZoneMode = .automatic
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt. /// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
var sensorOrientation = SensorOrientation.identity var sensorOrientation = SensorOrientation.identity
/// Nur für den Neigungsmesser: welche Fahrzeuggrafik die Fahrzeug-Ansicht zeigt.
var vehicleGraphicStyle = VehicleGraphicStyle.vanster
init(id: UUID = UUID(), init(id: UUID = UUID(),
name: String, name: String,
@@ -137,7 +135,5 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
// ?? .automatic // ?? .automatic
sensorOrientation = try container.decodeIfPresent(SensorOrientation.self, sensorOrientation = try container.decodeIfPresent(SensorOrientation.self,
forKey: .sensorOrientation) ?? .identity forKey: .sensorOrientation) ?? .identity
vehicleGraphicStyle = try container.decodeIfPresent(VehicleGraphicStyle.self,
forKey: .vehicleGraphicStyle) ?? .vanster
} }
} }
+3 -2
View File
@@ -15,7 +15,7 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
init(id: UUID = UUID(), init(id: UUID = UUID(),
name: String, name: String,
symbol: String = "suv.side", symbol: String = "box.truck",
trackWidth: Double? = nil, trackWidth: Double? = nil,
wheelbase: Double? = nil) { wheelbase: Double? = nil) {
self.id = id self.id = id
@@ -47,6 +47,7 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft ein nicht /// Alle Namen gegen NSImage(systemSymbolName:) geprüft ein nicht
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen. /// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
static let symbols = [ static let symbols = [
"car", "car.side", "suv.side", "truck.pickup.side", "box.truck", "bus", "box.truck", "truck.pickup.side", "bus", "bus.doubledecker",
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
] ]
} }
+48
View File
@@ -0,0 +1,48 @@
import Foundation
/// Zustand des Solarladereglers.
struct SolarState: Equatable, Codable, Sendable {
var batteryVoltage: Double?
var pvVoltage: Double?
var pvCurrent: Double?
var pvPower: Double?
var controllerTemperature: Double?
var isBatteryCharging: Bool?
var isBatteryDischarging: Bool?
var isControllerActive: Bool?
var isCurrentLimited: Bool?
var hasReading: Bool {
batteryVoltage != nil || pvVoltage != nil || pvCurrent != nil || pvPower != nil
}
/// Kurzer Klartext, wie bei den übrigen Geräten als "Zustand" angezeigt.
var stateText: String? {
guard isControllerActive != nil else { return nil }
if isBatteryCharging == true { return "Lädt" }
if isControllerActive == true { return "Aktiv" }
return "Inaktiv"
}
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
snapshot.metrics = [
Metric("pv_power", "Solarleistung", pvPower, unit: "W", precision: 0, primary: true),
Metric("pv_voltage", "PV-Spannung", pvVoltage, unit: "V", precision: 1),
Metric("pv_current", "PV-Strom", pvCurrent, unit: "A", precision: 1),
Metric("battery_voltage", "Batteriespannung", batteryVoltage, unit: "V", precision: 2),
]
if let controllerTemperature {
snapshot.metrics.append(
Metric("controller_temperature", "Reglertemperatur", controllerTemperature,
unit: "°C", precision: 0)
)
}
snapshot.state = stateText
if isCurrentLimited == true {
snapshot.offReasons = ["PV-Strombegrenzung aktiv"]
}
return snapshot
}
}
-52
View File
@@ -1,52 +0,0 @@
import Foundation
/// Welche Silhouette die Fahrzeug-Ansicht des Neigungsmessers zeigt.
///
/// Jeder Fall ist ein zusammengehöriges Bilderset aus Seiten- und
/// Heckansicht in Assets.xcassets, beide als Vorlage ("template")
/// exportiert, damit `foregroundStyle` sie einfärben kann. Im Picker wird
/// nur die Seitenansicht gezeigt.
enum VehicleGraphicStyle: String, CaseIterable, Identifiable, Codable, Sendable {
case vanster
case california
var id: String { rawValue }
var title: String {
switch self {
case .vanster: return "Vanster"
case .california: return "California"
}
}
var sideImageName: String {
switch self {
case .vanster: return "VansterSide"
case .california: return "CaliforniaSide"
}
}
/// Für California liegt noch keine eigene Heckansicht vor, deshalb
/// vorerst die von Vanster.
var rearImageName: String {
switch self {
case .vanster, .california: return "VansterRear"
}
}
/// Seitenverhältnis der jeweiligen Bilder ohne das würde eine Grafik
/// mit anderen Proportionen in der gemeinsamen Bildhülle verzerrt oder
/// ungewollt verkleinert erscheinen.
var sideAspect: Double {
switch self {
case .vanster: return VehicleTilt.sideAspect
case .california: return 1
}
}
var rearAspect: Double {
switch self {
case .vanster, .california: return VehicleTilt.rearAspect
}
}
}
+1 -7
View File
@@ -32,11 +32,5 @@ gefüllte Form, in der die Details erhalten bleiben.
```bash ```bash
swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift
sips --resampleHeight 600 heckansicht.png --out /tmp/rear.png sips --resampleHeight 600 heckansicht.png --out /tmp/rear.png
/tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VansterRear.imageset/VansterRear.png /tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png
``` ```
Jedes Fahrzeug-Grafikset (siehe `VehicleGraphicStyle`) hat sein eigenes
Bildpaar `<Name>Side`/`<Name>Rear`. Hat die Vorlage schon einen
transparenten Hintergrund, geht die Quelldatei direkt ins Skript. Liegt sie
auf weißem Grund, muss der Rand vorher weg sonst wird die ganze Fläche
mitgefärbt.
@@ -22,12 +22,12 @@
<key>VanAligneComplication.xcscheme_^#shared#^_</key> <key>VanAligneComplication.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>2</integer> <integer>3</integer>
</dict> </dict>
<key>VanAligneiOSWidgetExtension.xcscheme_^#shared#^_</key> <key>VanAligneiOSWidgetExtension.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>3</integer> <integer>2</integer>
</dict> </dict>
</dict> </dict>
</dict> </dict>
@@ -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()
@@ -90,7 +81,7 @@ private struct LevelBubbleGlyph: View {
struct VanAligneiOSWidgetLiveActivity: Widget { struct VanAligneiOSWidgetLiveActivity: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
ActivityConfiguration(for: LevelActivityAttributes.self) { context in ActivityConfiguration(for: LevelActivityAttributes.self) { context in
LevelActivityContent(attributes: context.attributes, state: context.state) LockScreenLevelView(attributes: context.attributes, state: context.state)
.activityBackgroundTint(Color(white: 0.08)) .activityBackgroundTint(Color(white: 0.08))
.activitySystemActionForegroundColor(.white) .activitySystemActionForegroundColor(.white)
@@ -118,114 +109,30 @@ struct VanAligneiOSWidgetLiveActivity: Widget {
} }
.keylineTint(context.state.isLevel ? .green : .orange) .keylineTint(context.state.isLevel ? .green : .orange)
} }
.supplementalActivityFamilies([.small])
} }
} }
/// Wählt je nach Darstellungsgrösse zwischen Sperrbildschirm- und /// Sperrbildschirm- und Banner-Ansicht. Dieselbe Ansicht landet automatisch
/// Kompaktansicht. /// auch im CarPlay-Dashboard, sobald das iPhone verbunden ist ein eigenes
/// /// CarPlay-App-Target ist dafür nicht nötig.
/// Ohne `.small`-Familie fällt CarPlay (wie die Smart Stack der Uhr) auf
/// `compactLeading`/`compactTrailing` der Dynamic Island zurück dort steht
/// nur das generische Symbol und, weil `compactTrailing` leer bleibt, gar
/// kein Text. Erst die `.small`-Familie liefert CarPlay Libelle und Text.
private struct LevelActivityContent: View {
let attributes: LevelActivityAttributes
let state: LevelActivityAttributes.ContentState
@Environment(\.activityFamily) private var activityFamily
var body: some View {
switch activityFamily {
case .small:
CompactLevelView(attributes: attributes, state: state)
default:
LockScreenLevelView(attributes: attributes, state: state)
}
}
}
/// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus
/// ein Blick-Symbol statt eines ganzen Satzes zum Lesen im Vorbeifahren.
///
/// 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 {
let attributes: LevelActivityAttributes
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 {
HStack(spacing: 14) {
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40)
Group {
if !hasReading {
Text("")
} else if state.isLevel {
Image(systemName: "checkmark.circle.fill")
} else {
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)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
/// Sperrbildschirm- und Banner-Ansicht.
private struct LockScreenLevelView: View { 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))"
} }
+6
View File
@@ -22,9 +22,15 @@ esphome:
esp32: esp32:
board: esp32-s3-devkitc-1 board: esp32-s3-devkitc-1
flash_size: 16MB
framework: framework:
type: esp-idf type: esp-idf
# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
psram:
mode: octal
speed: 80MHz
logger: logger:
level: WARN level: WARN
+332
View File
@@ -0,0 +1,332 @@
# VanAlign Pro - Neigungsmessung über BLE (SIMULATION)
#
# Kopie von esp32_ble.yaml für den Fall, dass gerade kein MPU6050 zum
# Anschliessen vorhanden ist. Der `platform: mpu6050`-Sensor sowie der
# i2c-Bus wurden entfernt und durch Template-Sensoren ersetzt, die
# plausible, sich langsam ändernde Beschleunigungswerte erzeugen (ein
# gedachter Sensor, der gemütlich hin- und herschaukelt). Pitch/Roll,
# Kalibrierung und die BLE-Charakteristiken funktionieren dadurch exakt wie
# im Original - nur eben ohne angeschlossene Hardware.
#
# Name und Friendly Name sind bewusst auf "-sim" abgeändert, damit dieses
# Gerät im Netzwerk/BLE nicht mit einem echten VanAlign-Gerät kollidiert.
#
# Sobald wieder ein echter MPU6050 verfügbar ist, einfach esp32_ble.yaml
# weiterverwenden - diese Datei ist nur zum Testen der App/BLE-Anbindung.
esphome:
name: vanalign-sim
friendly_name: "VanAlign Pro (Sim)"
esp32:
board: esp32-s3-devkitc-1
flash_size: 16MB
framework:
type: esp-idf
# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
psram:
mode: octal
speed: 80MHz
logger:
level: WARN
espnow:
channel: 1
sensor:
# Simulierte Rohwerte anstelle des physischen MPU6050. Die Sensor-Lage
# (Pitch/Roll) wandert langsam und stetig, wie es ein tatsächlich leicht
# schaukelndes Fahrzeug/Werkstück tun würde (Perioden ~75s/~113s).
- platform: template
name: "MPU6050 Accel X (Sim)"
id: accel_x
internal: true
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
float pitch_rad = (15.0f * sin(t / 12.0f)) * 3.14159265f / 180.0f;
float roll_rad = (10.0f * sin(t / 18.0f + 1.0f)) * 3.14159265f / 180.0f;
return -9.80665f * sin(roll_rad) * cos(pitch_rad);
- platform: template
name: "MPU6050 Accel Y (Sim)"
id: accel_y
internal: true
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
float pitch_rad = (15.0f * sin(t / 12.0f)) * 3.14159265f / 180.0f;
return 9.80665f * sin(pitch_rad);
- platform: template
name: "MPU6050 Accel Z (Sim)"
id: accel_z
internal: true
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
float pitch_rad = (15.0f * sin(t / 12.0f)) * 3.14159265f / 180.0f;
float roll_rad = (10.0f * sin(t / 18.0f + 1.0f)) * 3.14159265f / 180.0f;
return 9.80665f * cos(roll_rad) * cos(pitch_rad);
# Gyro-Werte werden nur zur Anzeige simuliert (kleine Winkelgeschwindigkeit
# passend zur Schaukelbewegung oben, kein realer Bezug nötig).
- platform: template
name: "MPU6050 Gyro X-Achse"
id: mpu_gyro_x
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
return (10.0f / 18.0f) * cos(t / 18.0f + 1.0f) * 3.14159265f / 180.0f;
- platform: template
name: "MPU6050 Gyro Y-Achse"
id: mpu_gyro_y
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
return (15.0f / 12.0f) * cos(t / 12.0f) * 3.14159265f / 180.0f;
- platform: template
name: "MPU6050 Gyro Z-Achse"
id: mpu_gyro_z
update_interval: 0.1s
lambda: |-
return 0.0f;
- platform: template
name: "Neigung Pitch"
id: pitch
icon: mdi:caravan
unit_of_measurement: "°"
accuracy_decimals: 1
update_interval: 0.1s
lambda: |-
if (isnan(id(accel_x).state) || isnan(id(accel_y).state) || isnan(id(accel_z).state)) {
return NAN;
}
float raw = atan2(id(accel_y).state, sqrt(pow(id(accel_x).state, 2) + pow(id(accel_z).state, 2))) * (180.0 / 3.14159265);
return raw - id(pitch_offset); // Offset wird hier subtrahiert
filters:
- sliding_window_moving_average:
window_size: 8
send_every: 1
- exponential_moving_average:
alpha: 0.2
- platform: template
name: "Neigung Roll"
id: roll
icon: mdi:axis-x-rotate-clockwise
unit_of_measurement: "°"
accuracy_decimals: 1
update_interval: 0.1s
lambda: |-
if (isnan(id(accel_x).state) || isnan(id(accel_z).state)) {
return NAN;
}
float raw = atan2(-id(accel_x).state, id(accel_z).state) * (180.0 / 3.14159265);
return raw - id(roll_offset); // Offset wird hier subtrahiert
filters:
- sliding_window_moving_average:
window_size: 8
send_every: 1
- exponential_moving_average:
alpha: 0.2
globals:
- id: pitch_offset
type: float
restore_value: yes
initial_value: '0.0'
- id: roll_offset
type: float
restore_value: yes
initial_value: '0.0'
# Die Einbaulage liegt im Gerät, nicht in den Apps: Sie beschreibt, wie der
# Sensor im Fahrzeug sitzt eine Eigenschaft des Einbaus, nicht des Telefons.
# Damit sehen iPhone, Uhr und Android dasselbe, ohne sie je einzeln zu
# bestimmen. Angewandt wird sie weiterhin in den Apps; das Gerät verwahrt sie
# nur, sonst rechneten ältere Clients die Korrektur ein zweites Mal.
- id: orientation_version
type: uint8_t
restore_value: yes
initial_value: '0'
- id: orientation_source
type: uint8_t
restore_value: yes
initial_value: '0'
- id: orientation_invert_long
type: bool
restore_value: yes
initial_value: 'false'
- id: orientation_invert_lat
type: bool
restore_value: yes
initial_value: 'false'
- id: orientation_twist
type: float
restore_value: yes
initial_value: '0.0'
- id: enable_captive
type: bool
restore_value: yes
initial_value: 'false'
esp32_ble_server:
services:
- uuid: 2a24b789-7aab-4535-af3e-ee76a35cc42d
advertise: true
characteristics:
- id: pitch_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233424
description: "Pitch"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pitch).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: roll_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233425
description: "Roll"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(roll).state;
memcpy(v.data(), &val, sizeof(float));
return v;
# Die gespeicherten Nullpunkte, zwei Floats. Daran erkennen die Apps,
# ob überhaupt schon kalibriert wurde.
- id: offsets_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233426
description: "Kalibrier-Offsets"
read: true
value: !lambda |-
std::vector<unsigned char> v(2 * sizeof(float));
float p = id(pitch_offset);
float r = id(roll_offset);
memcpy(v.data(), &p, sizeof(float));
memcpy(v.data() + sizeof(float), &r, sizeof(float));
return v;
# Die Einbaulage, acht Byte:
#
# 0 Version, 1 = gültig gesetzt, 0 = nie geschrieben
# 1 Längsachse: 0 = Pitch des Sensors, 1 = Roll des Sensors
# 2 längs umgekehrt (0/1)
# 3 quer umgekehrt (0/1)
# 4..7 Verdrehung um die Hochachse, float32, Grad
- id: orientation_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233428
description: "Einbaulage"
read: true
write: true
value: !lambda |-
std::vector<unsigned char> v(8, 0);
v[0] = id(orientation_version);
v[1] = id(orientation_source);
v[2] = id(orientation_invert_long) ? 1 : 0;
v[3] = id(orientation_invert_lat) ? 1 : 0;
float t = id(orientation_twist);
memcpy(v.data() + 4, &t, sizeof(float));
return v;
on_write:
then:
- lambda: |-
if (x.size() < 8 || x[0] != 1) {
ESP_LOGW("vanalign", "Einbaulage verworfen: %d Byte, Version %d",
(int) x.size(), x.empty() ? -1 : (int) x[0]);
return;
}
float t;
memcpy(&t, x.data() + 4, sizeof(float));
if (!std::isfinite(t) || fabsf(t) > 180.0f) {
ESP_LOGW("vanalign", "Einbaulage verworfen: Verdrehung %.1f", t);
return;
}
id(orientation_version) = 1;
id(orientation_source) = x[1];
id(orientation_invert_long) = x[2] != 0;
id(orientation_invert_lat) = x[3] != 0;
id(orientation_twist) = t;
ESP_LOGI("vanalign", "Einbaulage gespeichert: Quelle=%d laengs=%d quer=%d verdreht=%.1f",
(int) x[1], (int) x[2], (int) x[3], t);
- id: calib_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233427
description: "Kalibriere Neigung"
write: true
on_write:
then:
- lambda: |-
bool reset = !x.empty() && (x[0] == 0x00 || x[0] == '0');
if (reset) {
id(reset_calibration).execute();
} else {
id(calibrate_level).execute();
}
#web_server:
# port: 80
#ota:
# platform: web_server
#wifi:
# ap:
# ssid: "VanAlign-Setup"
# password: "kalibrierung"
script:
# Die aktuelle Lage wird zur neuen Null. Knopf und Bluetooth laufen hier
# zusammen, damit sie nicht auseinanderdriften.
- id: calibrate_level
then:
- lambda: |-
if (isnan(id(accel_x).state) || isnan(id(accel_y).state) || isnan(id(accel_z).state)) {
ESP_LOGW("vanalign", "Kalibrierung abgebrochen: keine Sensorwerte");
return;
}
id(pitch_offset) = atan2(id(accel_y).state, sqrt(pow(id(accel_x).state, 2) + pow(id(accel_z).state, 2))) * (180.0 / 3.14159265);
id(roll_offset) = atan2(-id(accel_x).state, id(accel_z).state) * (180.0 / 3.14159265);
ESP_LOGI("vanalign", "Kalibriert: pitch_offset=%.2f roll_offset=%.2f", id(pitch_offset), id(roll_offset));
- id: reset_calibration
then:
- lambda: |-
id(pitch_offset) = 0.0f;
id(roll_offset) = 0.0f;
ESP_LOGI("vanalign", "Kalibrierung zurückgesetzt");
button:
- platform: template
name: "Kalibriere Neigung"
id: calib_button
on_press:
- script.execute: calibrate_level
- platform: template
name: "Kalibrierung zurücksetzen"
id: calib_reset_button
on_press:
- script.execute: reset_calibration
- platform: restart
name: "ESP Restart"
text_sensor:
- platform: template
name: "Firmware Version"
id: firmware_version
icon: mdi:tag
lambda: |-
return {"v1.0.2-sim"};
+233
View File
@@ -0,0 +1,233 @@
# VanAlign Solar - Votronic Solarladeregler über BLE
#
# Zweiter ESP32 im Fahrzeug, unabhängig vom Neigungssensor (esp32_ble.yaml).
# Liest den Votronic-Solarladeregler über den Displaylink-Port (UART) mit der
# externen Komponente github://syssi/esphome-votronic aus und stellt die
# Werte - genau wie beim Neigungssensor - über einen eigenen BLE-Service
# bereit. Zusätzlich (für Debug-Zwecke) läuft WLAN mit und die Werte werden
# auch auf dem eingebauten Webserver (Port 80) angezeigt - MQTT und die API
# sind weiterhin nicht enthalten. WLAN-Zugangsdaten liegen in secrets.yaml
# (lokal anzulegen, ist per .gitignore ausgeschlossen).
#
# Verkabelung: UART TX=GPIO4, RX=GPIO5 an den Displaylink-Port des Reglers,
# Baudrate 1000 (kein Tippfehler - das Votronic-Protokoll nutzt diese
# ungewöhnlich niedrige Rate). Board/Pins ggf. an die tatsächlich verbaute
# Hardware anpassen, hier als ESP32-S3-DevKitC-1 wie beim Neigungssensor
# angenommen.
#
# BLE-Service 05c9a349-2b8e-4b1d-9c9d-c247e9a6a001 (wird beworben):
#
# Charakteristik UUID (Ende) Inhalt
# Batteriespg. ...a101 Float32 LE, Volt
# PV-Spannung ...a102 Float32 LE, Volt
# PV-Strom ...a103 Float32 LE, Ampere
# PV-Leistung ...a104 Float32 LE, Watt
# Reglertemp. ...a105 Float32 LE, °C
# Statusflags ...a106 1 Byte, Bitmaske: Bit0 Batterie lädt,
# Bit1 Batterie entlädt, Bit2 PV-Regler
# aktiv, Bit3 PV-Strombegrenzung, Bit4 AES
# PV-Modus-ID ...a107 1 Byte, roher Wert aus pv_mode_setting_id
# Batteriestatus ...a108 1 Byte, rohe Bitmaske aus dem Regler
# Reglerstatus ...a109 1 Byte, rohe Bitmaske aus dem Regler
#
# Alle Charakteristiken sind reine Lesewerte, wie beim Neigungssensor fragt
# der Client sie im Takt ab.
esphome:
name: vanalign-solar
friendly_name: "VanAlign Solar"
esp32:
board: esp32-s3-devkitc-1
flash_size: 16MB
framework:
type: esp-idf
# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
psram:
mode: octal
speed: 80MHz
logger:
level: WARN
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
web_server:
port: 80
external_components:
- source: github://syssi/esphome-votronic@main
refresh: 0s
uart:
- id: uart_0
baud_rate: 1000
tx_pin: GPIO4
rx_pin: GPIO5
votronic:
- id: votronic0
uart_id: uart_0
rx_timeout: 150ms
throttle: 2s
sensor:
- platform: votronic
votronic_id: votronic0
battery_computer_battery_voltage:
name: "Batteriespannung"
id: battery_voltage
pv_voltage:
name: "PV Spannung"
id: pv_voltage
pv_current:
name: "PV Strom"
id: pv_current
pv_power:
name: "PV Leistung"
id: pv_power
pv_controller_temperature:
name: "Reglertemperatur"
id: pv_temperature
pv_mode_setting_id:
name: "PV Modus-ID"
id: pv_mode_id
pv_battery_status_bitmask:
name: "PV Batteriestatus (Bitmaske)"
id: pv_battery_status_bitmask
pv_controller_status_bitmask:
name: "PV Reglerstatus (Bitmaske)"
id: pv_controller_status_bitmask
binary_sensor:
- platform: votronic
votronic_id: votronic0
battery_computer_charging:
name: "Batterie lädt"
id: battery_charging
battery_computer_discharging:
name: "Batterie entlädt"
id: battery_discharging
pv_controller_active:
name: "PV Regler aktiv"
id: pv_active
pv_current_reduction:
name: "PV Strombegrenzung"
id: pv_current_reduction
pv_aes_active:
name: "PV AES aktiv"
id: pv_aes_active
text_sensor:
- platform: votronic
votronic_id: votronic0
pv_mode_setting:
name: "PV Modus"
pv_battery_status:
name: "PV Batteriestatus"
pv_controller_status:
name: "PV Reglerstatus"
- platform: template
name: "Firmware Version"
id: firmware_version
icon: mdi:tag
lambda: |-
return {"v1.0.0"};
esp32_ble_server:
services:
- uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a001
advertise: true
characteristics:
- id: battery_voltage_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a101
description: "Batteriespannung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(battery_voltage).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_voltage_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a102
description: "PV Spannung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_voltage).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_current_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a103
description: "PV Strom"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_current).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_power_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a104
description: "PV Leistung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_power).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_temperature_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a105
description: "Reglertemperatur"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_temperature).state;
memcpy(v.data(), &val, sizeof(float));
return v;
# Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
# Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
- id: status_flags_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a106
description: "Statusflags"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
uint8_t flags = 0;
if (id(battery_charging).state) flags |= (1 << 0);
if (id(battery_discharging).state) flags |= (1 << 1);
if (id(pv_active).state) flags |= (1 << 2);
if (id(pv_current_reduction).state) flags |= (1 << 3);
if (id(pv_aes_active).state) flags |= (1 << 4);
v[0] = flags;
return v;
- id: pv_mode_id_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a107
description: "PV Modus-ID"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_mode_id).state;
return v;
- id: pv_battery_status_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a108
description: "PV Batteriestatus (Bitmaske)"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_battery_status_bitmask).state;
return v;
- id: pv_controller_status_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a109
description: "PV Reglerstatus (Bitmaske)"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_controller_status_bitmask).state;
return v;
button:
- platform: restart
name: "ESP Restart"
+265
View File
@@ -0,0 +1,265 @@
# VanAlign Solar - Votronic Solarladeregler über BLE (SIMULATION)
#
# Kopie von esp32_ble_solar.yaml für den Fall, dass gerade kein Votronic-
# Regler zum Anschliessen vorhanden ist. Die `platform: votronic`-Sensoren
# sowie UART/external_components wurden entfernt und durch Template-Sensoren
# ersetzt, die einen plausiblen Tagesverlauf simulieren: PV-Spannung/-Strom
# folgen einem "Sonnenstand" (ein gedachter, ca. 6 Minuten langer Tag), die
# Batteriespannung schwankt gemütlich mit, und die Statusflags/Bitmasken
# leiten sich aus diesen Werten ab. Die BLE-Charakteristiken funktionieren
# dadurch exakt wie im Original - nur eben ohne angeschlossene Hardware.
#
# Name und Friendly Name sind bewusst auf "-sim" abgeändert, damit dieses
# Gerät im BLE-Umfeld nicht mit einem echten VanAlign-Solar-Gerät kollidiert.
#
# Für Debug-Zwecke läuft WLAN mit und die Werte werden zusätzlich auf dem
# eingebauten Webserver (Port 80) angezeigt; WLAN-Zugangsdaten liegen in
# secrets.yaml (lokal anzulegen, ist per .gitignore ausgeschlossen). Alle
# simulierten Werte aktualisieren sich höchstens jede Sekunde, damit sich
# Änderungen beim Debuggen zügig zeigen.
#
# Sobald wieder ein echter Votronic-Regler verfügbar ist, einfach
# esp32_ble_solar.yaml weiterverwenden - diese Datei ist nur zum Testen der
# App/BLE-Anbindung.
esphome:
name: vanalign-solar-sim
friendly_name: "VanAlign Solar (Sim)"
esp32:
board: esp32-s3-devkitc-1
flash_size: 16MB
framework:
type: esp-idf
# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
psram:
mode: octal
speed: 80MHz
logger:
level: WARN
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
web_server:
port: 80
sensor:
# Simulierter "Sonnenstand": 0 nachts, sanfter Buckel tagsüber, Periode
# ca. 6 Minuten - reicht zum Beobachten eines vollen Auf/Ab in der App.
- platform: template
name: "Sonnenstand (Sim)"
id: sun_level
internal: true
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return std::max(0.0f, sinf(t / 180.0f));
- platform: template
name: "Batteriespannung (Sim)"
id: battery_voltage
unit_of_measurement: "V"
accuracy_decimals: 2
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return 12.6f + 0.8f * id(sun_level).state + 0.05f * sin(t / 20.0f);
- platform: template
name: "PV Spannung (Sim)"
id: pv_voltage
unit_of_measurement: "V"
accuracy_decimals: 2
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return id(sun_level).state > 0.01f ? (19.0f + 1.0f * sin(t / 25.0f)) : 0.0f;
- platform: template
name: "PV Strom (Sim)"
id: pv_current
unit_of_measurement: "A"
accuracy_decimals: 2
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return id(sun_level).state * (6.0f + 1.5f * sin(t / 17.0f));
- platform: template
name: "PV Leistung (Sim)"
id: pv_power
unit_of_measurement: "W"
accuracy_decimals: 1
update_interval: 1s
lambda: |-
return id(pv_voltage).state * id(pv_current).state;
- platform: template
name: "Reglertemperatur (Sim)"
id: pv_temperature
unit_of_measurement: "°C"
accuracy_decimals: 1
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return 22.0f + 10.0f * id(sun_level).state + 1.0f * sin(t / 11.0f);
- platform: template
name: "PV Modus-ID (Sim)"
id: pv_mode_id
update_interval: 1s
lambda: |-
return 3.0f; // fester simulierter Modus (MPPT)
- platform: template
name: "PV Batteriestatus (Bitmaske, Sim)"
id: pv_battery_status_bitmask
update_interval: 1s
lambda: |-
return id(sun_level).state > 0.01f ? 1.0f : 0.0f;
- platform: template
name: "PV Reglerstatus (Bitmaske, Sim)"
id: pv_controller_status_bitmask
update_interval: 1s
lambda: |-
return id(sun_level).state > 0.01f ? 2.0f : 0.0f;
binary_sensor:
- platform: template
name: "Batterie lädt (Sim)"
id: battery_charging
lambda: |-
return id(pv_current).state > 0.2f;
- platform: template
name: "Batterie entlädt (Sim)"
id: battery_discharging
lambda: |-
return id(pv_current).state <= 0.2f;
- platform: template
name: "PV Regler aktiv (Sim)"
id: pv_active
lambda: |-
return id(sun_level).state > 0.01f;
- platform: template
name: "PV Strombegrenzung (Sim)"
id: pv_current_reduction
lambda: |-
float t = millis() / 1000.0f;
return id(pv_active).state && sin(t / 60.0f) > 0.9f;
- platform: template
name: "PV AES aktiv (Sim)"
id: pv_aes_active
lambda: |-
return id(battery_voltage).state > 13.3f;
text_sensor:
- platform: template
name: "Firmware Version"
id: firmware_version
icon: mdi:tag
lambda: |-
return {"v1.0.0-sim"};
esp32_ble_server:
services:
- uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a001
advertise: true
characteristics:
- id: battery_voltage_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a101
description: "Batteriespannung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(battery_voltage).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_voltage_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a102
description: "PV Spannung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_voltage).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_current_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a103
description: "PV Strom"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_current).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_power_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a104
description: "PV Leistung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_power).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_temperature_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a105
description: "Reglertemperatur"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_temperature).state;
memcpy(v.data(), &val, sizeof(float));
return v;
# Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
# Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
- id: status_flags_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a106
description: "Statusflags"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
uint8_t flags = 0;
if (id(battery_charging).state) flags |= (1 << 0);
if (id(battery_discharging).state) flags |= (1 << 1);
if (id(pv_active).state) flags |= (1 << 2);
if (id(pv_current_reduction).state) flags |= (1 << 3);
if (id(pv_aes_active).state) flags |= (1 << 4);
v[0] = flags;
return v;
- id: pv_mode_id_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a107
description: "PV Modus-ID"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_mode_id).state;
return v;
- id: pv_battery_status_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a108
description: "PV Batteriestatus (Bitmaske)"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_battery_status_bitmask).state;
return v;
- id: pv_controller_status_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a109
description: "PV Reglerstatus (Bitmaske)"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_controller_status_bitmask).state;
return v;
button:
- platform: restart
name: "ESP Restart"