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