diff --git a/CamperMonitor.xcodeproj/project.pbxproj b/CamperMonitor.xcodeproj/project.pbxproj index 35bbf90..c78cca9 100644 --- a/CamperMonitor.xcodeproj/project.pbxproj +++ b/CamperMonitor.xcodeproj/project.pbxproj @@ -9,6 +9,11 @@ /* Begin PBXBuildFile section */ AA0000000000000000000027 /* CamperMonitorWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000016 /* CamperMonitorWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; AA0000000000000000000042 /* CamperMonitorComplication.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000032 /* CamperMonitorComplication.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + AA0000000000000000000046 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000053 /* ActivityKit.framework */; }; + AA0000000000000000000047 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000052 /* SwiftUI.framework */; }; + AA0000000000000000000048 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000051 /* WidgetKit.framework */; }; + AA0000000000000000000049 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000053 /* ActivityKit.framework */; }; + AA0000000000000000000050 /* VanAligneiOSWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000054 /* VanAligneiOSWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -26,6 +31,13 @@ remoteGlobalIDString = AA0000000000000000000037; remoteInfo = CamperMonitorComplication; }; + AA0000000000000000000063 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AA0000000000000000000009 /* Project object */; + proxyType = 1; + remoteGlobalIDString = AA0000000000000000000062; + remoteInfo = VanAligneiOSWidgetExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -51,6 +63,17 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + AA0000000000000000000065 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + AA0000000000000000000050 /* VanAligneiOSWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -59,8 +82,22 @@ AA0000000000000000000030 /* CamperMonitor-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitor-Info.plist"; sourceTree = ""; }; AA0000000000000000000032 /* CamperMonitorComplication.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CamperMonitorComplication.appex; sourceTree = BUILT_PRODUCTS_DIR; }; AA0000000000000000000045 /* CamperMonitorComplication-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "CamperMonitorComplication-Info.plist"; sourceTree = ""; }; + AA0000000000000000000051 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + AA0000000000000000000052 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + AA0000000000000000000053 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; + AA0000000000000000000054 /* VanAligneiOSWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = VanAligneiOSWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + AA0000000000000000000055 /* Exceptions for "VanAligneiOSWidget" folder in "VanAligneiOSWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = AA0000000000000000000062 /* VanAligneiOSWidgetExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ AA0000000000000000000002 /* CamperMonitor */ = { isa = PBXFileSystemSynchronizedRootGroup; @@ -82,6 +119,19 @@ path = CamperMonitorComplication; sourceTree = ""; }; + AA0000000000000000000056 /* VanAligneiOSWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + AA0000000000000000000055 /* Exceptions for "VanAligneiOSWidget" folder in "VanAligneiOSWidgetExtension" target */, + ); + path = VanAligneiOSWidget; + sourceTree = ""; + }; + AA0000000000000000000057 /* SharedActivity */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SharedActivity; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -89,6 +139,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + AA0000000000000000000049 /* ActivityKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -106,6 +157,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000000000000000000058 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AA0000000000000000000047 /* SwiftUI.framework in Frameworks */, + AA0000000000000000000048 /* WidgetKit.framework in Frameworks */, + AA0000000000000000000046 /* ActivityKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -116,7 +177,10 @@ AA0000000000000000000017 /* CamperMonitorWatch */, AA0000000000000000000033 /* CamperMonitorComplication */, AA0000000000000000000018 /* Shared */, + AA0000000000000000000057 /* SharedActivity */, AA0000000000000000000031 /* Config */, + AA0000000000000000000056 /* VanAligneiOSWidget */, + AA0000000000000000000059 /* Frameworks */, AA0000000000000000000005 /* Products */, ); sourceTree = ""; @@ -127,10 +191,21 @@ AA0000000000000000000001 /* CamperMonitor.app */, AA0000000000000000000016 /* CamperMonitorWatch.app */, AA0000000000000000000032 /* CamperMonitorComplication.appex */, + AA0000000000000000000054 /* VanAligneiOSWidgetExtension.appex */, ); name = Products; sourceTree = ""; }; + AA0000000000000000000059 /* Frameworks */ = { + isa = PBXGroup; + children = ( + AA0000000000000000000051 /* WidgetKit.framework */, + AA0000000000000000000052 /* SwiftUI.framework */, + AA0000000000000000000053 /* ActivityKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; AA0000000000000000000031 /* Config */ = { isa = PBXGroup; children = ( @@ -151,15 +226,18 @@ AA0000000000000000000003 /* Frameworks */, AA0000000000000000000008 /* Resources */, AA0000000000000000000026 /* Embed Watch Content */, + AA0000000000000000000065 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( AA0000000000000000000029 /* PBXTargetDependency */, + AA0000000000000000000064 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( AA0000000000000000000002 /* CamperMonitor */, AA0000000000000000000018 /* Shared */, + AA0000000000000000000057 /* SharedActivity */, ); name = CamperMonitor; productName = CamperMonitor; @@ -210,6 +288,27 @@ productReference = AA0000000000000000000032 /* CamperMonitorComplication.appex */; productType = "com.apple.product-type.app-extension"; }; + AA0000000000000000000062 /* VanAligneiOSWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA0000000000000000000068 /* Build configuration list for PBXNativeTarget "VanAligneiOSWidgetExtension" */; + buildPhases = ( + AA0000000000000000000060 /* Sources */, + AA0000000000000000000058 /* Frameworks */, + AA0000000000000000000061 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AA0000000000000000000056 /* VanAligneiOSWidget */, + AA0000000000000000000057 /* SharedActivity */, + ); + name = VanAligneiOSWidgetExtension; + productName = VanAligneiOSWidgetExtension; + productReference = AA0000000000000000000054 /* VanAligneiOSWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -229,6 +328,9 @@ AA0000000000000000000037 = { CreatedOnToolsVersion = 26.6; }; + AA0000000000000000000062 = { + CreatedOnToolsVersion = 26.6; + }; }; }; buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */; @@ -249,6 +351,7 @@ AA0000000000000000000006 /* CamperMonitor */, AA0000000000000000000022 /* CamperMonitorWatch */, AA0000000000000000000037 /* CamperMonitorComplication */, + AA0000000000000000000062 /* VanAligneiOSWidgetExtension */, ); }; /* End PBXProject section */ @@ -275,6 +378,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000000000000000000061 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -299,6 +409,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AA0000000000000000000060 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -313,6 +430,11 @@ target = AA0000000000000000000037 /* CamperMonitorComplication */; targetProxy = AA0000000000000000000043 /* PBXContainerItemProxy */; }; + AA0000000000000000000064 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = AA0000000000000000000062 /* VanAligneiOSWidgetExtension */; + targetProxy = AA0000000000000000000063 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -555,6 +677,64 @@ }; name = Release; }; + AA0000000000000000000066 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6AP73NYV5W; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = VanAligneiOSWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = VanAligneiOSWidget; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.VanAligneiOSWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AA0000000000000000000067 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6AP73NYV5W; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = VanAligneiOSWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = VanAligneiOSWidget; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor.VanAligneiOSWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -594,6 +774,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + AA0000000000000000000068 /* Build configuration list for PBXNativeTarget "VanAligneiOSWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000000066 /* Debug */, + AA0000000000000000000067 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = AA0000000000000000000009 /* Project object */; diff --git a/CamperMonitor/Assets.xcassets/CaliforniaSide.imageset/CaliforniaSide.png b/CamperMonitor/Assets.xcassets/CaliforniaSide.imageset/CaliforniaSide.png new file mode 100644 index 0000000..57a56c2 Binary files /dev/null and b/CamperMonitor/Assets.xcassets/CaliforniaSide.imageset/CaliforniaSide.png differ diff --git a/CamperMonitor/Assets.xcassets/CaliforniaSide.imageset/Contents.json b/CamperMonitor/Assets.xcassets/CaliforniaSide.imageset/Contents.json new file mode 100644 index 0000000..ceefcab --- /dev/null +++ b/CamperMonitor/Assets.xcassets/CaliforniaSide.imageset/Contents.json @@ -0,0 +1,10 @@ +{ + "images" : [ + { + "filename" : "CaliforniaSide.png", + "idiom" : "universal" + } + ], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "template-rendering-intent" : "template" } +} diff --git a/CamperMonitor/Assets.xcassets/VehicleRear.imageset/Contents.json b/CamperMonitor/Assets.xcassets/VansterRear.imageset/Contents.json similarity index 82% rename from CamperMonitor/Assets.xcassets/VehicleRear.imageset/Contents.json rename to CamperMonitor/Assets.xcassets/VansterRear.imageset/Contents.json index a0f8834..7848505 100644 --- a/CamperMonitor/Assets.xcassets/VehicleRear.imageset/Contents.json +++ b/CamperMonitor/Assets.xcassets/VansterRear.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "VehicleRear.png", + "filename" : "VansterRear.png", "idiom" : "universal" } ], diff --git a/CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png b/CamperMonitor/Assets.xcassets/VansterRear.imageset/VansterRear.png similarity index 100% rename from CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png rename to CamperMonitor/Assets.xcassets/VansterRear.imageset/VansterRear.png diff --git a/CamperMonitor/Assets.xcassets/VehicleSide.imageset/Contents.json b/CamperMonitor/Assets.xcassets/VansterSide.imageset/Contents.json similarity index 82% rename from CamperMonitor/Assets.xcassets/VehicleSide.imageset/Contents.json rename to CamperMonitor/Assets.xcassets/VansterSide.imageset/Contents.json index 7e18a2d..dfeb5ba 100644 --- a/CamperMonitor/Assets.xcassets/VehicleSide.imageset/Contents.json +++ b/CamperMonitor/Assets.xcassets/VansterSide.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "VehicleSide.png", + "filename" : "VansterSide.png", "idiom" : "universal" } ], diff --git a/CamperMonitor/Assets.xcassets/VehicleSide.imageset/VehicleSide.png b/CamperMonitor/Assets.xcassets/VansterSide.imageset/VansterSide.png similarity index 100% rename from CamperMonitor/Assets.xcassets/VehicleSide.imageset/VehicleSide.png rename to CamperMonitor/Assets.xcassets/VansterSide.imageset/VansterSide.png diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index 00965fe..7471b5e 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -17,6 +17,8 @@ struct Discovery: Identifiable, Hashable { var looksLikeSupported: Bool /// Der Neigungsmesser bewirbt seinen Dienst, ist also sicher erkennbar. var isLevelSensor = false + /// Der Solarladeregler bewirbt seinen Dienst ebenso. + var isVotronicSolarESPSensor = false var isVictron: Bool { victronRecordType != nil } @@ -30,6 +32,7 @@ struct Discovery: Identifiable, Hashable { return "Victron · " + Self.victronRecordName(type) } if isLevelSensor { return "VanAlign Neigungsmesser" } + if isVotronicSolarESPSensor { return "VotronicSolarESP" } if looksLikeSupported { return "Sieht nach BMS oder Kühlbox aus" } return "Bluetooth-Gerät" } @@ -149,9 +152,15 @@ final class BluetoothManager: NSObject { private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:] private(set) var fridgeStates: [UUID: AlpicoolState] = [:] private(set) var levelStates: [UUID: LevelState] = [:] + private(set) var votronicSolarESPStates: [UUID: VotronicSolarESPState] = [:] private(set) var isBluetoothReady = false private(set) var bluetoothStatusText = "Bluetooth wird gestartet…" + /// Verbindet die Live Activity des Neigungsmessers mit dem Funkgeschehen – + /// gesetzt von `CamperMonitorApp`, damit diese Schicht nichts über + /// ActivityKit wissen muss, wenn niemand zuhört. + var activityManager: LevelActivityManager? + /// Solange true, werden alle gefundenen Peripherals gesammelt. var isDiscovering = false { didSet { @@ -185,6 +194,7 @@ final class BluetoothManager: NSObject { private var bmsSessions: [UUID: BMSSession] = [:] private var levelSessions: [UUID: LevelSession] = [:] + private var votronicSolarESPSessions: [UUID: VotronicSolarESPSession] = [:] private var connectedPeripherals: [UUID: CBPeripheral] = [:] private var reconnectTimer: DispatchSourceTimer? @@ -309,6 +319,7 @@ final class BluetoothManager: NSObject { bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) } fridgeStates = fridgeStates.filter { known.contains($0.key) } levelStates = levelStates.filter { known.contains($0.key) } + votronicSolarESPStates = votronicSolarESPStates.filter { known.contains($0.key) } // Geräte, die nur auf Anforderung verbunden werden, zeigen bis dahin // ihren zuletzt gestellten Stand. @@ -516,6 +527,11 @@ final class BluetoothManager: NSObject { levelSessions[peripheralID] = nil disconnect(peripheralID) } + for (peripheralID, session) in votronicSolarESPSessions where !wanted.contains(peripheralID) { + session.stop() + votronicSolarESPSessions[peripheralID] = nil + disconnect(peripheralID) + } // Einstellungen an bestehende Sitzungen weiterreichen. for device in devices { bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode @@ -561,9 +577,11 @@ final class BluetoothManager: NSObject { discoveryFlushTimer?.cancel(); discoveryFlushTimer = nil for (_, session) in bmsSessions { session.stop() } for (_, session) in levelSessions { session.stop() } + for (_, session) in votronicSolarESPSessions { session.stop() } for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) } bmsSessions.removeAll() levelSessions.removeAll() + votronicSolarESPSessions.removeAll() connectedPeripherals.removeAll() connectedSince.removeAll() pendingControls.removeAll() @@ -755,7 +773,8 @@ final class BluetoothManager: NSObject { } let services = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [] entry.isLevelSensor = services.contains(LevelSession.serviceUUID) - entry.looksLikeSupported = entry.isLevelSensor + entry.isVotronicSolarESPSensor = services.contains(VotronicSolarESPSession.serviceUUID) + entry.looksLikeSupported = entry.isLevelSensor || entry.isVotronicSolarESPSensor || Self.looksLikeSupported(name: entry.name) pendingDiscoveries[peripheral.identifier] = entry } @@ -842,10 +861,23 @@ extension BluetoothManager: CBCentralManagerDelegate { queue: queue, onUpdate: { [weak self] snapshot in self?.record(snapshot) }, onStateChange: { [weak self] state in - self?.publish { self?.linkStates[device.id] = state } + self?.publish { + self?.linkStates[device.id] = state + if state == .live { + // Verbindung wieder da: ein anstehendes Ende + // verwerfen oder eine zwischenzeitlich doch schon + // beendete Live Activity wieder aufnehmen. + self?.activityManager?.handleReconnect( + deviceID: device.id, + state: self?.levelStates[device.id] ?? LevelState()) + } + } }, onLevelState: { [weak self] state in - 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 // Im Gerät steht, wie der Sensor eingebaut ist – für alle @@ -859,6 +891,27 @@ extension BluetoothManager: CBCentralManagerDelegate { return } + if device.role == .solar { + // Bewusst kein Draht zu `activityManager`: Der Solarertrag soll + // nicht in der Live Activity/CarPlay auftauchen, die ist dem + // Neigungsmesser vorbehalten. + let session = VotronicSolarESPSession( + deviceID: device.id, + peripheral: peripheral, + queue: queue, + onUpdate: { [weak self] snapshot in self?.record(snapshot) }, + onStateChange: { [weak self] state in + self?.publish { self?.linkStates[device.id] = state } + }, + onVotronicSolarESPState: { [weak self] state in + self?.publish { self?.votronicSolarESPStates[device.id] = state } + } + ) + votronicSolarESPSessions[peripheral.identifier] = session + session.start() + return + } + let session = BMSSession( deviceID: device.id, peripheral: peripheral, @@ -906,6 +959,8 @@ extension BluetoothManager: CBCentralManagerDelegate { bmsSessions[peripheral.identifier] = nil levelSessions[peripheral.identifier]?.handleDisconnect() levelSessions[peripheral.identifier] = nil + votronicSolarESPSessions[peripheral.identifier]?.handleDisconnect() + votronicSolarESPSessions[peripheral.identifier] = nil connectedPeripherals[peripheral.identifier] = nil let lifetime = connectedSince.removeValue(forKey: peripheral.identifier) @@ -920,7 +975,13 @@ extension BluetoothManager: CBCentralManagerDelegate { self.showLastKnownFridgeSettings(for: deviceID) } } else if let device = managed[peripheral.identifier] { - publish { self.linkStates[device.id] = .searching } + let deviceName = store.devices.first { $0.id == device.id }?.name ?? "" + publish { + self.linkStates[device.id] = .searching + // Erst nach einer Gnadenfrist wirklich beenden – sonst + // flackerte die Live Activity bei jedem kurzen Funkloch. + self.activityManager?.handleDisconnect(deviceID: device.id, deviceName: deviceName) + } if lifetime >= stableConnection { // Die Verbindung stand und ist weggefallen - im Fahrzeug der // Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das diff --git a/CamperMonitor/CamperMonitorApp.swift b/CamperMonitor/CamperMonitorApp.swift index bf4eacc..8c92b90 100644 --- a/CamperMonitor/CamperMonitorApp.swift +++ b/CamperMonitor/CamperMonitorApp.swift @@ -5,14 +5,18 @@ struct CamperMonitorApp: App { @State private var store: DeviceStore @State private var bluetooth: BluetoothManager @State private var watch: PhoneWatchLink + @State private var levelActivity: LevelActivityManager @Environment(\.scenePhase) private var scenePhase init() { let store = DeviceStore() let bluetooth = BluetoothManager(store: store) + let levelActivity = LevelActivityManager() + bluetooth.activityManager = levelActivity _store = State(initialValue: store) _bluetooth = State(initialValue: bluetooth) _watch = State(initialValue: PhoneWatchLink(store: store, bluetooth: bluetooth)) + _levelActivity = State(initialValue: levelActivity) } var body: some Scene { @@ -21,6 +25,7 @@ struct CamperMonitorApp: App { .environment(store) .environment(bluetooth) .environment(watch) + .environment(levelActivity) .task { watch.activate() } } .onChange(of: scenePhase) { _, phase in @@ -38,7 +43,11 @@ struct CamperMonitorApp: App { case .active: bluetooth.start() 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: break } diff --git a/CamperMonitor/LiveActivity/LevelActivityManager.swift b/CamperMonitor/LiveActivity/LevelActivityManager.swift new file mode 100644 index 0000000..35ef118 --- /dev/null +++ b/CamperMonitor/LiveActivity/LevelActivityManager.swift @@ -0,0 +1,119 @@ +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 26 zeigt +/// CarPlay eine laufende Live Activity automatisch im Dashboard an; ein +/// eigenes CarPlay-App-Target braucht es dafür nicht – die Inhalte kommen +/// aber nicht von der Sperrbildschirm-Ansicht, sondern von der +/// `.small`-Aktivitätsfamilie (siehe `VanAligneiOSWidgetLiveActivity`). +@Observable +final class LevelActivityManager { + private(set) var trackedDeviceID: UUID? + @ObservationIgnored private var activity: Activity? + + /// Wie lange nach einem Verbindungsabbruch gewartet wird, bevor die + /// Aktivität wirklich endet. Verbindungen fallen im Fahrzeug regelmässig + /// kurz weg – ohne diese Gnadenfrist flackerte die Anzeige bei jedem + /// kurzen Funkloch aus und wieder ein. + static let disconnectGrace: TimeInterval = 12 + + @ObservationIgnored private var pendingEnd: Task? + /// Gerät, dessen Aktivität wegen einer länger anhaltenden Trennung + /// tatsächlich beendet wurde – bei der nächsten Wiederverbindung wird sie + /// automatisch neu gestartet, damit das kein bewusster Nutzer-Stop war. + @ObservationIgnored private var deviceToResume: (id: UUID, name: String)? + + /// Ob gerade irgendeine Aktivität läuft – die App muss dafür im + /// 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) { + pendingEnd?.cancel() + pendingEnd = nil + deviceToResume = nil + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + endActivity() + 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) } + } + + /// Bewusstes Beenden durch den Nutzer – anders als bei einem + /// Verbindungsabbruch soll das bei der nächsten Verbindung nicht + /// automatisch wieder aufleben. + func end() { + pendingEnd?.cancel() + pendingEnd = nil + deviceToResume = nil + endActivity() + } + + /// BLE-Verbindung weg: nicht sofort beenden, sondern erst nach einer + /// kurzen Gnadenfrist – meldet sich das Gerät vorher zurück + /// (`handleReconnect`), passiert gar nichts. + func handleDisconnect(deviceID: UUID, deviceName: String) { + guard trackedDeviceID == deviceID, activity != nil, pendingEnd == nil else { return } + pendingEnd = Task { [weak self] in + try? await Task.sleep(for: .seconds(Self.disconnectGrace)) + guard let self, !Task.isCancelled, self.trackedDeviceID == deviceID else { return } + self.deviceToResume = (deviceID, deviceName) + self.endActivity() + self.pendingEnd = nil + } + } + + /// BLE-Verbindung wieder da: ein noch anstehendes Ende verwerfen, oder – + /// falls die Gnadenfrist schon abgelaufen und die Aktivität wirklich + /// beendet war – sie mit dem letzten bekannten Stand neu starten. + func handleReconnect(deviceID: UUID, state: LevelState) { + if pendingEnd != nil, trackedDeviceID == deviceID { + pendingEnd?.cancel() + pendingEnd = nil + return + } + guard let resume = deviceToResume, resume.id == deviceID else { return } + deviceToResume = nil + start(deviceID: deviceID, deviceName: resume.name, state: state) + } + + private func endActivity() { + guard let activity else { return } + 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() + ) + } +} diff --git a/CamperMonitor/Store/DemoData.swift b/CamperMonitor/Store/DemoData.swift index c1160fd..e2ad63b 100644 --- a/CamperMonitor/Store/DemoData.swift +++ b/CamperMonitor/Store/DemoData.swift @@ -59,8 +59,13 @@ enum DemoData { name: "Nivellierung", role: .leveling, profileID: Profile.defaultID, peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000E1")!) + static let votronicSolar = ConfiguredDevice( + id: UUID(uuidString: "00000000-0000-0000-0000-0000000000D0")!, + name: "VotronicSolarESP", role: .solar, profileID: Profile.defaultID, + peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000D1")!) + static var devices: [ConfiguredDevice] { - [solar, booster, battery, fridge, level, caravanSolar] + [solar, booster, battery, fridge, level, votronicSolar, caravanSolar] } /// Leicht schräg stehend, damit die Libelle etwas zu zeigen hat. @@ -83,6 +88,20 @@ enum DemoData { return state } + /// Mittags, gute Sonne. + static var votronicSolarESPState: VotronicSolarESPState { + var state = VotronicSolarESPState() + state.batteryVoltage = 13.9 + state.pvVoltage = 19.4 + state.pvCurrent = 6.2 + state.pvPower = 120 + state.controllerTemperature = 34 + state.isBatteryCharging = true + state.isControllerActive = true + state.isCurrentLimited = false + return state + } + static func snapshots() -> [DeviceSnapshot] { var solarSnapshot = DeviceSnapshot(deviceID: solar.id, timestamp: Date(), rssi: -58) solarSnapshot.state = "Konstantspannung (Absorption)" @@ -131,6 +150,7 @@ enum DemoData { return [solarSnapshot, boosterSnapshot, batterySnapshot, fridgeState.snapshot(deviceID: fridge.id, rssi: -66), levelState.snapshot(deviceID: level.id, rssi: -70), + votronicSolarESPState.snapshot(deviceID: votronicSolar.id, rssi: -58), caravanSnapshot] } diff --git a/CamperMonitor/Views/AddDeviceView.swift b/CamperMonitor/Views/AddDeviceView.swift index cb98135..357f7fa 100644 --- a/CamperMonitor/Views/AddDeviceView.swift +++ b/CamperMonitor/Views/AddDeviceView.swift @@ -195,6 +195,8 @@ private struct ConfigureDeviceView: View { } } else if discovery.isLevelSensor { role = .leveling + } else if discovery.isVotronicSolarESPSensor { + role = .solar } else if let name = discovery.name?.lowercased(), ["alpicool", "icecube", "ice cube", "fridge", "cool"].contains(where: name.contains) { role = .fridge @@ -206,6 +208,8 @@ private struct ConfigureDeviceView: View { // ohnehin darüber unter "Gefunden als". if discovery.isLevelSensor { name = "Nivellierung" + } else if discovery.isVotronicSolarESPSensor { + name = role.title } else if let advertised = discovery.name, advertised.count <= 20, advertised.contains(" ") || advertised.rangeOfCharacter(from: .decimalDigits) == nil { name = advertised diff --git a/CamperMonitor/Views/AlignmentAssistantView.swift b/CamperMonitor/Views/AlignmentAssistantView.swift index f2e6cf2..4c66857 100644 --- a/CamperMonitor/Views/AlignmentAssistantView.swift +++ b/CamperMonitor/Views/AlignmentAssistantView.swift @@ -11,6 +11,7 @@ struct AlignmentAssistantView: View { @Environment(BluetoothManager.self) private var bluetooth @Environment(\.dismiss) private var dismiss + @Environment(\.verticalSizeClass) private var verticalSizeClass @State private var assistant = AlignmentAssistant() @State private var didAnnounceTarget = false @@ -18,48 +19,18 @@ struct AlignmentAssistantView: View { private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() } + /// iPhone im Querformat meldet eine kompakte Höhe – das ist das + /// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst. + private var isLandscape: Bool { verticalSizeClass == .compact } + var body: some View { NavigationStack { - ScrollView { - VStack(spacing: 24) { - Picker("Darstellung", selection: $displayStyle) { - ForEach(LevelDisplayStyle.allCases) { style in - Text(style.title).tag(style) - } - } - .pickerStyle(.segmented) - .padding(.top, 8) - - switch displayStyle { - case .bubble: - LevelBubble(pitch: state.pitch, roll: state.roll) - .frame(maxWidth: 320) - case .vehicle: - VehicleTiltView(pitch: state.pitch, roll: state.roll) - } - - if !isLive { disconnectedBanner } - - adviceBanner - - readings - - if let best = assistant.best, let gain = assistant.improvementAtBest, - let seconds = assistant.timeSinceBest { - bestPointCard(best: best, gain: gain, seconds: seconds) - } - - wedgeSection - - Button("Neu beginnen", systemImage: "arrow.counterclockwise") { - assistant.reset() - didAnnounceTarget = false - } - .buttonStyle(.bordered) - .padding(.bottom, 24) + Group { + if isLandscape { + landscapeLayout + } else { + portraitLayout } - .padding(.horizontal) - .frame(maxWidth: .infinity) } .background(Color(.systemGroupedBackground)) .navigationTitle("Ausrichtungs-Assistent") @@ -95,10 +66,94 @@ struct AlignmentAssistantView: View { } } + // MARK: - Layouts + + private var portraitLayout: some View { + ScrollView { + VStack(spacing: 24) { + picker.padding(.top, 8) + + display + + if !isLive { disconnectedBanner } + + adviceBanner + + readings + + if let best = assistant.best, let gain = assistant.improvementAtBest, + let seconds = assistant.timeSinceBest { + bestPointCard(best: best, gain: gain, seconds: seconds) + } + + wedgeSection + + resetButton + .padding(.bottom, 24) + } + .padding(.horizontal) + .frame(maxWidth: .infinity) + } + } + + /// Anzeige links, alles zum Rangieren Nötige rechts – ohne Scrollen, denn + /// im Querformat schaut man beiläufig hin, nicht in Ruhe. Die + /// Bestpunkt-Karte und der ausführliche Verbindungs-Hinweis bleiben dafür + /// dem Hochformat vorbehalten; die Keilhöhen bleiben in jedem Fall sichtbar. + private var landscapeLayout: some View { + HStack(alignment: .top, spacing: 16) { + VStack(spacing: 8) { + picker + display + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + + VStack(spacing: 8) { + if !isLive { compactDisconnectedBanner } + adviceBanner + readings + wedgeSection + resetButton + } + .frame(maxWidth: .infinity) + } + .padding(12) + } + // MARK: - Bausteine private var isLive: Bool { bluetooth.linkStates[device.id] == .live } + private var picker: some View { + Picker("Darstellung", selection: $displayStyle) { + ForEach(LevelDisplayStyle.allCases) { style in + Text(style.title).tag(style) + } + } + .pickerStyle(.segmented) + } + + @ViewBuilder + private var display: some View { + switch displayStyle { + case .bubble: + LevelBubble(pitch: state.pitch, roll: state.roll) + .frame(maxWidth: isLandscape ? 220 : 320, maxHeight: isLandscape ? 160 : .infinity) + case .vehicle: + VehicleTiltView(pitch: state.pitch, roll: state.roll, + style: device.vehicleGraphicStyle, compact: isLandscape) + } + } + + private var resetButton: some View { + Button("Neu beginnen", systemImage: "arrow.counterclockwise") { + assistant.reset() + didAnnounceTarget = false + } + .buttonStyle(.bordered) + } + /// Reisst die Verbindung beim Rangieren ab, stehen die Zahlen still. Ohne /// Hinweis sähe das aus, als hinge die App – man rangiert dann nach einem /// Wert, der längst nicht mehr gilt. @@ -120,40 +175,54 @@ struct AlignmentAssistantView: View { .background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 16)) } + /// Kurzform für Querformat: derselbe Hinweis in einer Zeile. + private var compactDisconnectedBanner: some View { + Label("Nicht verbunden – Anzeige steht still", systemImage: "antenna.radiowaves.left.and.right.slash") + .font(.caption.weight(.medium)) + .foregroundStyle(.orange) + .lineLimit(1) + .minimumScaleFactor(0.8) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(Color.orange.opacity(0.15), in: .rect(cornerRadius: 10)) + } + private var adviceBanner: some View { HStack(spacing: 12) { Image(systemName: assistant.hasReachedTarget ? "checkmark.circle.fill" : assistant.trend.symbol) - .font(.title) + .font(isLandscape ? .title2 : .title) .foregroundStyle(assistant.hasReachedTarget ? Color.green : Color.accentColor) Text(assistant.advice) - .font(.title3.weight(.medium)) + .font(isLandscape ? .subheadline.weight(.medium) : .title3.weight(.medium)) .frame(maxWidth: .infinity, alignment: .leading) } - .padding() + .padding(isLandscape ? 10 : 16) .background(assistant.hasReachedTarget ? Color.green.opacity(0.15) : Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16)) } private var readings: some View { - HStack(spacing: 12) { - reading("Längs", state.pitch) - reading("Quer", state.roll) - reading("Gesamt", assistant.current?.deviation) + HStack(spacing: isLandscape ? 8 : 12) { + reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch)) + reading("Quer", LevelDirectionFormatting.rollTile(state.roll)) + if !isLandscape { + reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation)) + } } } - private func reading(_ title: String, _ value: Double?) -> some View { - VStack(spacing: 4) { - Text(value.map { String(format: "%.1f°", $0) } ?? "–") - .font(.title2.weight(.semibold).monospacedDigit()) + private func reading(_ title: String, _ text: String) -> some View { + VStack(spacing: isLandscape ? 1 : 4) { + Text(text) + .font((isLandscape ? Font.callout : .title2).weight(.semibold).monospacedDigit()) Text(title) - .font(.caption) + .font(.caption2) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) - .padding(.vertical, 12) + .padding(.vertical, isLandscape ? 6 : 12) .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 12)) } @@ -187,23 +256,25 @@ struct AlignmentAssistantView: View { trackWidth: width, wheelbase: base) }() - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: isLandscape ? 6 : 10) { Label("Auffahrkeile", systemImage: "triangle.fill") - .font(.headline) + .font(isLandscape ? .subheadline.weight(.semibold) : .headline) if profile?.trackWidth == nil || profile?.wheelbase == nil { Text("Für die Keilhöhe fehlen Spurweite und Radstand. Beides lässt " + "sich beim Fahrzeug hinterlegen.") - .font(.callout) - .foregroundStyle(.secondary) - } else if let lift, !lift.isNegligible { - WheelLiftPlan(lift: lift) - .frame(maxWidth: .infinity) - - Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad " - + "bleibt liegen, die übrigen werden auf seine Höhe gebracht.") .font(.caption) .foregroundStyle(.secondary) + } else if let lift, !lift.isNegligible { + WheelLiftPlan(lift: lift, isCompact: isLandscape) + .frame(maxWidth: .infinity) + + if !isLandscape { + Text("Zentimeter unter das jeweilige Rad. Das höchststehende Rad " + + "bleibt liegen, die übrigen werden auf seine Höhe gebracht.") + .font(.caption) + .foregroundStyle(.secondary) + } } else { Text("Keine Keile nötig.") .font(.callout) @@ -211,7 +282,7 @@ struct AlignmentAssistantView: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .padding() + .padding(isLandscape ? 10 : 16) .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16)) } @@ -229,4 +300,3 @@ struct AlignmentAssistantView: View { #endif } } - diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift index d9f98ae..1ef8f5b 100644 --- a/CamperMonitor/Views/DeviceDetailView.swift +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -7,6 +7,7 @@ struct DeviceDetailView: View { @Environment(DeviceStore.self) private var store @Environment(BluetoothManager.self) private var bluetooth @Environment(\.dismiss) private var dismiss + @Environment(\.verticalSizeClass) private var verticalSizeClass @State private var editedName = "" /// Nur zum Vergleich mit dem, was das Gerät sendet. Eingetragen wird der @@ -37,104 +38,126 @@ struct DeviceDetailView: View { private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] } private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching } + + /// iPhone im Querformat meldet eine kompakte Höhe – das ist das + /// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst. + private var isLandscape: Bool { verticalSizeClass == .compact } + + /// Nur der Neigungsmesser bekommt die Querformat-Sonderbehandlung + /// (Verbindungsstatus ans Ende, Anzeige auf Bildschirmhöhe) – andere + /// Sensoren behalten ihre bisherige Reihenfolge und Grösse. + private var showsCompactLevelLayout: Bool { isLandscape && currentDevice.role == .leveling } + private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] } var body: some View { - List { - statusSection - if needsKeyAttention { keyPrompt } + // Die Höhe der Libelle/Fahrzeug-Anzeige im Querformat richtet sich + // nach der tatsächlich verfügbaren Bildschirmhöhe, nicht nach einem + // festen Wert – deshalb misst ein GeometryReader die Liste von aussen. + GeometryReader { geometry in + List { + // Im Querformat soll die Libelle/Fahrzeug-Ansicht sofort + // sichtbar sein, ohne erst am Verbindungsstatus + // vorbeizuscrollen – der rutscht dort ganz ans Ende. Gilt + // nur für den Neigungsmesser, siehe `showsCompactLevelLayout`. + if !showsCompactLevelLayout { statusSection } + if needsKeyAttention { keyPrompt } - if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus { - FridgeControls(device: currentDevice, state: fridge) - } + if currentDevice.role == .fridge, let fridge = bluetooth.fridgeStates[device.id], fridge.hasStatus { + FridgeControls(device: currentDevice, state: fridge) + } - if currentDevice.role == .leveling { - LevelControls(device: currentDevice, - state: bluetooth.levelStates[device.id] ?? LevelState()) - } + if currentDevice.role == .leveling { + LevelControls(device: currentDevice, + state: bluetooth.levelStates[device.id] ?? LevelState(), + availableHeight: geometry.size.height) + } - if let snapshot, !snapshot.metrics.isEmpty { - Section("Messwerte") { - ForEach(snapshot.metrics) { metric in - LabeledContent(metric.label) { - Text(metric.formattedWithUnit) - .monospacedDigit() - .foregroundStyle(metric.value == nil ? .secondary : .primary) + if let snapshot, !snapshot.metrics.isEmpty { + Section("Messwerte") { + ForEach(snapshot.metrics) { metric in + LabeledContent(metric.label) { + Text(metric.formattedWithUnit) + .monospacedDigit() + .foregroundStyle(metric.value == nil ? .secondary : .primary) + } } } } - } - if samples.count > 1, let primary = snapshot?.primaryMetric { - Section("Verlauf – \(primary.label)") { - Chart(samples) { sample in - AreaMark(x: .value("Zeit", sample.time), - y: .value(primary.label, sample.value)) - .foregroundStyle(.tint.opacity(0.15)) - LineMark(x: .value("Zeit", sample.time), - y: .value(primary.label, sample.value)) - .foregroundStyle(.tint) - .interpolationMethod(.monotone) - } - .chartYAxisLabel(primary.unit) - .frame(height: 180) - .padding(.vertical, 8) - } - } - - if let snapshot, !snapshot.cellVoltages.isEmpty { - cellSection(snapshot.cellVoltages) - } - - if let snapshot, !snapshot.info.isEmpty { - Section("Gerät") { - ForEach(snapshot.info) { item in - LabeledContent(item.label, value: item.value) + if samples.count > 1, let primary = snapshot?.primaryMetric { + Section("Verlauf – \(primary.label)") { + Chart(samples) { sample in + AreaMark(x: .value("Zeit", sample.time), + y: .value(primary.label, sample.value)) + .foregroundStyle(.tint.opacity(0.15)) + LineMark(x: .value("Zeit", sample.time), + y: .value(primary.label, sample.value)) + .foregroundStyle(.tint) + .interpolationMethod(.monotone) + } + .chartYAxisLabel(primary.unit) + .frame(height: 180) + .padding(.vertical, 8) } } - } - if let snapshot, snapshot.temperatures.count > 1 { - Section("Temperaturen") { - ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in - LabeledContent("Fühler \(index + 1)") { - Text(String(format: "%.0f °C", value)).monospacedDigit() + if let snapshot, !snapshot.cellVoltages.isEmpty { + cellSection(snapshot.cellVoltages) + } + + if let snapshot, !snapshot.info.isEmpty { + Section("Gerät") { + ForEach(snapshot.info) { item in + LabeledContent(item.label, value: item.value) } } } - } - if currentDevice.role.transport == .advertisement { - if showsTechnicalDetails { diagnosticsSection } - } else if showsTechnicalDetails { - bmsDiagnosticsSection - } + if showsCompactLevelLayout { statusSection } - settingsSection - } - .navigationTitle(currentDevice.name) - .navigationBarTitleDisplayMode(.inline) - .onDisappear { - saveName() - // Die Kühlbox wird nur verbunden, solange man sie ansieht – jede - // Verbindung meldet sich an ihrem Display an. - bluetooth.endSession(for: currentDevice) - } - .onAppear { - editedName = currentDevice.name - keyInput = store.victronKeyText(for: device.id) ?? "" - bluetooth.beginSession(for: currentDevice) - } - .confirmationDialog("Gerät entfernen?", - isPresented: $showDeleteConfirmation, - titleVisibility: .visible) { - Button("Entfernen", role: .destructive) { - store.remove(device) - bluetooth.refreshConfiguration() - dismiss() + if let snapshot, snapshot.temperatures.count > 1 { + Section("Temperaturen") { + ForEach(Array(snapshot.temperatures.enumerated()), id: \.offset) { index, value in + LabeledContent("Fühler \(index + 1)") { + Text(String(format: "%.0f °C", value)).monospacedDigit() + } + } + } + } + + if currentDevice.role.transport == .advertisement { + if showsTechnicalDetails { diagnosticsSection } + } else if showsTechnicalDetails { + bmsDiagnosticsSection + } + + settingsSection + } + .navigationTitle(currentDevice.name) + .navigationBarTitleDisplayMode(.inline) + .onDisappear { + saveName() + // Die Kühlbox wird nur verbunden, solange man sie ansieht – jede + // Verbindung meldet sich an ihrem Display an. + bluetooth.endSession(for: currentDevice) + } + .onAppear { + editedName = currentDevice.name + keyInput = store.victronKeyText(for: device.id) ?? "" + bluetooth.beginSession(for: currentDevice) + } + .confirmationDialog("Gerät entfernen?", + isPresented: $showDeleteConfirmation, + titleVisibility: .visible) { + Button("Entfernen", role: .destructive) { + store.remove(device) + bluetooth.refreshConfiguration() + dismiss() + } + } message: { + Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.") } - } message: { - Text("Die Einstellungen und der hinterlegte Schlüssel werden gelöscht.") } } diff --git a/CamperMonitor/Views/LevelView.swift b/CamperMonitor/Views/LevelView.swift index e427162..869cabb 100644 --- a/CamperMonitor/Views/LevelView.swift +++ b/CamperMonitor/Views/LevelView.swift @@ -104,45 +104,76 @@ struct LevelBubble: View { struct LevelControls: View { let device: ConfiguredDevice let state: LevelState + /// Höhe der umgebenden Liste, von `DeviceDetailView` per `GeometryReader` + /// gemessen. Nur im Querformat gebraucht, um die Anzeige auf + /// Bildschirmhöhe zu bringen statt sie auf einen festen Wert zu kappen. + var availableHeight: CGFloat? @Environment(BluetoothManager.self) private var bluetooth @Environment(DeviceStore.self) private var store + @Environment(LevelActivityManager.self) private var levelActivity + @Environment(\.verticalSizeClass) private var verticalSizeClass @State private var showAssistant = false @AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble + /// iPhone im Querformat meldet eine kompakte Höhe – das ist das + /// zuverlässige Signal dafür, nicht die Geräteausrichtung selbst. + private var isLandscape: Bool { verticalSizeClass == .compact } + + /// Verfügbare Höhe abzüglich grober Reserve für Listenränder und die + /// eigene vertikale Auffüllung – genug, um praktisch den ganzen + /// Bildschirm zu nutzen, ohne über den unteren Rand hinauszuschiessen. + private var landscapeContentHeight: CGFloat { + max(150, (availableHeight ?? 350) - 56) + } + var body: some View { Section { - VStack(spacing: 16) { - Picker("Darstellung", selection: $displayStyle) { - ForEach(LevelDisplayStyle.allCases) { style in - Text(style.title).tag(style) + if isLandscape { + HStack(alignment: .center, spacing: 20) { + display + .frame(maxWidth: .infinity) + + VStack(spacing: 12) { + picker + if let instruction = state.instruction { + Label(instruction, + systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(state.isLevel ? Color.green : Color.primary) + .multilineTextAlignment(.center) + } + HStack(spacing: 16) { + reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch)) + reading("Quer", LevelDirectionFormatting.rollTile(state.roll)) + } + } + .frame(maxWidth: .infinity) + } + .frame(height: landscapeContentHeight) + .padding(.vertical, 4) + } else { + VStack(spacing: 16) { + picker + + display + + if let instruction = state.instruction { + Label(instruction, + systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle") + .font(.headline) + .foregroundStyle(state.isLevel ? Color.green : Color.primary) + .multilineTextAlignment(.center) + } + + HStack(spacing: 24) { + reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch)) + reading("Quer", LevelDirectionFormatting.rollTile(state.roll)) } } - .pickerStyle(.segmented) - - switch displayStyle { - case .bubble: - LevelBubble(pitch: state.pitch, roll: state.roll) - .frame(maxHeight: 220) - case .vehicle: - VehicleTiltView(pitch: state.pitch, roll: state.roll) - } - - if let instruction = state.instruction { - Label(instruction, - systemImage: state.isLevel ? "checkmark.circle.fill" : "arrow.up.circle") - .font(.headline) - .foregroundStyle(state.isLevel ? Color.green : Color.primary) - .multilineTextAlignment(.center) - } - - HStack(spacing: 24) { - reading("Längs", state.pitch) - reading("Quer", state.roll) - } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) } Section { @@ -177,6 +208,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 // der Libelle verstellte ein Fehlgriff beim Ablesen den Nullpunkt. Section { @@ -194,17 +248,52 @@ struct LevelControls: View { } } + private var picker: some View { + Picker("Darstellung", selection: $displayStyle) { + ForEach(LevelDisplayStyle.allCases) { style in + Text(style.title).tag(style) + } + } + .pickerStyle(.segmented) + } + + @ViewBuilder + private var display: some View { + switch displayStyle { + case .bubble: + LevelBubble(pitch: state.pitch, roll: state.roll) + .frame(maxHeight: isLandscape ? landscapeContentHeight : 220) + case .vehicle: + VehicleTiltView(pitch: state.pitch, roll: state.roll, + style: device.vehicleGraphicStyle, compact: isLandscape, + compactPanelHeight: max(60, landscapeContentHeight - 40)) + } + } + private var isLive: Bool { bluetooth.linkStates[device.id] == .live } + private var liveActivityBinding: Binding { + 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 /// ohne ihn stimmt die Anzeige nicht. private var setupSummary: String { state.isKnownUncalibrated ? "Nullpunkt fehlt" : device.sensorOrientation.summary } - private func reading(_ title: String, _ value: Double?) -> some View { + private func reading(_ title: String, _ text: String) -> some View { VStack(spacing: 2) { - Text(value.map { String(format: "%.1f°", $0) } ?? "–") + Text(text) .font(.title2.weight(.semibold).monospacedDigit()) Text(title) .font(.caption) diff --git a/CamperMonitor/Views/VehicleTiltView.swift b/CamperMonitor/Views/VehicleTiltView.swift index f979ce7..8c4c6ef 100644 --- a/CamperMonitor/Views/VehicleTiltView.swift +++ b/CamperMonitor/Views/VehicleTiltView.swift @@ -15,31 +15,39 @@ import SwiftUI struct VehicleTiltView: View { let pitch: Double? let roll: Double? + var style: VehicleGraphicStyle = .vanster + /// Für Querformat: beide Ansichten nebeneinander statt untereinander, + /// kleinere Schrift, ohne Überhöhungs-Hinweis – muss ohne Scrollen in die + /// Bildschirmhöhe passen. + var compact = false + /// Bildhöhe je Panel im Querformat – vom Aufrufer an die tatsächlich + /// verfügbare Bildschirmhöhe angepasst, statt fest verdrahtet. + var compactPanelHeight: CGFloat = 62 var body: some View { - VStack(spacing: 20) { - tiltPanel(image: "VehicleSide", - angle: pitch, - title: "Längs", - lowerLabel: "Front", - upperLabel: "Heck", - aspect: VehicleTilt.sideAspect) + if compact { + HStack(alignment: .top, spacing: 16) { + tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs", + lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect) + tiltPanel(image: style.rearImageName, angle: roll, title: "Quer", + lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect) + } + } else { + VStack(spacing: 20) { + tiltPanel(image: style.sideImageName, angle: pitch, title: "Längs", + lowerLabel: "Front", upperLabel: "Heck", aspect: style.sideAspect) + tiltPanel(image: style.rearImageName, angle: roll, title: "Quer", + lowerLabel: "links", upperLabel: "rechts", aspect: style.rearAspect) - tiltPanel(image: "VehicleRear", - angle: roll, - title: "Quer", - lowerLabel: "links", - upperLabel: "rechts", - aspect: VehicleTilt.rearAspect) - - // Ohne diesen Hinweis nähme man den Bildwinkel für den echten. - Text(String(format: "Neigung %.0f-fach überhöht dargestellt – " - + "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.", - VehicleTilt.exaggeration)) - .font(.caption2) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) + // Ohne diesen Hinweis nähme man den Bildwinkel für den echten. + Text(String(format: "Neigung %.0f-fach überhöht dargestellt – " + + "sonst wäre sie kaum zu erkennen. Die Gradzahlen sind echt.", + VehicleTilt.exaggeration)) + .font(.caption2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } } } @@ -49,13 +57,13 @@ struct VehicleTiltView: View { lowerLabel: String, upperLabel: String, aspect: Double) -> some View { - VStack(spacing: 8) { + VStack(spacing: compact ? 4 : 8) { HStack { Text(title) - .font(.subheadline.weight(.medium)) + .font(compact ? .caption2.weight(.medium) : .subheadline.weight(.medium)) Spacer() Text(angle.map { String(format: "%.1f°", $0) } ?? "–") - .font(.subheadline.weight(.semibold).monospacedDigit()) + .font((compact ? Font.caption2 : .subheadline).weight(.semibold).monospacedDigit()) .foregroundStyle(VehicleTilt.colour(for: angle)) } @@ -75,14 +83,14 @@ struct VehicleTiltView: View { .animation(.spring(duration: 0.4), value: angle) .opacity(angle == nil ? 0.3 : 1) } - .frame(height: 110) + .frame(height: compact ? compactPanelHeight : 110) HStack { Text(lowerLabel) Spacer() Text(upperLabel) } - .font(.caption2) + .font(compact ? .system(size: 9) : .caption2) .foregroundStyle(.secondary) } } diff --git a/CamperMonitorWatch/Assets.xcassets/VehicleRear.imageset/Contents.json b/CamperMonitorWatch/Assets.xcassets/VansterRear.imageset/Contents.json similarity index 82% rename from CamperMonitorWatch/Assets.xcassets/VehicleRear.imageset/Contents.json rename to CamperMonitorWatch/Assets.xcassets/VansterRear.imageset/Contents.json index a0f8834..7848505 100644 --- a/CamperMonitorWatch/Assets.xcassets/VehicleRear.imageset/Contents.json +++ b/CamperMonitorWatch/Assets.xcassets/VansterRear.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "VehicleRear.png", + "filename" : "VansterRear.png", "idiom" : "universal" } ], diff --git a/CamperMonitorWatch/Assets.xcassets/VehicleRear.imageset/VehicleRear.png b/CamperMonitorWatch/Assets.xcassets/VansterRear.imageset/VansterRear.png similarity index 100% rename from CamperMonitorWatch/Assets.xcassets/VehicleRear.imageset/VehicleRear.png rename to CamperMonitorWatch/Assets.xcassets/VansterRear.imageset/VansterRear.png diff --git a/CamperMonitorWatch/Assets.xcassets/VehicleSide.imageset/Contents.json b/CamperMonitorWatch/Assets.xcassets/VansterSide.imageset/Contents.json similarity index 82% rename from CamperMonitorWatch/Assets.xcassets/VehicleSide.imageset/Contents.json rename to CamperMonitorWatch/Assets.xcassets/VansterSide.imageset/Contents.json index 7e18a2d..dfeb5ba 100644 --- a/CamperMonitorWatch/Assets.xcassets/VehicleSide.imageset/Contents.json +++ b/CamperMonitorWatch/Assets.xcassets/VansterSide.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "VehicleSide.png", + "filename" : "VansterSide.png", "idiom" : "universal" } ], diff --git a/CamperMonitorWatch/Assets.xcassets/VehicleSide.imageset/VehicleSide.png b/CamperMonitorWatch/Assets.xcassets/VansterSide.imageset/VansterSide.png similarity index 100% rename from CamperMonitorWatch/Assets.xcassets/VehicleSide.imageset/VehicleSide.png rename to CamperMonitorWatch/Assets.xcassets/VansterSide.imageset/VansterSide.png diff --git a/CamperMonitorWatch/Views/WatchVehicleView.swift b/CamperMonitorWatch/Views/WatchVehicleView.swift index f0c4c4b..ce95873 100644 --- a/CamperMonitorWatch/Views/WatchVehicleView.swift +++ b/CamperMonitorWatch/Views/WatchVehicleView.swift @@ -14,9 +14,9 @@ struct WatchVehicleView: View { /// Die angeschriebenen Gradzahlen bleiben echt. var body: some View { VStack(spacing: 8) { - panel(image: "VehicleSide", angle: pitch, title: "Längs", + panel(image: "VansterSide", angle: pitch, title: "Längs", lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect) - panel(image: "VehicleRear", angle: roll, title: "Quer", + panel(image: "VansterRear", angle: roll, title: "Quer", lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect) } } diff --git a/Shared/Bluetooth/VotronicSolarESPProtocol.swift b/Shared/Bluetooth/VotronicSolarESPProtocol.swift new file mode 100644 index 0000000..f61ebac --- /dev/null +++ b/Shared/Bluetooth/VotronicSolarESPProtocol.swift @@ -0,0 +1,55 @@ +import Foundation + +/// VotronicSolarESP – zweiter ESP32 im Fahrzeug, liest einen +/// Votronic-Solarladeregler aus und stellt die Werte über einen eigenen +/// BLE-Dienst bereit. Siehe `firmware/vanalign/esp32_ble_solar.yaml`. +/// +/// Wie beim Neigungsmesser: kein Rahmenprotokoll, jede Messgrösse liegt in +/// einer eigenen Charakteristik, alle sind reine Lesewerte, der Client fragt +/// sie im Takt ab. +enum VotronicSolarESPProtocol { + + /// Wird vom Gerät beworben, das Gerät ist darüber auffindbar. + static let serviceUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A001" + + static let batteryVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A101" + static let pvVoltageUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A102" + static let pvCurrentUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A103" + static let pvPowerUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A104" + static let controllerTempUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A105" + /// Bit0 Batterie lädt, Bit1 Batterie entlädt, Bit2 PV-Regler aktiv, + /// Bit3 PV-Strombegrenzung, Bit4 AES aktiv. + static let statusFlagsUUID = "05C9A349-2B8E-4B1D-9C9D-C247E9A6A106" + + /// Liest einen Messwert aus vier Bytes, little-endian – wie beim + /// Neigungsmesser legt die Firmware den Float per `memcpy` ab. + static func float(from data: Data) -> Double? { + guard data.count >= 4 else { return nil } + var raw: UInt32 = 0 + for (index, byte) in data.prefix(4).enumerated() { + raw |= UInt32(byte) << UInt32(8 * index) + } + let value = Float(bitPattern: raw) + guard value.isFinite else { return nil } + return Double(value) + } + + struct StatusFlags { + var isBatteryCharging = false + var isBatteryDischarging = false + var isControllerActive = false + var isCurrentLimited = false + var isAESActive = false + } + + static func statusFlags(from data: Data) -> StatusFlags? { + guard let byte = data.first else { return nil } + var flags = StatusFlags() + flags.isBatteryCharging = byte & (1 << 0) != 0 + flags.isBatteryDischarging = byte & (1 << 1) != 0 + flags.isControllerActive = byte & (1 << 2) != 0 + flags.isCurrentLimited = byte & (1 << 3) != 0 + flags.isAESActive = byte & (1 << 4) != 0 + return flags + } +} diff --git a/Shared/Bluetooth/VotronicSolarESPSession.swift b/Shared/Bluetooth/VotronicSolarESPSession.swift new file mode 100644 index 0000000..080a6ad --- /dev/null +++ b/Shared/Bluetooth/VotronicSolarESPSession.swift @@ -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 VotronicSolarESPSession: NSObject { + + static let serviceUUID = CBUUID(string: VotronicSolarESPProtocol.serviceUUID) + private static let batteryVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.batteryVoltageUUID) + private static let pvVoltageUUID = CBUUID(string: VotronicSolarESPProtocol.pvVoltageUUID) + private static let pvCurrentUUID = CBUUID(string: VotronicSolarESPProtocol.pvCurrentUUID) + private static let pvPowerUUID = CBUUID(string: VotronicSolarESPProtocol.pvPowerUUID) + private static let controllerTempUUID = CBUUID(string: VotronicSolarESPProtocol.controllerTempUUID) + private static let statusFlagsUUID = CBUUID(string: VotronicSolarESPProtocol.statusFlagsUUID) + + let deviceID: UUID + private let queue: DispatchQueue + private let peripheral: CBPeripheral + private let onUpdate: (DeviceSnapshot) -> Void + private let onStateChange: (DeviceLinkState) -> Void + private let onVotronicSolarESPState: (VotronicSolarESPState) -> Void + + private var characteristics: [CBUUID: CBCharacteristic] = [:] + private var state = VotronicSolarESPState() + private var pollTimer: DispatchSourceTimer? + + /// Reicht für einen Solarregler, dessen Werte sich über Sekunden ändern – + /// deutlich seltener als beim Ausrichten mit dem Neigungsmesser. + var pollInterval: TimeInterval = 5 + + init(deviceID: UUID, + peripheral: CBPeripheral, + queue: DispatchQueue, + onUpdate: @escaping (DeviceSnapshot) -> Void, + onStateChange: @escaping (DeviceLinkState) -> Void, + onVotronicSolarESPState: @escaping (VotronicSolarESPState) -> Void) { + self.deviceID = deviceID + self.queue = queue + self.peripheral = peripheral + self.onUpdate = onUpdate + self.onStateChange = onStateChange + self.onVotronicSolarESPState = onVotronicSolarESPState + super.init() + peripheral.delegate = self + } + + // MARK: - Lebenszyklus + + func start() { + onStateChange(.connecting) + peripheral.discoverServices([Self.serviceUUID]) + } + + func stop() { + pollTimer?.cancel() + pollTimer = nil + characteristics.removeAll() + } + + func handleDisconnect() { + pollTimer?.cancel() + pollTimer = nil + characteristics.removeAll() + } + + // MARK: - Abfrage + + private func startPolling() { + guard pollTimer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now(), repeating: pollInterval) + timer.setEventHandler { [weak self] in self?.readAll() } + timer.resume() + pollTimer = timer + } + + private func readAll() { + guard peripheral.state == .connected else { return } + for characteristic in characteristics.values where characteristic.properties.contains(.read) { + peripheral.readValue(for: characteristic) + } + } + + private func publish() { + guard state.hasReading else { return } + onStateChange(.live) + onVotronicSolarESPState(state) + onUpdate(state.snapshot(deviceID: deviceID, rssi: nil)) + } +} + +// MARK: - CBPeripheralDelegate + +extension VotronicSolarESPSession: CBPeripheralDelegate { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error { + onStateChange(.failed(error.localizedDescription)) + return + } + guard let service = peripheral.services?.first(where: { $0.uuid == Self.serviceUUID }) else { + onStateChange(.failed("Solarladeregler-Dienst nicht gefunden")) + return + } + peripheral.discoverCharacteristics( + [Self.batteryVoltageUUID, Self.pvVoltageUUID, Self.pvCurrentUUID, + Self.pvPowerUUID, Self.controllerTempUUID, Self.statusFlagsUUID], + for: service + ) + } + + func peripheral(_ peripheral: CBPeripheral, + didDiscoverCharacteristicsFor service: CBService, + error: Error?) { + guard error == nil, let found = service.characteristics else { + onStateChange(.failed(error?.localizedDescription ?? "Keine Merkmale gefunden")) + return + } + for characteristic in found { + characteristics[characteristic.uuid] = characteristic + } + guard !characteristics.isEmpty else { + onStateChange(.failed("Solarwerte nicht gefunden")) + return + } + startPolling() + readAll() + } + + func peripheral(_ peripheral: CBPeripheral, + didUpdateValueFor characteristic: CBCharacteristic, + error: Error?) { + guard error == nil, let value = characteristic.value else { return } + + switch characteristic.uuid { + case Self.batteryVoltageUUID: + state.batteryVoltage = VotronicSolarESPProtocol.float(from: value) + case Self.pvVoltageUUID: + state.pvVoltage = VotronicSolarESPProtocol.float(from: value) + case Self.pvCurrentUUID: + state.pvCurrent = VotronicSolarESPProtocol.float(from: value) + case Self.pvPowerUUID: + state.pvPower = VotronicSolarESPProtocol.float(from: value) + case Self.controllerTempUUID: + state.controllerTemperature = VotronicSolarESPProtocol.float(from: value) + case Self.statusFlagsUUID: + guard let flags = VotronicSolarESPProtocol.statusFlags(from: value) else { return } + state.isBatteryCharging = flags.isBatteryCharging + state.isBatteryDischarging = flags.isBatteryDischarging + state.isControllerActive = flags.isControllerActive + state.isCurrentLimited = flags.isCurrentLimited + default: + return + } + publish() + } +} diff --git a/Shared/Models/ConfiguredDevice.swift b/Shared/Models/ConfiguredDevice.swift index 84349fb..d43cc9a 100644 --- a/Shared/Models/ConfiguredDevice.swift +++ b/Shared/Models/ConfiguredDevice.swift @@ -9,6 +9,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable { case bms case fridge case leveling + case solar var id: String { rawValue } @@ -20,6 +21,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable { case .bms: return "Batterie / BMS" case .fridge: return "Kühlbox" case .leveling: return "Nivellierung" + case .solar: return "VotronicSolarESP" } } @@ -31,6 +33,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable { case .bms: return "battery.100percent.bolt" case .fridge: return "refrigerator" case .leveling: return "level" + case .solar: return "sun.max" } } @@ -50,7 +53,7 @@ enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable { /// braucht eine echte GATT-Verbindung. var transport: DeviceTransport { switch self { - case .bms, .fridge, .leveling: return .connect + case .bms, .fridge, .leveling, .solar: return .connect default: return .advertisement } } @@ -98,6 +101,8 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable { var fridgeZoneMode: FridgeZoneMode = .automatic /// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt. var sensorOrientation = SensorOrientation.identity + /// Nur für den Neigungsmesser: welche Fahrzeuggrafik die Fahrzeug-Ansicht zeigt. + var vehicleGraphicStyle = VehicleGraphicStyle.vanster init(id: UUID = UUID(), name: String, @@ -128,5 +133,7 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable { ?? .automatic sensorOrientation = try container.decodeIfPresent(SensorOrientation.self, forKey: .sensorOrientation) ?? .identity + vehicleGraphicStyle = try container.decodeIfPresent(VehicleGraphicStyle.self, + forKey: .vehicleGraphicStyle) ?? .vanster } } diff --git a/Shared/Models/VehicleGraphicStyle.swift b/Shared/Models/VehicleGraphicStyle.swift new file mode 100644 index 0000000..571387d --- /dev/null +++ b/Shared/Models/VehicleGraphicStyle.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Welche Silhouette die Fahrzeug-Ansicht des Neigungsmessers zeigt. +/// +/// Jeder Fall ist ein zusammengehöriges Bilderset aus Seiten- und +/// Heckansicht in Assets.xcassets, beide als Vorlage ("template") +/// exportiert, damit `foregroundStyle` sie einfärben kann. Im Picker wird +/// nur die Seitenansicht gezeigt. +enum VehicleGraphicStyle: String, CaseIterable, Identifiable, Codable, Sendable { + case vanster + case california + + var id: String { rawValue } + + var title: String { + switch self { + case .vanster: return "Vanster" + case .california: return "California" + } + } + + var sideImageName: String { + switch self { + case .vanster: return "VansterSide" + case .california: return "CaliforniaSide" + } + } + + /// Für California liegt noch keine eigene Heckansicht vor, deshalb + /// vorerst die von Vanster. + var rearImageName: String { + switch self { + case .vanster, .california: return "VansterRear" + } + } + + /// Seitenverhältnis der jeweiligen Bilder – ohne das würde eine Grafik + /// mit anderen Proportionen in der gemeinsamen Bildhülle verzerrt oder + /// ungewollt verkleinert erscheinen. + var sideAspect: Double { + switch self { + case .vanster: return VehicleTilt.sideAspect + case .california: return 1 + } + } + + var rearAspect: Double { + switch self { + case .vanster, .california: return VehicleTilt.rearAspect + } + } +} diff --git a/Shared/Models/VotronicSolarESPState.swift b/Shared/Models/VotronicSolarESPState.swift new file mode 100644 index 0000000..c59105c --- /dev/null +++ b/Shared/Models/VotronicSolarESPState.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Zustand des Solarladereglers. +struct VotronicSolarESPState: Equatable, Codable, Sendable { + var batteryVoltage: Double? + var pvVoltage: Double? + var pvCurrent: Double? + var pvPower: Double? + var controllerTemperature: Double? + + var isBatteryCharging: Bool? + var isBatteryDischarging: Bool? + var isControllerActive: Bool? + var isCurrentLimited: Bool? + + var hasReading: Bool { + batteryVoltage != nil || pvVoltage != nil || pvCurrent != nil || pvPower != nil + } + + /// Kurzer Klartext, wie bei den übrigen Geräten als "Zustand" angezeigt. + var stateText: String? { + guard isControllerActive != nil else { return nil } + if isBatteryCharging == true { return "Lädt" } + if isControllerActive == true { return "Aktiv" } + return "Inaktiv" + } + + func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot { + var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi) + snapshot.metrics = [ + Metric("pv_power", "Solarleistung", pvPower, unit: "W", precision: 0, primary: true), + Metric("pv_voltage", "PV-Spannung", pvVoltage, unit: "V", precision: 1), + Metric("pv_current", "PV-Strom", pvCurrent, unit: "A", precision: 1), + Metric("battery_voltage", "Batteriespannung", batteryVoltage, unit: "V", precision: 2), + ] + if let controllerTemperature { + snapshot.metrics.append( + Metric("controller_temperature", "Reglertemperatur", controllerTemperature, + unit: "°C", precision: 0) + ) + } + snapshot.state = stateText + if isCurrentLimited == true { + snapshot.offReasons = ["PV-Strombegrenzung aktiv"] + } + return snapshot + } +} diff --git a/SharedActivity/LevelActivityAttributes.swift b/SharedActivity/LevelActivityAttributes.swift new file mode 100644 index 0000000..21c2ce5 --- /dev/null +++ b/SharedActivity/LevelActivityAttributes.swift @@ -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 +} diff --git a/SharedActivity/LevelDirectionFormatting.swift b/SharedActivity/LevelDirectionFormatting.swift new file mode 100644 index 0000000..8be80a7 --- /dev/null +++ b/SharedActivity/LevelDirectionFormatting.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Formatiert Neigungswerte mit vorangestelltem Richtungsbuchstaben statt +/// Vorzeichen – "H 1.8°" statt "1.8°", "L 0.9°" statt "-0.9°". +/// +/// Gemeinsam für App und Live-Activity-Widget, deshalb hier statt in +/// `Shared/`, das die Extension nicht mitkompiliert (siehe +/// `LevelActivityAttributes.swift`). +enum LevelDirectionFormatting { + /// "1.8°" – Betrag ohne Vorzeichen, für Stellen, die die Richtung schon + /// im Wort oder Buchstaben ausdrücken. + static func magnitude(_ value: Double?) -> String { + guard let value else { return "–" } + return String(format: "%.1f°", abs(value)) + } + + /// Längsneigung als Kachel: positiv (Heck höher) → "H", negativ (Front + /// höher) → "F". Muss zum Vorzeichen von `LevelState.pitch` passen. + static func pitchTile(_ value: Double?) -> String { + guard let value else { return "–" } + return "\(value >= 0 ? "H" : "F") \(magnitude(value))" + } + + /// Querneigung als Kachel: positiv (rechts höher) → "R", negativ (links + /// höher) → "L". Muss zum Vorzeichen von `LevelState.roll` passen. + static func rollTile(_ value: Double?) -> String { + guard let value else { return "–" } + return "\(value >= 0 ? "R" : "L") \(magnitude(value))" + } +} diff --git a/Tools/README.md b/Tools/README.md index 52ef1c9..6115c0f 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -32,5 +32,11 @@ gefüllte Form, in der die Details erhalten bleiben. ```bash swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift sips --resampleHeight 600 heckansicht.png --out /tmp/rear.png -/tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png +/tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VansterRear.imageset/VansterRear.png ``` + +Jedes Fahrzeug-Grafikset (siehe `VehicleGraphicStyle`) hat sein eigenes +Bildpaar `Side`/`Rear`. Hat die Vorlage schon einen +transparenten Hintergrund, geht die Quelldatei direkt ins Skript. Liegt sie +auf weißem Grund, muss der Rand vorher weg – sonst wird die ganze Fläche +mitgefärbt. diff --git a/VanAligneiOSWidget/AppIntent.swift b/VanAligneiOSWidget/AppIntent.swift new file mode 100644 index 0000000..60a97b1 --- /dev/null +++ b/VanAligneiOSWidget/AppIntent.swift @@ -0,0 +1,18 @@ +// +// AppIntent.swift +// VanAligneiOSWidget +// +// Created by Christopher Helberg on 02.09.26. +// + +import WidgetKit +import AppIntents + +struct ConfigurationAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "Configuration" } + static var description: IntentDescription { "This is an example widget." } + + // An example configurable parameter. + @Parameter(title: "Favorite Emoji", default: "😃") + var favoriteEmoji: String +} diff --git a/VanAligneiOSWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/VanAligneiOSWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/VanAligneiOSWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/VanAligneiOSWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/VanAligneiOSWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/VanAligneiOSWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/VanAligneiOSWidget/Assets.xcassets/Contents.json b/VanAligneiOSWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/VanAligneiOSWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/VanAligneiOSWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/VanAligneiOSWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/VanAligneiOSWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/VanAligneiOSWidget/Info.plist b/VanAligneiOSWidget/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/VanAligneiOSWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/VanAligneiOSWidget/VanAligneiOSWidget.swift b/VanAligneiOSWidget/VanAligneiOSWidget.swift new file mode 100644 index 0000000..5ca569e --- /dev/null +++ b/VanAligneiOSWidget/VanAligneiOSWidget.swift @@ -0,0 +1,90 @@ +// +// VanAligneiOSWidget.swift +// VanAligneiOSWidget +// +// Created by Christopher Helberg on 02.09.26. +// + +import WidgetKit +import SwiftUI + +struct Provider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> SimpleEntry { + SimpleEntry(date: Date(), configuration: ConfigurationAppIntent()) + } + + func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry { + SimpleEntry(date: Date(), configuration: configuration) + } + + func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline { + var entries: [SimpleEntry] = [] + + // Generate a timeline consisting of five entries an hour apart, starting from the current date. + let currentDate = Date() + for hourOffset in 0 ..< 5 { + let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! + let entry = SimpleEntry(date: entryDate, configuration: configuration) + entries.append(entry) + } + + return Timeline(entries: entries, policy: .atEnd) + } + +// func relevances() async -> WidgetRelevances { +// // Generate a list containing the contexts this widget is relevant in. +// } +} + +struct SimpleEntry: TimelineEntry { + let date: Date + let configuration: ConfigurationAppIntent +} + +struct VanAligneiOSWidgetEntryView : View { + var entry: Provider.Entry + + var body: some View { + VStack { + Text("Time:") + Text(entry.date, style: .time) + + Text("Favorite Emoji:") + Text(entry.configuration.favoriteEmoji) + + } + } +} + +struct VanAligneiOSWidget: Widget { + let kind: String = "VanAligneiOSWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in + VanAligneiOSWidgetEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + + } + } +} + +extension ConfigurationAppIntent { + fileprivate static var smiley: ConfigurationAppIntent { + let intent = ConfigurationAppIntent() + intent.favoriteEmoji = "😀" + return intent + } + + fileprivate static var starEyes: ConfigurationAppIntent { + let intent = ConfigurationAppIntent() + intent.favoriteEmoji = "🤩" + return intent + } +} + +#Preview(as: .systemSmall) { + VanAligneiOSWidget() +} timeline: { + SimpleEntry(date: .now, configuration: .smiley) + SimpleEntry(date: .now, configuration: .starEyes) +} diff --git a/VanAligneiOSWidget/VanAligneiOSWidgetBundle.swift b/VanAligneiOSWidget/VanAligneiOSWidgetBundle.swift new file mode 100644 index 0000000..6cba3f1 --- /dev/null +++ b/VanAligneiOSWidget/VanAligneiOSWidgetBundle.swift @@ -0,0 +1,18 @@ +// +// VanAligneiOSWidgetBundle.swift +// VanAligneiOSWidget +// +// Created by Christopher Helberg on 02.09.26. +// + +import WidgetKit +import SwiftUI + +@main +struct VanAligneiOSWidgetBundle: WidgetBundle { + var body: some Widget { + VanAligneiOSWidget() + VanAligneiOSWidgetControl() + VanAligneiOSWidgetLiveActivity() + } +} diff --git a/VanAligneiOSWidget/VanAligneiOSWidgetControl.swift b/VanAligneiOSWidget/VanAligneiOSWidgetControl.swift new file mode 100644 index 0000000..9fb60c3 --- /dev/null +++ b/VanAligneiOSWidget/VanAligneiOSWidgetControl.swift @@ -0,0 +1,77 @@ +// +// VanAligneiOSWidgetControl.swift +// VanAligneiOSWidget +// +// Created by Christopher Helberg on 02.09.26. +// + +import AppIntents +import SwiftUI +import WidgetKit + +struct VanAligneiOSWidgetControl: ControlWidget { + static let kind: String = "fototeddy.VanAligneiOS.VanAligneiOSWidget" + + var body: some ControlWidgetConfiguration { + AppIntentControlConfiguration( + kind: Self.kind, + provider: Provider() + ) { value in + ControlWidgetToggle( + "Start Timer", + isOn: value.isRunning, + action: StartTimerIntent(value.name) + ) { isRunning in + Label(isRunning ? "On" : "Off", systemImage: "timer") + } + } + .displayName("Timer") + .description("A an example control that runs a timer.") + } +} + +extension VanAligneiOSWidgetControl { + struct Value { + var isRunning: Bool + var name: String + } + + struct Provider: AppIntentControlValueProvider { + func previewValue(configuration: TimerConfiguration) -> Value { + VanAligneiOSWidgetControl.Value(isRunning: false, name: configuration.timerName) + } + + func currentValue(configuration: TimerConfiguration) async throws -> Value { + let isRunning = true // Check if the timer is running + return VanAligneiOSWidgetControl.Value(isRunning: isRunning, name: configuration.timerName) + } + } +} + +struct TimerConfiguration: ControlConfigurationIntent { + static let title: LocalizedStringResource = "Timer Name Configuration" + + @Parameter(title: "Timer Name", default: "Timer") + var timerName: String +} + +struct StartTimerIntent: SetValueIntent { + static let title: LocalizedStringResource = "Start a timer" + + @Parameter(title: "Timer Name") + var name: String + + @Parameter(title: "Timer is running") + var value: Bool + + init() {} + + init(_ name: String) { + self.name = name + } + + func perform() async throws -> some IntentResult { + // Start the timer… + return .result() + } +} diff --git a/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift b/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift new file mode 100644 index 0000000..76b315f --- /dev/null +++ b/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift @@ -0,0 +1,288 @@ +// +// VanAligneiOSWidgetLiveActivity.swift +// VanAligneiOSWidget +// +// Created by Christopher Helberg on 02.09.26. +// + +import ActivityKit +import WidgetKit +import SwiftUI + +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) + } +} + +/// Muss mit `LevelState.levelTolerance` übereinstimmen. +private let levelTolerance: Double = 0.5 + +/// Grün bis Toleranz, orange bis 2°, sonst rot – dieselbe Skala für Blase +/// und Text, wie in der App (`VehicleTilt.colour`). Hier dupliziert, weil +/// die Extension `Shared/` nicht mitkompiliert (siehe +/// `LevelActivityAttributes.swift`). +private func levelColour(forDeviation deviation: Double?) -> Color { + guard let deviation else { return .white.opacity(0.4) } + if deviation <= levelTolerance { return .green } + if deviation <= 2 { return .orange } + return .red +} + +/// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte +/// 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 + + private var hasReading: Bool { pitch != nil || roll != nil } + + private var bubbleColor: Color { + guard let pitch, let roll else { return .white.opacity(0.4) } + return levelColour(forDeviation: max(abs(pitch), abs(roll))) + } + + var body: some View { + 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(levelTolerance / 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 unten – 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 { + var body: some WidgetConfiguration { + ActivityConfiguration(for: LevelActivityAttributes.self) { context in + LevelActivityContent(attributes: context.attributes, state: context.state) + .activityBackgroundTint(Color(white: 0.08)) + .activitySystemActionForegroundColor(.white) + + } dynamicIsland: { context in + // Die Dynamic Island bleibt bewusst so klein wie möglich – Libelle + // und Zahlenwerte gehören auf Sperrbildschirm/CarPlay, hier reicht + // ein Blick auf Icon und, aufgeklappt, die Kurzanweisung. + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) + .font(.title3) + } + DynamicIslandExpandedRegion(.bottom) { + Text(context.state.instruction ?? context.attributes.deviceName) + .font(.caption) + .foregroundStyle(context.state.isLevel ? .green : .primary) + .lineLimit(1) + } + } compactLeading: { + LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) + } compactTrailing: { + EmptyView() + } minimal: { + LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) + } + .keylineTint(context.state.isLevel ? .green : .orange) + } + .supplementalActivityFamilies([.small]) + } +} + +/// Wählt je nach Darstellungsgrösse zwischen Sperrbildschirm- und +/// Kompaktansicht. +/// +/// Ohne `.small`-Familie fällt CarPlay (wie die Smart Stack der Uhr) auf +/// `compactLeading`/`compactTrailing` der Dynamic Island zurück – dort steht +/// nur das generische Symbol und, weil `compactTrailing` leer bleibt, gar +/// kein Text. Erst die `.small`-Familie liefert CarPlay Libelle und Text. +private struct LevelActivityContent: View { + let attributes: LevelActivityAttributes + let state: LevelActivityAttributes.ContentState + + @Environment(\.activityFamily) private var activityFamily + + var body: some View { + switch activityFamily { + case .small: + CompactLevelView(attributes: attributes, state: state) + default: + LockScreenLevelView(attributes: attributes, state: state) + } + } +} + +/// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus +/// ein Blick-Symbol statt eines ganzen Satzes – zum Lesen im Vorbeifahren. +/// +/// Gezeigt wird nur, welche Seite höher steht: Buchstabe (Längsneigung), +/// Pfeil, Buchstabe (Querneigung) – z.B. "H ↑ L" für "Heck und links stehen +/// höher". Ein einzelner Pfeil in der Mitte reicht, weil ohnehin nur die +/// höher stehende Seite benannt wird ("höher" ist implizit "nach oben"). +private struct CompactLevelView: View { + let attributes: LevelActivityAttributes + let state: LevelActivityAttributes.ContentState + + private var hasReading: Bool { state.pitch != nil || state.roll != nil } + + /// "H"/"F", nur wenn ausserhalb der Toleranz. + private var pitchLetter: String? { + guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil } + return pitch >= 0 ? "H" : "F" + } + + /// "R"/"L", nur wenn ausserhalb der Toleranz. + private var rollLetter: String? { + guard let roll = state.roll, abs(roll) > levelTolerance else { return nil } + return roll >= 0 ? "R" : "L" + } + + /// Dieselbe Skala wie die Blase, über die grössere der beiden Neigungen. + private var statusColor: Color { + guard let pitch = state.pitch, let roll = state.roll else { return .white.opacity(0.4) } + return levelColour(forDeviation: max(abs(pitch), abs(roll))) + } + + var body: some View { + HStack(spacing: 14) { + LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40) + + Group { + if !hasReading { + Text("–") + } else if state.isLevel { + Image(systemName: "checkmark.circle.fill") + } else { + HStack(spacing: 6) { + if let pitchLetter { Text(pitchLetter) } + Image(systemName: "arrow.up") + if let rollLetter { Text(rollLetter) } + } + } + } + // Skalierbarer Textstil statt fester Punktgrösse: CarPlay- + // Bildschirme unterscheiden sich in Grösse, die exakten Masse + // eines Displays sind aber nicht abfragbar. `minimumScaleFactor` + // lässt die Schrift so gross wie möglich, aber so klein wie + // nötig werden, um in den vom System zugeteilten Platz zu passen. + .font(.title2.weight(.bold)) + .minimumScaleFactor(0.5) + .lineLimit(1) + .foregroundStyle(statusColor) + + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } +} + +/// Sperrbildschirm- und Banner-Ansicht. +private struct LockScreenLevelView: View { + let attributes: LevelActivityAttributes + let state: LevelActivityAttributes.ContentState + + /// Längszeile, z.B. "Heck steht höher 1.8°" – nur wenn ausserhalb der + /// Toleranz, genau wie bei `LevelState.instruction` in der App. + private var pitchLine: String? { + guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil } + let label = pitch >= 0 ? "Heck steht höher" : "Front steht höher" + return "\(label) \(LevelDirectionFormatting.magnitude(pitch))" + } + + /// Querzeile, z.B. "links steht höher 0.9°". + private var rollLine: String? { + guard let roll = state.roll, abs(roll) > levelTolerance else { return nil } + let label = roll >= 0 ? "rechts steht höher" : "links steht höher" + return "\(label) \(LevelDirectionFormatting.magnitude(roll))" + } + + var body: some View { + HStack(spacing: 20) { + LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 64) + + VStack(alignment: .leading, spacing: 6) { + Text(attributes.deviceName) + .font(.caption) + .foregroundStyle(.secondary) + + if pitchLine == nil && rollLine == nil { + Text(state.pitch == nil && state.roll == nil ? "Keine Messwerte" : "Steht eben") + .font(.title3.weight(.semibold)) + .foregroundStyle(state.isLevel ? .green : .white) + } else { + if let pitchLine { + Text(pitchLine).font(.title3.weight(.semibold)) + } + if let rollLine { + Text(rollLine).font(.title3.weight(.semibold)) + } + } + } + + Spacer(minLength: 0) + } + .padding(16) + .foregroundStyle(.white) + } +} + +extension LevelActivityAttributes { + fileprivate static var preview: LevelActivityAttributes { + LevelActivityAttributes(deviceName: "Nivellierung") + } +} + +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() +} contentStates: { + LevelActivityAttributes.ContentState.level + LevelActivityAttributes.ContentState.tilted +} diff --git a/firmware/vanalign/esp32_ble_sim.yaml b/firmware/vanalign/esp32_ble_sim.yaml new file mode 100644 index 0000000..c7c92cd --- /dev/null +++ b/firmware/vanalign/esp32_ble_sim.yaml @@ -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 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 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 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 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"}; diff --git a/firmware/vanalign/esp32_ble_solar.yaml b/firmware/vanalign/esp32_ble_solar.yaml new file mode 100644 index 0000000..16ca5c9 --- /dev/null +++ b/firmware/vanalign/esp32_ble_solar.yaml @@ -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 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 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 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 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 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 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 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 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 v(1, 0); + v[0] = (uint8_t) id(pv_controller_status_bitmask).state; + return v; + +button: + - platform: restart + name: "ESP Restart" diff --git a/firmware/vanalign/esp32_ble_solar_sim.yaml b/firmware/vanalign/esp32_ble_solar_sim.yaml new file mode 100644 index 0000000..7c2a90a --- /dev/null +++ b/firmware/vanalign/esp32_ble_solar_sim.yaml @@ -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 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 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 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 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 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 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 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 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 v(1, 0); + v[0] = (uint8_t) id(pv_controller_status_bitmask).state; + return v; + +button: + - platform: restart + name: "ESP Restart"