This commit is contained in:
BiasF
2026-08-30 10:36:52 +02:00
commit fdcc644828
30 changed files with 4152 additions and 0 deletions
+65
View File
@@ -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
+266
View File
@@ -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))
}
}
+297
View File
@@ -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)
}
}
+61
View File
@@ -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)
}
}
}
+123
View File
@@ -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
}
}
+214
View File
@@ -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 047: 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 4855: 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"
}
}
+200
View File
@@ -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
}
}
+31
View File
@@ -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)
}
}
+89
View File
@@ -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
}
}
}
+32
View File
@@ -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",
]
}
+101
View File
@@ -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)
}
}
+110
View File
@@ -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))
}
}
}
+184
View File
@@ -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)
}
}
+59
View File
@@ -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
}
}
+239
View File
@@ -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")
}
}
+109
View File
@@ -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)
}
}
+137
View File
@@ -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
}
}
+298
View File
@@ -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)
}
}
+168
View File
@@ -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
}
}
}
}
+136
View File
@@ -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.
+316
View File
@@ -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
View File
@@ -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"