Compare commits

...
4 Commits
Author SHA1 Message Date
fototeddyandClaude Sonnet 5 77b8cd3f18 Update Xcode workspace UI state
No code changes; Xcode's own session state (open tabs/scroll position)
after building and running the solar integration in the simulator.

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 00:04:20 +02:00
fototeddyandClaude Sonnet 5 9a3b5cb472 Fix device signing/companion ID, duplicate-device crash, background updates, and add level bubble to Live Activity
- Assign the shared development team to the widget extension and watch
  complication targets, and correct the watch app's companion bundle
  identifier so on-device installs succeed after the bundle ID rename
  to de.s0.fototeddy.VanAligneiOS.
- Guard against two ConfiguredDevices sharing a peripheralID (e.g. a
  double-tapped "Sichern"), which crashed BluetoothManager's
  Dictionary(uniqueKeysWithValues:) on launch.
- Keep Bluetooth running in the background while a Live Activity is
  active, so lock screen/CarPlay keep receiving fresh readings instead
  of freezing at the last value before backgrounding.
- Render the actual bubble-level graphic (not just a static icon) in
  the Live Activity's lock screen view and expanded Dynamic Island.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:50:08 +02:00
fototeddyandClaude Sonnet 5 bfe2b00a5c Add Live Activity for level sensor (surfaces on CarPlay automatically)
Replaces the ActivityKit boilerplate with a real lock screen/Dynamic
Island UI driven by live pitch/roll readings, toggled from the
leveling device's detail screen. Since iOS 17 shows any running Live
Activity on CarPlay's dashboard without a dedicated CarPlay app entitlement,
this covers the requested CarPlay use case without one.

Also fixes the widget extension never being embedded into the app
(missing Embed Foundation Extensions build phase and target
dependency), which meant no widget or Live Activity could ever have
rendered regardless of code changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:39:01 +02:00
21 changed files with 1623 additions and 81 deletions
+72 -8
View File
@@ -17,6 +17,8 @@ struct Discovery: Identifiable, Hashable {
var looksLikeSupported: Bool var looksLikeSupported: Bool
/// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar. /// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar.
var isLevelSensor = false var isLevelSensor = false
/// Der Solarladeregler bewirbt seinen Dienst ebenso.
var isSolarSensor = false
//var isVictron: Bool { victronRecordType != nil } //var isVictron: Bool { victronRecordType != nil }
@@ -31,6 +33,7 @@ struct Discovery: Identifiable, Hashable {
var subtitle: String { var subtitle: String {
if isLevelSensor { return "VanAlign Neigungsmesser" } if isLevelSensor { return "VanAlign Neigungsmesser" }
if isSolarSensor { return "VanAlign Solarladeregler" }
return "Bluetooth-Gerät" return "Bluetooth-Gerät"
} }
/*var subtitle: String { /*var subtitle: String {
@@ -125,6 +128,12 @@ struct HistorySample: Identifiable, Hashable {
let value: Double let value: Double
} }
/// Verlauf mehrerer Messgrössen eines Geräts, je Metrik-Schlüssel (siehe
/// `Metric.key`). Nur im Arbeitsspeicher: Sinn ist die Grafik während der
/// laufenden App-Sitzung, nicht ein dauerhaftes Log ein Neustart der App
/// oder das Beenden im Hintergrund darf den Verlauf also verwerfen.
typealias DeviceHistory = [String: [HistorySample]]
/// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Geräten und hält /// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Geräten und hält
/// parallel die Verbindung zum Neigungsmesser. /// parallel die Verbindung zum Neigungsmesser.
/// ///
@@ -150,11 +159,12 @@ final class BluetoothManager: NSObject {
private(set) var snapshots: [UUID: DeviceSnapshot] = [:] private(set) var snapshots: [UUID: DeviceSnapshot] = [:]
private(set) var linkStates: [UUID: DeviceLinkState] = [:] private(set) var linkStates: [UUID: DeviceLinkState] = [:]
private(set) var discoveries: [UUID: Discovery] = [:] private(set) var discoveries: [UUID: Discovery] = [:]
private(set) var history: [UUID: [HistorySample]] = [:] private(set) var history: [UUID: DeviceHistory] = [:]
//private(set) var diagnostics: [UUID: VictronDiagnostics] = [:] //private(set) var diagnostics: [UUID: VictronDiagnostics] = [:]
//private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:] //private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
//private(set) var fridgeStates: [UUID: AlpicoolState] = [:] //private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
private(set) var levelStates: [UUID: LevelState] = [:] private(set) var levelStates: [UUID: LevelState] = [:]
private(set) var solarStates: [UUID: SolarState] = [:]
private(set) var isBluetoothReady = false private(set) var isBluetoothReady = false
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…" private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
@@ -191,6 +201,7 @@ final class BluetoothManager: NSObject {
//private var bmsSessions: [UUID: BMSSession] = [:] //private var bmsSessions: [UUID: BMSSession] = [:]
private var levelSessions: [UUID: LevelSession] = [:] private var levelSessions: [UUID: LevelSession] = [:]
private var solarSessions: [UUID: SolarSession] = [:]
private var connectedPeripherals: [UUID: CBPeripheral] = [:] private var connectedPeripherals: [UUID: CBPeripheral] = [:]
private var reconnectTimer: DispatchSourceTimer? private var reconnectTimer: DispatchSourceTimer?
@@ -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 {
guard let value = metric.value else { continue }
var samples = deviceHistory[metric.key] ?? []
// Höchstens alle fünf Sekunden einen Punkt aufnehmen. // Höchstens alle fünf Sekunden einen Punkt aufnehmen.
if let last = samples.last, snapshot.timestamp.timeIntervalSince(last.time) < 5 { return } if let last = samples.last,
snapshot.timestamp.timeIntervalSince(last.time) < 5 { continue }
samples.append(HistorySample(time: snapshot.timestamp, value: value)) samples.append(HistorySample(time: snapshot.timestamp, value: value))
if samples.count > self.historyLimit { if samples.count > self.historyLimit {
samples.removeFirst(samples.count - self.historyLimit) samples.removeFirst(samples.count - self.historyLimit)
} }
self.history[snapshot.deviceID] = samples deviceHistory[metric.key] = samples
}
self.history[snapshot.deviceID] = deviceHistory
} }
} }
@@ -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)
+10 -1
View File
@@ -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()
)
}
}
+40 -1
View File
@@ -59,8 +59,47 @@ enum DemoData {
name: "Nivellierung", role: .leveling, profileID: Profile.defaultID, name: "Nivellierung", role: .leveling, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000E1")!) peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000E1")!)
static let solar = ConfiguredDevice(
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000D0")!,
name: "Solar Dach", role: .solar, profileID: Profile.defaultID,
peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000D1")!)
static var devices: [ConfiguredDevice] { static var devices: [ConfiguredDevice] {
[/*solar, booster, battery, fridge, */level/*, caravanSolar*/] [/*booster, battery, fridge, */level, solar/*, caravanSolar*/]
}
/// Mittags, gute Sonne.
static var solarState: SolarState {
var state = SolarState()
state.batteryVoltage = 13.9
state.pvVoltage = 19.4
state.pvCurrent = 6.2
state.pvPower = 120
state.controllerTemperature = 34
state.isBatteryCharging = true
state.isControllerActive = true
state.isCurrentLimited = false
return state
}
/// Ein paar Stunden Verlauf je Kanal, damit die Grafik im Demo-Modus
/// sofort etwas zeigt statt erst nach ein paar Minuten Laufzeit.
static func solarHistory() -> DeviceHistory {
let now = Date()
func series(around value: Double, noise: Double) -> [HistorySample] {
(0..<120).reversed().map { step in
let t = Double(step)
let wave = sin(t / 14) * value * noise + cos(t / 31) * value * (noise / 2)
return HistorySample(time: now.addingTimeInterval(-t * 60),
value: max(0, value + wave))
}
}
return [
"pv_power": series(around: 120, noise: 0.35),
"pv_voltage": series(around: 19.4, noise: 0.08),
"pv_current": series(around: 6.2, noise: 0.3),
"battery_voltage": series(around: 13.9, noise: 0.03),
]
} }
/// Leicht schräg stehend, damit die Libelle etwas zu zeigen hat. /// Leicht schräg stehend, damit die Libelle etwas zu zeigen hat.
+7
View File
@@ -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)
+4
View File
@@ -195,6 +195,8 @@ private struct ConfigureDeviceView: View {
} }
} else*/ if discovery.isLevelSensor { } else*/ if discovery.isLevelSensor {
role = .leveling role = .leveling
} else if discovery.isSolarSensor {
role = .solar
} /*else if let name = discovery.name?.lowercased(), } /*else if let name = discovery.name?.lowercased(),
["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) { ["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) {
role = .fridge role = .fridge
@@ -206,6 +208,8 @@ private struct ConfigureDeviceView: View {
// ohnehin darüber unter "Gefunden als". // ohnehin darüber unter "Gefunden als".
if discovery.isLevelSensor { if discovery.isLevelSensor {
name = "Nivellierung" name = "Nivellierung"
} else if discovery.isSolarSensor {
name = "Solarladeregler"
} else if let advertised = discovery.name, advertised.count <= 20, } else if let advertised = discovery.name, advertised.count <= 20,
advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil { advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil {
name = advertised name = advertised
+46 -6
View File
@@ -35,9 +35,31 @@ struct DeviceDetailView: View {
store.devices.first { $0.id == device.id } ?? device store.devices.first { $0.id == device.id } ?? device
} }
/// Nur solange die App läuft: siehe `DeviceHistory`.
@State private var selectedHistoryMetricKey: String?
private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] } private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] }
private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching } private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching }
private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] } private var deviceHistory: DeviceHistory { bluetooth.history[device.id] ?? [:] }
/// Nur Metriken, zu denen sich bereits ein Verlauf mit mehr als einem
/// Punkt angesammelt hat sonst gäbe es nichts zu zeichnen.
private var chartableMetrics: [Metric] {
(snapshot?.metrics ?? []).filter { (deviceHistory[$0.key]?.count ?? 0) > 1 }
}
private var selectedMetric: Metric? {
if let key = selectedHistoryMetricKey,
let metric = chartableMetrics.first(where: { $0.key == key }) {
return metric
}
return chartableMetrics.first(where: \.isPrimary) ?? chartableMetrics.first
}
private var samples: [HistorySample] {
guard let selectedMetric else { return [] }
return deviceHistory[selectedMetric.key] ?? []
}
var body: some View { var body: some View {
List { List {
@@ -65,20 +87,38 @@ struct DeviceDetailView: View {
} }
} }
if samples.count > 1, let primary = snapshot?.primaryMetric { if let metric = selectedMetric, samples.count > 1 {
Section("Verlauf \(primary.label)") { Section {
if chartableMetrics.count > 1 {
Picker("Messgrösse", selection: Binding(
get: { metric.key },
set: { selectedHistoryMetricKey = $0 }
)) {
ForEach(chartableMetrics) { candidate in
Text(candidate.label).tag(candidate.key)
}
}
.pickerStyle(.segmented)
.listRowInsets(EdgeInsets())
.padding(.horizontal)
.padding(.top, 4)
}
Chart(samples) { sample in Chart(samples) { sample in
AreaMark(x: .value("Zeit", sample.time), AreaMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value)) y: .value(metric.label, sample.value))
.foregroundStyle(.tint.opacity(0.15)) .foregroundStyle(.tint.opacity(0.15))
LineMark(x: .value("Zeit", sample.time), LineMark(x: .value("Zeit", sample.time),
y: .value(primary.label, sample.value)) y: .value(metric.label, sample.value))
.foregroundStyle(.tint) .foregroundStyle(.tint)
.interpolationMethod(.monotone) .interpolationMethod(.monotone)
} }
.chartYAxisLabel(primary.unit) .chartYAxisLabel(metric.unit)
.frame(height: 180) .frame(height: 180)
.padding(.vertical, 8) .padding(.vertical, 8)
} header: {
Text("Verlauf \(metric.label)")
} footer: {
Text("Nur für die laufende Sitzung wird beim Neustart der App verworfen.")
} }
} }
+37
View File
@@ -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 {
+160
View File
@@ -0,0 +1,160 @@
import CoreBluetooth
import Foundation
/// Hält die Verbindung zum Solarladeregler.
///
/// Einfacher noch als `LevelSession`: die Firmware bietet für keine
/// Charakteristik `notify` an (siehe `esp32_ble_solar.yaml`), es wird also
/// immer im Takt abgefragt statt abonniert.
final class SolarSession: NSObject {
static let serviceUUID = CBUUID(string: VanAlignSolarProtocol.serviceUUID)
private static let batteryVoltageUUID = CBUUID(string: VanAlignSolarProtocol.batteryVoltageUUID)
private static let pvVoltageUUID = CBUUID(string: VanAlignSolarProtocol.pvVoltageUUID)
private static let pvCurrentUUID = CBUUID(string: VanAlignSolarProtocol.pvCurrentUUID)
private static let pvPowerUUID = CBUUID(string: VanAlignSolarProtocol.pvPowerUUID)
private static let controllerTempUUID = CBUUID(string: VanAlignSolarProtocol.controllerTempUUID)
private static let statusFlagsUUID = CBUUID(string: VanAlignSolarProtocol.statusFlagsUUID)
let deviceID: UUID
private let queue: DispatchQueue
private let peripheral: CBPeripheral
private let onUpdate: (DeviceSnapshot) -> Void
private let onStateChange: (DeviceLinkState) -> Void
private let onSolarState: (SolarState) -> Void
private var characteristics: [CBUUID: CBCharacteristic] = [:]
private var state = SolarState()
private var pollTimer: DispatchSourceTimer?
/// Reicht für einen Solarregler, dessen Werte sich über Sekunden ändern
/// deutlich seltener als beim Ausrichten mit dem Neigungsmesser.
var pollInterval: TimeInterval = 5
init(deviceID: UUID,
peripheral: CBPeripheral,
queue: DispatchQueue,
onUpdate: @escaping (DeviceSnapshot) -> Void,
onStateChange: @escaping (DeviceLinkState) -> Void,
onSolarState: @escaping (SolarState) -> Void) {
self.deviceID = deviceID
self.queue = queue
self.peripheral = peripheral
self.onUpdate = onUpdate
self.onStateChange = onStateChange
self.onSolarState = onSolarState
super.init()
peripheral.delegate = self
}
// MARK: - Lebenszyklus
func start() {
onStateChange(.connecting)
peripheral.discoverServices([Self.serviceUUID])
}
func stop() {
pollTimer?.cancel()
pollTimer = nil
characteristics.removeAll()
}
func handleDisconnect() {
pollTimer?.cancel()
pollTimer = nil
characteristics.removeAll()
}
// MARK: - Abfrage
private func startPolling() {
guard pollTimer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now(), repeating: pollInterval)
timer.setEventHandler { [weak self] in self?.readAll() }
timer.resume()
pollTimer = timer
}
private func readAll() {
guard peripheral.state == .connected else { return }
for characteristic in characteristics.values where characteristic.properties.contains(.read) {
peripheral.readValue(for: characteristic)
}
}
private func publish() {
guard state.hasReading else { return }
onStateChange(.live)
onSolarState(state)
onUpdate(state.snapshot(deviceID: deviceID, rssi: nil))
}
}
// MARK: - CBPeripheralDelegate
extension SolarSession: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
if let error {
onStateChange(.failed(error.localizedDescription))
return
}
guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else {
onStateChange(.failed("Solarladeregler-Dienst nicht gefunden"))
return
}
peripheral.discoverCharacteristics(
[Self.batteryVoltageUUID, Self.pvVoltageUUID, Self.pvCurrentUUID,
Self.pvPowerUUID, Self.controllerTempUUID, Self.statusFlagsUUID],
for: service
)
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
guard error == nil, let found = service.characteristics else {
onStateChange(.failed(error?.localizedDescription ?? "Keine Merkmale gefunden"))
return
}
for characteristic in found {
characteristics[characteristic.uuid] = characteristic
}
guard !characteristics.isEmpty else {
onStateChange(.failed("Solarwerte nicht gefunden"))
return
}
startPolling()
readAll()
}
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard error == nil, let value = characteristic.value else { return }
switch characteristic.uuid {
case Self.batteryVoltageUUID:
state.batteryVoltage = VanAlignSolarProtocol.float(from: value)
case Self.pvVoltageUUID:
state.pvVoltage = VanAlignSolarProtocol.float(from: value)
case Self.pvCurrentUUID:
state.pvCurrent = VanAlignSolarProtocol.float(from: value)
case Self.pvPowerUUID:
state.pvPower = VanAlignSolarProtocol.float(from: value)
case Self.controllerTempUUID:
state.controllerTemperature = VanAlignSolarProtocol.float(from: value)
case Self.statusFlagsUUID:
guard let flags = VanAlignSolarProtocol.statusFlags(from: value) else { return }
state.isBatteryCharging = flags.isBatteryCharging
state.isBatteryDischarging = flags.isBatteryDischarging
state.isControllerActive = flags.isControllerActive
state.isCurrentLimited = flags.isCurrentLimited
default:
return
}
publish()
}
}
@@ -0,0 +1,55 @@
import Foundation
/// Solarladeregler VanAlign Solar" zweiter ESP32 im Fahrzeug, liest einen
/// Votronic-Solarladeregler aus und stellt die Werte über einen eigenen
/// BLE-Dienst bereit. Siehe `firmware/vanalign/esp32_ble_solar.yaml`.
///
/// Wie beim Neigungsmesser: kein Rahmenprotokoll, jede Messgrösse liegt in
/// einer eigenen Charakteristik, alle sind reine Lesewerte, der Client fragt
/// sie im Takt ab.
enum VanAlignSolarProtocol {
/// Wird vom Gerät beworben, das Gerät ist darüber auffindbar.
static let serviceUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A001"
static let batteryVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A101"
static let pvVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A102"
static let pvCurrentUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A103"
static let pvPowerUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A104"
static let controllerTempUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A105"
/// Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
/// Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
static let statusFlagsUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A106"
/// Liest einen Messwert aus vier Bytes, little-endian wie beim
/// Neigungsmesser legt die Firmware den Float per `memcpy` ab.
static func float(from data: Data) -> Double? {
guard data.count >= 4 else { return nil }
var raw: UInt32 = 0
for (index, byte) in data.prefix(4).enumerated() {
raw |= UInt32(byte) << UInt32(8 * index)
}
let value = Float(bitPattern: raw)
guard value.isFinite else { return nil }
return Double(value)
}
struct StatusFlags {
var isBatteryCharging = false
var isBatteryDischarging = false
var isControllerActive = false
var isCurrentLimited = false
var isAESActive = false
}
static func statusFlags(from data: Data) -> StatusFlags? {
guard let byte = data.first else { return nil }
var flags = StatusFlags()
flags.isBatteryCharging = byte & (1 << 0) != 0
flags.isBatteryDischarging = byte & (1 << 1) != 0
flags.isControllerActive = byte & (1 << 2) != 0
flags.isCurrentLimited = byte & (1 << 3) != 0
flags.isAESActive = byte & (1 << 4) != 0
return flags
}
}
+3 -3
View File
@@ -4,33 +4,33 @@ import Foundation
/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird. /// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable { enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable {
//case chargeBooster //case chargeBooster
//case solarCharger
//case batteryMonitor //case batteryMonitor
//case bms //case bms
//case fridge //case fridge
case leveling case leveling
case solar
var id: String { rawValue } var id: String { rawValue }
var title: String { var title: String {
switch self { switch self {
//case .chargeBooster: return "Ladebooster" //case .chargeBooster: return "Ladebooster"
//case .solarCharger: return "Solarladeregler"
//case .batteryMonitor: return "Batteriemonitor" //case .batteryMonitor: return "Batteriemonitor"
//case .bms: return "Batterie / BMS" //case .bms: return "Batterie / BMS"
//case .fridge: return "Kühlbox" //case .fridge: return "Kühlbox"
case .leveling: return "Nivellierung" case .leveling: return "Nivellierung"
case .solar: return "Solarladeregler"
} }
} }
var symbol: String { var symbol: String {
switch self { switch self {
//case .chargeBooster: return "bolt.car" //case .chargeBooster: return "bolt.car"
//case .solarCharger: return "sun.max"
//case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent" //case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent"
//case .bms: return "battery.100percent.bolt" //case .bms: return "battery.100percent.bolt"
//case .fridge: return "refrigerator" //case .fridge: return "refrigerator"
case .leveling: return "level" case .leveling: return "level"
case .solar: return "sun.max"
} }
} }
+48
View File
@@ -0,0 +1,48 @@
import Foundation
/// Zustand des Solarladereglers.
struct SolarState: Equatable, Codable, Sendable {
var batteryVoltage: Double?
var pvVoltage: Double?
var pvCurrent: Double?
var pvPower: Double?
var controllerTemperature: Double?
var isBatteryCharging: Bool?
var isBatteryDischarging: Bool?
var isControllerActive: Bool?
var isCurrentLimited: Bool?
var hasReading: Bool {
batteryVoltage != nil || pvVoltage != nil || pvCurrent != nil || pvPower != nil
}
/// Kurzer Klartext, wie bei den übrigen Geräten als "Zustand" angezeigt.
var stateText: String? {
guard isControllerActive != nil else { return nil }
if isBatteryCharging == true { return "Lädt" }
if isControllerActive == true { return "Aktiv" }
return "Inaktiv"
}
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
snapshot.metrics = [
Metric("pv_power", "Solarleistung", pvPower, unit: "W", precision: 0, primary: true),
Metric("pv_voltage", "PV-Spannung", pvVoltage, unit: "V", precision: 1),
Metric("pv_current", "PV-Strom", pvCurrent, unit: "A", precision: 1),
Metric("battery_voltage", "Batteriespannung", batteryVoltage, unit: "V", precision: 2),
]
if let controllerTemperature {
snapshot.metrics.append(
Metric("controller_temperature", "Reglertemperatur", controllerTemperature,
unit: "°C", precision: 0)
)
}
snapshot.state = stateText
if isCurrentLimited == true {
snapshot.offReasons = ["PV-Strombegrenzung aktiv"]
}
return snapshot
}
}
@@ -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
}
+60 -18
View File
@@ -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;
@@ -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
} }
+6
View File
@@ -22,9 +22,15 @@ esphome:
esp32: esp32:
board: esp32-s3-devkitc-1 board: esp32-s3-devkitc-1
flash_size: 16MB
framework: framework:
type: esp-idf type: esp-idf
# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
psram:
mode: octal
speed: 80MHz
logger: logger:
level: WARN level: WARN
+332
View File
@@ -0,0 +1,332 @@
# VanAlign Pro - Neigungsmessung über BLE (SIMULATION)
#
# Kopie von esp32_ble.yaml für den Fall, dass gerade kein MPU6050 zum
# Anschliessen vorhanden ist. Der `platform: mpu6050`-Sensor sowie der
# i2c-Bus wurden entfernt und durch Template-Sensoren ersetzt, die
# plausible, sich langsam ändernde Beschleunigungswerte erzeugen (ein
# gedachter Sensor, der gemütlich hin- und herschaukelt). Pitch/Roll,
# Kalibrierung und die BLE-Charakteristiken funktionieren dadurch exakt wie
# im Original - nur eben ohne angeschlossene Hardware.
#
# Name und Friendly Name sind bewusst auf "-sim" abgeändert, damit dieses
# Gerät im Netzwerk/BLE nicht mit einem echten VanAlign-Gerät kollidiert.
#
# Sobald wieder ein echter MPU6050 verfügbar ist, einfach esp32_ble.yaml
# weiterverwenden - diese Datei ist nur zum Testen der App/BLE-Anbindung.
esphome:
name: vanalign-sim
friendly_name: "VanAlign Pro (Sim)"
esp32:
board: esp32-s3-devkitc-1
flash_size: 16MB
framework:
type: esp-idf
# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
psram:
mode: octal
speed: 80MHz
logger:
level: WARN
espnow:
channel: 1
sensor:
# Simulierte Rohwerte anstelle des physischen MPU6050. Die Sensor-Lage
# (Pitch/Roll) wandert langsam und stetig, wie es ein tatsächlich leicht
# schaukelndes Fahrzeug/Werkstück tun würde (Perioden ~75s/~113s).
- platform: template
name: "MPU6050 Accel X (Sim)"
id: accel_x
internal: true
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
float pitch_rad = (15.0f * sin(t / 12.0f)) * 3.14159265f / 180.0f;
float roll_rad = (10.0f * sin(t / 18.0f + 1.0f)) * 3.14159265f / 180.0f;
return -9.80665f * sin(roll_rad) * cos(pitch_rad);
- platform: template
name: "MPU6050 Accel Y (Sim)"
id: accel_y
internal: true
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
float pitch_rad = (15.0f * sin(t / 12.0f)) * 3.14159265f / 180.0f;
return 9.80665f * sin(pitch_rad);
- platform: template
name: "MPU6050 Accel Z (Sim)"
id: accel_z
internal: true
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
float pitch_rad = (15.0f * sin(t / 12.0f)) * 3.14159265f / 180.0f;
float roll_rad = (10.0f * sin(t / 18.0f + 1.0f)) * 3.14159265f / 180.0f;
return 9.80665f * cos(roll_rad) * cos(pitch_rad);
# Gyro-Werte werden nur zur Anzeige simuliert (kleine Winkelgeschwindigkeit
# passend zur Schaukelbewegung oben, kein realer Bezug nötig).
- platform: template
name: "MPU6050 Gyro X-Achse"
id: mpu_gyro_x
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
return (10.0f / 18.0f) * cos(t / 18.0f + 1.0f) * 3.14159265f / 180.0f;
- platform: template
name: "MPU6050 Gyro Y-Achse"
id: mpu_gyro_y
update_interval: 0.1s
lambda: |-
float t = millis() / 1000.0f;
return (15.0f / 12.0f) * cos(t / 12.0f) * 3.14159265f / 180.0f;
- platform: template
name: "MPU6050 Gyro Z-Achse"
id: mpu_gyro_z
update_interval: 0.1s
lambda: |-
return 0.0f;
- platform: template
name: "Neigung Pitch"
id: pitch
icon: mdi:caravan
unit_of_measurement: "°"
accuracy_decimals: 1
update_interval: 0.1s
lambda: |-
if (isnan(id(accel_x).state) || isnan(id(accel_y).state) || isnan(id(accel_z).state)) {
return NAN;
}
float raw = atan2(id(accel_y).state, sqrt(pow(id(accel_x).state, 2) + pow(id(accel_z).state, 2))) * (180.0 / 3.14159265);
return raw - id(pitch_offset); // Offset wird hier subtrahiert
filters:
- sliding_window_moving_average:
window_size: 8
send_every: 1
- exponential_moving_average:
alpha: 0.2
- platform: template
name: "Neigung Roll"
id: roll
icon: mdi:axis-x-rotate-clockwise
unit_of_measurement: "°"
accuracy_decimals: 1
update_interval: 0.1s
lambda: |-
if (isnan(id(accel_x).state) || isnan(id(accel_z).state)) {
return NAN;
}
float raw = atan2(-id(accel_x).state, id(accel_z).state) * (180.0 / 3.14159265);
return raw - id(roll_offset); // Offset wird hier subtrahiert
filters:
- sliding_window_moving_average:
window_size: 8
send_every: 1
- exponential_moving_average:
alpha: 0.2
globals:
- id: pitch_offset
type: float
restore_value: yes
initial_value: '0.0'
- id: roll_offset
type: float
restore_value: yes
initial_value: '0.0'
# Die Einbaulage liegt im Gerät, nicht in den Apps: Sie beschreibt, wie der
# Sensor im Fahrzeug sitzt eine Eigenschaft des Einbaus, nicht des Telefons.
# Damit sehen iPhone, Uhr und Android dasselbe, ohne sie je einzeln zu
# bestimmen. Angewandt wird sie weiterhin in den Apps; das Gerät verwahrt sie
# nur, sonst rechneten ältere Clients die Korrektur ein zweites Mal.
- id: orientation_version
type: uint8_t
restore_value: yes
initial_value: '0'
- id: orientation_source
type: uint8_t
restore_value: yes
initial_value: '0'
- id: orientation_invert_long
type: bool
restore_value: yes
initial_value: 'false'
- id: orientation_invert_lat
type: bool
restore_value: yes
initial_value: 'false'
- id: orientation_twist
type: float
restore_value: yes
initial_value: '0.0'
- id: enable_captive
type: bool
restore_value: yes
initial_value: 'false'
esp32_ble_server:
services:
- uuid: 2a24b789-7aab-4535-af3e-ee76a35cc42d
advertise: true
characteristics:
- id: pitch_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233424
description: "Pitch"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pitch).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: roll_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233425
description: "Roll"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(roll).state;
memcpy(v.data(), &val, sizeof(float));
return v;
# Die gespeicherten Nullpunkte, zwei Floats. Daran erkennen die Apps,
# ob überhaupt schon kalibriert wurde.
- id: offsets_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233426
description: "Kalibrier-Offsets"
read: true
value: !lambda |-
std::vector<unsigned char> v(2 * sizeof(float));
float p = id(pitch_offset);
float r = id(roll_offset);
memcpy(v.data(), &p, sizeof(float));
memcpy(v.data() + sizeof(float), &r, sizeof(float));
return v;
# Die Einbaulage, acht Byte:
#
# 0 Version, 1 = gültig gesetzt, 0 = nie geschrieben
# 1 Längsachse: 0 = Pitch des Sensors, 1 = Roll des Sensors
# 2 längs umgekehrt (0/1)
# 3 quer umgekehrt (0/1)
# 4..7 Verdrehung um die Hochachse, float32, Grad
- id: orientation_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233428
description: "Einbaulage"
read: true
write: true
value: !lambda |-
std::vector<unsigned char> v(8, 0);
v[0] = id(orientation_version);
v[1] = id(orientation_source);
v[2] = id(orientation_invert_long) ? 1 : 0;
v[3] = id(orientation_invert_lat) ? 1 : 0;
float t = id(orientation_twist);
memcpy(v.data() + 4, &t, sizeof(float));
return v;
on_write:
then:
- lambda: |-
if (x.size() < 8 || x[0] != 1) {
ESP_LOGW("vanalign", "Einbaulage verworfen: %d Byte, Version %d",
(int) x.size(), x.empty() ? -1 : (int) x[0]);
return;
}
float t;
memcpy(&t, x.data() + 4, sizeof(float));
if (!std::isfinite(t) || fabsf(t) > 180.0f) {
ESP_LOGW("vanalign", "Einbaulage verworfen: Verdrehung %.1f", t);
return;
}
id(orientation_version) = 1;
id(orientation_source) = x[1];
id(orientation_invert_long) = x[2] != 0;
id(orientation_invert_lat) = x[3] != 0;
id(orientation_twist) = t;
ESP_LOGI("vanalign", "Einbaulage gespeichert: Quelle=%d laengs=%d quer=%d verdreht=%.1f",
(int) x[1], (int) x[2], (int) x[3], t);
- id: calib_ble
uuid: cad48e28-7fbe-41cf-bae9-d77a6c233427
description: "Kalibriere Neigung"
write: true
on_write:
then:
- lambda: |-
bool reset = !x.empty() && (x[0] == 0x00 || x[0] == '0');
if (reset) {
id(reset_calibration).execute();
} else {
id(calibrate_level).execute();
}
#web_server:
# port: 80
#ota:
# platform: web_server
#wifi:
# ap:
# ssid: "VanAlign-Setup"
# password: "kalibrierung"
script:
# Die aktuelle Lage wird zur neuen Null. Knopf und Bluetooth laufen hier
# zusammen, damit sie nicht auseinanderdriften.
- id: calibrate_level
then:
- lambda: |-
if (isnan(id(accel_x).state) || isnan(id(accel_y).state) || isnan(id(accel_z).state)) {
ESP_LOGW("vanalign", "Kalibrierung abgebrochen: keine Sensorwerte");
return;
}
id(pitch_offset) = atan2(id(accel_y).state, sqrt(pow(id(accel_x).state, 2) + pow(id(accel_z).state, 2))) * (180.0 / 3.14159265);
id(roll_offset) = atan2(-id(accel_x).state, id(accel_z).state) * (180.0 / 3.14159265);
ESP_LOGI("vanalign", "Kalibriert: pitch_offset=%.2f roll_offset=%.2f", id(pitch_offset), id(roll_offset));
- id: reset_calibration
then:
- lambda: |-
id(pitch_offset) = 0.0f;
id(roll_offset) = 0.0f;
ESP_LOGI("vanalign", "Kalibrierung zurückgesetzt");
button:
- platform: template
name: "Kalibriere Neigung"
id: calib_button
on_press:
- script.execute: calibrate_level
- platform: template
name: "Kalibrierung zurücksetzen"
id: calib_reset_button
on_press:
- script.execute: reset_calibration
- platform: restart
name: "ESP Restart"
text_sensor:
- platform: template
name: "Firmware Version"
id: firmware_version
icon: mdi:tag
lambda: |-
return {"v1.0.2-sim"};
+233
View File
@@ -0,0 +1,233 @@
# VanAlign Solar - Votronic Solarladeregler über BLE
#
# Zweiter ESP32 im Fahrzeug, unabhängig vom Neigungssensor (esp32_ble.yaml).
# Liest den Votronic-Solarladeregler über den Displaylink-Port (UART) mit der
# externen Komponente github://syssi/esphome-votronic aus und stellt die
# Werte - genau wie beim Neigungssensor - über einen eigenen BLE-Service
# bereit. Zusätzlich (für Debug-Zwecke) läuft WLAN mit und die Werte werden
# auch auf dem eingebauten Webserver (Port 80) angezeigt - MQTT und die API
# sind weiterhin nicht enthalten. WLAN-Zugangsdaten liegen in secrets.yaml
# (lokal anzulegen, ist per .gitignore ausgeschlossen).
#
# Verkabelung: UART TX=GPIO4, RX=GPIO5 an den Displaylink-Port des Reglers,
# Baudrate 1000 (kein Tippfehler - das Votronic-Protokoll nutzt diese
# ungewöhnlich niedrige Rate). Board/Pins ggf. an die tatsächlich verbaute
# Hardware anpassen, hier als ESP32-S3-DevKitC-1 wie beim Neigungssensor
# angenommen.
#
# BLE-Service 05c9a349-2b8e-4b1d-9c9d-c247e9a6a001 (wird beworben):
#
# Charakteristik UUID (Ende) Inhalt
# Batteriespg. ...a101 Float32 LE, Volt
# PV-Spannung ...a102 Float32 LE, Volt
# PV-Strom ...a103 Float32 LE, Ampere
# PV-Leistung ...a104 Float32 LE, Watt
# Reglertemp. ...a105 Float32 LE, °C
# Statusflags ...a106 1 Byte, Bitmaske: Bit0 Batterie lädt,
# Bit1 Batterie entlädt, Bit2 PV-Regler
# aktiv, Bit3 PV-Strombegrenzung, Bit4 AES
# PV-Modus-ID ...a107 1 Byte, roher Wert aus pv_mode_setting_id
# Batteriestatus ...a108 1 Byte, rohe Bitmaske aus dem Regler
# Reglerstatus ...a109 1 Byte, rohe Bitmaske aus dem Regler
#
# Alle Charakteristiken sind reine Lesewerte, wie beim Neigungssensor fragt
# der Client sie im Takt ab.
esphome:
name: vanalign-solar
friendly_name: "VanAlign Solar"
esp32:
board: esp32-s3-devkitc-1
flash_size: 16MB
framework:
type: esp-idf
# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
psram:
mode: octal
speed: 80MHz
logger:
level: WARN
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
web_server:
port: 80
external_components:
- source: github://syssi/esphome-votronic@main
refresh: 0s
uart:
- id: uart_0
baud_rate: 1000
tx_pin: GPIO4
rx_pin: GPIO5
votronic:
- id: votronic0
uart_id: uart_0
rx_timeout: 150ms
throttle: 2s
sensor:
- platform: votronic
votronic_id: votronic0
battery_computer_battery_voltage:
name: "Batteriespannung"
id: battery_voltage
pv_voltage:
name: "PV Spannung"
id: pv_voltage
pv_current:
name: "PV Strom"
id: pv_current
pv_power:
name: "PV Leistung"
id: pv_power
pv_controller_temperature:
name: "Reglertemperatur"
id: pv_temperature
pv_mode_setting_id:
name: "PV Modus-ID"
id: pv_mode_id
pv_battery_status_bitmask:
name: "PV Batteriestatus (Bitmaske)"
id: pv_battery_status_bitmask
pv_controller_status_bitmask:
name: "PV Reglerstatus (Bitmaske)"
id: pv_controller_status_bitmask
binary_sensor:
- platform: votronic
votronic_id: votronic0
battery_computer_charging:
name: "Batterie lädt"
id: battery_charging
battery_computer_discharging:
name: "Batterie entlädt"
id: battery_discharging
pv_controller_active:
name: "PV Regler aktiv"
id: pv_active
pv_current_reduction:
name: "PV Strombegrenzung"
id: pv_current_reduction
pv_aes_active:
name: "PV AES aktiv"
id: pv_aes_active
text_sensor:
- platform: votronic
votronic_id: votronic0
pv_mode_setting:
name: "PV Modus"
pv_battery_status:
name: "PV Batteriestatus"
pv_controller_status:
name: "PV Reglerstatus"
- platform: template
name: "Firmware Version"
id: firmware_version
icon: mdi:tag
lambda: |-
return {"v1.0.0"};
esp32_ble_server:
services:
- uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a001
advertise: true
characteristics:
- id: battery_voltage_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a101
description: "Batteriespannung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(battery_voltage).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_voltage_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a102
description: "PV Spannung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_voltage).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_current_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a103
description: "PV Strom"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_current).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_power_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a104
description: "PV Leistung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_power).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_temperature_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a105
description: "Reglertemperatur"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_temperature).state;
memcpy(v.data(), &val, sizeof(float));
return v;
# Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
# Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
- id: status_flags_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a106
description: "Statusflags"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
uint8_t flags = 0;
if (id(battery_charging).state) flags |= (1 << 0);
if (id(battery_discharging).state) flags |= (1 << 1);
if (id(pv_active).state) flags |= (1 << 2);
if (id(pv_current_reduction).state) flags |= (1 << 3);
if (id(pv_aes_active).state) flags |= (1 << 4);
v[0] = flags;
return v;
- id: pv_mode_id_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a107
description: "PV Modus-ID"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_mode_id).state;
return v;
- id: pv_battery_status_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a108
description: "PV Batteriestatus (Bitmaske)"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_battery_status_bitmask).state;
return v;
- id: pv_controller_status_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a109
description: "PV Reglerstatus (Bitmaske)"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_controller_status_bitmask).state;
return v;
button:
- platform: restart
name: "ESP Restart"
+265
View File
@@ -0,0 +1,265 @@
# VanAlign Solar - Votronic Solarladeregler über BLE (SIMULATION)
#
# Kopie von esp32_ble_solar.yaml für den Fall, dass gerade kein Votronic-
# Regler zum Anschliessen vorhanden ist. Die `platform: votronic`-Sensoren
# sowie UART/external_components wurden entfernt und durch Template-Sensoren
# ersetzt, die einen plausiblen Tagesverlauf simulieren: PV-Spannung/-Strom
# folgen einem "Sonnenstand" (ein gedachter, ca. 6 Minuten langer Tag), die
# Batteriespannung schwankt gemütlich mit, und die Statusflags/Bitmasken
# leiten sich aus diesen Werten ab. Die BLE-Charakteristiken funktionieren
# dadurch exakt wie im Original - nur eben ohne angeschlossene Hardware.
#
# Name und Friendly Name sind bewusst auf "-sim" abgeändert, damit dieses
# Gerät im BLE-Umfeld nicht mit einem echten VanAlign-Solar-Gerät kollidiert.
#
# Für Debug-Zwecke läuft WLAN mit und die Werte werden zusätzlich auf dem
# eingebauten Webserver (Port 80) angezeigt; WLAN-Zugangsdaten liegen in
# secrets.yaml (lokal anzulegen, ist per .gitignore ausgeschlossen). Alle
# simulierten Werte aktualisieren sich höchstens jede Sekunde, damit sich
# Änderungen beim Debuggen zügig zeigen.
#
# Sobald wieder ein echter Votronic-Regler verfügbar ist, einfach
# esp32_ble_solar.yaml weiterverwenden - diese Datei ist nur zum Testen der
# App/BLE-Anbindung.
esphome:
name: vanalign-solar-sim
friendly_name: "VanAlign Solar (Sim)"
esp32:
board: esp32-s3-devkitc-1
flash_size: 16MB
framework:
type: esp-idf
# N16R8: 16 MB Flash + 8 MB PSRAM, beim S3 als Octal-PSRAM angebunden.
psram:
mode: octal
speed: 80MHz
logger:
level: WARN
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
web_server:
port: 80
sensor:
# Simulierter "Sonnenstand": 0 nachts, sanfter Buckel tagsüber, Periode
# ca. 6 Minuten - reicht zum Beobachten eines vollen Auf/Ab in der App.
- platform: template
name: "Sonnenstand (Sim)"
id: sun_level
internal: true
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return std::max(0.0f, sinf(t / 180.0f));
- platform: template
name: "Batteriespannung (Sim)"
id: battery_voltage
unit_of_measurement: "V"
accuracy_decimals: 2
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return 12.6f + 0.8f * id(sun_level).state + 0.05f * sin(t / 20.0f);
- platform: template
name: "PV Spannung (Sim)"
id: pv_voltage
unit_of_measurement: "V"
accuracy_decimals: 2
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return id(sun_level).state > 0.01f ? (19.0f + 1.0f * sin(t / 25.0f)) : 0.0f;
- platform: template
name: "PV Strom (Sim)"
id: pv_current
unit_of_measurement: "A"
accuracy_decimals: 2
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return id(sun_level).state * (6.0f + 1.5f * sin(t / 17.0f));
- platform: template
name: "PV Leistung (Sim)"
id: pv_power
unit_of_measurement: "W"
accuracy_decimals: 1
update_interval: 1s
lambda: |-
return id(pv_voltage).state * id(pv_current).state;
- platform: template
name: "Reglertemperatur (Sim)"
id: pv_temperature
unit_of_measurement: "°C"
accuracy_decimals: 1
update_interval: 1s
lambda: |-
float t = millis() / 1000.0f;
return 22.0f + 10.0f * id(sun_level).state + 1.0f * sin(t / 11.0f);
- platform: template
name: "PV Modus-ID (Sim)"
id: pv_mode_id
update_interval: 1s
lambda: |-
return 3.0f; // fester simulierter Modus (MPPT)
- platform: template
name: "PV Batteriestatus (Bitmaske, Sim)"
id: pv_battery_status_bitmask
update_interval: 1s
lambda: |-
return id(sun_level).state > 0.01f ? 1.0f : 0.0f;
- platform: template
name: "PV Reglerstatus (Bitmaske, Sim)"
id: pv_controller_status_bitmask
update_interval: 1s
lambda: |-
return id(sun_level).state > 0.01f ? 2.0f : 0.0f;
binary_sensor:
- platform: template
name: "Batterie lädt (Sim)"
id: battery_charging
lambda: |-
return id(pv_current).state > 0.2f;
- platform: template
name: "Batterie entlädt (Sim)"
id: battery_discharging
lambda: |-
return id(pv_current).state <= 0.2f;
- platform: template
name: "PV Regler aktiv (Sim)"
id: pv_active
lambda: |-
return id(sun_level).state > 0.01f;
- platform: template
name: "PV Strombegrenzung (Sim)"
id: pv_current_reduction
lambda: |-
float t = millis() / 1000.0f;
return id(pv_active).state && sin(t / 60.0f) > 0.9f;
- platform: template
name: "PV AES aktiv (Sim)"
id: pv_aes_active
lambda: |-
return id(battery_voltage).state > 13.3f;
text_sensor:
- platform: template
name: "Firmware Version"
id: firmware_version
icon: mdi:tag
lambda: |-
return {"v1.0.0-sim"};
esp32_ble_server:
services:
- uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a001
advertise: true
characteristics:
- id: battery_voltage_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a101
description: "Batteriespannung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(battery_voltage).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_voltage_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a102
description: "PV Spannung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_voltage).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_current_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a103
description: "PV Strom"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_current).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_power_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a104
description: "PV Leistung"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_power).state;
memcpy(v.data(), &val, sizeof(float));
return v;
- id: pv_temperature_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a105
description: "Reglertemperatur"
read: true
value: !lambda |-
std::vector<unsigned char> v(sizeof(float));
float val = id(pv_temperature).state;
memcpy(v.data(), &val, sizeof(float));
return v;
# Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv,
# Bit3 PV-Strombegrenzung, Bit4 AES aktiv.
- id: status_flags_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a106
description: "Statusflags"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
uint8_t flags = 0;
if (id(battery_charging).state) flags |= (1 << 0);
if (id(battery_discharging).state) flags |= (1 << 1);
if (id(pv_active).state) flags |= (1 << 2);
if (id(pv_current_reduction).state) flags |= (1 << 3);
if (id(pv_aes_active).state) flags |= (1 << 4);
v[0] = flags;
return v;
- id: pv_mode_id_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a107
description: "PV Modus-ID"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_mode_id).state;
return v;
- id: pv_battery_status_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a108
description: "PV Batteriestatus (Bitmaske)"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_battery_status_bitmask).state;
return v;
- id: pv_controller_status_ble
uuid: 05c9a349-2b8e-4b1d-9c9d-c247e9a6a109
description: "PV Reglerstatus (Bitmaske)"
read: true
value: !lambda |-
std::vector<unsigned char> v(1, 0);
v[0] = (uint8_t) id(pv_controller_status_bitmask).state;
return v;
button:
- platform: restart
name: "ESP Restart"