forked from fritob/Camper-Monitor
init
This commit is contained in:
+65
@@ -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
|
||||
@@ -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 = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
AA0000000000000000000005 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AA0000000000000000000001 /* CamperMonitor.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme LastUpgradeVersion = "2660" version = "1.7">
|
||||
<BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry buildForTesting = "YES" buildForRunning = "YES" buildForProfiling = "YES" buildForArchiving = "YES" buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA0000000000000000000006"
|
||||
BuildableName = "CamperMonitor.app"
|
||||
BlueprintName = "CamperMonitor"
|
||||
ReferencedContainer = "container:CamperMonitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables></Testables>
|
||||
</TestAction>
|
||||
<LaunchAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA0000000000000000000006"
|
||||
BuildableName = "CamperMonitor.app"
|
||||
BlueprintName = "CamperMonitor"
|
||||
ReferencedContainer = "container:CamperMonitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction buildConfiguration = "Release" shouldUseLaunchSchemeArgsEnv = "YES" savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AA0000000000000000000006"
|
||||
BuildableName = "CamperMonitor.app"
|
||||
BlueprintName = "CamperMonitor"
|
||||
ReferencedContainer = "container:CamperMonitor.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction buildConfiguration = "Debug"></AnalyzeAction>
|
||||
<ArchiveAction buildConfiguration = "Release" revealArchiveInOrganizer = "YES"></ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images" : [ { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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..<width {
|
||||
let absolute = bitOffset + i
|
||||
let byte = bytes[absolute / 8]
|
||||
let bit = (byte >> 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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <adr> <cmd> 08 <8 Datenbytes> <Prüfsumme>`.
|
||||
/// 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 <Startregister> <Anzahl> <CRC16>`.
|
||||
///
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
@@ -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..<min(48, registers.count) {
|
||||
let millivolts = Int(registers[i])
|
||||
if millivolts > 500 && millivolts < 5000 {
|
||||
cellMillivolts[i + 1] = millivolts
|
||||
}
|
||||
}
|
||||
|
||||
// Register 48–55: Temperaturfühler mit Offset 40.
|
||||
sensorTemperatures.removeAll(keepingCapacity: true)
|
||||
for i in 48..<min(56, registers.count) {
|
||||
let raw = Int(registers[i])
|
||||
if raw > 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"
|
||||
}
|
||||
}
|
||||
@@ -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 <Kommando> <Länge=00> <Prüfsumme 2 Byte> 77
|
||||
/// Antwort: DD <Kommando> <Status> <Länge> <Daten…> <Prüfsumme 2 Byte> 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..<sensorCount {
|
||||
let offset = 23 + sensor * 2
|
||||
guard offset + 1 < d.count else { break }
|
||||
readings.append((Double(u16(offset)) - 2731) / 10)
|
||||
}
|
||||
temperatures = readings
|
||||
|
||||
case 0x04:
|
||||
var millivolts: [Int] = []
|
||||
var offset = 0
|
||||
while offset + 1 < d.count {
|
||||
millivolts.append(u16(offset))
|
||||
offset += 2
|
||||
}
|
||||
cellMillivolts = millivolts
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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: 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<UUID> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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<T: Equatable>(_ 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..<width { bits.append(UInt8((value >> 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)
|
||||
Executable
+18
@@ -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"
|
||||
Reference in New Issue
Block a user