Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77b8cd3f18 | ||
|
|
6d7b32d622 | ||
|
|
9a3b5cb472 | ||
|
|
bfe2b00a5c |
@@ -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?
|
||||||
|
|
||||||
@@ -240,6 +251,10 @@ final class BluetoothManager: NSObject {
|
|||||||
// MARK: - Sonstiges
|
// MARK: - Sonstiges
|
||||||
|
|
||||||
private let store: DeviceStore
|
private let store: DeviceStore
|
||||||
|
/// Von aussen gesetzt, sobald die App bereit ist. Optional, damit
|
||||||
|
/// `BluetoothManager` nichts über Live Activities wissen muss, wenn keine
|
||||||
|
/// läuft.
|
||||||
|
var activityManager: LevelActivityManager?
|
||||||
private let isDemo = DemoData.isEnabled
|
private let isDemo = DemoData.isEnabled
|
||||||
/// Wieviele Messpunkte je Gerät im Verlauf behalten werden.
|
/// Wieviele Messpunkte je Gerät im Verlauf behalten werden.
|
||||||
private let historyLimit = 720
|
private let historyLimit = 720
|
||||||
@@ -265,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
|
||||||
@@ -322,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.
|
||||||
@@ -513,7 +534,11 @@ final class BluetoothManager: NSObject {
|
|||||||
// MARK: - Ablauf auf der Funk-Queue
|
// MARK: - Ablauf auf der Funk-Queue
|
||||||
|
|
||||||
private func applyConfiguration(_ devices: [ManagedDevice]) {
|
private func applyConfiguration(_ devices: [ManagedDevice]) {
|
||||||
managed = Dictionary(uniqueKeysWithValues: devices.map { ($0.peripheralID, $0) })
|
// Mit `uniqueKeysWithValues` stürzt das bei doppelt eingerichteten
|
||||||
|
// Geräten (gleiches Peripheral, aus Versehen zweimal hinzugefügt) ab.
|
||||||
|
// Der letzte Eintrag gewinnt – besser eine überschriebene Dublette als
|
||||||
|
// ein Absturz der ganzen Funk-Schicht.
|
||||||
|
managed = Dictionary(devices.map { ($0.peripheralID, $0) }, uniquingKeysWith: { _, latest in latest })
|
||||||
|
|
||||||
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören.
|
// Verbindungen zu Geräten lösen, die nicht mehr dazugehören.
|
||||||
let wanted = Set(devices.filter { shouldStayConnected($0) }.map(\.peripheralID))
|
let wanted = Set(devices.filter { shouldStayConnected($0) }.map(\.peripheralID))
|
||||||
@@ -527,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
|
||||||
@@ -572,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()
|
||||||
@@ -684,15 +716,20 @@ final class BluetoothManager: NSObject {
|
|||||||
private func record(_ snapshot: DeviceSnapshot) {
|
private func record(_ snapshot: DeviceSnapshot) {
|
||||||
publish {
|
publish {
|
||||||
self.snapshots[snapshot.deviceID] = snapshot
|
self.snapshots[snapshot.deviceID] = snapshot
|
||||||
guard let primary = snapshot.primaryMetric, let value = primary.value else { return }
|
var deviceHistory = self.history[snapshot.deviceID] ?? [:]
|
||||||
var samples = self.history[snapshot.deviceID] ?? []
|
for metric in snapshot.metrics {
|
||||||
// Höchstens alle fünf Sekunden einen Punkt aufnehmen.
|
guard let value = metric.value else { continue }
|
||||||
if let last = samples.last, snapshot.timestamp.timeIntervalSince(last.time) < 5 { return }
|
var samples = deviceHistory[metric.key] ?? []
|
||||||
samples.append(HistorySample(time: snapshot.timestamp, value: value))
|
// Höchstens alle fünf Sekunden einen Punkt aufnehmen.
|
||||||
if samples.count > self.historyLimit {
|
if let last = samples.last,
|
||||||
samples.removeFirst(samples.count - self.historyLimit)
|
snapshot.timestamp.timeIntervalSince(last.time) < 5 { continue }
|
||||||
|
samples.append(HistorySample(time: snapshot.timestamp, value: value))
|
||||||
|
if samples.count > self.historyLimit {
|
||||||
|
samples.removeFirst(samples.count - self.historyLimit)
|
||||||
|
}
|
||||||
|
deviceHistory[metric.key] = samples
|
||||||
}
|
}
|
||||||
self.history[snapshot.deviceID] = samples
|
self.history[snapshot.deviceID] = deviceHistory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,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,
|
||||||
@@ -892,7 +930,10 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
self?.publish { self?.linkStates[device.id] = state }
|
self?.publish { self?.linkStates[device.id] = state }
|
||||||
},
|
},
|
||||||
onLevelState: { [weak self] state in
|
onLevelState: { [weak self] state in
|
||||||
self?.publish { self?.levelStates[device.id] = state }
|
self?.publish {
|
||||||
|
self?.levelStates[device.id] = state
|
||||||
|
self?.activityManager?.update(deviceID: device.id, state: state)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDeviceOrientation: { [weak self] orientation in
|
onDeviceOrientation: { [weak self] orientation in
|
||||||
// Im Gerät steht, wie der Sensor eingebaut ist – für alle
|
// Im Gerät steht, wie der Sensor eingebaut ist – für alle
|
||||||
@@ -906,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,
|
||||||
@@ -953,6 +1015,8 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
//bmsSessions[peripheral.identifier] = nil
|
//bmsSessions[peripheral.identifier] = nil
|
||||||
levelSessions[peripheral.identifier]?.handleDisconnect()
|
levelSessions[peripheral.identifier]?.handleDisconnect()
|
||||||
levelSessions[peripheral.identifier] = nil
|
levelSessions[peripheral.identifier] = nil
|
||||||
|
solarSessions[peripheral.identifier]?.handleDisconnect()
|
||||||
|
solarSessions[peripheral.identifier] = nil
|
||||||
connectedPeripherals[peripheral.identifier] = nil
|
connectedPeripherals[peripheral.identifier] = nil
|
||||||
|
|
||||||
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
|
let lifetime = connectedSince.removeValue(forKey: peripheral.identifier)
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ struct CamperMonitorApp: App {
|
|||||||
@State private var store: DeviceStore
|
@State private var store: DeviceStore
|
||||||
@State private var bluetooth: BluetoothManager
|
@State private var bluetooth: BluetoothManager
|
||||||
@State private var watch: PhoneWatchLink
|
@State private var watch: PhoneWatchLink
|
||||||
|
@State private var levelActivity: LevelActivityManager
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let store = DeviceStore()
|
let store = DeviceStore()
|
||||||
let bluetooth = BluetoothManager(store: store)
|
let bluetooth = BluetoothManager(store: store)
|
||||||
|
let levelActivity = LevelActivityManager()
|
||||||
|
bluetooth.activityManager = levelActivity
|
||||||
_store = State(initialValue: store)
|
_store = State(initialValue: store)
|
||||||
_bluetooth = State(initialValue: bluetooth)
|
_bluetooth = State(initialValue: bluetooth)
|
||||||
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
|
_watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth))
|
||||||
|
_levelActivity = State(initialValue: levelActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
@@ -21,6 +25,7 @@ struct CamperMonitorApp: App {
|
|||||||
.environment(store)
|
.environment(store)
|
||||||
.environment(bluetooth)
|
.environment(bluetooth)
|
||||||
.environment(watch)
|
.environment(watch)
|
||||||
|
.environment(levelActivity)
|
||||||
.task { watch.activate() }
|
.task { watch.activate() }
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
@@ -38,7 +43,11 @@ struct CamperMonitorApp: App {
|
|||||||
case .active:
|
case .active:
|
||||||
bluetooth.start()
|
bluetooth.start()
|
||||||
case .background:
|
case .background:
|
||||||
if !watch.wantsLiveUpdates { bluetooth.stop() }
|
// Läuft gerade eine Live Activity, muss der Neigungsmesser
|
||||||
|
// auch mit dunklem Bildschirm weiter Werte liefern – sonst
|
||||||
|
// friert die Anzeige auf Sperrbildschirm und CarPlay beim
|
||||||
|
// ersten Wegdrücken der App ein.
|
||||||
|
if !watch.wantsLiveUpdates && !levelActivity.isActive { bluetooth.stop() }
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import ActivityKit
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Startet, aktualisiert und beendet die Live Activity des Neigungsmessers.
|
||||||
|
///
|
||||||
|
/// Es läuft höchstens eine Aktivität gleichzeitig – mehr als einen
|
||||||
|
/// Neigungsmesser gibt es im aktiven Profil ohnehin nicht. Seit iOS 17 zeigt
|
||||||
|
/// CarPlay eine laufende Live Activity automatisch im Dashboard an; ein
|
||||||
|
/// eigenes CarPlay-App-Target braucht es dafür nicht.
|
||||||
|
@Observable
|
||||||
|
final class LevelActivityManager {
|
||||||
|
private(set) var trackedDeviceID: UUID?
|
||||||
|
@ObservationIgnored private var activity: Activity<LevelActivityAttributes>?
|
||||||
|
|
||||||
|
/// Ob gerade irgendeine Aktivität läuft – die App muss dafür im
|
||||||
|
/// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die
|
||||||
|
/// Anzeige beim ersten Sperren des Bildschirms ein.
|
||||||
|
var isActive: Bool { trackedDeviceID != nil }
|
||||||
|
|
||||||
|
func isActive(for deviceID: UUID) -> Bool {
|
||||||
|
trackedDeviceID == deviceID && activity != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(deviceID: UUID, deviceName: String, state: LevelState) {
|
||||||
|
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||||
|
end()
|
||||||
|
let attributes = LevelActivityAttributes(deviceName: deviceName)
|
||||||
|
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
|
||||||
|
do {
|
||||||
|
activity = try Activity.request(attributes: attributes, content: content)
|
||||||
|
trackedDeviceID = deviceID
|
||||||
|
} catch {
|
||||||
|
// Kann z.B. an fehlender Nutzerfreigabe liegen – dann bleibt es
|
||||||
|
// einfach bei der Anzeige in der App, es gibt sonst nichts zu tun.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wird bei jeder neuen Messung aufgerufen, unabhängig davon, ob gerade
|
||||||
|
/// eine Aktivität läuft – kein Aufwand ohne aktive Anzeige.
|
||||||
|
func update(deviceID: UUID, state: LevelState) {
|
||||||
|
guard trackedDeviceID == deviceID, let activity else { return }
|
||||||
|
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
|
||||||
|
Task { await activity.update(content) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
guard let activity else { return }
|
||||||
|
trackedDeviceID = nil
|
||||||
|
self.activity = nil
|
||||||
|
Task { await activity.end(nil, dismissalPolicy: .immediate) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func contentState(from state: LevelState) -> LevelActivityAttributes.ContentState {
|
||||||
|
LevelActivityAttributes.ContentState(
|
||||||
|
pitch: state.pitch,
|
||||||
|
roll: state.roll,
|
||||||
|
isLevel: state.isLevel,
|
||||||
|
instruction: state.instruction,
|
||||||
|
isCalibrated: state.isCalibrated,
|
||||||
|
updatedAt: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -94,7 +94,14 @@ final class DeviceStore {
|
|||||||
// MARK: - Geräte
|
// MARK: - Geräte
|
||||||
|
|
||||||
/// Legt ein Gerät im gerade gewählten Profil an.
|
/// Legt ein Gerät im gerade gewählten Profil an.
|
||||||
|
///
|
||||||
|
/// Dasselbe Peripheral doppelt im selben Profil anzulegen, würde später
|
||||||
|
/// beim Aufbau der Funk-Konfiguration abstürzen (die dortige Zuordnung
|
||||||
|
/// nach Peripheral-ID verlangt Eindeutigkeit) – etwa bei einem
|
||||||
|
/// Doppel-Tipp auf „Sichern“. Ein zweiter Eintrag ersetzt darum den
|
||||||
|
/// ersten, statt sich danebenzustellen.
|
||||||
func add(_ device: ConfiguredDevice, victronKey: String? = nil) {
|
func add(_ device: ConfiguredDevice, victronKey: String? = nil) {
|
||||||
|
devices.removeAll { $0.profileID == device.profileID && $0.peripheralID == device.peripheralID }
|
||||||
devices.append(device)
|
devices.append(device)
|
||||||
if let victronKey {
|
if let victronKey {
|
||||||
setVictronKey(victronKey, for: device.id)
|
setVictronKey(victronKey, for: device.id)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -35,9 +35,31 @@ struct DeviceDetailView: View {
|
|||||||
store.devices.first { $0.id == device.id } ?? device
|
store.devices.first { $0.id == device.id } ?? device
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nur solange die App läuft: siehe `DeviceHistory`.
|
||||||
|
@State private var selectedHistoryMetricKey: String?
|
||||||
|
|
||||||
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
|
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
|
||||||
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
|
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
|
||||||
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] }
|
private var deviceHistory: DeviceHistory { bluetooth.history[device.id] ?? [:] }
|
||||||
|
|
||||||
|
/// Nur Metriken, zu denen sich bereits ein Verlauf mit mehr als einem
|
||||||
|
/// Punkt angesammelt hat – sonst gäbe es nichts zu zeichnen.
|
||||||
|
private var chartableMetrics: [Metric] {
|
||||||
|
(snapshot?.metrics ?? []).filter { (deviceHistory[$0.key]?.count ?? 0) > 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedMetric: Metric? {
|
||||||
|
if let key = selectedHistoryMetricKey,
|
||||||
|
let metric = chartableMetrics.first(where: { $0.key == key }) {
|
||||||
|
return metric
|
||||||
|
}
|
||||||
|
return chartableMetrics.first(where: \.isPrimary) ?? chartableMetrics.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private var samples: [HistorySample] {
|
||||||
|
guard let selectedMetric else { return [] }
|
||||||
|
return deviceHistory[selectedMetric.key] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
@@ -65,20 +87,38 @@ struct DeviceDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if samples.count > 1, let primary = snapshot?.primaryMetric {
|
if let metric = selectedMetric, samples.count > 1 {
|
||||||
Section("Verlauf – \(primary.label)") {
|
Section {
|
||||||
|
if chartableMetrics.count > 1 {
|
||||||
|
Picker("Messgrösse", selection: Binding(
|
||||||
|
get: { metric.key },
|
||||||
|
set: { selectedHistoryMetricKey = $0 }
|
||||||
|
)) {
|
||||||
|
ForEach(chartableMetrics) { candidate in
|
||||||
|
Text(candidate.label).tag(candidate.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
Chart(samples) { sample in
|
Chart(samples) { sample in
|
||||||
AreaMark(x: .value("Zeit", sample.time),
|
AreaMark(x: .value("Zeit", sample.time),
|
||||||
y: .value(primary.label, sample.value))
|
y: .value(metric.label, sample.value))
|
||||||
.foregroundStyle(.tint.opacity(0.15))
|
.foregroundStyle(.tint.opacity(0.15))
|
||||||
LineMark(x: .value("Zeit", sample.time),
|
LineMark(x: .value("Zeit", sample.time),
|
||||||
y: .value(primary.label, sample.value))
|
y: .value(metric.label, sample.value))
|
||||||
.foregroundStyle(.tint)
|
.foregroundStyle(.tint)
|
||||||
.interpolationMethod(.monotone)
|
.interpolationMethod(.monotone)
|
||||||
}
|
}
|
||||||
.chartYAxisLabel(primary.unit)
|
.chartYAxisLabel(metric.unit)
|
||||||
.frame(height: 180)
|
.frame(height: 180)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
} header: {
|
||||||
|
Text("Verlauf – \(metric.label)")
|
||||||
|
} footer: {
|
||||||
|
Text("Nur für die laufende Sitzung – wird beim Neustart der App verworfen.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ struct LevelControls: View {
|
|||||||
|
|
||||||
@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
|
||||||
@State private var showAssistant = false
|
@State private var showAssistant = false
|
||||||
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
|
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
|
||||||
|
|
||||||
@@ -177,6 +178,29 @@ struct LevelControls: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: liveActivityBinding) {
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Live Activity")
|
||||||
|
Text("Sperrbildschirm, Dynamic Island und CarPlay")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "widget.small")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ohne laufende Messwerte gäbe es nichts anzuzeigen – und beim
|
||||||
|
// Umschalten hätte die Activity sofort einen veralteten Stand.
|
||||||
|
.disabled(!isLive || !state.hasReading)
|
||||||
|
} footer: {
|
||||||
|
Text(!isLive
|
||||||
|
? "Braucht laufende Messwerte. Der Neigungsmesser ist gerade nicht verbunden."
|
||||||
|
: "Zeigt die Neigung, solange sie läuft – auch bei gesperrtem Bildschirm. "
|
||||||
|
+ "Ist das iPhone mit CarPlay verbunden, erscheint sie automatisch auch dort.")
|
||||||
|
}
|
||||||
|
|
||||||
// Einbaulage und Nullpunkt liegen auf einer eigenen Seite. Direkt unter
|
// Einbaulage und Nullpunkt liegen auf einer eigenen Seite. Direkt unter
|
||||||
// der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt.
|
// der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt.
|
||||||
Section {
|
Section {
|
||||||
@@ -196,6 +220,19 @@ struct LevelControls: View {
|
|||||||
|
|
||||||
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
private var isLive: Bool { bluetooth.linkStates[device.id] == .live }
|
||||||
|
|
||||||
|
private var liveActivityBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { levelActivity.isActive(for: device.id) },
|
||||||
|
set: { isOn in
|
||||||
|
if isOn {
|
||||||
|
levelActivity.start(deviceID: device.id, deviceName: device.name, state: state)
|
||||||
|
} else {
|
||||||
|
levelActivity.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Was auf der Einrichtungsseite zu holen ist – der Nullpunkt zuerst, denn
|
/// Was auf der Einrichtungsseite zu holen ist – der Nullpunkt zuerst, denn
|
||||||
/// ohne ihn stimmt die Anzeige nicht.
|
/// ohne ihn stimmt die Anzeige nicht.
|
||||||
private var setupSummary: String {
|
private var setupSummary: String {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import ActivityKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Inhalt der Neigungsmesser-Live-Activity.
|
||||||
|
///
|
||||||
|
/// Die App und die Widget-Extension kompilieren diese Datei je für sich in
|
||||||
|
/// ihr eigenes Modul – sie darf deshalb nicht von `Shared/` abhängen, sonst
|
||||||
|
/// müsste die Extension auch Bluetooth- und WatchConnectivity-Code mitbauen.
|
||||||
|
/// Werte wie `isLevel` und `instruction` kommen darum schon fertig berechnet
|
||||||
|
/// aus `LevelState` an, die Extension zeigt nur an.
|
||||||
|
struct LevelActivityAttributes: ActivityAttributes {
|
||||||
|
struct ContentState: Codable, Hashable {
|
||||||
|
var pitch: Double?
|
||||||
|
var roll: Double?
|
||||||
|
var isLevel: Bool
|
||||||
|
var instruction: String?
|
||||||
|
var isCalibrated: Bool
|
||||||
|
var updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name des Neigungsmessers, wie in der App vergeben.
|
||||||
|
var deviceName: String
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
9DB55BAF3048B29B00CFD174 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DB55BAE3048B29B00CFD174 /* WidgetKit.framework */; };
|
9DB55BAF3048B29B00CFD174 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DB55BAE3048B29B00CFD174 /* WidgetKit.framework */; };
|
||||||
9DB55BB13048B29B00CFD174 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DB55BB03048B29B00CFD174 /* SwiftUI.framework */; };
|
9DB55BB13048B29B00CFD174 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DB55BB03048B29B00CFD174 /* SwiftUI.framework */; };
|
||||||
AA0000000000000000000027 /* VanAligneWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* VanAligneWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
AA0000000000000000000027 /* VanAligneWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* VanAligneWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
AA0000000000000000000043 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000042 /* ActivityKit.framework */; };
|
||||||
|
AA0000000000000000000044 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000042 /* ActivityKit.framework */; };
|
||||||
|
AA0000000000000000000047 /* VanAligneiOSWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9DB55BAC3048B29B00CFD174 /* VanAligneiOSWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -32,6 +35,13 @@
|
|||||||
remoteGlobalIDString = AA0000000000000000000022;
|
remoteGlobalIDString = AA0000000000000000000022;
|
||||||
remoteInfo = CamperMonitorWatch;
|
remoteInfo = CamperMonitorWatch;
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000045 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = AA0000000000000000000009 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 9DB55BAB3048B29B00CFD174;
|
||||||
|
remoteInfo = VanAligneiOSWidgetExtension;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -46,6 +56,17 @@
|
|||||||
name = "Embed Watch Content";
|
name = "Embed Watch Content";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000048 /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
AA0000000000000000000047 /* VanAligneiOSWidgetExtension.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -59,6 +80,7 @@
|
|||||||
AA0000000000000000000001 /* VanAligne.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanAligne.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
AA0000000000000000000001 /* VanAligne.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanAligne.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AA0000000000000000000016 /* VanAligneWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanAligneWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
AA0000000000000000000016 /* VanAligneWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VanAligneWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AA0000000000000000000032 /* VanAligneComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VanAligneComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
AA0000000000000000000032 /* VanAligneComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VanAligneComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
AA0000000000000000000042 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -100,6 +122,11 @@
|
|||||||
path = CamperMonitorComplication;
|
path = CamperMonitorComplication;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000041 /* SharedActivity */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = SharedActivity;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -109,6 +136,7 @@
|
|||||||
files = (
|
files = (
|
||||||
9DB55BB13048B29B00CFD174 /* SwiftUI.framework in Frameworks */,
|
9DB55BB13048B29B00CFD174 /* SwiftUI.framework in Frameworks */,
|
||||||
9DB55BAF3048B29B00CFD174 /* WidgetKit.framework in Frameworks */,
|
9DB55BAF3048B29B00CFD174 /* WidgetKit.framework in Frameworks */,
|
||||||
|
AA0000000000000000000044 /* ActivityKit.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -116,6 +144,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
AA0000000000000000000043 /* ActivityKit.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -141,6 +170,7 @@
|
|||||||
children = (
|
children = (
|
||||||
9DB55BAE3048B29B00CFD174 /* WidgetKit.framework */,
|
9DB55BAE3048B29B00CFD174 /* WidgetKit.framework */,
|
||||||
9DB55BB03048B29B00CFD174 /* SwiftUI.framework */,
|
9DB55BB03048B29B00CFD174 /* SwiftUI.framework */,
|
||||||
|
AA0000000000000000000042 /* ActivityKit.framework */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -152,6 +182,7 @@
|
|||||||
AA0000000000000000000017 /* CamperMonitorWatch */,
|
AA0000000000000000000017 /* CamperMonitorWatch */,
|
||||||
AA0000000000000000000033 /* CamperMonitorComplication */,
|
AA0000000000000000000033 /* CamperMonitorComplication */,
|
||||||
AA0000000000000000000018 /* Shared */,
|
AA0000000000000000000018 /* Shared */,
|
||||||
|
AA0000000000000000000041 /* SharedActivity */,
|
||||||
AA0000000000000000000031 /* Config */,
|
AA0000000000000000000031 /* Config */,
|
||||||
9DB55BB23048B29B00CFD174 /* VanAligneiOSWidget */,
|
9DB55BB23048B29B00CFD174 /* VanAligneiOSWidget */,
|
||||||
9DB55BAD3048B29B00CFD174 /* Frameworks */,
|
9DB55BAD3048B29B00CFD174 /* Frameworks */,
|
||||||
@@ -198,6 +229,7 @@
|
|||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
9DB55BB23048B29B00CFD174 /* VanAligneiOSWidget */,
|
9DB55BB23048B29B00CFD174 /* VanAligneiOSWidget */,
|
||||||
|
AA0000000000000000000041 /* SharedActivity */,
|
||||||
);
|
);
|
||||||
name = VanAligneiOSWidgetExtension;
|
name = VanAligneiOSWidgetExtension;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -214,15 +246,18 @@
|
|||||||
AA0000000000000000000003 /* Frameworks */,
|
AA0000000000000000000003 /* Frameworks */,
|
||||||
AA0000000000000000000008 /* Resources */,
|
AA0000000000000000000008 /* Resources */,
|
||||||
AA0000000000000000000026 /* Embed Watch Content */,
|
AA0000000000000000000026 /* Embed Watch Content */,
|
||||||
|
AA0000000000000000000048 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
AA0000000000000000000029 /* PBXTargetDependency */,
|
AA0000000000000000000029 /* PBXTargetDependency */,
|
||||||
|
AA0000000000000000000046 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
AA0000000000000000000002 /* CamperMonitor */,
|
AA0000000000000000000002 /* CamperMonitor */,
|
||||||
AA0000000000000000000018 /* Shared */,
|
AA0000000000000000000018 /* Shared */,
|
||||||
|
AA0000000000000000000041 /* SharedActivity */,
|
||||||
);
|
);
|
||||||
name = VanAligne;
|
name = VanAligne;
|
||||||
productName = CamperMonitor;
|
productName = CamperMonitor;
|
||||||
@@ -398,6 +433,11 @@
|
|||||||
target = AA0000000000000000000022 /* VanAligneWatch */;
|
target = AA0000000000000000000022 /* VanAligneWatch */;
|
||||||
targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */;
|
targetProxy = AA0000000000000000000028 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
AA0000000000000000000046 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 9DB55BAB3048B29B00CFD174 /* VanAligneiOSWidgetExtension */;
|
||||||
|
targetProxy = AA0000000000000000000045 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -435,7 +475,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = V5C6Q86XJR;
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
@@ -454,7 +494,7 @@
|
|||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.VanAligneiOSWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.VanAligneiOSWidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -501,7 +541,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = V5C6Q86XJR;
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
@@ -520,7 +560,7 @@
|
|||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.VanAligneiOSWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.VanAligneiOSWidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -596,13 +636,14 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = P8J6283TT5;
|
DEVELOPMENT_TEAM = V5C6Q86XJR;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
|
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
|
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
||||||
|
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
@@ -612,7 +653,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS;
|
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -628,13 +669,14 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_ASSET_PATHS = "";
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
DEVELOPMENT_TEAM = P8J6283TT5;
|
DEVELOPMENT_TEAM = V5C6Q86XJR;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
|
INFOPLIST_FILE = "Config/CamperMonitor-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
|
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth.";
|
||||||
|
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
@@ -644,7 +686,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS;
|
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -659,18 +701,18 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = P8J6283TT5;
|
DEVELOPMENT_TEAM = V5C6Q86XJR;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
|
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
|
||||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
|
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
|
||||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = fototeddy.VanAligneiOS;
|
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.s0.fototeddy.VanAligneiOS;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.watchkitapp;
|
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.watchkitapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -691,18 +733,18 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = P8J6283TT5;
|
DEVELOPMENT_TEAM = V5C6Q86XJR;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
|
INFOPLIST_KEY_CFBundleDisplayName = "VanAligne Pro";
|
||||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
|
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen des Neigungsmessers direkt an der Uhr, ohne Umweg ueber das iPhone.";
|
||||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = fototeddy.VanAligneiOS;
|
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = de.s0.fototeddy.VanAligneiOS;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.watchkitapp;
|
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.watchkitapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -722,7 +764,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = V5C6Q86XJR;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
|
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
|
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
|
||||||
@@ -732,7 +774,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.watchkitapp.levelwidget;
|
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.watchkitapp.levelwidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
@@ -753,7 +795,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = V5C6Q86XJR;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
|
INFOPLIST_FILE = "Config/CamperMonitorComplication-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
|
INFOPLIST_KEY_CFBundleDisplayName = Nivellierung;
|
||||||
@@ -763,7 +805,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = fototeddy.VanAligneiOS.watchkitapp.levelwidget;
|
PRODUCT_BUNDLE_IDENTIFIER = de.s0.fototeddy.VanAligneiOS.watchkitapp.levelwidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
|
|||||||
BIN
Binary file not shown.
+2
-2
@@ -22,12 +22,12 @@
|
|||||||
<key>VanAligneComplication.xcscheme_^#shared#^_</key>
|
<key>VanAligneComplication.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>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>
|
||||||
|
|||||||
@@ -9,72 +9,186 @@ import ActivityKit
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct VanAligneiOSWidgetAttributes: ActivityAttributes {
|
/// Formatiert Neigungswerte wie in der App – °, eine Nachkommastelle, "–"
|
||||||
public struct ContentState: Codable, Hashable {
|
/// wenn kein Messwert da ist. Eigene Kopie, da die Extension nicht von
|
||||||
// Dynamic stateful properties about your activity go here!
|
/// `Shared/Models/Metric.swift` abhängt.
|
||||||
var emoji: String
|
private func degreesText(_ value: Double?) -> String {
|
||||||
|
guard let value else { return "–" }
|
||||||
|
return String(format: "%.1f°", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LevelIcon: View {
|
||||||
|
let isLevel: Bool
|
||||||
|
let hasReading: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Image(systemName: hasReading ? (isLevel ? "checkmark.circle.fill" : "level") : "level")
|
||||||
|
.foregroundStyle(hasReading ? (isLevel ? .green : .orange) : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte
|
||||||
|
/// Dynamic Island – dieselbe Optik wie `LevelBubble` in der App, aber ohne
|
||||||
|
/// deren Abhängigkeit auf `Shared/Models/LevelState.swift`, das in der
|
||||||
|
/// Extension nicht mitkompiliert wird.
|
||||||
|
private struct LevelBubbleGlyph: View {
|
||||||
|
let pitch: Double?
|
||||||
|
let roll: Double?
|
||||||
|
let isLevel: Bool
|
||||||
|
var diameter: CGFloat = 54
|
||||||
|
|
||||||
|
/// Bis zu welcher Neigung die Blase ausschlägt, wie in der App-Libelle.
|
||||||
|
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 bubbleColor: Color {
|
||||||
|
guard let pitch, let roll else { return .white.opacity(0.4) }
|
||||||
|
let deviation = max(abs(pitch), abs(roll))
|
||||||
|
if deviation <= tolerance { return .green }
|
||||||
|
if deviation <= 2 { return .orange }
|
||||||
|
return .red
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed non-changing properties about your activity go here!
|
var body: some View {
|
||||||
var name: String
|
let radius = diameter / 2
|
||||||
|
let bubble = diameter * 0.22
|
||||||
|
let travel = radius - bubble / 2 - 3
|
||||||
|
let offsetX = clamped(roll) / range * travel
|
||||||
|
let offsetY = clamped(pitch) / range * travel
|
||||||
|
let toleranceRadius = max(tolerance / range * travel, bubble * 0.55)
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.white.opacity(0.1))
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(Color.white.opacity(0.3), lineWidth: 1)
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(isLevel ? Color.green : Color.white.opacity(0.25),
|
||||||
|
lineWidth: isLevel ? 2 : 1)
|
||||||
|
.frame(width: toleranceRadius * 2, height: toleranceRadius * 2)
|
||||||
|
Circle()
|
||||||
|
.fill(bubbleColor)
|
||||||
|
.frame(width: bubble, height: bubble)
|
||||||
|
// Positiver Pitch heisst: Heck steht höher, die Blase wandert
|
||||||
|
// also nach oben – wie bei der echten Wasserwaage in der App.
|
||||||
|
.offset(x: offsetX, y: -offsetY)
|
||||||
|
.opacity(hasReading ? 1 : 0.3)
|
||||||
|
}
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clamped(_ value: Double?) -> Double {
|
||||||
|
guard let value else { return 0 }
|
||||||
|
return min(max(value, -range), range)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VanAligneiOSWidgetLiveActivity: Widget {
|
struct VanAligneiOSWidgetLiveActivity: Widget {
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
ActivityConfiguration(for: VanAligneiOSWidgetAttributes.self) { context in
|
ActivityConfiguration(for: LevelActivityAttributes.self) { context in
|
||||||
// Lock screen/banner UI goes here
|
LockScreenLevelView(attributes: context.attributes, state: context.state)
|
||||||
VStack {
|
.activityBackgroundTint(Color(white: 0.08))
|
||||||
Text("Hello \(context.state.emoji)")
|
.activitySystemActionForegroundColor(.white)
|
||||||
}
|
|
||||||
.activityBackgroundTint(Color.cyan)
|
|
||||||
.activitySystemActionForegroundColor(Color.black)
|
|
||||||
|
|
||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
// Expanded UI goes here. Compose the expanded UI through
|
|
||||||
// various regions, like leading/trailing/center/bottom
|
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
Text("Leading")
|
LevelBubbleGlyph(pitch: context.state.pitch, roll: context.state.roll,
|
||||||
|
isLevel: context.state.isLevel, diameter: 44)
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
Text("Trailing")
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(degreesText(context.state.pitch)).monospacedDigit()
|
||||||
|
Text(degreesText(context.state.roll)).monospacedDigit()
|
||||||
|
}
|
||||||
|
.font(.headline)
|
||||||
}
|
}
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
Text("Bottom \(context.state.emoji)")
|
Text(context.state.instruction ?? context.attributes.deviceName)
|
||||||
// more content
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(context.state.isLevel ? .green : .primary)
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
Text("L")
|
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
Text("T \(context.state.emoji)")
|
Text(degreesText(context.state.pitch)).monospacedDigit()
|
||||||
} minimal: {
|
} minimal: {
|
||||||
Text(context.state.emoji)
|
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
|
||||||
}
|
}
|
||||||
.widgetURL(URL(string: "http://www.apple.com"))
|
.keylineTint(context.state.isLevel ? .green : .orange)
|
||||||
.keylineTint(Color.red)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VanAligneiOSWidgetAttributes {
|
/// Sperrbildschirm- und Banner-Ansicht. Dieselbe Ansicht landet automatisch
|
||||||
fileprivate static var preview: VanAligneiOSWidgetAttributes {
|
/// auch im CarPlay-Dashboard, sobald das iPhone verbunden ist – ein eigenes
|
||||||
VanAligneiOSWidgetAttributes(name: "World")
|
/// CarPlay-App-Target ist dafür nicht nötig.
|
||||||
|
private struct LockScreenLevelView: View {
|
||||||
|
let attributes: LevelActivityAttributes
|
||||||
|
let state: LevelActivityAttributes.ContentState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(attributes.deviceName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(state.instruction ?? "Keine Messwerte")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(state.isLevel ? .green : .white)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
reading("Längs", state.pitch)
|
||||||
|
reading("Quer", state.roll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reading(_ title: String, _ value: Double?) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(degreesText(value))
|
||||||
|
.font(.subheadline.monospacedDigit())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VanAligneiOSWidgetAttributes.ContentState {
|
extension LevelActivityAttributes {
|
||||||
fileprivate static var smiley: VanAligneiOSWidgetAttributes.ContentState {
|
fileprivate static var preview: LevelActivityAttributes {
|
||||||
VanAligneiOSWidgetAttributes.ContentState(emoji: "😀")
|
LevelActivityAttributes(deviceName: "Nivellierung")
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate static var starEyes: VanAligneiOSWidgetAttributes.ContentState {
|
|
||||||
VanAligneiOSWidgetAttributes.ContentState(emoji: "🤩")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview("Notification", as: .content, using: VanAligneiOSWidgetAttributes.preview) {
|
extension LevelActivityAttributes.ContentState {
|
||||||
|
fileprivate static var level: LevelActivityAttributes.ContentState {
|
||||||
|
LevelActivityAttributes.ContentState(pitch: 0.1, roll: -0.2, isLevel: true,
|
||||||
|
instruction: "Steht eben", isCalibrated: true,
|
||||||
|
updatedAt: .now)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate static var tilted: LevelActivityAttributes.ContentState {
|
||||||
|
LevelActivityAttributes.ContentState(pitch: 2.4, roll: -1.1, isLevel: false,
|
||||||
|
instruction: "Heck steht höher, links steht höher",
|
||||||
|
isCalibrated: true, updatedAt: .now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Notification", as: .content, using: LevelActivityAttributes.preview) {
|
||||||
VanAligneiOSWidgetLiveActivity()
|
VanAligneiOSWidgetLiveActivity()
|
||||||
} contentStates: {
|
} contentStates: {
|
||||||
VanAligneiOSWidgetAttributes.ContentState.smiley
|
LevelActivityAttributes.ContentState.level
|
||||||
VanAligneiOSWidgetAttributes.ContentState.starEyes
|
LevelActivityAttributes.ContentState.tilted
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"};
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user