@@ -4,22 +4,27 @@ import Foundation
// / H ä l t d i e G A T T - V e r b i n d u n g z u e i n e m B M S , p o l l t d i e W e r t e u n d m e l d e t f e r t i g e
// / S n a p s h o t s z u r ü c k .
// /
// / U n t e r s t ü t z t d r e i D i a l e k t e u n d e r k e n n t s e l b s t , w e l c h e n d a s G e r ä t s p r i c h t :
// / Z w e i D i n g e s i n d b e i d i e s e n G e r ä t e n n i c h t v o r h e r s e h b a r u n d w e r d e n d e s h a l b
// / a u s p r o b i e r t s t a t t v o r a u s g e s e t z t :
// /
// / * * * D a l y k l a s s i s c h * * – 1 3 - B y t e - R a h m e n , b e g i n n e n d m i t ` A 5 `
// / * * * D a l y M o d b u s * * – ` D 2 0 3 … ` , n e u e r e D a l y - F i r m w a r e
// / * * * J B D / X i a o x i a n g * * – ` D D A 5 … ` , u . a . i n W a t t C y c l e - A k k u s
// /
// / A u c h d i e B L E - C h a r a k t e r i s t i k e n w e r d e n g e s u c h t s t a t t v o r a u s g e s e t z t : d i e M o d u l e
// / u n t e r s c h e i d e n s i c h z w i s c h e n H e r s t e l l e r n u n d F e r t i g u n g s c h a r g e n .
// / 1 . * * Ü b e r w e l c h e C h a r a k t e r i s t i k e n g e s p r o c h e n w i r d . * * I m s e l b e n D i e n s t s e h e n
// / o f t m e h r e r e C h a r a k t e r i s t i k e n b e s c h r e i b b a r a u s , n u r e i n e n i m m t a b e r
// / w i r k l i c h K o m m a n d o s a n . D i e S e s s i o n s t e l l t a l l e s i n n v o l l e n P a a r e a u s
// / S c h r e i b - u n d B e n a c h r i c h t i g u n g s - C h a r a k t e r i s t i k z u s a m m e n u n d a r b e i t e t s i e
// / d e r R e i h e n a c h a b , b i s e i n e s a n t w o r t e t .
// / 2 . * * W e l c h e s P r o t o k o l l g e s p r o c h e n w i r d . * * A u f j e d e m P a a r w e r d e n D a l y
// / ( k l a s s i s c h u n d M o d b u s ) u n d J B D / X i a o x i a n g a n g e f r a g t ; d e r e r s t e g ü l t i g e
// / R a h m e n l e g t d e n D i a l e k t f e s t .
final class BMSSession : NSObject {
// / B e k a n n t e D i e n s t e , i n R e i h e n f o l g e d e r W a h r s c h e i n l i c h k e i t .
private static let preferredServices : [ CBUUID ] = [
CBUUID ( string : " FFF0 " ) , // D a l y
CBUUID ( string : " FF00 " ) , // J B D
CBUUID ( string : " FFE0 " ) ,
CBUUID ( string : " 6E400001-B5A3-F393-E0A9-E50E24DCCA9E " ) , // N o r d i c U A R T
// / B e k a n n t e P a a r e , d i e z u e r s t v e r s u c h t w e r d e n .
private static let knownPairs : [ ( service : String , write : String , notify : String ) ] = [
( " FFF0 " , " FFF2 " , " FFF1 " ) , // D a l y u n d v i e l e b a u g l e i c h e M o d u l e
( " FF00 " , " FF02 " , " FF01 " ) , // J B D / X i a o x i a n g
( " FFE0 " , " FFE1 " , " FFE1 " ) ,
( " 6E400001-B5A3-F393-E0A9-E50E24DCCA9E " ,
" 6E400002-B5A3-F393-E0A9-E50E24DCCA9E " ,
" 6E400003-B5A3-F393-E0A9-E50E24DCCA9E " ) , // N o r d i c U A R T
]
enum Dialect : String {
@@ -29,25 +34,51 @@ final class BMSSession: NSObject {
case jbd = " JBD / Xiaoxiang "
}
// / E i n K a n d i d a t : w o r ü b e r g e s c h r i e b e n , w o r ü b e r g e l a u s c h t u n d w i e g e s c h r i e b e n
// / w i r d . D e r S c h r e i b m o d u s g e h ö r t d a z u , w e i l m a n c h e M o d u l e n u r d i e e i n e o d e r
// / n u r d i e a n d e r e V a r i a n t e a n n e h m e n .
private struct Endpoint {
let write : CBCharacteristic
let notify : CBCharacteristic
let writeType : CBCharacteristicWriteType
let isKnownPair : Bool
var label : String {
let mode = writeType = = . withoutResponse ? " ohne Bestätigung " : " mit Bestätigung "
return " \( write . uuid . uuidString ) → \( notify . uuid . uuidString ) , \( mode ) "
}
}
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 var endpoints : [ Endpoint ] = [ ]
private var endpointIndex = 0
private var pendingServices = 0
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 ?
private var receivedByteCount = 0
private var sentFrameCount = 0
private var gattSummary : [ String ] = [ ]
// / R u n d e n o h n e v e r w e r t b a r e A n t w o r t a u f d e m a k t u e l l e n K a n d i d a t e n .
private var silentRounds = 0
// / A b s t a n d z w i s c h e n z w e i A b f r a g e r u n d e n .
// / A b s t a n d z w i s c h e n z w e i A b f r a g e r u n d e n i m N o r m a l b e t r i e b .
var pollInterval : TimeInterval = 5
// / K ü r z e r , s o l a n g e n o c h g e s u c h t w i r d – s o n s t d a u e r t d a s D u r c h p r o b i e r e n l a n g .
private var searchInterval : TimeInterval = 8
private var currentEndpoint : Endpoint ? {
endpoints . indices . contains ( endpointIndex ) ? endpoints [ endpointIndex ] : nil
}
init ( deviceID : UUID ,
peripheral : CBPeripheral ,
@@ -73,11 +104,11 @@ final class BMSSession: NSObject {
func stop ( ) {
pollTimer ? . invalidate ( )
pollTimer = nil
if let notifyCharacteristic , peripheral . state = = . connected {
peripheral . setNotifyValue ( false , for : notifyCharacteristic )
if let notify = currentEndpoint ? . notify , peripheral . state = = . connected {
peripheral . setNotifyValue ( false , for : notify )
}
writeCharacteristic = nil
notifyCharacteristic = nil
endpoints . removeAll ( )
endpointIndex = 0
dialect = . unknown
buffer . removeAll ( )
}
@@ -85,80 +116,148 @@ final class BMSSession: NSObject {
func handleDisconnect ( ) {
pollTimer ? . invalidate ( )
pollTimer = nil
writeCharacteristic = nil
notifyCharacteristic = nil
endpoints . removeAll ( )
endpointIndex = 0
buffer . removeAll ( )
}
// MARK: - K a n d i d a t e n
// / S t e l l t n a c h d e r D i e n s t s u c h e a l l e P a a r e z u s a m m e n : b e k a n n t e K o m b i n a t i o n e n
// / z u e r s t , d a n a c h j e d e a n d e r e S c h r e i b - / N o t i f y - K o m b i n a t i o n i m s e l b e n D i e n s t .
private func buildEndpoints ( ) {
var candidates : [ Endpoint ] = [ ]
for service in peripheral . services ? ? [ ] {
let characteristics = service . characteristics ? ? [ ]
let writable = characteristics . filter {
$0 . properties . contains ( . write ) || $0 . properties . contains ( . writeWithoutResponse )
}
let notifying = characteristics . filter {
$0 . properties . contains ( . notify ) || $0 . properties . contains ( . indicate )
}
guard ! writable . isEmpty , ! notifying . isEmpty else { continue }
for write in writable {
for notify in notifying {
let known = Self . knownPairs . contains {
CBUUID ( string : $0 . service ) = = service . uuid
&& CBUUID ( string : $0 . write ) = = write . uuid
&& CBUUID ( string : $0 . notify ) = = notify . uuid
}
// B e i d e S c h r e i b a r t e n a n b i e t e n , s o f e r n d a s G e r ä t s i e k a n n .
if write . properties . contains ( . writeWithoutResponse ) {
candidates . append ( Endpoint ( write : write , notify : notify ,
writeType : . withoutResponse , isKnownPair : known ) )
}
if write . properties . contains ( . write ) {
candidates . append ( Endpoint ( write : write , notify : notify ,
writeType : . withResponse , isKnownPair : known ) )
}
}
}
}
// B e k a n n t e P a a r e n a c h v o r n , d e r R e s t i n F u n d r e i h e n f o l g e .
endpoints = candidates . sorted { lhs , rhs in
lhs . isKnownPair && ! rhs . isKnownPair
}
endpointIndex = 0
guard ! endpoints . isEmpty else {
onStateChange ( . failed ( " Keine passenden Bluetooth-Merkmale gefunden " ) )
publishDiagnostics ( )
return
}
activateCurrentEndpoint ( )
}
private func activateCurrentEndpoint ( ) {
guard let endpoint = currentEndpoint else { return }
silentRounds = 0
buffer . removeAll ( )
peripheral . setNotifyValue ( true , for : endpoint . notify )
publishDiagnostics ( )
}
// / W e c h s e l t a u f d e n n ä c h s t e n K a n d i d a t e n . S i n d a l l e d u r c h , w i r d v o n v o r n
// / b e g o n n e n – d a s G e r ä t k a n n z w i s c h e n z e i t l i c h a u f g e w a c h t s e i n .
private func advanceEndpoint ( ) {
guard let previous = currentEndpoint else { return }
if peripheral . state = = . connected {
peripheral . setNotifyValue ( false , for : previous . notify )
}
endpointIndex = ( endpointIndex + 1 ) % endpoints . count
onStateChange ( . connecting )
activateCurrentEndpoint ( )
beginPolling ( )
}
// MARK: - A b f r a g e
private func beginPolling ( ) {
pollTimer ? . invalidate ( )
onStateChange ( . live )
poll ( )
pollTimer = Timer . scheduledTimer ( withTimeInterval : pollInterval , repeats : true ) { [ weak self ] _ in
let interval = dialect = = . unknown ? searchInterval : pollInterval
pollTimer = Timer . scheduledTimer ( withTimeInterval : interval , repeats : true ) { [ weak self ] _ in
self ? . poll ( )
}
}
private func poll ( ) {
guard peripheral . state = = . connected , writeCharacteristic != nil else { return }
guard peripheral . state = = . connected , currentEndpoint != nil else { return }
switch dialect {
case . unknown :
probeDialects ( )
// A l l e d r e i P r o t o k o l l e a n f r a g e n ; w a s a n t w o r t e t , g e w i n n t .
sendSequence ( [
DalyProtocol . requestFrame ( . soc ) ,
JBDProtocol . requestFrame ( . basicInfo ) ,
DalyProtocol . modbusReadFrame ( ) ,
] , spacing : 1.2 , thenGiveUpAfter : 5 )
case . dalyClassic :
sendSequence ( DalyProtocol . Command . allCases . map { DalyProtocol . requestFrame ( $0 ) } )
sendSequence ( DalyProtocol . Command . allCases . map { DalyProtocol . requestFrame ( $0 ) } ,
spacing : 0.25 , thenGiveUpAfter : 2 )
case . dalyModbus :
sendSequence ( [ DalyProtocol . modbusReadFrame ( ) ] )
sendSequence ( [ DalyProtocol . modbusReadFrame ( ) ] , spacing : 0.25 , thenGiveUpAfter : 2 )
case . jbd :
sendSequence ( JBDProtocol . Command . allCases . map { JBDProtocol . requestFrame ( $0 ) } )
sendSequence ( JBDProtocol . Command . allCases . map { JBDProtocol . requestFrame ( $0 ) } ,
spacing : 0.25 , thenGiveUpAfter : 2 )
}
}
// / N a c h e i n a n d e r a l l e b e k a n n t e n A n f r a g e n s c h i c k e n . D e r e r s t e g ü l t i g e R a h m e n
// / i n d e r A n t w o r t l e g t d e n D i a l e k t f e s t .
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 )
}
// / K o m m a n d o s l e i c h t v e r s e t z t s e n d e n – m a n c h e M o d u l e v e r s c h l u c k e n A n f r a g e n ,
// / d i e z u d i c h t a u f e i n a n d e r f o l g e n .
private func sendSequence ( _ frames : [ Data ] ) {
private func sendSequence ( _ frames : [ Data ] , spacing : TimeInterval , thenGiveUpAfter grace : TimeInterval ) {
for ( index , frame ) in frames . enumerated ( ) {
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + Double ( index ) * 0.25 ) { [ weak self ] in
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + Double ( index ) * spacing ) { [ weak self ] in
self ? . send ( frame )
}
}
checkForSilence ( after : Double ( frames . count ) * 0.25 + 2 )
checkForSilence ( after : Double ( frames . count ) * spacing + grace )
}
// / K o m m t m e h r e r e R u n d e n n i c h t s B r a u c h b a r e s z u r ü c k , w i r d d e r e r k a n n t e
// / D i a l e k t v e r w o r f e n u n d n e u g e s u c h t .
// / K o m m t n i c h t s B r a u c h b a r e s z u r ü c k , w i r d d e r n ä c h s t e K a n d i d a t v e r s u c h t .
private func checkForSilence ( after delay : TimeInterval ) {
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + delay ) { [ weak self ] in
guard let self else { return }
guard let self , self . peripheral . state = = . connected else { return }
guard ! self . hasUsableData else {
self . silentRounds = 0
return
}
self . silentRounds += 1
if self . silentRounds >= 3 {
self . silentRounds = 0
self . publishDiagnostics ( )
// S o l a n g e n o c h k e i n P r o t o k o l l s t e h t , z ü g i g w e i t e r p r o b i e r e n .
let limit = self . dialect = = . unknown ? 1 : 3
guard self . silentRounds > limit else { return }
self . silentRounds = 0
if self . endpoints . count > 1 {
self . advanceEndpoint ( )
} else {
self . dialect = . unknown
self . onStateChange ( . failed ( " Keine verwertbare Antwort vom BMS " ) )
self . publishDiagnostics ( )
self . onStateChange ( . failed ( " Keine Antwort vom BMS " ) )
}
}
}
@@ -168,26 +267,24 @@ final class BMSSession: NSObject {
}
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 )
guard let endpoint = currentEndpoint , peripheral . state = = . connected else { return }
sentFrameCount += 1
peripheral . writeValue ( data , for : endpoint . write , type : endpoint . writeType )
}
// MARK: - A u s w e r t u n g
private func consume ( _ data : Data ) {
lastResponse = data
receivedByteCount += data . count
buffer . append ( contentsOf : [ UInt8 ] ( data ) )
if buffer . count > 512 { buffer . removeFirst ( buffer . count - 512 ) }
// J B D z u e r s t : d e r R a h m e n i s t d u r c h S t a r t - , E n d b y t e u n d P r ü f s u m m e
// e i n d e u t i g u n d k a n n n i c h t m i t d e n D a l y - R a h m e n v e r w e c h s e l t w e r d e n .
// J B D z u e r s t : S t a r t - , E n d b y t e u n d P r ü f s u m m e m a c h e n d e n R a h m e n e i n d e u t i g .
let ( jbdFrames , jbdRemainder ) = JBDProtocol . extractFrames ( from : buffer )
if ! jbdFrames . isEmpty {
buffer = jbdRemainder
dialect = . jbd
silentRounds = 0
adopt ( . jbd )
for frame in jbdFrames { jbdState . apply ( frame ) }
publish ( jbdState . snapshot ( deviceID : deviceID , rssi : nil ) , usable : jbdState . hasUsableData )
return
@@ -196,8 +293,7 @@ final class BMSSession: NSObject {
if let start = buffer . firstIndex ( where : { $0 = = 0xD2 } ) ,
let registers = DalyProtocol . parseModbusResponse ( Array ( buffer [ start . . . ] ) ) {
buffer . removeAll ( )
dialect = . dalyModbus
silentRounds = 0
adopt ( . dalyModbus )
dalyState . apply ( registers : registers )
publish ( dalyState . snapshot ( deviceID : deviceID , rssi : nil ) , usable : dalyState . hasUsableData )
return
@@ -206,17 +302,26 @@ final class BMSSession: NSObject {
let ( dalyFrames , dalyRemainder ) = DalyProtocol . extractA5Frames ( from : buffer )
if ! dalyFrames . isEmpty {
buffer = dalyRemainder
dialect = . dalyClassic
silentRounds = 0
adopt ( . dalyClassic )
for frame in dalyFrames { dalyState . apply ( frame ) }
publish ( dalyState . snapshot ( deviceID : deviceID , rssi : nil ) , usable : dalyState . hasUsableData )
return
}
// N i c h t s e r k a n n t – t r o t z d e m m e l d e n , d a m i t d i e D i a g n o s e e t w a s z e i g t .
// E t w a s k a m a n , l i e ß s i c h a b e r n i c h t z u o r d n e n : f ü r d i e D i a g n o s e s i c h t b a r
// m a c h e n , d a m i t s i c h d a s P r o t o k o l l n a c h t r ä g l i c h b e s t i m m e n l ä s s t .
publishDiagnostics ( )
}
// / E r s t e r v e r w e r t b a r e r R a h m e n : K a n d i d a t u n d D i a l e k t s t e h e n f e s t , a b j e t z t
// / i m n o r m a l e n T a k t a b f r a g e n .
private func adopt ( _ newDialect : Dialect ) {
silentRounds = 0
guard dialect != newDialect else { return }
dialect = newDialect
beginPolling ( )
}
private func publish ( _ snapshot : DeviceSnapshot , usable : Bool ) {
publishDiagnostics ( )
guard usable else { return }
@@ -227,13 +332,30 @@ final class BMSSession: NSObject {
private func publishDiagnostics ( ) {
onDiagnostics ( BMSDiagnostics (
dialect : dialect . rawValue ,
serviceUUID : writeCharacteristic ? . service ? . uuid . uuidString ,
writeUUID : writeCharacteristic ? . uuid . uuidString ,
notifyUUID : notifyCharacteristic ? . uuid . uuidString ,
endpointLabel : currentEndpoint ? . label ,
endpointPosition : endpoints . isEmpty ? nil : . init ( endpointIndex + 1 , endpoints . count ) ,
serviceUUID : currentEndpoint ? . write . service ? . uuid . uuidString ,
gattSummary : gattSummary ,
sentFrames : sentFrameCount ,
receivedBytes : receivedByteCount ,
lastResponseHex : lastResponse . map { $0 . map { String ( format : " %02X " , $0 ) } . joined ( separator : " " ) } ,
updated : Date ( )
) )
}
// / M e n s c h e n l e s b a r e r G A T T - B a u m f ü r d i e D i a g n o s e .
private func appendToSummary ( _ service : CBService ) {
gattSummary . append ( " Dienst \( service . uuid . uuidString ) " )
for characteristic in service . characteristics ? ? [ ] {
var traits : [ String ] = [ ]
if characteristic . properties . contains ( . read ) { traits . append ( " read " ) }
if characteristic . properties . contains ( . write ) { traits . append ( " write " ) }
if characteristic . properties . contains ( . writeWithoutResponse ) { traits . append ( " write-nr " ) }
if characteristic . properties . contains ( . notify ) { traits . append ( " notify " ) }
if characteristic . properties . contains ( . indicate ) { traits . append ( " indicate " ) }
gattSummary . append ( " \( characteristic . uuid . uuidString ) \( traits . joined ( separator : " , " ) ) " )
}
}
}
// MARK: - C B P e r i p h e r a l D e l e g a t e
@@ -245,7 +367,14 @@ extension BMSSession: CBPeripheralDelegate {
onStateChange ( . failed ( error . localizedDescription ) )
return
}
for service in peripheral . services ? ? [ ] {
let services = peripheral . services ? ? [ ]
gattSummary . removeAll ( )
pendingServices = services . count
guard pendingServices > 0 else {
onStateChange ( . failed ( " Gerät bietet keine Bluetooth-Dienste an " ) )
return
}
for service in services {
peripheral . discoverCharacteristics ( nil , for : service )
}
}
@@ -253,37 +382,26 @@ extension BMSSession: CBPeripheralDelegate {
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 }
// E i n e n b e k a n n t e n D i e n s t i m m e r b e v o r z u g e n , s o n s t d e n e r s t b e s t e n n e h m e n .
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 )
appendToSummary ( service )
pendingServices -= 1
// E r s t w e n n a l l e D i e n s t e d u r c h s i n d , s t e h t d i e K a n d i d a t e n l i s t e f e s t .
guard pendingServices <= 0 else { return }
buildEndpoints ( )
}
func peripheral ( _ peripheral : CBPeripheral ,
didUpdateNotificationStateFor characteristic : CBCharacteristic ,
error : Error ? ) {
if let error {
onStateChange ( . failed ( error . localizedDescription ) )
// D i e s e r K a n d i d a t l ä s s t s i c h n i c h t a b o n n i e r e n – n ä c h s t e n v e r s u c h e n .
if endpoints . count > 1 {
advanceEndpoint ( )
} else {
onStateChange ( . failed ( error . localizedDescription ) )
}
return
}
if characteristic . isNotifying , characteristic = = notifyCharacteristic {
publishDiagnostics ( )
if characteristic . isNotifying , characteristic = = currentEndpoint ? . notify {
beginPolling ( )
}
}
@@ -291,7 +409,7 @@ extension BMSSession: CBPeripheralDelegate {
func peripheral ( _ peripheral : CBPeripheral ,
didUpdateValueFor characteristic : CBCharacteristic ,
error : Error ? ) {
guard error = = nil , let value = characteristic . value else { return }
guard error = = nil , let value = characteristic . value , ! value . isEmpty else { return }
consume ( value )
}
}