From fdcc6448284d803ac969c14ae3f7d0d1352e2397 Mon Sep 17 00:00:00 2001 From: BiasF Date: Sun, 30 Aug 2026 10:36:52 +0200 Subject: [PATCH] init --- .gitignore | 65 +++ CamperMonitor.xcodeproj/project.pbxproj | 266 +++++++++++ .../xcschemes/CamperMonitor.xcscheme | 43 ++ .../AccentColor.colorset/Contents.json | 4 + .../AppIcon.appiconset/Contents.json | 4 + CamperMonitor/Assets.xcassets/Contents.json | 1 + CamperMonitor/Bluetooth/AESCounterMode.swift | 46 ++ CamperMonitor/Bluetooth/BMSSession.swift | 297 ++++++++++++ CamperMonitor/Bluetooth/BitReader.swift | 61 +++ .../Bluetooth/BluetoothManager.swift | 437 ++++++++++++++++++ CamperMonitor/Bluetooth/DalyProtocol.swift | 123 +++++ CamperMonitor/Bluetooth/DalyState.swift | 214 +++++++++ CamperMonitor/Bluetooth/JBDProtocol.swift | 200 ++++++++ .../Bluetooth/VictronAdvertisement.swift | 279 +++++++++++ CamperMonitor/CamperMonitorApp.swift | 31 ++ CamperMonitor/Models/ConfiguredDevice.swift | 85 ++++ CamperMonitor/Models/DeviceSnapshot.swift | 89 ++++ CamperMonitor/Models/Profile.swift | 32 ++ CamperMonitor/Models/VictronCodes.swift | 101 ++++ CamperMonitor/Store/DemoData.swift | 110 +++++ CamperMonitor/Store/DeviceStore.swift | 184 ++++++++ CamperMonitor/Store/KeychainStore.swift | 59 +++ CamperMonitor/Views/AddDeviceView.swift | 239 ++++++++++ CamperMonitor/Views/DashboardView.swift | 109 +++++ CamperMonitor/Views/DeviceCard.swift | 137 ++++++ CamperMonitor/Views/DeviceDetailView.swift | 298 ++++++++++++ CamperMonitor/Views/ProfilesView.swift | 168 +++++++ README.md | 136 ++++++ Tests/main.swift | 316 +++++++++++++ run-tests.sh | 18 + 30 files changed, 4152 insertions(+) create mode 100644 .gitignore create mode 100644 CamperMonitor.xcodeproj/project.pbxproj create mode 100644 CamperMonitor.xcodeproj/xcshareddata/xcschemes/CamperMonitor.xcscheme create mode 100644 CamperMonitor/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 CamperMonitor/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 CamperMonitor/Assets.xcassets/Contents.json create mode 100644 CamperMonitor/Bluetooth/AESCounterMode.swift create mode 100644 CamperMonitor/Bluetooth/BMSSession.swift create mode 100644 CamperMonitor/Bluetooth/BitReader.swift create mode 100644 CamperMonitor/Bluetooth/BluetoothManager.swift create mode 100644 CamperMonitor/Bluetooth/DalyProtocol.swift create mode 100644 CamperMonitor/Bluetooth/DalyState.swift create mode 100644 CamperMonitor/Bluetooth/JBDProtocol.swift create mode 100644 CamperMonitor/Bluetooth/VictronAdvertisement.swift create mode 100644 CamperMonitor/CamperMonitorApp.swift create mode 100644 CamperMonitor/Models/ConfiguredDevice.swift create mode 100644 CamperMonitor/Models/DeviceSnapshot.swift create mode 100644 CamperMonitor/Models/Profile.swift create mode 100644 CamperMonitor/Models/VictronCodes.swift create mode 100644 CamperMonitor/Store/DemoData.swift create mode 100644 CamperMonitor/Store/DeviceStore.swift create mode 100644 CamperMonitor/Store/KeychainStore.swift create mode 100644 CamperMonitor/Views/AddDeviceView.swift create mode 100644 CamperMonitor/Views/DashboardView.swift create mode 100644 CamperMonitor/Views/DeviceCard.swift create mode 100644 CamperMonitor/Views/DeviceDetailView.swift create mode 100644 CamperMonitor/Views/ProfilesView.swift create mode 100644 README.md create mode 100644 Tests/main.swift create mode 100755 run-tests.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7affa43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +### macOS +# Finder metadata +.DS_Store + +# Thumbnails +._* + +# Custom folder icons +Icon + +# Volume root files +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +### Xcode +# Xcode user settings +xcuserdata/ + +# Xcode build artifacts +build/ +DerivedData/ + +# Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Xcode state files +*.xccheckout +*.moved-aside +*.xcuserstate +*.xcscmblueprint + + +### Swift +# Xcode user settings +xcuserdata/ + +# Xcode build data +DerivedData/ + +# Swift/Obj-C specific +*.hmap + +# App packaging +*.ipa +*.dSYM.zip +*.dSYM + +# Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# Package.resolved \ No newline at end of file diff --git a/CamperMonitor.xcodeproj/project.pbxproj b/CamperMonitor.xcodeproj/project.pbxproj new file mode 100644 index 0000000..bb03511 --- /dev/null +++ b/CamperMonitor.xcodeproj/project.pbxproj @@ -0,0 +1,266 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + AA0000000000000000000001 /* CamperMonitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CamperMonitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + AA0000000000000000000002 /* CamperMonitor */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CamperMonitor; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + AA0000000000000000000003 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AA0000000000000000000004 = { + isa = PBXGroup; + children = ( + AA0000000000000000000002 /* CamperMonitor */, + AA0000000000000000000005 /* Products */, + ); + sourceTree = ""; + }; + AA0000000000000000000005 /* Products */ = { + isa = PBXGroup; + children = ( + AA0000000000000000000001 /* CamperMonitor.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AA0000000000000000000006 /* CamperMonitor */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "CamperMonitor" */; + buildPhases = ( + AA0000000000000000000007 /* Sources */, + AA0000000000000000000003 /* Frameworks */, + AA0000000000000000000008 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AA0000000000000000000002 /* CamperMonitor */, + ); + name = CamperMonitor; + productName = CamperMonitor; + productReference = AA0000000000000000000001 /* CamperMonitor.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AA0000000000000000000009 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2660; + LastUpgradeCheck = 2660; + TargetAttributes = { + AA0000000000000000000006 = { + CreatedOnToolsVersion = 26.6; + }; + }; + }; + buildConfigurationList = AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + de, + ); + mainGroup = AA0000000000000000000004; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = AA0000000000000000000005 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AA0000000000000000000006 /* CamperMonitor */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AA0000000000000000000008 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AA0000000000000000000007 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + AA0000000000000000000012 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + AA0000000000000000000013 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + AA0000000000000000000014 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = 6AP73NYV5W; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AA0000000000000000000015 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = 6AP73NYV5W; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Camper Monitor"; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Zum Auslesen von Victron-Geraeten und dem Daly BMS per Bluetooth."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.fritob.CamperMonitor; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AA0000000000000000000010 /* Build configuration list for PBXProject "CamperMonitor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000000012 /* Debug */, + AA0000000000000000000013 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AA0000000000000000000011 /* Build configuration list for PBXNativeTarget "CamperMonitor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000000014 /* Debug */, + AA0000000000000000000015 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AA0000000000000000000009 /* Project object */; +} diff --git a/CamperMonitor.xcodeproj/xcshareddata/xcschemes/CamperMonitor.xcscheme b/CamperMonitor.xcodeproj/xcshareddata/xcschemes/CamperMonitor.xcscheme new file mode 100644 index 0000000..b30322c --- /dev/null +++ b/CamperMonitor.xcodeproj/xcshareddata/xcschemes/CamperMonitor.xcscheme @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CamperMonitor/Assets.xcassets/AccentColor.colorset/Contents.json b/CamperMonitor/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..965ee4d --- /dev/null +++ b/CamperMonitor/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,4 @@ +{ + "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.400", "green" : "0.620", "red" : "0.110" } }, "idiom" : "universal" } ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/CamperMonitor/Assets.xcassets/AppIcon.appiconset/Contents.json b/CamperMonitor/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2d72247 --- /dev/null +++ b/CamperMonitor/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [ { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/CamperMonitor/Assets.xcassets/Contents.json b/CamperMonitor/Assets.xcassets/Contents.json new file mode 100644 index 0000000..46cba7d --- /dev/null +++ b/CamperMonitor/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info" : { "author" : "xcode", "version" : 1 } } diff --git a/CamperMonitor/Bluetooth/AESCounterMode.swift b/CamperMonitor/Bluetooth/AESCounterMode.swift new file mode 100644 index 0000000..b59881c --- /dev/null +++ b/CamperMonitor/Bluetooth/AESCounterMode.swift @@ -0,0 +1,46 @@ +import CommonCrypto +import Foundation + +/// AES-128 im Counter-Modus. CryptoKit bietet CTR nicht an, deshalb CommonCrypto. +enum AESCounterMode { + /// - Parameters: + /// - data: Der verschlüsselte Nutzteil des Advertisements. + /// - key: 16 Byte Geräteschlüssel aus VictronConnect. + /// - nonce: Der 16-Byte-Zählerblock (Victron: Nonce little-endian in den + /// ersten beiden Bytes, Rest 0). + static func crypt(_ data: [UInt8], key: [UInt8], nonce: [UInt8]) -> [UInt8]? { + guard key.count == kCCKeySizeAES128, nonce.count == kCCBlockSizeAES128 else { return nil } + + var cryptor: CCCryptorRef? + let createStatus = key.withUnsafeBytes { keyBuffer in + nonce.withUnsafeBytes { ivBuffer in + CCCryptorCreateWithMode( + CCOperation(kCCEncrypt), // CTR ist symmetrisch + CCMode(kCCModeCTR), + CCAlgorithm(kCCAlgorithmAES), + CCPadding(ccNoPadding), + ivBuffer.baseAddress, + keyBuffer.baseAddress, key.count, + nil, 0, 0, + CCModeOptions(kCCModeOptionCTR_BE), + &cryptor + ) + } + } + guard createStatus == kCCSuccess, let cryptor else { return nil } + defer { CCCryptorRelease(cryptor) } + + var output = [UInt8](repeating: 0, count: data.count) + var moved = 0 + let updateStatus = data.withUnsafeBytes { input in + output.withUnsafeMutableBytes { out in + CCCryptorUpdate(cryptor, + input.baseAddress, data.count, + out.baseAddress, data.count, + &moved) + } + } + guard updateStatus == kCCSuccess else { return nil } + return Array(output.prefix(moved)) + } +} diff --git a/CamperMonitor/Bluetooth/BMSSession.swift b/CamperMonitor/Bluetooth/BMSSession.swift new file mode 100644 index 0000000..c1ec4f6 --- /dev/null +++ b/CamperMonitor/Bluetooth/BMSSession.swift @@ -0,0 +1,297 @@ +import CoreBluetooth +import Foundation + +/// Hält die GATT-Verbindung zu einem BMS, pollt die Werte und meldet fertige +/// Snapshots zurück. +/// +/// Unterstützt drei Dialekte und erkennt selbst, welchen das Gerät spricht: +/// +/// * **Daly klassisch** – 13-Byte-Rahmen, beginnend mit `A5` +/// * **Daly Modbus** – `D2 03 …`, neuere Daly-Firmware +/// * **JBD / Xiaoxiang** – `DD A5 …`, u.a. in WattCycle-Akkus +/// +/// Auch die BLE-Charakteristiken werden gesucht statt vorausgesetzt: die Module +/// unterscheiden sich zwischen Herstellern und Fertigungschargen. +final class BMSSession: NSObject { + + /// Bekannte Dienste, in Reihenfolge der Wahrscheinlichkeit. + private static let preferredServices: [CBUUID] = [ + CBUUID(string: "FFF0"), // Daly + CBUUID(string: "FF00"), // JBD + CBUUID(string: "FFE0"), + CBUUID(string: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"), // Nordic UART + ] + + enum Dialect: String { + case unknown = "wird ermittelt" + case dalyClassic = "Daly (klassisch)" + case dalyModbus = "Daly (Modbus)" + case jbd = "JBD / Xiaoxiang" + } + + let deviceID: UUID + private let peripheral: CBPeripheral + private let onUpdate: (DeviceSnapshot) -> Void + private let onStateChange: (DeviceLinkState) -> Void + private let onDiagnostics: (BMSDiagnostics) -> Void + + private var writeCharacteristic: CBCharacteristic? + private var notifyCharacteristic: CBCharacteristic? + + private(set) var dialect: Dialect = .unknown + private var dalyState = DalyState() + private var jbdState = JBDState() + private var buffer: [UInt8] = [] + private var pollTimer: Timer? + private var silentRounds = 0 + private var lastResponse: Data? + + /// Abstand zwischen zwei Abfragerunden. + var pollInterval: TimeInterval = 5 + + init(deviceID: UUID, + peripheral: CBPeripheral, + onUpdate: @escaping (DeviceSnapshot) -> Void, + onStateChange: @escaping (DeviceLinkState) -> Void, + onDiagnostics: @escaping (BMSDiagnostics) -> Void) { + self.deviceID = deviceID + self.peripheral = peripheral + self.onUpdate = onUpdate + self.onStateChange = onStateChange + self.onDiagnostics = onDiagnostics + super.init() + peripheral.delegate = self + } + + // MARK: - Lebenszyklus + + func start() { + onStateChange(.connecting) + peripheral.discoverServices(nil) + } + + func stop() { + pollTimer?.invalidate() + pollTimer = nil + if let notifyCharacteristic, peripheral.state == .connected { + peripheral.setNotifyValue(false, for: notifyCharacteristic) + } + writeCharacteristic = nil + notifyCharacteristic = nil + dialect = .unknown + buffer.removeAll() + } + + func handleDisconnect() { + pollTimer?.invalidate() + pollTimer = nil + writeCharacteristic = nil + notifyCharacteristic = nil + buffer.removeAll() + } + + // MARK: - Abfrage + + private func beginPolling() { + pollTimer?.invalidate() + onStateChange(.live) + poll() + pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in + self?.poll() + } + } + + private func poll() { + guard peripheral.state == .connected, writeCharacteristic != nil else { return } + + switch dialect { + case .unknown: + probeDialects() + case .dalyClassic: + sendSequence(DalyProtocol.Command.allCases.map { DalyProtocol.requestFrame($0) }) + case .dalyModbus: + sendSequence([DalyProtocol.modbusReadFrame()]) + case .jbd: + sendSequence(JBDProtocol.Command.allCases.map { JBDProtocol.requestFrame($0) }) + } + } + + /// Nacheinander alle bekannten Anfragen schicken. Der erste gültige Rahmen + /// in der Antwort legt den Dialekt fest. + private func probeDialects() { + let probes: [Data] = [ + DalyProtocol.requestFrame(.soc), + JBDProtocol.requestFrame(.basicInfo), + DalyProtocol.modbusReadFrame(), + ] + for (index, probe) in probes.enumerated() { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 1.5) { [weak self] in + guard let self, self.dialect == .unknown else { return } + self.send(probe) + } + } + checkForSilence(after: Double(probes.count) * 1.5 + 1.5) + } + + /// Kommandos leicht versetzt senden – manche Module verschlucken Anfragen, + /// die zu dicht aufeinander folgen. + private func sendSequence(_ frames: [Data]) { + for (index, frame) in frames.enumerated() { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.25) { [weak self] in + self?.send(frame) + } + } + checkForSilence(after: Double(frames.count) * 0.25 + 2) + } + + /// Kommt mehrere Runden nichts Brauchbares zurück, wird der erkannte + /// Dialekt verworfen und neu gesucht. + private func checkForSilence(after delay: TimeInterval) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + guard !self.hasUsableData else { + self.silentRounds = 0 + return + } + self.silentRounds += 1 + if self.silentRounds >= 3 { + self.silentRounds = 0 + self.dialect = .unknown + self.onStateChange(.failed("Keine verwertbare Antwort vom BMS")) + self.publishDiagnostics() + } + } + } + + private var hasUsableData: Bool { + dalyState.hasUsableData || jbdState.hasUsableData + } + + private func send(_ data: Data) { + guard let characteristic = writeCharacteristic else { return } + let type: CBCharacteristicWriteType = + characteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse + peripheral.writeValue(data, for: characteristic, type: type) + } + + // MARK: - Auswertung + + private func consume(_ data: Data) { + lastResponse = data + buffer.append(contentsOf: [UInt8](data)) + if buffer.count > 512 { buffer.removeFirst(buffer.count - 512) } + + // JBD zuerst: der Rahmen ist durch Start-, Endbyte und Prüfsumme + // eindeutig und kann nicht mit den Daly-Rahmen verwechselt werden. + let (jbdFrames, jbdRemainder) = JBDProtocol.extractFrames(from: buffer) + if !jbdFrames.isEmpty { + buffer = jbdRemainder + dialect = .jbd + silentRounds = 0 + for frame in jbdFrames { jbdState.apply(frame) } + publish(jbdState.snapshot(deviceID: deviceID, rssi: nil), usable: jbdState.hasUsableData) + return + } + + if let start = buffer.firstIndex(where: { $0 == 0xD2 }), + let registers = DalyProtocol.parseModbusResponse(Array(buffer[start...])) { + buffer.removeAll() + dialect = .dalyModbus + silentRounds = 0 + dalyState.apply(registers: registers) + publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData) + return + } + + let (dalyFrames, dalyRemainder) = DalyProtocol.extractA5Frames(from: buffer) + if !dalyFrames.isEmpty { + buffer = dalyRemainder + dialect = .dalyClassic + silentRounds = 0 + for frame in dalyFrames { dalyState.apply(frame) } + publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData) + return + } + + // Nichts erkannt – trotzdem melden, damit die Diagnose etwas zeigt. + publishDiagnostics() + } + + private func publish(_ snapshot: DeviceSnapshot, usable: Bool) { + publishDiagnostics() + guard usable else { return } + onStateChange(.live) + onUpdate(snapshot) + } + + private func publishDiagnostics() { + onDiagnostics(BMSDiagnostics( + dialect: dialect.rawValue, + serviceUUID: writeCharacteristic?.service?.uuid.uuidString, + writeUUID: writeCharacteristic?.uuid.uuidString, + notifyUUID: notifyCharacteristic?.uuid.uuidString, + lastResponseHex: lastResponse.map { $0.map { String(format: "%02X", $0) }.joined(separator: " ") }, + updated: Date() + )) + } +} + +// MARK: - CBPeripheralDelegate + +extension BMSSession: CBPeripheralDelegate { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error { + onStateChange(.failed(error.localizedDescription)) + return + } + for service in peripheral.services ?? [] { + peripheral.discoverCharacteristics(nil, for: service) + } + } + + func peripheral(_ peripheral: CBPeripheral, + didDiscoverCharacteristicsFor service: CBService, + error: Error?) { + guard error == nil, let characteristics = service.characteristics else { return } + + let writable = characteristics.first { + $0.properties.contains(.write) || $0.properties.contains(.writeWithoutResponse) + } + let notifying = characteristics.first { + $0.properties.contains(.notify) || $0.properties.contains(.indicate) + } + guard let writable, let notifying else { return } + + // Einen bekannten Dienst immer bevorzugen, sonst den erstbesten nehmen. + let isPreferred = Self.preferredServices.contains(service.uuid) + let alreadyPreferred = writeCharacteristic + .flatMap { $0.service?.uuid } + .map { Self.preferredServices.contains($0) } ?? false + guard writeCharacteristic == nil || (isPreferred && !alreadyPreferred) else { return } + + writeCharacteristic = writable + notifyCharacteristic = notifying + peripheral.setNotifyValue(true, for: notifying) + } + + func peripheral(_ peripheral: CBPeripheral, + didUpdateNotificationStateFor characteristic: CBCharacteristic, + error: Error?) { + if let error { + onStateChange(.failed(error.localizedDescription)) + return + } + if characteristic.isNotifying, characteristic == notifyCharacteristic { + publishDiagnostics() + beginPolling() + } + } + + func peripheral(_ peripheral: CBPeripheral, + didUpdateValueFor characteristic: CBCharacteristic, + error: Error?) { + guard error == nil, let value = characteristic.value else { return } + consume(value) + } +} diff --git a/CamperMonitor/Bluetooth/BitReader.swift b/CamperMonitor/Bluetooth/BitReader.swift new file mode 100644 index 0000000..29afdc5 --- /dev/null +++ b/CamperMonitor/Bluetooth/BitReader.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Liest Felder beliebiger Bitbreite aus einem Byte-Array. +/// +/// Victron packt die Felder seiner Werbedaten little-endian und bitweise ohne +/// Byte-Ausrichtung: das erste Feld beginnt am niederwertigsten Bit von Byte 0, +/// jedes weitere schliesst direkt an. +struct BitReader { + private let bytes: [UInt8] + private var bitOffset = 0 + + init(_ bytes: [UInt8]) { self.bytes = bytes } + + var bitsRemaining: Int { bytes.count * 8 - bitOffset } + + /// Liest `width` Bits als vorzeichenlose Zahl. Gibt nil zurück, wenn die + /// Daten zu kurz sind. + mutating func read(_ width: Int) -> UInt32? { + guard width > 0, width <= 32, bitsRemaining >= width else { return nil } + var result: UInt32 = 0 + for i in 0..> UInt8(absolute % 8)) & 1 + result |= UInt32(bit) << UInt32(i) + } + bitOffset += width + return result + } + + /// Wie `read`, liefert aber nil wenn alle Bits gesetzt sind – so markiert + /// Victron "Wert nicht verfügbar". + mutating func readOptional(_ width: Int) -> UInt32? { + guard let raw = read(width) else { return nil } + let notAvailable: UInt32 = width >= 32 ? .max : (1 << UInt32(width)) - 1 + return raw == notAvailable ? nil : raw + } + + /// Zweierkomplement-Feld. Der NA-Wert 0x7F..F wird zu nil. + mutating func readOptionalSigned(_ width: Int) -> Int32? { + guard width > 1, let raw = read(width) else { return nil } + let notAvailable: UInt32 = (1 << UInt32(width - 1)) - 1 + if raw == notAvailable { return nil } + let signBit: UInt32 = 1 << UInt32(width - 1) + if raw & signBit != 0 { + let mask: UInt32 = width >= 32 ? 0 : ~((1 << UInt32(width)) - 1) + return Int32(bitPattern: raw | mask) + } + return Int32(bitPattern: raw) + } + + /// Skaliertes, optionales Feld ohne Vorzeichen. + mutating func scaled(_ width: Int, _ factor: Double) -> Double? { + readOptional(width).map { Double($0) * factor } + } + + /// Skaliertes, optionales Feld mit Vorzeichen. + mutating func scaledSigned(_ width: Int, _ factor: Double) -> Double? { + readOptionalSigned(width).map { Double($0) * factor } + } +} diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift new file mode 100644 index 0000000..6be3c25 --- /dev/null +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -0,0 +1,437 @@ +import CoreBluetooth +import Foundation +import Observation + +/// Ein während des Einrichtens gefundenes Gerät. +struct Discovery: Identifiable, Hashable { + let id: UUID + var name: String? + /// Geglättet, damit die Signalanzeige nicht flackert. + var rssi: Int + /// Sortierschlüssel: die Reihenfolge des Auftauchens ist stabil, + /// die Signalstärke wäre es nicht. + var firstSeen: Date + var lastSeen: Date + var victronRecordType: UInt8? + var victronProductID: UInt16? + var looksLikeDaly: Bool + + var isVictron: Bool { victronRecordType != nil } + + var displayName: String { + if let name, !name.isEmpty { return name } + return isVictron ? "Victron-Gerät" : "Unbenanntes Gerät" + } + + var subtitle: String { + if let type = victronRecordType { + return "Victron · " + Self.victronRecordName(type) + } + if looksLikeDaly { return "Sieht nach Daly BMS aus" } + return "Bluetooth-Gerät" + } + + static func victronRecordName(_ type: UInt8) -> String { + switch VictronAdvertisement.RecordType(rawValue: type) { + case .solarCharger: return "Solarladeregler" + case .batteryMonitor: return "Batteriewächter" + case .inverter: return "Wechselrichter" + case .dcdcConverter: return "DC/DC-Lader (Orion-TR)" + case .orionXS: return "Orion XS" + case .acCharger: return "AC-Ladegerät" + case .smartLithium: return "Smart Lithium" + case .smartBatteryProtect: return "Battery Protect" + case .lynxSmartBMS: return "Lynx Smart BMS" + case .dcEnergyMeter: return "DC-Energiezähler" + default: return String(format: "Typ 0x%02X", type) + } + } +} + +/// Was ein Victron-Gerät unverschlüsselt mitsendet. Wird unabhängig vom +/// Schlüssel gefüllt und hilft, eine falsche Eingabe einzugrenzen. +struct VictronDiagnostics: Hashable { + var productID: UInt16 + var recordType: UInt8 + var expectedKeyFirstByte: UInt8 + var nonce: UInt16 + var payloadLength: Int + var rawHex: String + var updated: Date + + var recordName: String { Discovery.victronRecordName(recordType) } + var productIDText: String { String(format: "0x%04X", productID) } + var expectedKeyText: String { String(format: "0x%02X", expectedKeyFirstByte) } +} + +/// Was bei der BMS-Verbindung erkannt wurde. Zeigt, ob und welches Protokoll +/// greift, und gibt die letzte Rohantwort zum Nachsehen aus. +struct BMSDiagnostics: Hashable { + var dialect: String + var serviceUUID: String? + var writeUUID: String? + var notifyUUID: String? + var lastResponseHex: String? + var updated: Date +} + +/// Ein Messpunkt für die Verlaufsgrafik. +struct HistorySample: Identifiable, Hashable { + let id = UUID() + let time: Date + let value: Double +} + +/// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Victron-Werbedaten und +/// hält parallel die Verbindung zum Daly-BMS. +@Observable +final class BluetoothManager: NSObject { + + private(set) var snapshots: [UUID: DeviceSnapshot] = [:] + private(set) var linkStates: [UUID: DeviceLinkState] = [:] + private(set) var discoveries: [UUID: Discovery] = [:] + private(set) var history: [UUID: [HistorySample]] = [:] + private(set) var diagnostics: [UUID: VictronDiagnostics] = [:] + private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:] + private(set) var isBluetoothReady = false + private(set) var bluetoothStatusText = "Bluetooth wird gestartet…" + + /// Solange true, werden alle gefundenen Peripherals gesammelt. + var isDiscovering = false { + didSet { + restartScan() + isDiscovering ? startDiscoveryFlush() : stopDiscoveryFlush() + } + } + + private let store: DeviceStore + private var central: CBCentralManager? + private var bmsSessions: [UUID: BMSSession] = [:] + private var connectedPeripherals: [UUID: CBPeripheral] = [:] + private var reconnectTimer: Timer? + + /// Advertisements treffen mehrmals pro Sekunde und Gerät ein. Sie werden + /// hier gesammelt und nur im Sekundentakt an die Ansicht durchgereicht – + /// sonst baut sich die Liste unter dem Finger ständig neu auf. + private var pendingDiscoveries: [UUID: Discovery] = [:] + private var discoveryFlushTimer: Timer? + + /// Wieviele Messpunkte je Gerät im Verlauf behalten werden. + private let historyLimit = 720 + + /// Im Demo-Modus wird nichts gefunkt, die Werte kommen aus `DemoData`. + private let isDemo = DemoData.isEnabled + + init(store: DeviceStore) { + self.store = store + super.init() + if isDemo { + loadDemoData() + return + } + central = CBCentralManager(delegate: self, queue: .main) + } + + private func loadDemoData() { + isBluetoothReady = true + bluetoothStatusText = "Demo-Modus" + for snapshot in DemoData.snapshots() { + snapshots[snapshot.deviceID] = snapshot + linkStates[snapshot.deviceID] = .live + if let value = snapshot.primaryMetric?.value { + history[snapshot.deviceID] = DemoData.history(for: snapshot.deviceID, around: value) + } + } + } + + // MARK: - Steuerung + + func start() { + guard !isDemo, central?.state == .poweredOn else { return } + restartScan() + connectManagedPeripherals() + scheduleReconnects() + } + + func stop() { + guard !isDemo else { return } + central?.stopScan() + reconnectTimer?.invalidate() + reconnectTimer = nil + stopDiscoveryFlush() + for (_, session) in bmsSessions { session.stop() } + for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) } + bmsSessions.removeAll() + connectedPeripherals.removeAll() + } + + /// Nach Änderungen an der Geräteliste aufrufen. + func refreshConfiguration() { + guard !isDemo else { return } + let managed = Set(store.activeDevices.filter { $0.role.transport == .connect }.map(\.peripheralID)) + for (peripheralID, session) in bmsSessions where !managed.contains(peripheralID) { + session.stop() + bmsSessions[peripheralID] = nil + if let peripheral = connectedPeripherals[peripheralID] { + central?.cancelPeripheralConnection(peripheral) + connectedPeripherals[peripheralID] = nil + } + } + let known = Set(store.activeDevices.map(\.id)) + snapshots = snapshots.filter { known.contains($0.key) } + linkStates = linkStates.filter { known.contains($0.key) } + history = history.filter { known.contains($0.key) } + diagnostics = diagnostics.filter { known.contains($0.key) } + bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) } + connectManagedPeripherals() + restartScan() + } + + func clearDiscoveries() { + discoveries.removeAll() + pendingDiscoveries.removeAll() + } + + private func startDiscoveryFlush() { + discoveryFlushTimer?.invalidate() + discoveryFlushTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + guard let self else { return } + // Verschwundene Geräte erst nach einer Weile fallen lassen, damit + // ein Eintrag nicht wegen eines verpassten Pakets verschwindet. + let cutoff = Date().addingTimeInterval(-20) + self.pendingDiscoveries = self.pendingDiscoveries.filter { $0.value.lastSeen > cutoff } + self.discoveries = self.pendingDiscoveries + } + } + + private func stopDiscoveryFlush() { + discoveryFlushTimer?.invalidate() + discoveryFlushTimer = nil + } + + // MARK: - Scannen + + private func restartScan() { + guard !isDemo, central?.state == .poweredOn else { return } + central?.stopScan() + // Victron sendet seine Werte im Advertisement, also müssen auch + // Wiederholungen durchgereicht werden. + central?.scanForPeripherals( + withServices: nil, + options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] + ) + } + + private func connectManagedPeripherals() { + guard !isDemo, central?.state == .poweredOn else { return } + for device in store.activeDevices where device.role.transport == .connect { + connectIfNeeded(device) + } + } + + private func connectIfNeeded(_ device: ConfiguredDevice) { + let peripheralID = device.peripheralID + if let existing = connectedPeripherals[peripheralID], + existing.state == .connected || existing.state == .connecting { + return + } + guard let peripheral = central?.retrievePeripherals(withIdentifiers: [peripheralID]).first else { + linkStates[device.id] = .searching + return + } + connectedPeripherals[peripheralID] = peripheral + linkStates[device.id] = .connecting + central?.connect(peripheral, options: nil) + } + + /// Verbindungen fallen im Fahrzeug regelmässig weg – deshalb regelmässig + /// nachfassen statt nur auf das Disconnect-Ereignis zu reagieren. + private func scheduleReconnects() { + reconnectTimer?.invalidate() + reconnectTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in + self?.connectManagedPeripherals() + } + } + + // MARK: - Auswertung + + private func record(_ snapshot: DeviceSnapshot) { + snapshots[snapshot.deviceID] = snapshot + guard let primary = snapshot.primaryMetric, let value = primary.value else { return } + var samples = history[snapshot.deviceID] ?? [] + // Höchstens alle fünf Sekunden einen Punkt aufnehmen. + if let last = samples.last, snapshot.timestamp.timeIntervalSince(last.time) < 5 { return } + samples.append(HistorySample(time: snapshot.timestamp, value: value)) + if samples.count > historyLimit { samples.removeFirst(samples.count - historyLimit) } + history[snapshot.deviceID] = samples + } + + private func handleVictronAdvertisement(_ manufacturerData: Data, + device: ConfiguredDevice, + rssi: Int) { + // Zuerst den unverschlüsselten Rahmen festhalten – gerade wenn der + // Schlüssel nicht passt, ist das die einzige verwertbare Information. + if let envelope = VictronAdvertisement.envelope(from: manufacturerData) { + diagnostics[device.id] = VictronDiagnostics( + productID: envelope.productID, + recordType: envelope.recordType, + expectedKeyFirstByte: envelope.keyCheckByte, + nonce: envelope.nonce, + payloadLength: envelope.ciphertext.count, + rawHex: manufacturerData.map { String(format: "%02X", $0) }.joined(separator: " "), + updated: Date() + ) + } + + guard let key = store.victronKeyBytes(for: device.id) else { + linkStates[device.id] = .needsKey + return + } + do { + let snapshot = try VictronAdvertisement.decode(manufacturerData: manufacturerData, + key: key, + deviceID: device.id, + rssi: rssi) + linkStates[device.id] = .live + record(snapshot) + } catch let error as VictronAdvertisement.DecodeError { + switch error { + case .notVictron: + break // Fremdes Advertisement desselben Peripherals, ignorieren. + default: + linkStates[device.id] = .failed(error.localizedDescription) + } + } catch { + linkStates[device.id] = .failed(error.localizedDescription) + } + } + + private func updateDiscovery(peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi: Int) { + let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data + let envelope = manufacturerData.flatMap { VictronAdvertisement.envelope(from: $0) } + let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name + + let existing = pendingDiscoveries[peripheral.identifier] + var entry = existing ?? Discovery( + id: peripheral.identifier, + name: name, + rssi: rssi, + firstSeen: Date(), + lastSeen: Date(), + victronRecordType: nil, + victronProductID: nil, + looksLikeDaly: false + ) + // Gleitender Mittelwert über die letzten Messungen. + entry.rssi = existing.map { ($0.rssi * 3 + rssi) / 4 } ?? rssi + entry.lastSeen = Date() + if let name, !name.isEmpty { entry.name = name } + if let envelope { + entry.victronRecordType = envelope.recordType + entry.victronProductID = envelope.productID + } + entry.looksLikeDaly = Self.looksLikeDaly(name: entry.name) + pendingDiscoveries[peripheral.identifier] = entry + } + + /// Die BLE-Module der Daly-BMS melden sich meist als "DL-…" oder tragen + /// "BMS" im Namen. Nur ein Hinweis für die Liste – auswählbar ist alles. + private static func looksLikeDaly(name: String?) -> Bool { + guard let name = name?.lowercased() else { return false } + return name.hasPrefix("dl-") || name.contains("daly") || name.contains("bms") + || name.contains("bulltron") || name.hasPrefix("smart bms") + } +} + +// MARK: - CBCentralManagerDelegate + +extension BluetoothManager: CBCentralManagerDelegate { + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + switch central.state { + case .poweredOn: + isBluetoothReady = true + bluetoothStatusText = "Bereit" + start() + case .poweredOff: + isBluetoothReady = false + bluetoothStatusText = "Bluetooth ist ausgeschaltet" + case .unauthorized: + isBluetoothReady = false + bluetoothStatusText = "Bluetooth-Zugriff wurde nicht erlaubt" + case .unsupported: + isBluetoothReady = false + bluetoothStatusText = "Dieses Gerät unterstützt kein Bluetooth LE" + default: + isBluetoothReady = false + bluetoothStatusText = "Bluetooth wird gestartet…" + } + } + + func centralManager(_ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber) { + let rssi = RSSI.intValue + + if isDiscovering { + updateDiscovery(peripheral: peripheral, advertisementData: advertisementData, rssi: rssi) + } + + guard let device = store.device(withPeripheralID: peripheral.identifier) else { return } + + switch device.role.transport { + case .advertisement: + guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data + else { return } + handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi) + + case .connect: + // Das BMS wurde gesehen – falls die Verbindung fehlt, jetzt aufbauen. + if connectedPeripherals[peripheral.identifier] == nil { + connectIfNeeded(device) + } + } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + guard let device = store.device(withPeripheralID: peripheral.identifier) else { return } + connectedPeripherals[peripheral.identifier] = peripheral + + let session = BMSSession( + deviceID: device.id, + peripheral: peripheral, + onUpdate: { [weak self] snapshot in self?.record(snapshot) }, + onStateChange: { [weak self] state in self?.linkStates[device.id] = state }, + onDiagnostics: { [weak self] info in self?.bmsDiagnostics[device.id] = info } + ) + bmsSessions[peripheral.identifier] = session + session.start() + } + + func centralManager(_ central: CBCentralManager, + didFailToConnect peripheral: CBPeripheral, + error: Error?) { + if let device = store.device(withPeripheralID: peripheral.identifier) { + linkStates[device.id] = .failed(error?.localizedDescription ?? "Verbindung fehlgeschlagen") + } + connectedPeripherals[peripheral.identifier] = nil + } + + func centralManager(_ central: CBCentralManager, + didDisconnectPeripheral peripheral: CBPeripheral, + error: Error?) { + bmsSessions[peripheral.identifier]?.handleDisconnect() + bmsSessions[peripheral.identifier] = nil + connectedPeripherals[peripheral.identifier] = nil + + if let device = store.device(withPeripheralID: peripheral.identifier) { + linkStates[device.id] = .searching + // Direkt wieder anfragen; iOS stellt die Verbindung her, sobald das + // Gerät wieder in Reichweite ist. + connectIfNeeded(device) + } + } +} diff --git a/CamperMonitor/Bluetooth/DalyProtocol.swift b/CamperMonitor/Bluetooth/DalyProtocol.swift new file mode 100644 index 0000000..14380c4 --- /dev/null +++ b/CamperMonitor/Bluetooth/DalyProtocol.swift @@ -0,0 +1,123 @@ +import Foundation + +/// Reine Protokoll-Logik für Daly-BMS – ohne CoreBluetooth, damit sie sich +/// isoliert prüfen lässt. +/// +/// Daly hat zwei Generationen im Umlauf: +/// +/// * **Klassisch (`A5`)** – 13-Byte-Rahmen `A5 08 <8 Datenbytes> `. +/// Verbreitet bei den Smart-BMS mit dem blauen BLE-Stick, wie sie in vielen +/// Bulltron-Akkus stecken. +/// * **Neu (`D2`)** – Modbus-RTU über BLE, `D2 03 `. +/// +/// Welche Generation verbaut ist, erkennt `DalySession` anhand der Antwort. +enum DalyProtocol { + + // MARK: - Klassisches A5-Protokoll + + enum Command: UInt8, CaseIterable { + case soc = 0x90 // Spannung, Strom, Ladezustand + case cellVoltageMinMax = 0x91 + case temperatureMinMax = 0x92 + case mosfetStatus = 0x93 + case statusInfo = 0x94 + case cellVoltages = 0x95 + case cellTemperatures = 0x96 + } + + /// Adresse des Anfragenden. 0x80 = Bluetooth-Modul. + static let hostAddress: UInt8 = 0x80 + + static func requestFrame(_ command: Command) -> Data { + var frame: [UInt8] = [0xA5, hostAddress, command.rawValue, 0x08] + frame.append(contentsOf: [UInt8](repeating: 0, count: 8)) + frame.append(frame.reduce(0) { UInt8(($0 &+ $1) & 0xFF) }) + return Data(frame) + } + + struct Frame { + let address: UInt8 + let command: UInt8 + let payload: [UInt8] // immer 8 Bytes + } + + /// Sucht vollständige, prüfsummenkorrekte A5-Rahmen im Puffer und gibt sie + /// zusammen mit dem unverbrauchten Rest zurück. + static func extractA5Frames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) { + var frames: [Frame] = [] + var index = 0 + var lastConsumed = 0 + + while index + 13 <= buffer.count { + guard buffer[index] == 0xA5, buffer[index + 3] == 0x08 else { + index += 1 + continue + } + let slice = Array(buffer[index..<(index + 13)]) + let checksum = slice[0..<12].reduce(UInt8(0)) { UInt8(($0 &+ $1) & 0xFF) } + guard checksum == slice[12] else { + index += 1 + continue + } + frames.append(Frame(address: slice[1], + command: slice[2], + payload: Array(slice[4..<12]))) + index += 13 + lastConsumed = index + } + // Angefangene Rahmen aufheben – BLE liefert Antworten oft gestückelt. + let keepFrom = max(lastConsumed, max(0, buffer.count - 64)) + return (frames, Array(buffer[keepFrom...])) + } + + // MARK: - Modbus (D2) + + /// Ein Lesekommando über alle interessanten Register. + static func modbusReadFrame(start: UInt16 = 0, count: UInt16 = 62) -> Data { + var frame: [UInt8] = [0xD2, 0x03, + UInt8(start >> 8), UInt8(start & 0xFF), + UInt8(count >> 8), UInt8(count & 0xFF)] + let crc = crc16Modbus(frame) + frame.append(UInt8(crc & 0xFF)) + frame.append(UInt8(crc >> 8)) + return Data(frame) + } + + static func crc16Modbus(_ bytes: [UInt8]) -> UInt16 { + var crc: UInt16 = 0xFFFF + for byte in bytes { + crc ^= UInt16(byte) + for _ in 0..<8 { + if crc & 1 != 0 { + crc = (crc >> 1) ^ 0xA001 + } else { + crc >>= 1 + } + } + } + return crc + } + + /// Prüft einen vollständigen Modbus-Antwortrahmen und liefert die + /// Registerwerte. Gibt nil zurück, solange der Rahmen unvollständig ist. + static func parseModbusResponse(_ buffer: [UInt8]) -> [UInt16]? { + guard buffer.count >= 5, buffer[0] == 0xD2, buffer[1] == 0x03 else { return nil } + let byteCount = Int(buffer[2]) + let total = 3 + byteCount + 2 + guard buffer.count >= total else { return nil } + + let body = Array(buffer[0..<(3 + byteCount)]) + let expected = crc16Modbus(body) + let actual = UInt16(buffer[3 + byteCount]) | (UInt16(buffer[4 + byteCount]) << 8) + guard expected == actual else { return nil } + + var registers: [UInt16] = [] + registers.reserveCapacity(byteCount / 2) + var i = 3 + while i + 1 < 3 + byteCount { + registers.append(UInt16(body[i]) << 8 | UInt16(body[i + 1])) + i += 2 + } + return registers + } +} diff --git a/CamperMonitor/Bluetooth/DalyState.swift b/CamperMonitor/Bluetooth/DalyState.swift new file mode 100644 index 0000000..4ad9cda --- /dev/null +++ b/CamperMonitor/Bluetooth/DalyState.swift @@ -0,0 +1,214 @@ +import Foundation + +/// Sammelt die Antworten eines Daly-BMS. Das klassische Protokoll verteilt die +/// Werte auf mehrere Rahmen, deshalb wird hier über Abfragerunden hinweg +/// akkumuliert und erst am Ende ein Snapshot gebaut. +struct DalyState { + var totalVoltage: Double? + var current: Double? + var soc: Double? + + var maxCellMillivolts: Int? + var maxCellNumber: Int? + var minCellMillivolts: Int? + var minCellNumber: Int? + + var maxTemperature: Double? + var minTemperature: Double? + + var chargeMOSOn: Bool? + var dischargeMOSOn: Bool? + var chargeDischargeStatus: UInt8? + var remainingCapacityAh: Double? + + var cellCount: Int? + var temperatureSensorCount: Int? + var cycles: Int? + + /// Zellnummer (1-basiert) → Spannung in Millivolt. + var cellMillivolts: [Int: Int] = [:] + /// Sensornummer (1-basiert) → Temperatur in °C. + var sensorTemperatures: [Int: Double] = [:] + + /// Letzte Rohantwort, für die Diagnoseansicht. + var lastRawResponse: Data? + var usesModbus = false + + // MARK: - Klassisches Protokoll + + mutating func apply(_ frame: DalyProtocol.Frame) { + let d = frame.payload + func u16(_ i: Int) -> Int { Int(d[i]) << 8 | Int(d[i + 1]) } + + switch frame.command { + case 0x90: + totalVoltage = Double(u16(0)) * 0.1 + // Strom mit Offset 30000, damit Entladung negativ dargestellt wird. + current = Double(u16(4) - 30000) * 0.1 + soc = Double(u16(6)) * 0.1 + + case 0x91: + maxCellMillivolts = u16(0) + maxCellNumber = Int(d[2]) + minCellMillivolts = u16(3) + minCellNumber = Int(d[5]) + + case 0x92: + maxTemperature = Double(Int(d[0]) - 40) + minTemperature = Double(Int(d[2]) - 40) + + case 0x93: + chargeDischargeStatus = d[0] + chargeMOSOn = d[1] == 1 + dischargeMOSOn = d[2] == 1 + let capacityMilliAh = (UInt32(d[4]) << 24) | (UInt32(d[5]) << 16) + | (UInt32(d[6]) << 8) | UInt32(d[7]) + remainingCapacityAh = Double(capacityMilliAh) / 1000 + + case 0x94: + cellCount = Int(d[0]) + temperatureSensorCount = Int(d[1]) + cycles = u16(6) + + case 0x95: + // d[0] = Rahmennummer (1-basiert), danach drei Zellen à 2 Byte. + let frameNumber = Int(d[0]) + guard frameNumber > 0 else { break } + for slot in 0..<3 { + let cell = (frameNumber - 1) * 3 + slot + 1 + let millivolts = u16(1 + slot * 2) + if millivolts > 0 && millivolts < 6000 { + cellMillivolts[cell] = millivolts + } + } + + case 0x96: + let frameNumber = Int(d[0]) + guard frameNumber > 0 else { break } + for slot in 0..<7 { + let sensor = (frameNumber - 1) * 7 + slot + 1 + let raw = Int(d[1 + slot]) + if raw != 0 { + sensorTemperatures[sensor] = Double(raw - 40) + } + } + + default: + break + } + } + + // MARK: - Modbus-Protokoll + + /// Registerbelegung der neueren Daly-BMS. + /// + /// Achtung: Dieses Mapping variiert zwischen Firmwareständen. Die + /// Detailansicht zeigt deshalb die Rohantwort an, damit sich die Belegung + /// am realen Gerät nachprüfen lässt. + mutating func apply(registers: [UInt16]) { + usesModbus = true + func reg(_ i: Int) -> UInt16? { i < registers.count ? registers[i] : nil } + + // Register 0–47: Zellspannungen in mV, unbenutzte Plätze sind 0. + cellMillivolts.removeAll(keepingCapacity: true) + for i in 0.. 500 && millivolts < 5000 { + cellMillivolts[i + 1] = millivolts + } + } + + // Register 48–55: Temperaturfühler mit Offset 40. + sensorTemperatures.removeAll(keepingCapacity: true) + for i in 48.. 0 && raw < 200 { + sensorTemperatures[i - 47] = Double(raw - 40) + } + } + + if let v = reg(56), v > 0 { totalVoltage = Double(v) * 0.1 } + if let c = reg(57) { current = (Double(c) - 30000) * 0.1 } + if let s = reg(58), s <= 1000 { soc = Double(s) * 0.1 } + + maxCellMillivolts = cellMillivolts.values.max() + minCellMillivolts = cellMillivolts.values.min() + maxCellNumber = cellMillivolts.max(by: { $0.value < $1.value })?.key + minCellNumber = cellMillivolts.min(by: { $0.value < $1.value })?.key + maxTemperature = sensorTemperatures.values.max() + minTemperature = sensorTemperatures.values.min() + cellCount = cellMillivolts.isEmpty ? nil : cellMillivolts.count + temperatureSensorCount = sensorTemperatures.isEmpty ? nil : sensorTemperatures.count + } + + // MARK: - Ausgabe + + var hasUsableData: Bool { + totalVoltage != nil || soc != nil || !cellMillivolts.isEmpty + } + + func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot { + var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi) + + var metrics: [Metric] = [ + Metric("soc", "Ladezustand", soc, unit: "%", precision: 1, primary: true), + Metric("voltage", "Spannung", totalVoltage, unit: "V", precision: 2), + Metric("current", "Strom", current, unit: "A", precision: 1), + ] + if let v = totalVoltage, let a = current { + metrics.append(Metric("power", "Leistung", v * a, unit: "W", precision: 0)) + } + if let capacity = remainingCapacityAh { + metrics.append(Metric("capacity", "Restkapazität", capacity, unit: "Ah", precision: 1)) + } + if let maxV = maxCellMillivolts, let minV = minCellMillivolts { + metrics.append(Metric("cell_delta", "Zell-Differenz", Double(maxV - minV), unit: "mV", precision: 0)) + metrics.append(Metric("cell_max", "Höchste Zelle" + numberSuffix(maxCellNumber), + Double(maxV) / 1000, unit: "V", precision: 3)) + metrics.append(Metric("cell_min", "Niedrigste Zelle" + numberSuffix(minCellNumber), + Double(minV) / 1000, unit: "V", precision: 3)) + } + if let maxTemperature { + metrics.append(Metric("temp_max", "Temperatur", maxTemperature, unit: "°C", precision: 0)) + } + if let minTemperature, minTemperature != maxTemperature { + metrics.append(Metric("temp_min", "Temperatur min.", minTemperature, unit: "°C", precision: 0)) + } + if let cycles { + metrics.append(Metric("cycles", "Ladezyklen", Double(cycles), unit: "", precision: 0)) + } + snapshot.metrics = metrics + + snapshot.state = stateText + snapshot.cellVoltages = cellMillivolts + .sorted { $0.key < $1.key } + .map { Double($0.value) / 1000 } + snapshot.temperatures = sensorTemperatures.sorted { $0.key < $1.key }.map(\.value) + + var warnings: [String] = [] + if chargeMOSOn == false { warnings.append("Lade-MOSFET aus") } + if dischargeMOSOn == false { warnings.append("Entlade-MOSFET aus") } + snapshot.offReasons = warnings + + return snapshot + } + + private func numberSuffix(_ number: Int?) -> String { + number.map { " (Zelle \($0))" } ?? "" + } + + private var stateText: String? { + if let status = chargeDischargeStatus { + switch status { + case 0: return "Ruhend" + case 1: return "Lädt" + case 2: return "Entlädt" + default: break + } + } + guard let current else { return nil } + if current > 0.3 { return "Lädt" } + if current < -0.3 { return "Entlädt" } + return "Ruhend" + } +} diff --git a/CamperMonitor/Bluetooth/JBDProtocol.swift b/CamperMonitor/Bluetooth/JBDProtocol.swift new file mode 100644 index 0000000..898b4b9 --- /dev/null +++ b/CamperMonitor/Bluetooth/JBDProtocol.swift @@ -0,0 +1,200 @@ +import Foundation + +/// Protokoll der JBD-/Xiaoxiang-BMS, wie sie unter anderem in WattCycle-Akkus +/// verbaut sind. Bekannt auch als „Smart BMS“ oder Overkill-Solar-Protokoll. +/// +/// Rahmenaufbau: +/// +/// Anfrage: DD A5 77 +/// Antwort: DD 77 +/// +/// Die Prüfsumme ist `0x10000 − (Status + Länge + Daten)`, big-endian; in der +/// Anfrage entsprechend über Kommando und Länge. +enum JBDProtocol { + + enum Command: UInt8, CaseIterable { + case basicInfo = 0x03 + case cellVoltages = 0x04 + } + + static func requestFrame(_ command: Command) -> Data { + let checksum = checksum(over: [command.rawValue, 0x00]) + return Data([0xDD, 0xA5, command.rawValue, 0x00, + UInt8(checksum >> 8), UInt8(checksum & 0xFF), 0x77]) + } + + static func checksum(over bytes: [UInt8]) -> UInt16 { + let sum = bytes.reduce(UInt32(0)) { $0 + UInt32($1) } + return UInt16(truncatingIfNeeded: 0x1_0000 &- sum) + } + + struct Frame { + let command: UInt8 + let payload: [UInt8] + } + + /// Sucht vollständige, prüfsummenkorrekte Rahmen im Puffer. + static func extractFrames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) { + var frames: [Frame] = [] + var index = 0 + var consumed = 0 + + while index + 7 <= buffer.count { + guard buffer[index] == 0xDD else { + index += 1 + continue + } + let length = Int(buffer[index + 3]) + let total = 4 + length + 3 // Kopf + Daten + Prüfsumme + 0x77 + guard index + total <= buffer.count else { break } // Rest abwarten + + let frame = Array(buffer[index..<(index + total)]) + guard frame[total - 1] == 0x77 else { + index += 1 + continue + } + let expected = checksum(over: Array(frame[2..<(4 + length)])) + let actual = UInt16(frame[4 + length]) << 8 | UInt16(frame[5 + length]) + guard expected == actual else { + index += 1 + continue + } + // Status ≠ 0 meldet einen Fehler; der Rahmen ist dann leer. + if frame[2] == 0x00 { + frames.append(Frame(command: frame[1], + payload: Array(frame[4..<(4 + length)]))) + } + index += total + consumed = index + } + let keepFrom = max(consumed, max(0, buffer.count - 128)) + return (frames, Array(buffer[keepFrom...])) + } + + /// Klartext der Schutzabschaltungen aus der 16-Bit-Maske. + static func protectionReasons(_ mask: UInt16) -> [String] { + guard mask != 0 else { return [] } + let table: [(UInt16, String)] = [ + (1 << 0, "Zellüberspannung"), + (1 << 1, "Zellunterspannung"), + (1 << 2, "Batterie Überspannung"), + (1 << 3, "Batterie Unterspannung"), + (1 << 4, "Ladetemperatur zu hoch"), + (1 << 5, "Ladetemperatur zu niedrig"), + (1 << 6, "Entladetemperatur zu hoch"), + (1 << 7, "Entladetemperatur zu niedrig"), + (1 << 8, "Ladestrom zu hoch"), + (1 << 9, "Entladestrom zu hoch"), + (1 << 10, "Kurzschluss"), + (1 << 11, "Fehler im Messkreis"), + (1 << 12, "MOSFET gesperrt"), + ] + return table.filter { mask & $0.0 != 0 }.map(\.1) + } +} + +/// Sammelt die Antworten eines JBD-BMS. +struct JBDState { + var totalVoltage: Double? + var current: Double? + var remainingCapacityAh: Double? + var nominalCapacityAh: Double? + var cycles: Int? + var soc: Double? + var chargeMOSOn: Bool? + var dischargeMOSOn: Bool? + var protections: [String] = [] + var cellMillivolts: [Int] = [] + var temperatures: [Double] = [] + + var hasUsableData: Bool { totalVoltage != nil || soc != nil || !cellMillivolts.isEmpty } + + mutating func apply(_ frame: JBDProtocol.Frame) { + let d = frame.payload + func u16(_ i: Int) -> Int { Int(d[i]) << 8 | Int(d[i + 1]) } + func i16(_ i: Int) -> Int { Int(Int16(bitPattern: UInt16(u16(i)))) } + + switch frame.command { + case 0x03: + guard d.count >= 23 else { return } + totalVoltage = Double(u16(0)) * 0.01 // 10 mV je Schritt + current = Double(i16(2)) * 0.01 // 10 mA, negativ = Entladung + remainingCapacityAh = Double(u16(4)) * 0.01 + nominalCapacityAh = Double(u16(6)) * 0.01 + cycles = u16(8) + protections = JBDProtocol.protectionReasons(UInt16(u16(16))) + soc = Double(d[19]) + chargeMOSOn = d[20] & 0x01 != 0 + dischargeMOSOn = d[20] & 0x02 != 0 + + // Ab Byte 23 folgen die NTC-Fühler, je zwei Byte in Zehntel-Kelvin. + let sensorCount = Int(d[22]) + var readings: [Double] = [] + for sensor in 0.. DeviceSnapshot { + var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi) + + var metrics: [Metric] = [ + Metric("soc", "Ladezustand", soc, unit: "%", precision: 0, primary: true), + Metric("voltage", "Spannung", totalVoltage, unit: "V", precision: 2), + Metric("current", "Strom", current, unit: "A", precision: 1), + ] + if let v = totalVoltage, let a = current { + metrics.append(Metric("power", "Leistung", v * a, unit: "W", precision: 0)) + } + if let remainingCapacityAh { + metrics.append(Metric("capacity", "Restkapazität", remainingCapacityAh, unit: "Ah", precision: 1)) + } + if let nominalCapacityAh { + metrics.append(Metric("capacity_nominal", "Nennkapazität", nominalCapacityAh, unit: "Ah", precision: 1)) + } + if let maxV = cellMillivolts.max(), let minV = cellMillivolts.min() { + metrics.append(Metric("cell_delta", "Zell-Differenz", Double(maxV - minV), unit: "mV", precision: 0)) + metrics.append(Metric("cell_max", "Höchste Zelle", Double(maxV) / 1000, unit: "V", precision: 3)) + metrics.append(Metric("cell_min", "Niedrigste Zelle", Double(minV) / 1000, unit: "V", precision: 3)) + } + if let warmest = temperatures.max() { + metrics.append(Metric("temp_max", "Temperatur", warmest, unit: "°C", precision: 0)) + } + if let cycles { + metrics.append(Metric("cycles", "Ladezyklen", Double(cycles), unit: "", precision: 0)) + } + snapshot.metrics = metrics + + snapshot.cellVoltages = cellMillivolts.map { Double($0) / 1000 } + snapshot.temperatures = temperatures + snapshot.fault = protections.isEmpty ? nil : protections.joined(separator: ", ") + + var notes: [String] = [] + if chargeMOSOn == false { notes.append("Laden gesperrt") } + if dischargeMOSOn == false { notes.append("Entladen gesperrt") } + snapshot.offReasons = notes + + if let current { + if current > 0.3 { snapshot.state = "Lädt" } + else if current < -0.3 { snapshot.state = "Entlädt" } + else { snapshot.state = "Ruhend" } + } + return snapshot + } +} diff --git a/CamperMonitor/Bluetooth/VictronAdvertisement.swift b/CamperMonitor/Bluetooth/VictronAdvertisement.swift new file mode 100644 index 0000000..f367360 --- /dev/null +++ b/CamperMonitor/Bluetooth/VictronAdvertisement.swift @@ -0,0 +1,279 @@ +import Foundation + +/// Dekodiert die "Instant Readout"-Werbedaten von Victron-Geräten. +/// +/// Aufbau der Herstellerdaten (inkl. der zwei Bytes Company-ID, die +/// CoreBluetooth mitliefert): +/// +/// [0..1] E1 02 Company-ID 0x02E1 (Victron Energy) +/// [2..3] 10 00 Record-Typ "Product Advertisement", 16 Bit +/// [4..5] ll hh Produkt-ID, little-endian +/// [6] rr Art des Datensatzes (Solarlader, DC/DC, …) +/// [7..8] ll hh Nonce / Zähler, little-endian +/// [9] kk Erstes Byte des Geräteschlüssels (Prüfbyte) +/// [10..] Mit AES-128-CTR verschlüsselte Nutzdaten +/// +/// Die Aufteilung ist an einem Orion XS belegt: Datensatztyp 0x0F passt zum +/// Gerät, das Prüfbyte zum hinterlegten Schlüssel, und die verbleibenden +/// 14 Byte entsprechen genau der Länge eines Orion-XS-Datensatzes. +/// +/// Der Schlüssel stammt aus VictronConnect +/// (Gerät → Zahnrad → ⋮ → Produkt-Info → Verschlüsselungsdaten). +enum VictronAdvertisement { + + static let companyIdentifier: UInt16 = 0x02E1 + + enum RecordType: UInt8 { + case solarCharger = 0x01 + case batteryMonitor = 0x02 + case inverter = 0x03 + case dcdcConverter = 0x04 + case smartLithium = 0x05 + case inverterRS = 0x06 + case gxDevice = 0x07 + case acCharger = 0x08 + case smartBatteryProtect = 0x09 + case lynxSmartBMS = 0x0A + case multiRS = 0x0B + case veBus = 0x0C + case dcEnergyMeter = 0x0D + case orionXS = 0x0F + } + + enum DecodeError: Error, LocalizedError { + case notVictron + case malformed + case keyMismatch(expected: UInt8, got: UInt8) + case decryptionFailed + case unsupportedRecord(UInt8) + + var errorDescription: String? { + switch self { + case .notVictron: return "Kein Victron-Advertisement" + case .malformed: return "Advertisement zu kurz" + case .keyMismatch(let expected, let got): + return String(format: "Schlüssel passt nicht: Das Gerät sendet 0x%02X als erstes Byte, " + + "der eingetragene Schlüssel beginnt mit 0x%02X.", expected, got) + case .decryptionFailed: return "Entschlüsselung fehlgeschlagen" + case .unsupportedRecord(let r): + return String(format: "Datensatz-Typ 0x%02X wird nicht unterstützt", r) + } + } + } + + /// Der unverschlüsselte Rahmen – lässt sich auch ohne Schlüssel lesen und + /// wird beim Einrichten benutzt, um Victron-Geräte zu erkennen. + struct Envelope { + let productID: UInt16 + let recordType: UInt8 + let nonce: UInt16 + let keyCheckByte: UInt8 + let ciphertext: [UInt8] + + var knownRecord: RecordType? { RecordType(rawValue: recordType) } + + var productIDText: String { String(format: "0x%04X", productID) } + } + + static func envelope(from manufacturerData: Data) -> Envelope? { + let bytes = [UInt8](manufacturerData) + guard bytes.count >= 11 else { return nil } + let company = UInt16(bytes[0]) | (UInt16(bytes[1]) << 8) + guard company == companyIdentifier, bytes[2] == 0x10 else { return nil } + return Envelope( + productID: UInt16(bytes[4]) | (UInt16(bytes[5]) << 8), + recordType: bytes[6], + nonce: UInt16(bytes[7]) | (UInt16(bytes[8]) << 8), + keyCheckByte: bytes[9], + ciphertext: Array(bytes[10...]) + ) + } + + /// Entschlüsselt und interpretiert ein Advertisement. + static func decode(manufacturerData: Data, + key: [UInt8], + deviceID: UUID, + rssi: Int?) throws -> DeviceSnapshot { + guard let envelope = envelope(from: manufacturerData) else { + throw DecodeError.notVictron + } + guard key.count == 16 else { throw DecodeError.malformed } + guard key[0] == envelope.keyCheckByte else { + throw DecodeError.keyMismatch(expected: envelope.keyCheckByte, got: key[0]) + } + + // Zählerblock: Nonce little-endian in den ersten zwei Bytes, Rest null. + var counter = [UInt8](repeating: 0, count: 16) + counter[0] = UInt8(envelope.nonce & 0xFF) + counter[1] = UInt8(envelope.nonce >> 8) + + guard let plain = AESCounterMode.crypt(envelope.ciphertext, key: key, nonce: counter) else { + throw DecodeError.decryptionFailed + } + + var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi) + + switch envelope.knownRecord { + case .solarCharger: fill(solarCharger: plain, into: &snapshot) + case .dcdcConverter: fill(dcdcConverter: plain, into: &snapshot) + case .orionXS: fill(orionXS: plain, into: &snapshot) + case .batteryMonitor: fill(batteryMonitor: plain, into: &snapshot) + case .acCharger: fill(acCharger: plain, into: &snapshot) + default: throw DecodeError.unsupportedRecord(envelope.recordType) + } + return snapshot + } + + // MARK: - Datensätze + + /// 0x01 – Solarladeregler (SmartSolar / BlueSolar MPPT). + private static func fill(solarCharger bytes: [UInt8], into snapshot: inout DeviceSnapshot) { + var r = BitReader(bytes) + let state = r.readOptional(8) + let error = r.readOptional(8) + let batteryVoltage = r.scaledSigned(16, 0.01) + let batteryCurrent = r.scaledSigned(16, 0.1) + let yieldToday = r.scaled(16, 0.01) + let pvPower = r.scaled(16, 1) + let loadCurrent = r.scaled(9, 0.1) + + snapshot.state = VictronCodes.deviceState(state) + snapshot.fault = VictronCodes.chargerError(error) + snapshot.metrics = [ + Metric("pv_power", "PV-Leistung", pvPower, unit: "W", precision: 0, primary: true), + Metric("battery_voltage", "Batteriespannung", batteryVoltage, unit: "V", precision: 2), + Metric("battery_current", "Ladestrom", batteryCurrent, unit: "A", precision: 1), + Metric("yield_today", "Ertrag heute", yieldToday, unit: "kWh", precision: 2), + Metric("load_current", "Laststrom", loadCurrent, unit: "A", precision: 1), + ] + if let v = batteryVoltage, let a = batteryCurrent { + snapshot.metrics.insert( + Metric("battery_power", "Ladeleistung", v * a, unit: "W", precision: 0), at: 1) + } + } + + /// 0x04 – DC/DC-Wandler (Orion-TR Smart Ladebooster). + private static func fill(dcdcConverter bytes: [UInt8], into snapshot: inout DeviceSnapshot) { + var r = BitReader(bytes) + let state = r.readOptional(8) + let error = r.readOptional(8) + let inputVoltage = r.scaled(16, 0.01) + let outputVoltage = r.scaledSigned(16, 0.01) + let offReason = r.readOptional(32) + + snapshot.state = VictronCodes.deviceState(state) + snapshot.fault = VictronCodes.chargerError(error) + snapshot.offReasons = VictronCodes.offReasons(offReason) + snapshot.metrics = [ + Metric("output_voltage", "Ausgang (Aufbaubatterie)", outputVoltage, unit: "V", precision: 2, primary: true), + Metric("input_voltage", "Eingang (Starterbatterie)", inputVoltage, unit: "V", precision: 2), + ] + } + + /// 0x0F – Orion XS. Sendet im Gegensatz zum Orion-TR auch Ströme. + private static func fill(orionXS bytes: [UInt8], into snapshot: inout DeviceSnapshot) { + var r = BitReader(bytes) + let state = r.readOptional(8) + let error = r.readOptional(8) + let outputVoltage = r.scaled(16, 0.01) + let outputCurrent = r.scaledSigned(16, 0.1) + let inputVoltage = r.scaled(16, 0.01) + let inputCurrent = r.scaledSigned(16, 0.1) + let offReason = r.readOptional(32) + + snapshot.state = VictronCodes.deviceState(state) + snapshot.fault = VictronCodes.chargerError(error) + snapshot.offReasons = VictronCodes.offReasons(offReason) + + var metrics: [Metric] = [] + if let v = outputVoltage, let a = outputCurrent { + metrics.append(Metric("output_power", "Ladeleistung", v * a, unit: "W", precision: 0, primary: true)) + } + metrics += [ + Metric("output_voltage", "Ausgang (Aufbaubatterie)", outputVoltage, unit: "V", precision: 2, + primary: outputCurrent == nil), + Metric("output_current", "Ladestrom", outputCurrent, unit: "A", precision: 1), + Metric("input_voltage", "Eingang (Starterbatterie)", inputVoltage, unit: "V", precision: 2), + Metric("input_current", "Eingangsstrom", inputCurrent, unit: "A", precision: 1), + ] + snapshot.metrics = metrics + } + + /// 0x08 – AC-Ladegerät (Blue Smart IP65/IP22), falls im Camper verbaut. + private static func fill(acCharger bytes: [UInt8], into snapshot: inout DeviceSnapshot) { + var r = BitReader(bytes) + let state = r.readOptional(8) + let error = r.readOptional(8) + let voltage1 = r.scaled(13, 0.01) + let current1 = r.scaled(11, 0.1) + let voltage2 = r.scaled(13, 0.01) + let current2 = r.scaled(11, 0.1) + let voltage3 = r.scaled(13, 0.01) + let current3 = r.scaled(11, 0.1) + let temperature = r.scaled(7, 1) + let acCurrent = r.scaled(9, 0.1) + + snapshot.state = VictronCodes.deviceState(state) + snapshot.fault = VictronCodes.chargerError(error) + snapshot.metrics = [ + Metric("out1_voltage", "Ausgang 1 Spannung", voltage1, unit: "V", precision: 2, primary: true), + Metric("out1_current", "Ausgang 1 Strom", current1, unit: "A", precision: 1), + Metric("out2_voltage", "Ausgang 2 Spannung", voltage2, unit: "V", precision: 2), + Metric("out2_current", "Ausgang 2 Strom", current2, unit: "A", precision: 1), + Metric("out3_voltage", "Ausgang 3 Spannung", voltage3, unit: "V", precision: 2), + Metric("out3_current", "Ausgang 3 Strom", current3, unit: "A", precision: 1), + Metric("ac_current", "AC-Eingangsstrom", acCurrent, unit: "A", precision: 1), + ] + if let temperature { + snapshot.temperatures = [temperature - 40] + } + } + + /// 0x02 – Batteriewächter (SmartShunt, BMV). + private static func fill(batteryMonitor bytes: [UInt8], into snapshot: inout DeviceSnapshot) { + var r = BitReader(bytes) + let timeToGo = r.scaled(16, 1) // Minuten + let voltage = r.scaledSigned(16, 0.01) + let alarm = r.readOptional(16) + let auxRaw = r.read(16) + let auxType = r.read(2) + let current = r.scaledSigned(22, 0.001) + let consumedAh = r.scaled(20, 0.1) + let soc = r.scaled(10, 0.1) + + snapshot.state = nil + let alarms = VictronCodes.alarmReasons(alarm) + snapshot.fault = alarms.isEmpty ? nil : alarms.joined(separator: ", ") + + var metrics: [Metric] = [ + Metric("soc", "Ladezustand", soc, unit: "%", precision: 1, primary: true), + Metric("voltage", "Spannung", voltage, unit: "V", precision: 2), + Metric("current", "Strom", current, unit: "A", precision: 2), + ] + if let v = voltage, let a = current { + metrics.append(Metric("power", "Leistung", v * a, unit: "W", precision: 0)) + } + // Entnommene Kapazität wird positiv gesendet, ist aber eine Entnahme. + metrics.append(Metric("consumed", "Entnommen", consumedAh.map { -$0 }, unit: "Ah", precision: 1)) + if let timeToGo, timeToGo < 65535 { + metrics.append(Metric("ttg", "Restlaufzeit", timeToGo / 60, unit: "h", precision: 1)) + } + + // Der Hilfseingang ist je nach Konfiguration Starterbatterie, + // Mittenspannung oder Temperatur. + if let auxRaw, let auxType, auxRaw != 0xFFFF { + switch auxType { + case 0: + let starter = Double(Int16(bitPattern: UInt16(auxRaw))) * 0.01 + metrics.append(Metric("aux_starter", "Starterbatterie", starter, unit: "V", precision: 2)) + case 1: + metrics.append(Metric("aux_mid", "Mittenspannung", Double(auxRaw) * 0.01, unit: "V", precision: 2)) + case 2: + snapshot.temperatures = [Double(auxRaw) * 0.01 - 273.15] + default: + break + } + } + snapshot.metrics = metrics + } +} diff --git a/CamperMonitor/CamperMonitorApp.swift b/CamperMonitor/CamperMonitorApp.swift new file mode 100644 index 0000000..453c23c --- /dev/null +++ b/CamperMonitor/CamperMonitorApp.swift @@ -0,0 +1,31 @@ +import SwiftUI + +@main +struct CamperMonitorApp: App { + @State private var store: DeviceStore + @State private var bluetooth: BluetoothManager + @Environment(\.scenePhase) private var scenePhase + + init() { + let store = DeviceStore() + _store = State(initialValue: store) + _bluetooth = State(initialValue: BluetoothManager(store: store)) + } + + var body: some Scene { + WindowGroup { + DashboardView() + .environment(store) + .environment(bluetooth) + } + .onChange(of: scenePhase) { _, phase in + // Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt + // werden, also Funk sparen und beim Zurückkommen neu starten. + switch phase { + case .active: bluetooth.start() + case .background: bluetooth.stop() + default: break + } + } + } +} diff --git a/CamperMonitor/Models/ConfiguredDevice.swift b/CamperMonitor/Models/ConfiguredDevice.swift new file mode 100644 index 0000000..0580756 --- /dev/null +++ b/CamperMonitor/Models/ConfiguredDevice.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Welche Rolle ein Gerät im Camper spielt. Bestimmt Icon, Sortierung und +/// welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird. +enum DeviceRole: String, Codable, CaseIterable, Identifiable, Sendable { + case chargeBooster + case solarCharger + case batteryMonitor + case bms + + var id: String { rawValue } + + var title: String { + switch self { + case .chargeBooster: return "Ladebooster" + case .solarCharger: return "Solarladeregler" + case .batteryMonitor: return "Batteriemonitor" + case .bms: return "Batterie / BMS" + } + } + + var symbol: String { + switch self { + case .chargeBooster: return "bolt.car" + case .solarCharger: return "sun.max" + case .batteryMonitor: return "gauge.with.dots.needle.bottom.50percent" + case .bms: return "battery.100percent.bolt" + } + } + + /// Victron-Geräte werden passiv über das Advertisement gelesen, das Daly BMS + /// braucht eine echte GATT-Verbindung. + var transport: DeviceTransport { + self == .bms ? .connect : .advertisement + } +} + +enum DeviceTransport: Sendable { + /// Passives Mitlesen der BLE-Werbedaten (Victron Instant Readout). + case advertisement + /// Verbindungsaufbau, Kommando schreiben, Antwort per Notify lesen (Daly). + case connect +} + +/// Ein vom Nutzer eingerichtetes Gerät. Der Victron-Schlüssel liegt nicht hier, +/// sondern in der Keychain (siehe `KeychainStore`). +struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable { + var id: UUID + var name: String + var role: DeviceRole + /// Zu welchem Fahrzeug das Gerät gehört. + var profileID: UUID + /// CoreBluetooth-Identifier des Peripherals. Pro iPhone stabil, aber nicht + /// geräteübergreifend – deshalb wird beim Einrichten neu gescannt. + var peripheralID: UUID + /// Zuletzt gesehener Advertised Name, nur zur Wiedererkennung in der UI. + var advertisedName: String? + + init(id: UUID = UUID(), + name: String, + role: DeviceRole, + profileID: UUID, + peripheralID: UUID, + advertisedName: String? = nil) { + self.id = id + self.name = name + self.role = role + self.profileID = profileID + self.peripheralID = peripheralID + self.advertisedName = advertisedName + } + + /// Geräte, die vor der Profilverwaltung angelegt wurden, haben noch keine + /// Zuordnung – die landen im Standardprofil. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + role = try container.decode(DeviceRole.self, forKey: .role) + profileID = try container.decodeIfPresent(UUID.self, forKey: .profileID) + ?? Profile.defaultID + peripheralID = try container.decode(UUID.self, forKey: .peripheralID) + advertisedName = try container.decodeIfPresent(String.self, forKey: .advertisedName) + } +} diff --git a/CamperMonitor/Models/DeviceSnapshot.swift b/CamperMonitor/Models/DeviceSnapshot.swift new file mode 100644 index 0000000..abc4c61 --- /dev/null +++ b/CamperMonitor/Models/DeviceSnapshot.swift @@ -0,0 +1,89 @@ +import Foundation + +/// Eine einzelne Messgrösse in einer bereits formatierten Form. +struct Metric: Identifiable, Hashable, Sendable { + let key: String + let label: String + let value: Double? + let unit: String + /// Nachkommastellen für die Anzeige. + let precision: Int + /// Wird auf der Kachel gross dargestellt. + let isPrimary: Bool + + var id: String { key } + + init(_ key: String, + _ label: String, + _ value: Double?, + unit: String, + precision: Int = 2, + primary: Bool = false) { + self.key = key + self.label = label + self.value = value + self.unit = unit + self.precision = precision + self.isPrimary = primary + } + + var formatted: String { + guard let value else { return "–" } + return String(format: "%.\(precision)f", value) + } + + var formattedWithUnit: String { + guard value != nil else { return "–" } + return unit.isEmpty ? formatted : "\(formatted) \(unit)" + } +} + +/// Der komplette, zuletzt empfangene Zustand eines Geräts. +struct DeviceSnapshot: Identifiable, Sendable { + var id: UUID { deviceID } + var deviceID: UUID + var timestamp: Date + var metrics: [Metric] = [] + /// z.B. "Bulk", "Float", "Aus" + var state: String? + /// Klartext einer aktiven Störung, sonst nil. + var fault: String? + /// Grund, warum das Gerät gerade nicht lädt (Victron Off-Reason). + var offReasons: [String] = [] + var rssi: Int? + /// Einzelzellspannungen in Volt (nur BMS). + var cellVoltages: [Double] = [] + /// Temperaturfühler in °C (nur BMS). + var temperatures: [Double] = [] + + var primaryMetric: Metric? { + metrics.first(where: \.isPrimary) ?? metrics.first + } + + var age: TimeInterval { Date().timeIntervalSince(timestamp) } + + /// Werte älter als eine Minute gelten als veraltet – Victron sendet etwa + /// jede Sekunde, das Daly wird alle paar Sekunden gepollt. + var isStale: Bool { age > 60 } +} + +/// Verbindungszustand für die UI. +enum DeviceLinkState: Equatable, Sendable { + case idle + case searching + case connecting + case live + case needsKey + case failed(String) + + var label: String { + switch self { + case .idle: return "Inaktiv" + case .searching: return "Suche…" + case .connecting: return "Verbinde…" + case .live: return "Live" + case .needsKey: return "Schlüssel fehlt" + case .failed(let m): return m + } + } +} diff --git a/CamperMonitor/Models/Profile.swift b/CamperMonitor/Models/Profile.swift new file mode 100644 index 0000000..7e2d65a --- /dev/null +++ b/CamperMonitor/Models/Profile.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Ein Fahrzeug. Jedes Profil hat seinen eigenen Satz Geräte; die App zeigt und +/// funkt immer nur für das gerade gewählte. +struct Profile: Identifiable, Codable, Hashable, Sendable { + var id: UUID + var name: String + /// SF-Symbol für die Auswahl im Dashboard. + var symbol: String + + init(id: UUID = UUID(), name: String, symbol: String = "box.truck") { + self.id = id + self.name = name + self.symbol = symbol + } + + /// Profil, dem Geräte aus der Zeit vor der Profilverwaltung zugeordnet + /// werden. Feste Kennung, damit die Zuordnung beim Update erhalten bleibt. + static let defaultID = UUID(uuidString: "00000000-0000-0000-0000-00000000C001")! + + static var initial: Profile { + Profile(id: defaultID, name: "Mein Camper") + } + + /// Auswahl für die Profilbearbeitung. + /// Alle Namen gegen NSImage(systemSymbolName:) geprüft – ein nicht + /// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen. + static let symbols = [ + "box.truck", "truck.pickup.side", "bus", "bus.doubledecker", + "car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2", + ] +} diff --git a/CamperMonitor/Models/VictronCodes.swift b/CamperMonitor/Models/VictronCodes.swift new file mode 100644 index 0000000..f881694 --- /dev/null +++ b/CamperMonitor/Models/VictronCodes.swift @@ -0,0 +1,101 @@ +import Foundation + +/// Klartexte für die Zustands- und Fehlercodes aus den Victron-Werbedaten. +enum VictronCodes { + + /// VE.Reg 0x0201 – Betriebszustand des Laders. + static func deviceState(_ code: UInt32?) -> String? { + guard let code else { return nil } + switch code { + case 0: return "Aus" + case 1: return "Stromsparmodus" + case 2: return "Störung" + case 3: return "Konstantstrom (Bulk)" + case 4: return "Konstantspannung (Absorption)" + case 5: return "Erhaltung (Float)" + case 6: return "Lagerung" + case 7: return "Ausgleichsladung" + case 9: return "Wechselrichten" + case 11: return "Netzteilbetrieb" + case 245: return "Startet" + case 246: return "Wiederholte Absorption" + case 247: return "Auto-Ausgleich" + case 248: return "Battery Safe" + case 252: return "Externe Steuerung" + default: return "Zustand \(code)" + } + } + + /// VE.Reg 0xEDDA – Ladefehler. 0 bedeutet "kein Fehler". + static func chargerError(_ code: UInt32?) -> String? { + guard let code, code != 0 else { return nil } + switch code { + case 1: return "Batterietemperatur zu hoch" + case 2: return "Batteriespannung zu hoch" + case 3: return "Temperatursensor defekt" + case 4: return "Temperatursensor Kurzschluss" + case 5: return "Temperatursensor unplausibel" + case 6: return "Spannungsmessung defekt" + case 7: return "Spannungsmessung Kurzschluss" + case 8: return "Spannungsmessung unplausibel" + case 11: return "Zu hohe Restwelligkeit" + case 14: return "Batterietemperatur zu niedrig" + case 17: return "Lader überhitzt" + case 18: return "Lader Überstrom" + case 19: return "Stromrichtung verkehrt" + case 20: return "Bulk-Zeit überschritten" + case 21: return "Stromsensor defekt" + case 22: return "Interner Temperatursensor defekt" + case 26: return "Anschlussklemme überhitzt" + case 27: return "Kurzschluss im Lader" + case 28: return "Endstufenfehler" + case 29: return "Überladeschutz" + case 33: return "Eingangsspannung zu hoch (PV)" + case 34: return "Eingangsstrom zu hoch (PV)" + case 38: return "Eingang abgeschaltet (Batteriespannung)" + case 39: return "Eingang abgeschaltet (Stromfluss)" + case 65: return "Kommunikation verloren" + case 66: return "Konfiguration synchronisierter Lader fehlerhaft" + case 67: return "BMS-Verbindung verloren" + case 68: return "Netzwerk fehlkonfiguriert" + case 116: return "Kalibrierdaten verloren" + case 117: return "Inkompatible Firmware" + case 119: return "Einstellungen ungültig" + default: return "Fehler \(code)" + } + } + + /// VE.Reg 0x0207 – Bitmaske, warum das Gerät gerade nicht arbeitet. + static func offReasons(_ mask: UInt32?) -> [String] { + guard let mask, mask != 0 else { return [] } + let table: [(UInt32, String)] = [ + (0x0000_0001, "Keine Eingangsspannung"), + (0x0000_0002, "Per Schalter ausgeschaltet"), + (0x0000_0004, "Per Einstellung ausgeschaltet"), + (0x0000_0008, "Remote-Eingang"), + (0x0000_0010, "Schutzfunktion aktiv"), + (0x0000_0020, "Paygo"), + (0x0000_0040, "BMS"), + (0x0000_0080, "Motor-Abschalterkennung"), + (0x0000_0100, "Eingangsspannung wird geprüft"), + ] + return table.filter { mask & $0.0 != 0 }.map(\.1) + } + + /// VE.Reg 0xEEB8 – Alarmgründe des Batteriewächters. + static func alarmReasons(_ mask: UInt32?) -> [String] { + guard let mask, mask != 0 else { return [] } + let table: [(UInt32, String)] = [ + (0x0001, "Unterspannung"), + (0x0002, "Überspannung"), + (0x0004, "Niedriger Ladezustand"), + (0x0008, "Starterbatterie Unterspannung"), + (0x0010, "Starterbatterie Überspannung"), + (0x0020, "Temperatur zu niedrig"), + (0x0040, "Temperatur zu hoch"), + (0x0080, "Mittenspannung"), + (0x0100, "Ladung überfällig"), + ] + return table.filter { mask & $0.0 != 0 }.map(\.1) + } +} diff --git a/CamperMonitor/Store/DemoData.swift b/CamperMonitor/Store/DemoData.swift new file mode 100644 index 0000000..c83eb32 --- /dev/null +++ b/CamperMonitor/Store/DemoData.swift @@ -0,0 +1,110 @@ +import Foundation + +/// Füllt die App mit erfundenen Messwerten, damit sich die Ansichten ohne +/// Fahrzeug und ohne Bluetooth prüfen lassen. +/// +/// Nur in Debug-Builds und nur, wenn beim Start `CAMPER_DEMO=1` gesetzt ist: +/// +/// xcrun simctl launch --terminate-running-process \ +/// booted de.fritob.CamperMonitor +/// # mit: SIMCTL_CHILD_CAMPER_DEMO=1 davor +/// +/// Im normalen Betrieb wird hiervon nichts ausgeführt. +enum DemoData { + + static var isEnabled: Bool { + #if DEBUG + return ProcessInfo.processInfo.environment["CAMPER_DEMO"] == "1" + #else + return false + #endif + } + + static let mainProfile = Profile(id: Profile.defaultID, name: "Kastenwagen", symbol: "box.truck") + static let secondProfile = Profile( + id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C2")!, + name: "Wohnwagen", symbol: "caravan") + + static var profiles: [Profile] { [mainProfile, secondProfile] } + + static let booster = ConfiguredDevice( + id: UUID(uuidString: "00000000-0000-0000-0000-0000000000B0")!, + name: "Ladebooster", role: .chargeBooster, profileID: Profile.defaultID, + peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000B1")!) + + static let solar = ConfiguredDevice( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000050")!, + name: "Solar Dach", role: .solarCharger, profileID: Profile.defaultID, + peripheralID: UUID(uuidString: "00000000-0000-0000-0000-000000000051")!) + + static let battery = ConfiguredDevice( + id: UUID(uuidString: "00000000-0000-0000-0000-0000000000A0")!, + name: "Bulltron 200 Ah", role: .bms, profileID: Profile.defaultID, + peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000A1")!) + + static let caravanSolar = ConfiguredDevice( + id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C3")!, + name: "Solar Wohnwagen", role: .solarCharger, profileID: secondProfile.id, + peripheralID: UUID(uuidString: "00000000-0000-0000-0000-0000000000C4")!) + + static var devices: [ConfiguredDevice] { [solar, booster, battery, caravanSolar] } + + static func snapshots() -> [DeviceSnapshot] { + var solarSnapshot = DeviceSnapshot(deviceID: solar.id, timestamp: Date(), rssi: -58) + solarSnapshot.state = "Konstantspannung (Absorption)" + solarSnapshot.metrics = [ + Metric("pv_power", "PV-Leistung", 284, unit: "W", precision: 0, primary: true), + Metric("battery_power", "Ladeleistung", 262, unit: "W", precision: 0), + Metric("battery_voltage", "Batteriespannung", 14.12, unit: "V", precision: 2), + Metric("battery_current", "Ladestrom", 18.6, unit: "A", precision: 1), + Metric("yield_today", "Ertrag heute", 1.34, unit: "kWh", precision: 2), + ] + + var boosterSnapshot = DeviceSnapshot(deviceID: booster.id, timestamp: Date(), rssi: -71) + boosterSnapshot.state = "Aus" + boosterSnapshot.offReasons = ["Keine Eingangsspannung"] + boosterSnapshot.metrics = [ + Metric("output_voltage", "Ausgang (Aufbaubatterie)", 14.09, unit: "V", precision: 2, primary: true), + Metric("input_voltage", "Eingang (Starterbatterie)", 12.42, unit: "V", precision: 2), + ] + + let cells = [3.412, 3.418, 3.409, 3.421] + var batterySnapshot = DeviceSnapshot(deviceID: battery.id, timestamp: Date(), rssi: -64) + batterySnapshot.state = "Lädt" + batterySnapshot.cellVoltages = cells + batterySnapshot.temperatures = [21, 22] + batterySnapshot.metrics = [ + Metric("soc", "Ladezustand", 78.4, unit: "%", precision: 1, primary: true), + Metric("voltage", "Spannung", 13.66, unit: "V", precision: 2), + Metric("current", "Strom", 18.6, unit: "A", precision: 1), + Metric("power", "Leistung", 254, unit: "W", precision: 0), + Metric("capacity", "Restkapazität", 156.8, unit: "Ah", precision: 1), + Metric("cell_delta", "Zell-Differenz", 12, unit: "mV", precision: 0), + Metric("cell_max", "Höchste Zelle (Zelle 4)", 3.421, unit: "V", precision: 3), + Metric("cell_min", "Niedrigste Zelle (Zelle 3)", 3.409, unit: "V", precision: 3), + Metric("temp_max", "Temperatur", 22, unit: "°C", precision: 0), + Metric("cycles", "Ladezyklen", 143, unit: "", precision: 0), + ] + + var caravanSnapshot = DeviceSnapshot(deviceID: caravanSolar.id, timestamp: Date(), rssi: -77) + caravanSnapshot.state = "Erhaltung (Float)" + caravanSnapshot.metrics = [ + Metric("pv_power", "PV-Leistung", 62, unit: "W", precision: 0, primary: true), + Metric("battery_voltage", "Batteriespannung", 13.62, unit: "V", precision: 2), + Metric("battery_current", "Ladestrom", 4.4, unit: "A", precision: 1), + ] + + return [solarSnapshot, boosterSnapshot, batterySnapshot, caravanSnapshot] + } + + /// Ein paar Stunden Verlauf, damit die Grafik etwas zeigt. + static func history(for deviceID: UUID, around value: Double) -> [HistorySample] { + let now = Date() + return (0..<80).reversed().map { step in + let t = Double(step) + let wave = sin(t / 9) * value * 0.18 + cos(t / 23) * value * 0.07 + return HistorySample(time: now.addingTimeInterval(-t * 60), + value: max(0, value + wave)) + } + } +} diff --git a/CamperMonitor/Store/DeviceStore.swift b/CamperMonitor/Store/DeviceStore.swift new file mode 100644 index 0000000..ecdade4 --- /dev/null +++ b/CamperMonitor/Store/DeviceStore.swift @@ -0,0 +1,184 @@ +import Foundation +import Observation + +/// Verwaltet die Fahrzeugprofile, die darin eingerichteten Geräte und deren +/// Schlüssel. +@Observable +final class DeviceStore { + + private static let devicesKey = "configuredDevices" + private static let profilesKey = "profiles" + private static let activeProfileKey = "activeProfileID" + + /// Alle Geräte über alle Profile hinweg. + private(set) var devices: [ConfiguredDevice] = [] + private(set) var profiles: [Profile] = [] + + private(set) var activeProfileID: UUID = Profile.defaultID + + /// Zwischenspeicher, damit nicht bei jedem Advertisement die Keychain + /// befragt wird – das passiert bis zu mehrmals pro Sekunde. + @ObservationIgnored private var keyCache: [UUID: [UInt8]] = [:] + + init() { + if DemoData.isEnabled { + profiles = DemoData.profiles + devices = DemoData.devices + activeProfileID = DemoData.profiles[0].id + return + } + load() + } + + // MARK: - Profile + + var activeProfile: Profile? { + profiles.first { $0.id == activeProfileID } + } + + /// Die Geräte des gewählten Fahrzeugs. + var activeDevices: [ConfiguredDevice] { + devices.filter { $0.profileID == activeProfileID } + } + + var hasMultipleProfiles: Bool { profiles.count > 1 } + + func selectProfile(_ profile: Profile) { + guard profile.id != activeProfileID else { return } + activeProfileID = profile.id + save() + } + + @discardableResult + func addProfile(named name: String, symbol: String = "box.truck") -> Profile { + let profile = Profile(name: name, symbol: symbol) + profiles.append(profile) + save() + return profile + } + + func update(_ profile: Profile) { + guard let index = profiles.firstIndex(where: { $0.id == profile.id }) else { return } + profiles[index] = profile + save() + } + + /// Entfernt ein Profil samt seiner Geräte und Schlüssel. Das letzte Profil + /// bleibt bestehen – ohne Profil hätte die App keinen Ort für Geräte. + func removeProfile(_ profile: Profile) { + guard profiles.count > 1 else { return } + for device in devices where device.profileID == profile.id { + KeychainStore.setKey(nil, for: device.id) + keyCache[device.id] = nil + } + devices.removeAll { $0.profileID == profile.id } + profiles.removeAll { $0.id == profile.id } + if activeProfileID == profile.id, let first = profiles.first { + activeProfileID = first.id + } + save() + } + + func deviceCount(in profile: Profile) -> Int { + devices.count { $0.profileID == profile.id } + } + + // MARK: - Geräte + + /// Legt ein Gerät im gerade gewählten Profil an. + func add(_ device: ConfiguredDevice, victronKey: String? = nil) { + devices.append(device) + if let victronKey { + setVictronKey(victronKey, for: device.id) + } + save() + } + + func update(_ device: ConfiguredDevice) { + guard let index = devices.firstIndex(where: { $0.id == device.id }) else { return } + devices[index] = device + save() + } + + func remove(_ device: ConfiguredDevice) { + devices.removeAll { $0.id == device.id } + KeychainStore.setKey(nil, for: device.id) + keyCache[device.id] = nil + save() + } + + /// Nur im aktiven Profil suchen: die Geräte des anderen Fahrzeugs sollen + /// weder gelesen noch verbunden werden. + func device(withPeripheralID id: UUID) -> ConfiguredDevice? { + activeDevices.first { $0.peripheralID == id } + } + + // MARK: - Victron-Schlüssel + + func victronKeyText(for deviceID: UUID) -> String? { + KeychainStore.key(for: deviceID) + } + + func setVictronKey(_ hex: String?, for deviceID: UUID) { + let trimmed = hex?.trimmingCharacters(in: .whitespacesAndNewlines) + let value = (trimmed?.isEmpty ?? true) ? nil : trimmed + KeychainStore.setKey(value, for: deviceID) + keyCache[deviceID] = value?.hexBytes + } + + /// Der Schlüssel als Bytes, oder nil wenn keiner hinterlegt bzw. der + /// hinterlegte keine 16 Byte lang ist. + func victronKeyBytes(for deviceID: UUID) -> [UInt8]? { + if let cached = keyCache[deviceID] { return cached } + guard let bytes = KeychainStore.key(for: deviceID)?.hexBytes, bytes.count == 16 else { + return nil + } + keyCache[deviceID] = bytes + return bytes + } + + // MARK: - Persistenz + + private func load() { + let defaults = UserDefaults.standard + + if let data = defaults.data(forKey: Self.profilesKey), + let decoded = try? JSONDecoder().decode([Profile].self, from: data), + !decoded.isEmpty { + profiles = decoded + } else { + // Erster Start oder Update von einer Version ohne Profile. + profiles = [.initial] + } + + if let data = defaults.data(forKey: Self.devicesKey), + let decoded = try? JSONDecoder().decode([ConfiguredDevice].self, from: data) { + devices = decoded + } + + if let raw = defaults.string(forKey: Self.activeProfileKey), + let id = UUID(uuidString: raw), + profiles.contains(where: { $0.id == id }) { + activeProfileID = id + } else { + activeProfileID = profiles[0].id + } + + // Geräte, deren Profil nicht mehr existiert, wären sonst unerreichbar. + let known = Set(profiles.map(\.id)) + for index in devices.indices where !known.contains(devices[index].profileID) { + devices[index].profileID = profiles[0].id + } + } + + private func save() { + let defaults = UserDefaults.standard + if let data = try? JSONEncoder().encode(devices) { + defaults.set(data, forKey: Self.devicesKey) + } + if let data = try? JSONEncoder().encode(profiles) { + defaults.set(data, forKey: Self.profilesKey) + } + defaults.set(activeProfileID.uuidString, forKey: Self.activeProfileKey) + } +} diff --git a/CamperMonitor/Store/KeychainStore.swift b/CamperMonitor/Store/KeychainStore.swift new file mode 100644 index 0000000..a03904c --- /dev/null +++ b/CamperMonitor/Store/KeychainStore.swift @@ -0,0 +1,59 @@ +import Foundation +import Security + +/// Ablage für die Victron-Verschlüsselungsschlüssel. Die gehören nicht in die +/// UserDefaults, deshalb Keychain. +enum KeychainStore { + + private static let service = "de.fritob.CamperMonitor.victronKeys" + + static func setKey(_ hex: String?, for deviceID: UUID) { + let account = deviceID.uuidString + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(query as CFDictionary) + + guard let hex, let data = hex.data(using: .utf8) else { return } + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + SecItemAdd(query as CFDictionary, nil) + } + + static func key(for deviceID: UUID) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: deviceID.uuidString, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } +} + +extension String { + /// Wandelt einen Hex-String in Bytes. Leerzeichen, Doppelpunkte und ein + /// führendes "0x" werden ignoriert, damit sich der Schlüssel aus + /// VictronConnect einfach einfügen lässt. + var hexBytes: [UInt8]? { + var cleaned = self.lowercased() + .replacingOccurrences(of: "0x", with: "") + .filter { $0.isHexDigit } + guard !cleaned.isEmpty, cleaned.count % 2 == 0 else { return nil } + var bytes: [UInt8] = [] + bytes.reserveCapacity(cleaned.count / 2) + while !cleaned.isEmpty { + let pair = String(cleaned.prefix(2)) + cleaned.removeFirst(2) + guard let byte = UInt8(pair, radix: 16) else { return nil } + bytes.append(byte) + } + return bytes + } +} diff --git a/CamperMonitor/Views/AddDeviceView.swift b/CamperMonitor/Views/AddDeviceView.swift new file mode 100644 index 0000000..7a40b92 --- /dev/null +++ b/CamperMonitor/Views/AddDeviceView.swift @@ -0,0 +1,239 @@ +import SwiftUI + +/// Einrichten eines neuen Geräts: scannen, auswählen, benennen. +struct AddDeviceView: View { + @Environment(DeviceStore.self) private var store + @Environment(BluetoothManager.self) private var bluetooth + @Environment(\.dismiss) private var dismiss + + @State private var selected: Discovery? + /// Auf einem Stellplatz sind dutzende fremde Geräte in Reichweite. + @State private var showsAllDevices = false + + /// Nur Geräte anzeigen, die in den letzten Sekunden zu hören waren – + /// sonst füllt sich die Liste in Wohnmobilparks endlos. + private var visibleDiscoveries: [Discovery] { + // Bewusst nicht nach Signalstärke sortieren: die schwankt im + // Sekundentakt und die Liste würde unter dem Finger springen. + bluetooth.discoveries.values + .filter { showsAllDevices || $0.isVictron || $0.looksLikeDaly } + .sorted { lhs, rhs in + if lhs.isVictron != rhs.isVictron { return lhs.isVictron } + if lhs.looksLikeDaly != rhs.looksLikeDaly { return lhs.looksLikeDaly } + return lhs.firstSeen < rhs.firstSeen + } + } + + private var alreadyAdded: Set { + Set(store.activeDevices.map(\.peripheralID)) + } + + var body: some View { + NavigationStack { + List { + Picker("Anzeige", selection: $showsAllDevices) { + Text("Passende Geräte").tag(false) + Text("Alle").tag(true) + } + .pickerStyle(.segmented) + .listRowBackground(Color.clear) + + Section { + if visibleDiscoveries.isEmpty { + Label(showsAllDevices ? "Suche…" : "Noch nichts Passendes gefunden", + systemImage: "antenna.radiowaves.left.and.right") + .foregroundStyle(.secondary) + } + ForEach(visibleDiscoveries) { discovery in + Button { + selected = discovery + } label: { + row(for: discovery) + } + .disabled(alreadyAdded.contains(discovery.id)) + } + } header: { + HStack { + Text("Gefundene Geräte") + Spacer() + ProgressView().controlSize(.small) + } + } footer: { + Text("Victron-Geräte werden automatisch erkannt. Damit sie hier " + + "auftauchen, muss „Instant Readout“ in VictronConnect aktiv sein. " + + "Das BMS meldet sich meist als „DL-…“ – findest du es nicht, " + + "auf „Alle“ umschalten.") + } + } + .navigationTitle("Gerät für \(store.activeProfile?.name ?? "Camper")") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Fertig") { dismiss() } + } + } + .sheet(item: $selected) { discovery in + ConfigureDeviceView(discovery: discovery) { + dismiss() + } + } + .onAppear { + bluetooth.clearDiscoveries() + bluetooth.isDiscovering = true + } + .onDisappear { + bluetooth.isDiscovering = false + } + } + } + + private func row(for discovery: Discovery) -> some View { + HStack(spacing: 12) { + Image(systemName: discovery.isVictron ? "bolt.circle.fill" + : discovery.looksLikeDaly ? "battery.100percent" : "dot.radiowaves.left.and.right") + .font(.title3) + .foregroundStyle(discovery.isVictron || discovery.looksLikeDaly ? Color.accentColor : Color.secondary) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(discovery.displayName) + .foregroundStyle(.primary) + Text(discovery.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if alreadyAdded.contains(discovery.id) { + Text("Hinzugefügt") + .font(.caption) + .foregroundStyle(.secondary) + } else { + SignalBars(rssi: discovery.rssi) + } + } + } +} + +/// Formular für ein neu gewähltes Gerät. +private struct ConfigureDeviceView: View { + let discovery: Discovery + let onSaved: () -> Void + + @Environment(DeviceStore.self) private var store + @Environment(BluetoothManager.self) private var bluetooth + @Environment(\.dismiss) private var dismiss + + @State private var name: String = "" + @State private var role: DeviceRole = .solarCharger + @State private var key: String = "" + + private var needsKey: Bool { role.transport == .advertisement } + private var keyIsValid: Bool { key.hexBytes?.count == 16 } + private var canSave: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty && (!needsKey || key.isEmpty || keyIsValid) + } + + var body: some View { + NavigationStack { + Form { + Section("Gerät") { + LabeledContent("Gefunden als", value: discovery.displayName) + TextField("Name", text: $name) + Picker("Art", selection: $role) { + ForEach(DeviceRole.allCases) { role in + Text(role.title).tag(role) + } + } + } + + if needsKey { + Section { + TextField("32 Hex-Zeichen", text: $key) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + if !key.isEmpty && !keyIsValid { + Label("Der Schlüssel muss 16 Byte (32 Hex-Zeichen) haben.", + systemImage: "exclamationmark.circle") + .font(.caption) + .foregroundStyle(.orange) + } + } header: { + Text("Verschlüsselungsschlüssel") + } footer: { + Text("VictronConnect → Gerät → Zahnrad → ⋮ → Produkt-Info → " + + "„Instant Readout“ aktivieren → Verschlüsselungsdaten anzeigen. " + + "Kann auch später nachgetragen werden.") + } + } + } + .navigationTitle("Einrichten") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Sichern", action: save).disabled(!canSave) + } + } + .onAppear(perform: prefill) + } + } + + /// Aus dem Advertisement lässt sich Art und Name oft schon erraten. + private func prefill() { + if let recordType = discovery.victronRecordType { + switch VictronAdvertisement.RecordType(rawValue: recordType) { + case .solarCharger: role = .solarCharger + case .dcdcConverter, .orionXS: role = .chargeBooster + case .batteryMonitor: role = .batteryMonitor + default: role = .solarCharger + } + } else if discovery.looksLikeDaly { + role = .bms + } + name = discovery.name?.isEmpty == false ? discovery.name! : role.title + } + + private func save() { + let device = ConfiguredDevice( + name: name.trimmingCharacters(in: .whitespaces), + role: role, + profileID: store.activeProfileID, + peripheralID: discovery.id, + advertisedName: discovery.name + ) + store.add(device, victronKey: needsKey && keyIsValid ? key : nil) + bluetooth.refreshConfiguration() + dismiss() + onSaved() + } +} + +/// Signalstärke als drei Balken. +struct SignalBars: View { + let rssi: Int + + private var level: Int { + switch rssi { + case (-60)...: return 3 + case (-75)..<(-60): return 2 + case (-90)..<(-75): return 1 + default: return 0 + } + } + + var body: some View { + HStack(alignment: .bottom, spacing: 2) { + ForEach(1...3, id: \.self) { bar in + RoundedRectangle(cornerRadius: 1) + .fill(bar <= level ? Color.accentColor : Color.secondary.opacity(0.25)) + .frame(width: 3, height: CGFloat(bar) * 4 + 2) + } + } + .accessibilityLabel("Signalstärke \(level) von 3") + } +} diff --git a/CamperMonitor/Views/DashboardView.swift b/CamperMonitor/Views/DashboardView.swift new file mode 100644 index 0000000..aa5e6bd --- /dev/null +++ b/CamperMonitor/Views/DashboardView.swift @@ -0,0 +1,109 @@ +import SwiftUI + +struct DashboardView: View { + @Environment(DeviceStore.self) private var store + @Environment(BluetoothManager.self) private var bluetooth + + @State private var isAddingDevice = false + @State private var isManagingProfiles = false + + private let columns = [GridItem(.adaptive(minimum: 300), spacing: 16)] + + var body: some View { + NavigationStack { + ScrollView { + if !bluetooth.isBluetoothReady { + statusBanner + } + + if store.activeDevices.isEmpty { + emptyState + } else { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(store.activeDevices) { device in + NavigationLink { + DeviceDetailView(device: device) + } label: { + DeviceCard( + device: device, + snapshot: bluetooth.snapshots[device.id], + linkState: bluetooth.linkStates[device.id] ?? .searching + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.top, 8) + } + } + .background(Color(.systemGroupedBackground)) + .navigationTitle(store.activeProfile?.name ?? "Camper") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + profileMenu + } + ToolbarItem(placement: .primaryAction) { + Button("Gerät hinzufügen", systemImage: "plus") { + isAddingDevice = true + } + } + } + .sheet(isPresented: $isAddingDevice) { + AddDeviceView() + } + .sheet(isPresented: $isManagingProfiles) { + ProfilesView() + } + } + } + + /// Umschalter zwischen den Fahrzeugen. + private var profileMenu: some View { + Menu { + Picker("Fahrzeug", selection: Binding( + get: { store.activeProfileID }, + set: { id in + guard let profile = store.profiles.first(where: { $0.id == id }) else { return } + store.selectProfile(profile) + bluetooth.refreshConfiguration() + } + )) { + ForEach(store.profiles) { profile in + Label(profile.name, systemImage: profile.symbol).tag(profile.id) + } + } + Divider() + Button("Fahrzeuge verwalten…", systemImage: "gearshape") { + isManagingProfiles = true + } + } label: { + Label(store.activeProfile?.name ?? "Fahrzeug", + systemImage: store.activeProfile?.symbol ?? "box.truck") + .labelStyle(.iconOnly) + } + } + + private var statusBanner: some View { + Label(bluetooth.bluetoothStatusText, systemImage: "exclamationmark.triangle.fill") + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(.orange.opacity(0.15), in: .rect(cornerRadius: 12)) + .padding(.horizontal) + .padding(.top, 8) + } + + private var emptyState: some View { + ContentUnavailableView { + Label("Noch keine Geräte", systemImage: "antenna.radiowaves.left.and.right") + } description: { + Text("Füge \(store.activeProfile.map { "„\($0.name)“" } ?? "diesem Fahrzeug") " + + "den Ladebooster, den Solarladeregler und das BMS hinzu.") + } actions: { + Button("Gerät suchen") { isAddingDevice = true } + .buttonStyle(.borderedProminent) + } + .padding(.top, 60) + } +} diff --git a/CamperMonitor/Views/DeviceCard.swift b/CamperMonitor/Views/DeviceCard.swift new file mode 100644 index 0000000..fb45539 --- /dev/null +++ b/CamperMonitor/Views/DeviceCard.swift @@ -0,0 +1,137 @@ +import SwiftUI + +/// Kachel auf dem Dashboard: Hauptwert gross, darunter die wichtigsten +/// Nebenwerte und der Verbindungszustand. +struct DeviceCard: View { + let device: ConfiguredDevice + let snapshot: DeviceSnapshot? + let linkState: DeviceLinkState + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + + if let snapshot, let primary = snapshot.primaryMetric { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(primary.formatted) + .font(.system(size: 44, weight: .semibold, design: .rounded)) + .contentTransition(.numericText()) + Text(primary.unit) + .font(.title3.weight(.medium)) + .foregroundStyle(.secondary) + } + .foregroundStyle(snapshot.isStale ? .secondary : .primary) + + secondaryValues(for: snapshot) + } else { + Text(placeholderText) + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 18) + } + + footer + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 16)) + } + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: device.role.symbol) + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 1) { + Text(device.name) + .font(.headline) + Text(device.role.title) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true) + } + } + + private func secondaryValues(for snapshot: DeviceSnapshot) -> some View { + let others = snapshot.metrics + .filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil } + .prefix(3) + return HStack(spacing: 16) { + ForEach(Array(others)) { metric in + VStack(alignment: .leading, spacing: 1) { + Text(metric.formattedWithUnit) + .font(.subheadline.weight(.medium)) + .monospacedDigit() + Text(metric.label) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + } + + @ViewBuilder + private var footer: some View { + if let fault = snapshot?.fault { + Label(fault, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) + } else if let state = snapshot?.state { + // Bei "Aus" ist erst der Grund die eigentliche Information. + let reason = snapshot?.offReasons.first + Text(reason.map { "\(state) · \($0)" } ?? state) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } else if case .failed(let message) = linkState { + Text(message) + .font(.caption) + .foregroundStyle(.orange) + .lineLimit(2) + } + } + + private var placeholderText: String { + switch linkState { + case .needsKey: return "Verschlüsselungsschlüssel fehlt – im Detail eintragen." + case .failed(let message): return message + default: return "Warte auf Daten…" + } + } +} + +/// Kleiner Punkt, der Verbindungszustand und Aktualität zusammenfasst. +struct StatusDot: View { + let linkState: DeviceLinkState + let isStale: Bool + + var body: some View { + HStack(spacing: 5) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private var color: Color { + switch linkState { + case .live: return isStale ? .orange : .green + case .needsKey: return .orange + case .failed: return .red + default: return .secondary + } + } + + private var label: String { + if case .live = linkState, isStale { return "Veraltet" } + return linkState.label + } +} diff --git a/CamperMonitor/Views/DeviceDetailView.swift b/CamperMonitor/Views/DeviceDetailView.swift new file mode 100644 index 0000000..a35bb48 --- /dev/null +++ b/CamperMonitor/Views/DeviceDetailView.swift @@ -0,0 +1,298 @@ +import Charts +import SwiftUI + +struct DeviceDetailView: View { + let device: ConfiguredDevice + + @Environment(DeviceStore.self) private var store + @Environment(BluetoothManager.self) private var bluetooth + @Environment(\.dismiss) private var dismiss + + @State private var editedName = "" + @State private var keyInput = "" + @State private var showDeleteConfirmation = false + + private var snapshot: DeviceSnapshot? { bluetooth.snapshots[device.id] } + private var linkState: DeviceLinkState { bluetooth.linkStates[device.id] ?? .searching } + private var samples: [HistorySample] { bluetooth.history[device.id] ?? [] } + + var body: some View { + List { + statusSection + + 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.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 device.role.transport == .advertisement { + keySection + diagnosticsSection + } else { + bmsDiagnosticsSection + } + + settingsSection + } + .navigationTitle(device.name) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + editedName = device.name + keyInput = store.victronKeyText(for: device.id) ?? "" + } + .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.") + } + } + + // MARK: - Abschnitte + + private var statusSection: some View { + Section { + LabeledContent("Verbindung") { + StatusDot(linkState: linkState, isStale: snapshot?.isStale ?? true) + } + if let state = snapshot?.state { + LabeledContent("Zustand", value: state) + } + if let fault = snapshot?.fault { + Label(fault, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + ForEach(snapshot?.offReasons ?? [], id: \.self) { reason in + Label(reason, systemImage: "pause.circle") + .foregroundStyle(.orange) + } + if let snapshot { + LabeledContent("Aktualisiert vor") { + Text(snapshot.timestamp, style: .relative) + } + .foregroundStyle(.secondary) + } + if let rssi = snapshot?.rssi { + LabeledContent("Signal", value: "\(rssi) dBm") + .foregroundStyle(.secondary) + } + } + } + + private func cellSection(_ voltages: [Double]) -> some View { + let minimum = voltages.min() ?? 0 + let maximum = voltages.max() ?? 0 + return Section("Zellspannungen") { + Chart(Array(voltages.enumerated()), id: \.offset) { index, voltage in + // Kategoriale x-Achse: sonst stehen die Balken zwischen den + // Beschriftungen statt darüber. + BarMark( + x: .value("Zelle", "\(index + 1)"), + y: .value("Spannung", voltage) + ) + .foregroundStyle(voltage == maximum ? Color.orange + : voltage == minimum ? Color.blue : Color.accentColor) + // Die Zellnummer als Beschriftung am Balken statt über die + // x-Achse – die blendet Swift Charts in der Liste aus. + // Ab neun Zellen wird es zu eng, dann ordnet die Liste zu. + .annotation(position: .bottom, alignment: .center) { + if voltages.count <= 8 { + Text("\(index + 1)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .chartXAxis(.hidden) + // Der interessante Bereich sind die letzten Millivolt, nicht die + // absolute Spannung – deshalb eng um die Messwerte zoomen. + .chartYScale(domain: (minimum - 0.05)...(maximum + 0.05)) + .chartYAxisLabel("V") + .frame(height: 160) + .padding(.vertical, 8) + + ForEach(Array(voltages.enumerated()), id: \.offset) { index, voltage in + LabeledContent("Zelle \(index + 1)") { + Text(String(format: "%.3f V", voltage)).monospacedDigit() + } + } + } + } + + private var keySection: some View { + Section { + TextField("32 Hex-Zeichen", text: $keyInput) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onSubmit(saveKey) + Button("Schlüssel speichern", action: saveKey) + .disabled(keyInput.hexBytes?.count != 16) + } header: { + Text("Verschlüsselungsschlüssel") + } footer: { + Text("In VictronConnect: Gerät öffnen → Zahnrad → ⋮ → Produkt-Info → " + + "„Instant Readout“ einschalten → Verschlüsselungsdaten anzeigen. " + + "Der Schlüssel ist 16 Byte lang (32 Hex-Zeichen).") + } + } + + /// Zeigt, was das Gerät unverschlüsselt sendet. Wichtigster Wert ist das + /// erste Schlüsselbyte: stimmt es nicht mit der Eingabe überein, gehört der + /// Schlüssel zu einem anderen Victron-Gerät. + @ViewBuilder + private var diagnosticsSection: some View { + if let info = bluetooth.diagnostics[device.id] { + Section { + LabeledContent("Gerät sendet als erstes Schlüsselbyte") { + Text(info.expectedKeyText) + .font(.body.monospaced()) + .foregroundStyle(keyBytesAgree == false ? .red : .primary) + } + LabeledContent("Eingetragener Schlüssel beginnt mit") { + Text(enteredKeyText) + .font(.body.monospaced()) + .foregroundStyle(keyBytesAgree == false ? .red : .secondary) + } + LabeledContent("Datensatz", value: info.recordName) + LabeledContent("Produkt-ID") { + Text(info.productIDText).font(.body.monospaced()) + } + VStack(alignment: .leading, spacing: 4) { + Text("Rohdaten") + Text(info.rawHex) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } header: { + Text("Diagnose") + } footer: { + if keyBytesAgree == false { + Text("Die beiden Bytes müssen übereinstimmen. Tun sie das nicht, " + + "stammt der Schlüssel von einem anderen Victron-Gerät – in " + + "VictronConnect prüfen, ob wirklich dieses Gerät geöffnet war.") + } else { + Text("Diese Werte sendet das Gerät unverschlüsselt mit.") + } + } + } + } + + /// nil, solange kein vollständiger Schlüssel eingetragen ist. + private var keyBytesAgree: Bool? { + guard let expected = bluetooth.diagnostics[device.id]?.expectedKeyFirstByte, + let entered = keyInput.hexBytes?.first else { return nil } + return expected == entered + } + + private var enteredKeyText: String { + guard let byte = keyInput.hexBytes?.first else { return "–" } + return String(format: "0x%02X", byte) + } + + /// Welches Protokoll das BMS spricht und was zuletzt ankam. + @ViewBuilder + private var bmsDiagnosticsSection: some View { + if let info = bluetooth.bmsDiagnostics[device.id] { + Section { + LabeledContent("Erkanntes Protokoll", value: info.dialect) + if let service = info.serviceUUID { + LabeledContent("Dienst") { + Text(service).font(.caption.monospaced()).foregroundStyle(.secondary) + } + } + if let hex = info.lastResponseHex { + VStack(alignment: .leading, spacing: 4) { + Text("Letzte Antwort") + Text(hex) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } header: { + Text("Diagnose") + } footer: { + Text("Die App probiert Daly (klassisch und Modbus) sowie JBD/Xiaoxiang " + + "durch und übernimmt, was antwortet. Bleibt es bei „wird ermittelt“, " + + "spricht das BMS ein anderes Protokoll – dann hilft die Rohantwort weiter.") + } + } + } + + private var settingsSection: some View { + Section("Einstellungen") { + TextField("Name", text: $editedName) + .onSubmit(saveName) + Button("Namen übernehmen", action: saveName) + .disabled(editedName.trimmingCharacters(in: .whitespaces).isEmpty + || editedName == device.name) + LabeledContent("Typ", value: device.role.title) + LabeledContent("Bluetooth-ID") { + Text(device.peripheralID.uuidString.prefix(8) + "…") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Button("Gerät entfernen", role: .destructive) { + showDeleteConfirmation = true + } + } + } + + // MARK: - Aktionen + + private func saveKey() { + store.setVictronKey(keyInput, for: device.id) + bluetooth.refreshConfiguration() + } + + private func saveName() { + var updated = device + updated.name = editedName.trimmingCharacters(in: .whitespaces) + store.update(updated) + } +} diff --git a/CamperMonitor/Views/ProfilesView.swift b/CamperMonitor/Views/ProfilesView.swift new file mode 100644 index 0000000..57e49a9 --- /dev/null +++ b/CamperMonitor/Views/ProfilesView.swift @@ -0,0 +1,168 @@ +import SwiftUI + +/// Fahrzeuge anlegen, umbenennen und entfernen. +struct ProfilesView: View { + @Environment(DeviceStore.self) private var store + @Environment(BluetoothManager.self) private var bluetooth + @Environment(\.dismiss) private var dismiss + + @State private var editing: Profile? + @State private var pendingDeletion: Profile? + + var body: some View { + NavigationStack { + List { + Section { + ForEach(store.profiles) { profile in + row(for: profile) + } + } footer: { + Text("Jedes Fahrzeug hat seine eigenen Geräte. Die App liest immer " + + "nur die des gewählten Fahrzeugs aus.") + } + + Section { + Button("Fahrzeug hinzufügen", systemImage: "plus") { + let profile = store.addProfile(named: "Camper \(store.profiles.count + 1)") + editing = profile + } + } + } + .navigationTitle("Fahrzeuge") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Fertig") { dismiss() } + } + } + .sheet(item: $editing) { profile in + ProfileEditView(profile: profile) + } + .confirmationDialog("Fahrzeug entfernen?", + isPresented: .init(get: { pendingDeletion != nil }, + set: { if !$0 { pendingDeletion = nil } }), + titleVisibility: .visible) { + Button("Entfernen", role: .destructive) { + if let pendingDeletion { + store.removeProfile(pendingDeletion) + bluetooth.refreshConfiguration() + } + pendingDeletion = nil + } + } message: { + if let pendingDeletion { + Text("„\(pendingDeletion.name)“ und die \(store.deviceCount(in: pendingDeletion)) " + + "darin eingerichteten Geräte werden gelöscht.") + } + } + } + } + + private func row(for profile: Profile) -> some View { + HStack { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(profile.name) + Text(deviceSummary(for: profile)) + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: profile.symbol) + } + + Spacer() + + if profile.id == store.activeProfileID { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + .fontWeight(.semibold) + } + } + .contentShape(.rect) + .onTapGesture { + store.selectProfile(profile) + bluetooth.refreshConfiguration() + } + .swipeActions(edge: .trailing) { + // Das letzte Fahrzeug muss bleiben, sonst hätten Geräte keinen Ort. + if store.hasMultipleProfiles { + Button("Entfernen", systemImage: "trash", role: .destructive) { + pendingDeletion = profile + } + } + Button("Bearbeiten", systemImage: "pencil") { + editing = profile + } + .tint(.gray) + } + } + + private func deviceSummary(for profile: Profile) -> String { + let count = store.deviceCount(in: profile) + return count == 1 ? "1 Gerät" : "\(count) Geräte" + } +} + +/// Name und Symbol eines Fahrzeugs ändern. +private struct ProfileEditView: View { + let profile: Profile + + @Environment(DeviceStore.self) private var store + @Environment(\.dismiss) private var dismiss + + @State private var name = "" + @State private var symbol = "box.truck" + + private let columns = [GridItem(.adaptive(minimum: 60), spacing: 12)] + + var body: some View { + NavigationStack { + Form { + Section("Name") { + TextField("Name", text: $name) + } + Section("Symbol") { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(Profile.symbols, id: \.self) { candidate in + Button { + symbol = candidate + } label: { + Image(systemName: candidate) + .font(.title2) + .frame(width: 52, height: 52) + .background(symbol == candidate ? Color.accentColor.opacity(0.18) + : Color.secondary.opacity(0.08), + in: .rect(cornerRadius: 12)) + .foregroundStyle(symbol == candidate ? Color.accentColor : Color.primary) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + .navigationTitle("Fahrzeug") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Sichern") { + var updated = profile + updated.name = name.trimmingCharacters(in: .whitespaces) + updated.symbol = symbol + store.update(updated) + dismiss() + } + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .onAppear { + name = profile.name + symbol = profile.symbol + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..a899936 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# Camper Monitor + +iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest: + +| Gerät | Weg | Was ankommt | +|---|---|---| +| Victron Ladebooster (Orion-TR Smart / Orion XS) | Instant Readout im Advertisement | Ein-/Ausgangsspannung, beim XS auch Ströme und Ladeleistung, Zustand, Abschaltgrund | +| Victron Solarladeregler (SmartSolar MPPT) | Instant Readout im Advertisement | PV-Leistung, Batteriespannung, Ladestrom, Tagesertrag, Laststrom, Ladezustand (Bulk/Absorption/Float) | +| Daly BMS (Bulltron) | GATT-Verbindung, alle 5 s abgefragt | SoC, Spannung, Strom, Restkapazität, alle Einzelzellspannungen, Zelldifferenz, Temperaturen, Zyklen, MOSFET-Status | + +Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt. + +## Bauen und installieren + +```bash +open /Users/fritob/GIT/Camper-Management/CamperMonitor.xcodeproj +``` + +Dann in Xcode: + +1. Target `CamperMonitor` → **Signing & Capabilities** → dein Apple-Team auswählen. + Die Bundle-ID `de.fritob.CamperMonitor` ggf. anpassen, falls sie schon vergeben ist. +2. iPhone per Kabel anschließen, oben als Ziel wählen, ⌘R. + +Wichtig: **Der Simulator hat kein Bluetooth.** Die App startet dort, findet aber +nie ein Gerät. Zum Testen muss sie auf ein echtes iPhone. + +Mit einem kostenlosen Apple-Account läuft die App 7 Tage und muss dann neu +installiert werden; mit einem bezahlten Developer-Account ein Jahr. + +## Einrichtung in der App + +### Victron-Geräte + +Victron-Geräte senden ihre Messwerte etwa im Sekundentakt verschlüsselt im +BLE-Advertisement. Es wird nichts verbunden und nichts gestört – VictronConnect +kann parallel laufen. Dafür brauchst du pro Gerät einmalig den Schlüssel: + +1. VictronConnect öffnen, Gerät anwählen +2. Zahnrad (Einstellungen) → ⋮ oben rechts → **Produkt-Info** +3. **Instant Readout** einschalten +4. **Verschlüsselungsdaten anzeigen** → der Schlüssel sind 32 Hex-Zeichen + +In der App: **+** → Gerät aus der Liste wählen (Victron-Geräte werden erkannt +und die Art vorausgefüllt) → Schlüssel einfügen → Sichern. + +Der Schlüssel liegt in der iOS-Keychain, nicht in den UserDefaults. + +#### Wenn „Schlüssel passt nicht" erscheint + +Victron sendet das erste Byte des Schlüssels unverschlüsselt mit, damit sich ein +falscher Schlüssel sofort erkennen lässt. Die Detailansicht des Geräts hat dafür +den Abschnitt **Diagnose**: + +* *Gerät sendet als erstes Schlüsselbyte* – was das Gerät erwartet +* *Eingetragener Schlüssel beginnt mit* – was in der App steht + +Stimmen die beiden nicht überein, gehört der Schlüssel zu einem anderen Gerät. +Bei mehreren Victron-Geräten ist das die mit Abstand häufigste Ursache: In +VictronConnect prüfen, dass wirklich dieses Gerät geöffnet war, als der +Schlüssel angezeigt wurde. + +Die Diagnose zeigt außerdem Produkt-ID, Datensatztyp und die Rohdaten des +Advertisements – letztere lassen sich durch langes Antippen kopieren. + +### Daly BMS + +Kein Schlüssel nötig. In der Liste erscheint das BLE-Modul meist als `DL-…`. +Auswählen, Art auf **Batterie / BMS** stellen, Sichern. + +Die App erkennt selbst, ob das BMS das klassische `A5`-Protokoll oder das +neuere Modbus-Protokoll (`D2`) spricht, und sucht die passenden GATT- +Charakteristiken automatisch – die BLE-Module unterscheiden sich zwischen +Fertigungschargen. + +Nur **eine** App gleichzeitig kann mit dem BMS verbunden sein. Wenn die +Bulltron-/Daly-App offen ist, bekommt Camper Monitor keine Verbindung. + +## Ohne Fahrzeug ansehen + +Ein Demo-Modus füllt die App mit erfundenen Werten, damit sich die Ansichten +ohne Bluetooth prüfen lassen. In Xcode unter *Product → Scheme → Edit Scheme → +Run → Arguments* die Umgebungsvariable `CAMPER_DEMO` auf `1` setzen. Er greift +nur in Debug-Builds. + +## Protokolle prüfen + +```bash +./run-tests.sh +``` + +Prüft die Entschlüsselung und die Rahmenverarbeitung ohne Hardware und ohne +Simulator – unter anderem AES-CTR gegen den Referenzvektor aus NIST SP 800-38A +und die Prüfsummen beider Daly-Dialekte. + +## Aufbau + +``` +CamperMonitor/ +├── Models/ +│ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart +│ ├── DeviceSnapshot.swift Messwerte in Anzeigeform +│ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes +├── Bluetooth/ +│ ├── BluetoothManager.swift Zentraler Scan, Verbindungen, Verlauf +│ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten +│ ├── AESCounterMode.swift AES-128-CTR (CommonCrypto) +│ ├── BitReader.swift Bitweises Lesen der gepackten Felder +│ ├── DalyProtocol.swift Rahmenbau und Prüfsummen beider Dialekte +│ ├── DalyState.swift Sammelt Antworten zu einem Gesamtbild +│ └── DalySession.swift GATT-Verbindung und Abfragezyklus +├── Store/ +│ ├── DeviceStore.swift Geräteliste, Persistenz +│ └── KeychainStore.swift Victron-Schlüssel +└── Views/ + ├── DashboardView.swift Kachelübersicht + ├── DeviceCard.swift Eine Kachel + ├── DeviceDetailView.swift Alle Werte, Verlauf, Zellspannungen + └── AddDeviceView.swift Scannen und Einrichten +``` + +## Bekannte Grenzen + +* **Kein Hintergrundbetrieb.** iOS erlaubt das ungefilterte Scannen nach + Advertisements nur im Vordergrund. Die App pausiert, sobald sie in den + Hintergrund geht, und nimmt beim Zurückkommen wieder auf. +* **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen + Firmwareständen.** Das klassische `A5`-Protokoll ist gut dokumentiert und + sicher; falls dein BMS Modbus spricht und Werte unplausibel aussehen, muss + das Mapping in `DalyState.apply(registers:)` am realen Gerät nachgezogen + werden. +* Die Feldbelegungen der Victron-Datensätze stammen aus Victrons + „Extra Manufacturer Data“-Beschreibung. Solarladeregler und DC/DC-Wandler + sind die am besten belegten Typen. +* Die Bluetooth-Kennung eines Geräts ist pro iPhone eindeutig. Auf einem + zweiten Gerät müssen die Geräte neu eingerichtet werden. diff --git a/Tests/main.swift b/Tests/main.swift new file mode 100644 index 0000000..fb01e3b --- /dev/null +++ b/Tests/main.swift @@ -0,0 +1,316 @@ +import Foundation + +var failures = 0 +func check(_ name: String, _ condition: Bool, _ detail: String = "") { + if condition { print(" ok \(name)") } + else { failures += 1; print(" FAIL \(name) \(detail)") } +} +func checkEqual(_ name: String, _ actual: T?, _ expected: T?) { + check(name, actual == expected, "-> got \(String(describing: actual)), want \(String(describing: expected))") +} +func hex(_ bytes: [UInt8]) -> String { bytes.map { String(format: "%02x", $0) }.joined() } +func round2(_ v: Double) -> Double { (v * 100).rounded() / 100 } + +// MARK: 1 – AES-CTR gegen NIST SP 800-38A F.5.1 +print("AES-128-CTR (NIST SP 800-38A F.5.1)") +let nistKey = "2b7e151628aed2a6abf7158809cf4f3c".hexBytes! +let nistCounter = "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff".hexBytes! +let nistPlain = "6bc1bee22e409f96e93d7e117393172a".hexBytes! +let nistCipher = AESCounterMode.crypt(nistPlain, key: nistKey, nonce: nistCounter) +checkEqual("Block 1 stimmt mit dem Referenzvektor überein", + nistCipher.map(hex), "874d6191b620e3261bef6864990db6ce") + +// MARK: 2 – BitReader +print("\nBitReader") +var reader = BitReader([0xB5, 0x03]) +checkEqual("read(3) liest die untersten Bits zuerst", reader.read(3), 5) +checkEqual("read(5) setzt bitgenau fort", reader.read(5), 22) +checkEqual("read(8) liest das zweite Byte", reader.read(8), 3) +checkEqual("read hinter dem Ende liefert nil", reader.read(1), nil) + +var na = BitReader([0xFF, 0xFF]) +checkEqual("readOptional erkennt den NA-Wert", na.readOptional(16), nil) + +var signed = BitReader([0xFF, 0x7F, 0x9C, 0xFF, 0xFF, 0xFF]) +checkEqual("readOptionalSigned erkennt 0x7FFF als NA", signed.readOptionalSigned(16), nil) +checkEqual("readOptionalSigned dekodiert -100", signed.readOptionalSigned(16), -100) +checkEqual("readOptionalSigned dekodiert -1", signed.readOptionalSigned(16), -1) + +// MARK: 3 – Victron Solarladeregler, kompletter Weg +print("\nVictron Advertisement – Solarladeregler (0x01)") + +/// Packt Felder so, wie Victron sie sendet: LSB zuerst, ohne Byte-Ausrichtung. +struct BitWriter { + private var bits: [UInt8] = [] + mutating func write(_ value: UInt32, _ width: Int) { + for i in 0..> UInt32(i)) & 1)) } + } + var bytes: [UInt8] { + var out = [UInt8](repeating: 0, count: (bits.count + 7) / 8) + for (index, bit) in bits.enumerated() where bit == 1 { + out[index / 8] |= 1 << UInt8(index % 8) + } + return out + } +} + +let deviceKey = "aa112233445566778899aabbccddeeff".hexBytes! + +var writer = BitWriter() +writer.write(3, 8) // Zustand: Bulk +writer.write(0, 8) // kein Fehler +writer.write(1345, 16) // 13,45 V +writer.write(152, 16) // 15,2 A +writer.write(234, 16) // 2,34 kWh +writer.write(210, 16) // 210 W +writer.write(0x1FF, 9) // Laststrom nicht verfügbar +let plaintext = writer.bytes + +let nonce: UInt16 = 0x1234 +var counterBlock = [UInt8](repeating: 0, count: 16) +counterBlock[0] = UInt8(nonce & 0xFF) +counterBlock[1] = UInt8(nonce >> 8) +let encrypted = AESCounterMode.crypt(plaintext, key: deviceKey, nonce: counterBlock)! + +var advertisement: [UInt8] = [0xE1, 0x02, 0x10, 0x00, 0x4C, 0xA0, 0x01, + UInt8(nonce & 0xFF), UInt8(nonce >> 8), deviceKey[0]] +advertisement += encrypted + +let solar = try? VictronAdvertisement.decode(manufacturerData: Data(advertisement), + key: deviceKey, + deviceID: UUID(), + rssi: -55) +func value(_ snapshot: DeviceSnapshot?, _ key: String) -> Double? { + snapshot?.metrics.first { $0.key == key }?.value +} +check("Advertisement wird dekodiert", solar != nil) +checkEqual("Zustand als Klartext", solar?.state, "Konstantstrom (Bulk)") +checkEqual("kein Fehler gemeldet", solar?.fault, nil) +checkEqual("Batteriespannung", value(solar, "battery_voltage").map(round2), 13.45) +checkEqual("Ladestrom", value(solar, "battery_current").map { ($0 * 10).rounded() / 10 }, 15.2) +checkEqual("Tagesertrag", value(solar, "yield_today"), 2.34) +checkEqual("PV-Leistung", value(solar, "pv_power"), 210) +checkEqual("Laststrom bleibt leer (NA)", value(solar, "load_current"), nil) +checkEqual("PV-Leistung ist der Hauptwert", solar?.primaryMetric?.key, "pv_power") + +var wrongKey = deviceKey; wrongKey[0] = 0x00 +do { + _ = try VictronAdvertisement.decode(manufacturerData: Data(advertisement), + key: wrongKey, deviceID: UUID(), rssi: nil) + check("falscher Schlüssel wird abgewiesen", false) +} catch { + check("falscher Schlüssel wird abgewiesen", true) +} + +// MARK: 4 – Victron Orion XS +print("\nVictron Advertisement – Orion XS (0x0F)") +var xsWriter = BitWriter() +xsWriter.write(3, 8) // Bulk +xsWriter.write(0, 8) +xsWriter.write(1420, 16) // Ausgang 14,20 V +xsWriter.write(180, 16) // 18,0 A +xsWriter.write(1310, 16) // Eingang 13,10 V +xsWriter.write(210, 16) // 21,0 A +xsWriter.write(0x00000002, 32) // "Per Schalter ausgeschaltet" +let xsPlain = xsWriter.bytes +let xsEncrypted = AESCounterMode.crypt(xsPlain, key: deviceKey, nonce: counterBlock)! +// Rahmen wie vom echten Orion XS: Produkt-ID 0xA3F0, Datensatztyp 0x0F. +var xsAdvert: [UInt8] = [0xE1, 0x02, 0x10, 0x00, 0xF0, 0xA3, 0x0F, + UInt8(nonce & 0xFF), UInt8(nonce >> 8), deviceKey[0]] +xsAdvert += xsEncrypted +let xs = try? VictronAdvertisement.decode(manufacturerData: Data(xsAdvert), + key: deviceKey, deviceID: UUID(), rssi: nil) +let xsEnvelope = VictronAdvertisement.envelope(from: Data(xsAdvert)) +checkEqual("Produkt-ID aus dem Rahmen", xsEnvelope?.productID, 0xA3F0) +checkEqual("Datensatztyp aus dem Rahmen", xsEnvelope?.recordType, 0x0F) +checkEqual("Nonce aus dem Rahmen", xsEnvelope?.nonce, nonce) +checkEqual("Prüfbyte aus dem Rahmen", xsEnvelope?.keyCheckByte, deviceKey[0]) +checkEqual("Nutzdaten sind 14 Byte lang", xsEnvelope?.ciphertext.count, 14) +checkEqual("Ausgangsspannung", value(xs, "output_voltage").map(round2), 14.20) +checkEqual("Ladestrom", value(xs, "output_current").map { ($0 * 10).rounded() / 10 }, 18.0) +checkEqual("Eingangsspannung", value(xs, "input_voltage").map(round2), 13.10) +checkEqual("Ladeleistung wird gerechnet", value(xs, "output_power").map { ($0).rounded() }, 256) +checkEqual("Abschaltgrund im Klartext", xs?.offReasons.first, "Per Schalter ausgeschaltet") + +// MARK: 4b – Echtes Advertisement eines Orion XS +// Aus der Diagnoseansicht der App abgelesen. Ohne Schlüssel lässt sich der +// Inhalt nicht prüfen, wohl aber der Rahmen – und genau dort war der Fehler. +print("\nVictron Advertisement – echter Rahmen vom Gerät") +let realFrame = "E1 02 10 00 F0 A3 0F 17 28 3C 81 9C 8F 42 FC 94 5F 4D 59 C9 F8 73 DB 48".hexBytes! +let realEnvelope = VictronAdvertisement.envelope(from: Data(realFrame)) +check("wird als Victron erkannt", realEnvelope != nil) +checkEqual("Datensatztyp ist Orion XS", realEnvelope?.recordType, 0x0F) +checkEqual("Produkt-ID", realEnvelope?.productID, 0xA3F0) +checkEqual("Nonce", realEnvelope?.nonce, 0x2817) +checkEqual("Prüfbyte des Schlüssels", realEnvelope?.keyCheckByte, 0x3C) +checkEqual("Nutzdaten passen genau auf einen Orion-XS-Datensatz", + realEnvelope?.ciphertext.count, 14) + +// MARK: 5 – Daly, klassisches Protokoll +print("\nDaly A5-Protokoll") +let request = [UInt8](DalyProtocol.requestFrame(.soc)) +checkEqual("Anfragerahmen ist 13 Byte lang", request.count, 13) +checkEqual("Prüfsumme der Anfrage", request.last, 0xBD) + +/// Baut eine Antwort so, wie das BMS sie schickt. +func dalyResponse(command: UInt8, payload: [UInt8]) -> [UInt8] { + var frame: [UInt8] = [0xA5, 0x01, command, 0x08] + payload + frame.append(frame.reduce(0) { UInt8(($0 &+ $1) & 0xFF) }) + return frame +} + +// 13,42 V · 25,0 A Ladung · 87,5 % +let socPayload: [UInt8] = [0x00, 0x86, 0x00, 0x86, 0x76, 0x2A, 0x03, 0x6B] +// 30000 + 250 = 30250 = 0x765A -> +25,0 A ; SOC 875 = 0x036B -> 87,5 % +var stream = dalyResponse(command: 0x90, payload: socPayload) +stream += dalyResponse(command: 0x91, payload: [0x0D, 0x12, 0x03, 0x0C, 0xFE, 0x07, 0x00, 0x00]) +stream += dalyResponse(command: 0x92, payload: [0x40, 0x01, 0x3E, 0x02, 0x00, 0x00, 0x00, 0x00]) +stream += dalyResponse(command: 0x95, payload: [0x01, 0x0C, 0xFE, 0x0D, 0x00, 0x0D, 0x12, 0x00]) + +let (frames, remainder) = DalyProtocol.extractA5Frames(from: stream) +checkEqual("alle vier Rahmen erkannt", frames.count, 4) +checkEqual("nichts bleibt übrig", remainder.count, 0) + +var state = DalyState() +for frame in frames { state.apply(frame) } +let bms = state.snapshot(deviceID: UUID(), rssi: nil) +func bmsValue(_ key: String) -> Double? { bms.metrics.first { $0.key == key }?.value } +checkEqual("Gesamtspannung", bmsValue("voltage").map { ($0 * 100).rounded() / 100 }, 13.4) +checkEqual("Strom mit Offset 30000", bmsValue("current").map { ($0 * 10).rounded() / 10 }, 25.0) +checkEqual("Ladezustand", bmsValue("soc").map { ($0 * 10).rounded() / 10 }, 87.5) +checkEqual("Zell-Differenz in mV", bmsValue("cell_delta"), 20) +checkEqual("höchste Zelle", bmsValue("cell_max"), 3.346) +checkEqual("Temperatur", bmsValue("temp_max"), 24) +checkEqual("Zustand aus dem Strom abgeleitet", bms.state, "Lädt") +checkEqual("drei Zellspannungen aus Rahmen 0x95", bms.cellVoltages.count, 3) + +// Halber Rahmen am Ende muss aufgehoben werden. +let partial = Array(stream.prefix(13 + 6)) +let (someFrames, rest) = DalyProtocol.extractA5Frames(from: partial) +checkEqual("vollständiger Rahmen wird ausgewertet", someFrames.count, 1) +check("angefangener Rahmen bleibt im Puffer", rest.count >= 6) + +// Kaputte Prüfsumme darf nicht durchrutschen. +var corrupted = dalyResponse(command: 0x90, payload: socPayload) +corrupted[12] ^= 0xFF +checkEqual("falsche Prüfsumme wird verworfen", + DalyProtocol.extractA5Frames(from: corrupted).frames.count, 0) + +// MARK: 6 – Daly Modbus +print("\nDaly Modbus-Protokoll") +let modbusRequest = [UInt8](DalyProtocol.modbusReadFrame()) +checkEqual("Anfrage beginnt korrekt", Array(modbusRequest.prefix(6)), [0xD2, 0x03, 0x00, 0x00, 0x00, 0x3E]) +print(" CRC16 der Anfrage: \(hex(Array(modbusRequest.suffix(2))))") + +// Antwort mit vier Zellen und einem Temperaturfühler bauen. +var registers = [UInt16](repeating: 0, count: 62) +registers[0] = 3320; registers[1] = 3325; registers[2] = 3318; registers[3] = 3330 +registers[48] = 64 // 24 °C +registers[56] = 133 // 13,3 V +registers[57] = 29750 // -25,0 A +registers[58] = 642 // 64,2 % +var body: [UInt8] = [0xD2, 0x03, UInt8(registers.count * 2)] +for register in registers { body.append(UInt8(register >> 8)); body.append(UInt8(register & 0xFF)) } +let responseCRC = DalyProtocol.crc16Modbus(body) +body.append(UInt8(responseCRC & 0xFF)); body.append(UInt8(responseCRC >> 8)) + +let parsed = DalyProtocol.parseModbusResponse(body) +checkEqual("62 Register gelesen", parsed?.count, 62) +var modbusState = DalyState() +modbusState.apply(registers: parsed ?? []) +let modbusSnapshot = modbusState.snapshot(deviceID: UUID(), rssi: nil) +func modbusValue(_ key: String) -> Double? { modbusSnapshot.metrics.first { $0.key == key }?.value } +checkEqual("nur belegte Zellen zählen", modbusSnapshot.cellVoltages.count, 4) +checkEqual("Spannung", modbusValue("voltage").map { ($0 * 10).rounded() / 10 }, 13.3) +checkEqual("Entladestrom ist negativ", modbusValue("current").map { ($0 * 10).rounded() / 10 }, -25.0) +checkEqual("Ladezustand", modbusValue("soc").map { ($0 * 10).rounded() / 10 }, 64.2) +checkEqual("Zell-Differenz", modbusValue("cell_delta"), 12) +checkEqual("Zustand aus negativem Strom", modbusSnapshot.state, "Entlädt") + +var truncated = body; truncated.removeLast(30) +checkEqual("unvollständige Antwort wird nicht ausgewertet", + DalyProtocol.parseModbusResponse(truncated) == nil, true) +var badCRC = body; badCRC[body.count - 1] ^= 0xFF +checkEqual("falscher CRC wird verworfen", + DalyProtocol.parseModbusResponse(badCRC) == nil, true) + +// MARK: 7 – JBD / Xiaoxiang (WattCycle) +print("\nJBD-Protokoll") +let jbdRequest = [UInt8](JBDProtocol.requestFrame(.basicInfo)) +checkEqual("Anfragerahmen Basisinfo", jbdRequest, [0xDD, 0xA5, 0x03, 0x00, 0xFF, 0xFD, 0x77]) +checkEqual("Anfragerahmen Zellspannungen", + [UInt8](JBDProtocol.requestFrame(.cellVoltages)), + [0xDD, 0xA5, 0x04, 0x00, 0xFF, 0xFC, 0x77]) + +/// Baut eine Antwort, wie das BMS sie schickt. +func jbdResponse(command: UInt8, payload: [UInt8]) -> [UInt8] { + let body: [UInt8] = [0x00, UInt8(payload.count)] + payload + let sum = JBDProtocol.checksum(over: body) + return [0xDD, command] + body + [UInt8(sum >> 8), UInt8(sum & 0xFF), 0x77] +} + +// 13,25 V · -12,80 A (Entladung) · 88 % · 4 Zellen · 1 Fühler bei 23,0 °C +var basic: [UInt8] = [] +basic += [0x05, 0x2D] // 1325 -> 13,25 V +basic += [0xFB, 0x00] // -1280 -> -12,80 A +basic += [0x44, 0xC0] // 17600 -> 176,00 Ah Rest +basic += [0x4E, 0x20] // 20000 -> 200,00 Ah nominal +basic += [0x00, 0x2A] // 42 Zyklen +basic += [0x00, 0x00] // Produktionsdatum +basic += [0x00, 0x00, 0x00, 0x00] // Balancer +basic += [0x00, 0x00] // keine Schutzabschaltung +basic += [0x16] // Softwareversion +basic += [0x58] // 88 % +basic += [0x03] // beide MOSFET an +basic += [0x04] // 4 Zellen +basic += [0x01] // 1 Fühler +basic += [0x0B, 0x99] // 2969 -> (2969-2731)/10 = 23,8 °C + +let cells: [UInt8] = [0x0C, 0xFE, 0x0D, 0x12, 0x0D, 0x00, 0x0C, 0xF8] +var jbdStream = jbdResponse(command: 0x03, payload: basic) +jbdStream += jbdResponse(command: 0x04, payload: cells) + +let (jbdFrames, jbdRest) = JBDProtocol.extractFrames(from: jbdStream) +checkEqual("beide Rahmen erkannt", jbdFrames.count, 2) +checkEqual("nichts bleibt übrig", jbdRest.count, 0) + +var jbd = JBDState() +for frame in jbdFrames { jbd.apply(frame) } +let jbdSnapshot = jbd.snapshot(deviceID: UUID(), rssi: nil) +func jbdValue(_ key: String) -> Double? { jbdSnapshot.metrics.first { $0.key == key }?.value } +checkEqual("Spannung", jbdValue("voltage").map(round2), 13.25) +checkEqual("Entladestrom ist negativ", jbdValue("current").map(round2), -12.80) +checkEqual("Ladezustand", jbdValue("soc"), 88) +checkEqual("Restkapazität", jbdValue("capacity").map(round2), 176.0) +checkEqual("Nennkapazität", jbdValue("capacity_nominal").map(round2), 200.0) +checkEqual("Ladezyklen", jbdValue("cycles"), 42) +checkEqual("Temperatur aus Zehntel-Kelvin", jbdSnapshot.temperatures.first.map(round2), 23.8) +checkEqual("vier Zellspannungen", jbdSnapshot.cellVoltages.count, 4) +checkEqual("höchste Zelle", jbdValue("cell_max"), 3.346) +checkEqual("Zell-Differenz", jbdValue("cell_delta"), 26) +checkEqual("Zustand aus negativem Strom", jbdSnapshot.state, "Entlädt") +checkEqual("keine Störung gemeldet", jbdSnapshot.fault, nil) + +// Schutzabschaltung und gesperrte MOSFET +var tripped = basic +tripped[16] = 0x00; tripped[17] = 0x02 // Zellunterspannung +tripped[20] = 0x01 // nur Laden erlaubt +var trippedState = JBDState() +for frame in JBDProtocol.extractFrames(from: jbdResponse(command: 0x03, payload: tripped)).frames { + trippedState.apply(frame) +} +let trippedSnapshot = trippedState.snapshot(deviceID: UUID(), rssi: nil) +checkEqual("Schutzabschaltung im Klartext", trippedSnapshot.fault, "Zellunterspannung") +checkEqual("gesperrtes Entladen wird gemeldet", trippedSnapshot.offReasons.first, "Entladen gesperrt") + +// Kaputte Prüfsumme und angefangene Rahmen +var brokenJBD = jbdResponse(command: 0x03, payload: basic) +brokenJBD[brokenJBD.count - 2] ^= 0xFF +checkEqual("falsche Prüfsumme wird verworfen", + JBDProtocol.extractFrames(from: brokenJBD).frames.count, 0) +let halfJBD = Array(jbdStream.prefix(jbdStream.count - 4)) +checkEqual("nur der vollständige Rahmen wird ausgewertet", + JBDProtocol.extractFrames(from: halfJBD).frames.count, 1) + +print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.") +exit(failures == 0 ? 0 : 1) diff --git a/run-tests.sh b/run-tests.sh new file mode 100755 index 0000000..4102e6b --- /dev/null +++ b/run-tests.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Prüft die Protokoll-Logik (Victron-Entschlüsselung, Daly-Rahmen) ohne Hardware +# und ohne Simulator – die Dateien sind bewusst frei von CoreBluetooth und SwiftUI. +set -e +cd "$(dirname "$0")" +OUT=$(mktemp -d) +swiftc -O -o "$OUT/tests" \ + CamperMonitor/Bluetooth/AESCounterMode.swift \ + CamperMonitor/Bluetooth/BitReader.swift \ + CamperMonitor/Bluetooth/VictronAdvertisement.swift \ + CamperMonitor/Bluetooth/DalyProtocol.swift \ + CamperMonitor/Bluetooth/DalyState.swift \ + CamperMonitor/Bluetooth/JBDProtocol.swift \ + CamperMonitor/Models/DeviceSnapshot.swift \ + CamperMonitor/Models/VictronCodes.swift \ + CamperMonitor/Store/KeychainStore.swift \ + Tests/main.swift +"$OUT/tests"