2020-01-18 15:50:46 +00:00
const dbus = require ( 'dbus-next' ) ;
2020-01-17 14:50:58 +00:00
const crypto = require ( 'crypto' ) ;
const xor = require ( 'buffer-xor' ) ;
const EventEmitter = require ( 'events' ) ;
2021-01-18 17:21:37 +01:00
const logInfo = true ; // Normal operations
const logDebug = false ; // Chatty
const logVerbose = false ; // Very chatty
2020-01-17 14:50:58 +00:00
2021-01-18 17:21:37 +01:00
const consoleLogger = ( level ) => ( ... msg ) =>
console . log ( new Date ( ) . toISOString ( ) . replace ( 'T' , ' ' ) . substring ( 0 , 19 ) + 'Z' , level , 'plejd-ble' , ... msg ) ;
const getLogger = ( level , shouldLog ) => ( shouldLog ? consoleLogger ( level ) : ( ) => { } ) ;
2020-01-17 14:50:58 +00:00
2021-01-18 17:21:37 +01:00
const errLogger = getLogger ( 'ERR' , true ) ;
const infLogger = getLogger ( 'INF' , logInfo ) ;
const dbgLogger = getLogger ( 'DBG' , logDebug ) ;
const vrbLogger = getLogger ( 'vrb' , logVerbose ) ;
2020-01-17 14:50:58 +00:00
// UUIDs
2020-01-19 20:08:48 +00:00
const PLEJD _SERVICE = '31ba0001-6085-4726-be45-040c957391b5' ;
const DATA _UUID = '31ba0004-6085-4726-be45-040c957391b5' ;
const LAST _DATA _UUID = '31ba0005-6085-4726-be45-040c957391b5' ;
const AUTH _UUID = '31ba0009-6085-4726-be45-040c957391b5' ;
const PING _UUID = '31ba000a-6085-4726-be45-040c957391b5' ;
2020-01-17 14:50:58 +00:00
const BLE _CMD _DIM _CHANGE = '00c8' ;
const BLE _CMD _DIM2 _CHANGE = '0098' ;
const BLE _CMD _STATE _CHANGE = '0097' ;
const BLE _CMD _SCENE _TRIG = '0021' ;
const BLUEZ _SERVICE _NAME = 'org.bluez' ;
2020-01-18 15:50:46 +00:00
const DBUS _OM _INTERFACE = 'org.freedesktop.DBus.ObjectManager' ;
const DBUS _PROP _INTERFACE = 'org.freedesktop.DBus.Properties' ;
2020-01-17 14:50:58 +00:00
const BLUEZ _ADAPTER _ID = 'org.bluez.Adapter1' ;
const BLUEZ _DEVICE _ID = 'org.bluez.Device1' ;
const GATT _SERVICE _ID = 'org.bluez.GattService1' ;
const GATT _CHRC _ID = 'org.bluez.GattCharacteristic1' ;
2021-01-01 20:15:00 +01:00
const MAX _TRANSITION _STEPS _PER _SECOND = 5 ; // Could be made a setting
2021-01-18 17:21:37 +01:00
const MAX _RETRY _COUNT = 5 ; // Could be made a setting
2021-01-01 20:15:00 +01:00
2020-01-17 14:50:58 +00:00
class PlejdService extends EventEmitter {
2020-03-03 16:22:30 +01:00
constructor ( cryptoKey , devices , sceneManager , connectionTimeout , writeQueueWaitTime , keepAlive = false ) {
2020-01-17 14:50:58 +00:00
super ( ) ;
2021-01-18 17:21:37 +01:00
infLogger ( 'Starting Plejd BLE, resetting all device states.' ) ;
2020-01-17 14:50:58 +00:00
this . cryptoKey = Buffer . from ( cryptoKey . replace ( /-/g , '' ) , 'hex' ) ;
2020-02-29 15:54:08 +00:00
this . sceneManager = sceneManager ;
this . connectedDevice = null ;
2020-01-19 20:08:48 +00:00
this . plejdService = null ;
this . bleDevices = [ ] ;
2021-01-01 20:15:00 +01:00
this . bleDeviceTransitionTimers = { } ;
2020-01-17 14:50:58 +00:00
this . plejdDevices = { } ;
2020-02-29 15:54:08 +00:00
this . devices = devices ;
2020-01-17 14:50:58 +00:00
this . connectEventHooked = false ;
2020-01-27 20:43:52 +00:00
this . connectionTimeout = connectionTimeout ;
2020-03-03 16:22:30 +01:00
this . writeQueueWaitTime = writeQueueWaitTime ;
2020-02-29 15:54:08 +00:00
this . writeQueue = [ ] ;
this . writeQueueRef = null ;
2021-01-18 17:21:37 +01:00
this . initInProgress = null ;
2020-01-17 14:50:58 +00:00
// Holds a reference to all characteristics
this . characteristics = {
data : null ,
lastData : null ,
2020-01-19 20:08:48 +00:00
lastDataProperties : null ,
2020-01-17 14:50:58 +00:00
auth : null ,
ping : null
} ;
2020-01-18 15:50:46 +00:00
this . bus = dbus . systemBus ( ) ;
2020-01-19 20:08:48 +00:00
this . adapter = null ;
2020-01-17 14:50:58 +00:00
2021-01-18 17:21:37 +01:00
dbgLogger ( 'wiring events and waiting for BLE interface to power up.' ) ;
2020-01-17 14:50:58 +00:00
this . wireEvents ( ) ;
}
async init ( ) {
if ( this . objectManager ) {
this . objectManager . removeAllListeners ( ) ;
}
2021-01-13 01:47:05 +01:00
this . bleDevices = [ ] ;
2020-02-29 15:54:08 +00:00
this . connectedDevice = null ;
2021-01-13 01:47:05 +01:00
2020-01-21 14:24:02 +00:00
this . characteristics = {
data : null ,
lastData : null ,
lastDataProperties : null ,
auth : null ,
ping : null
} ;
2020-01-27 20:43:52 +00:00
2020-01-20 10:58:03 +00:00
clearInterval ( this . pingRef ) ;
2021-01-13 19:43:11 +01:00
clearTimeout ( this . writeQueueRef ) ;
2021-01-18 17:21:37 +01:00
infLogger ( 'init()' ) ;
2020-01-20 10:58:03 +00:00
2020-01-18 15:50:46 +00:00
const bluez = await this . bus . getProxyObject ( BLUEZ _SERVICE _NAME , '/' ) ;
this . objectManager = await bluez . getInterface ( DBUS _OM _INTERFACE ) ;
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
// We need to find the ble interface which implements the Adapter1 interface
const managedObjects = await this . objectManager . GetManagedObjects ( ) ;
2020-01-20 07:58:23 +00:00
let result = await this . _getInterface ( managedObjects , BLUEZ _ADAPTER _ID ) ;
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
if ( result ) {
this . adapter = result [ 1 ] ;
}
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
if ( ! this . adapter ) {
2021-01-18 17:21:37 +01:00
errLogger ( 'unable to find a bluetooth adapter that is compatible.' ) ;
2020-01-19 20:08:48 +00:00
return ;
2020-01-17 14:50:58 +00:00
}
2020-01-19 20:08:48 +00:00
for ( let path of Object . keys ( managedObjects ) ) {
const interfaces = Object . keys ( managedObjects [ path ] ) ;
2020-01-20 07:58:23 +00:00
2020-01-19 20:08:48 +00:00
if ( interfaces . indexOf ( BLUEZ _DEVICE _ID ) > - 1 ) {
const proxyObject = await this . bus . getProxyObject ( BLUEZ _SERVICE _NAME , path ) ;
const device = await proxyObject . getInterface ( BLUEZ _DEVICE _ID ) ;
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
const connected = managedObjects [ path ] [ BLUEZ _DEVICE _ID ] . Connected . value ;
if ( connected ) {
2021-01-18 17:21:37 +01:00
infLogger ( 'disconnecting ' + path ) ;
2020-01-19 20:08:48 +00:00
await device . Disconnect ( ) ;
}
await this . adapter . RemoveDevice ( path ) ;
}
2020-01-17 14:50:58 +00:00
}
2020-01-19 20:08:48 +00:00
this . objectManager . on ( 'InterfacesAdded' , this . onInterfacesAdded . bind ( this ) ) ;
this . adapter . SetDiscoveryFilter ( {
'UUIDs' : new dbus . Variant ( 'as' , [ PLEJD _SERVICE ] ) ,
'Transport' : new dbus . Variant ( 's' , 'le' )
} ) ;
2020-01-27 20:43:52 +00:00
try {
await this . adapter . StartDiscovery ( ) ;
2020-06-12 11:15:00 +02:00
} catch ( err ) {
2021-01-18 17:21:37 +01:00
errLogger ( 'failed to start discovery. Make sure no other add-on is currently scanning.' ) ;
2020-01-27 20:43:52 +00:00
return ;
}
2021-01-18 17:21:37 +01:00
return new Promise ( resolve =>
setTimeout ( ( ) => resolve (
this . _internalInit ( ) . catch ( ( err ) => { errLogger ( 'InternalInit exception! Will rethrow.' , err ) ; throw err ; } )
) , this . connectionTimeout * 1000
)
) ;
2020-01-19 20:08:48 +00:00
}
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
async _internalInit ( ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( ` Got ${ this . bleDevices . length } device(s). ` ) ;
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
for ( const plejd of this . bleDevices ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( ` Inspecting ${ plejd [ 'path' ] } ` ) ;
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
try {
const proxyObject = await this . bus . getProxyObject ( BLUEZ _SERVICE _NAME , plejd [ 'path' ] ) ;
const device = await proxyObject . getInterface ( BLUEZ _DEVICE _ID ) ;
const properties = await proxyObject . getInterface ( DBUS _PROP _INTERFACE ) ;
2020-01-20 07:58:23 +00:00
2020-01-19 20:08:48 +00:00
plejd [ 'rssi' ] = ( await properties . Get ( BLUEZ _DEVICE _ID , 'RSSI' ) ) . value ;
plejd [ 'instance' ] = device ;
2020-01-17 14:50:58 +00:00
2020-02-29 15:54:08 +00:00
const segments = plejd [ 'path' ] . split ( '/' ) ;
let fixedPlejdPath = segments [ segments . length - 1 ] . replace ( 'dev_' , '' ) ;
fixedPlejdPath = fixedPlejdPath . replace ( /_/g , '' ) ;
plejd [ 'device' ] = this . devices . find ( x => x . serialNumber === fixedPlejdPath ) ;
2021-01-18 17:21:37 +01:00
dbgLogger ( ` Discovered ${ plejd [ 'path' ] } with rssi ${ plejd [ 'rssi' ] } ` ) ;
2020-06-12 11:15:00 +02:00
} catch ( err ) {
2021-01-18 17:21:37 +01:00
errLogger ( ` Failed inspecting ${ plejd [ 'path' ] } . ` , err ) ;
2020-01-17 14:50:58 +00:00
}
2020-01-19 20:08:48 +00:00
}
const sortedDevices = this . bleDevices . sort ( ( a , b ) => b [ 'rssi' ] - a [ 'rssi' ] ) ;
let connectedDevice = null ;
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
for ( const plejd of sortedDevices ) {
try {
2020-01-27 20:43:52 +00:00
if ( plejd [ 'instance' ] ) {
2021-01-18 17:21:37 +01:00
infLogger ( ` Connecting to ${ plejd [ 'path' ] } ` ) ;
2020-01-27 20:43:52 +00:00
await plejd [ 'instance' ] . Connect ( ) ;
connectedDevice = plejd ;
break
}
2020-06-12 11:15:00 +02:00
} catch ( err ) {
2021-01-18 17:21:37 +01:00
errLogger ( 'Warning: unable to connect, will retry. ' , err ) ;
2020-01-19 20:08:48 +00:00
}
2020-01-17 14:50:58 +00:00
}
2020-01-19 20:08:48 +00:00
setTimeout ( async ( ) => {
await this . onDeviceConnected ( connectedDevice ) ;
await this . adapter . StopDiscovery ( ) ;
2020-01-27 20:43:52 +00:00
} , this . connectionTimeout * 1000 ) ;
2020-01-19 20:08:48 +00:00
}
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
async _getInterface ( managedObjects , iface ) {
const managedPaths = Object . keys ( managedObjects ) ;
for ( let path of managedPaths ) {
const pathInterfaces = Object . keys ( managedObjects [ path ] ) ;
if ( pathInterfaces . indexOf ( iface ) > - 1 ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( ` Found BLE interface ' ${ iface } ' at ${ path } ` ) ;
2020-01-19 20:08:48 +00:00
try {
const adapterObject = await this . bus . getProxyObject ( BLUEZ _SERVICE _NAME , path ) ;
return [ path , adapterObject . getInterface ( iface ) , adapterObject ] ;
2020-06-12 11:15:00 +02:00
} catch ( err ) {
2021-01-18 17:21:37 +01:00
errLogger ( ` Failed to get interface ' ${ iface } '. ` , err ) ;
2020-01-19 20:08:48 +00:00
}
2020-01-17 14:50:58 +00:00
}
2020-01-19 20:08:48 +00:00
}
return null ;
}
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
async onInterfacesAdded ( path , interfaces ) {
2021-01-01 20:15:00 +01:00
// const [adapter, dev, service, characteristic] = path.split('/').slice(3);
2020-01-19 20:08:48 +00:00
const interfaceKeys = Object . keys ( interfaces ) ;
if ( interfaceKeys . indexOf ( BLUEZ _DEVICE _ID ) > - 1 ) {
if ( interfaces [ BLUEZ _DEVICE _ID ] [ 'UUIDs' ] . value . indexOf ( PLEJD _SERVICE ) > - 1 ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( ` Found Plejd service on ${ path } ` ) ;
2020-06-12 11:15:00 +02:00
this . bleDevices . push ( {
'path' : path
} ) ;
2020-01-19 20:08:48 +00:00
} else {
2021-01-18 17:21:37 +01:00
errLogger ( 'Uh oh, no Plejd device!' ) ;
2020-01-17 14:50:58 +00:00
}
}
}
updateSettings ( settings ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( 'Got new settings: ' , settings ) ;
2020-01-17 14:50:58 +00:00
if ( settings . debug ) {
2021-01-18 17:21:37 +01:00
debug = true ;
2020-06-12 11:15:00 +02:00
} else {
2021-01-18 17:21:37 +01:00
debug = false ;
2020-01-17 14:50:58 +00:00
}
}
2021-01-18 17:21:37 +01:00
turnOn ( deviceId , command ) {
const deviceName = ( logVerbose || logDebug ) ? this . _getDeviceName ( deviceId ) : '' ;
infLogger ( ` Plejd got turn on command for ${ deviceName } ( ${ deviceId } ), brightness ${ command . brightness } ${ command . transition ? ` , transition: ${ command . transition } ` : '' } ` ) ;
this . _transitionTo ( deviceId , command . brightness , command . transition , deviceName ) ;
2021-01-01 20:15:00 +01:00
}
2020-01-17 14:50:58 +00:00
2021-01-18 17:21:37 +01:00
turnOff ( deviceId , command ) {
const deviceName = ( logVerbose || logDebug ) ? this . _getDeviceName ( deviceId ) : '' ;
infLogger ( ` Plejd got turn off command for ${ deviceName } ( ${ deviceId } ), brightness ${ command . brightness } ${ command . transition ? ` , transition: ${ command . transition } ` : '' } ` ) ;
this . _transitionTo ( deviceId , 0 , command . transition , deviceName ) ;
2021-01-01 20:15:00 +01:00
}
2020-01-17 14:50:58 +00:00
2021-01-18 17:21:37 +01:00
_clearDeviceTransitionTimer ( deviceId ) {
if ( this . bleDeviceTransitionTimers [ deviceId ] ) {
clearInterval ( this . bleDeviceTransitionTimers [ deviceId ] ) ;
2020-01-17 14:50:58 +00:00
}
}
2021-01-18 17:21:37 +01:00
_transitionTo ( deviceId , targetBrightness , transition , deviceName ) {
2021-01-02 10:04:24 +01:00
const initialBrightness = this . plejdDevices [ deviceId ] ? this . plejdDevices [ deviceId ] . state && this . plejdDevices [ deviceId ] . dim : null ;
this . _clearDeviceTransitionTimer ( deviceId ) ;
2020-01-17 14:50:58 +00:00
2021-01-02 10:04:24 +01:00
const isDimmable = this . devices . find ( d => d . id === deviceId ) . dimmable ;
2020-01-17 14:50:58 +00:00
2021-01-01 20:15:00 +01:00
if ( transition > 1 && isDimmable && ( initialBrightness || initialBrightness === 0 ) && ( targetBrightness || targetBrightness === 0 ) && targetBrightness !== initialBrightness ) {
// Transition time set, known initial and target brightness
// Calculate transition interval time based on delta brightness and max steps per second
// During transition, measure actual transition interval time and adjust stepping continously
// If transition <= 1 second, Plejd will do a better job than we can in transitioning so transitioning will be skipped
const deltaBrightness = targetBrightness - initialBrightness ;
const transitionSteps = Math . min ( Math . abs ( deltaBrightness ) , MAX _TRANSITION _STEPS _PER _SECOND * transition ) ;
const transitionInterval = transition * 1000 / transitionSteps ;
2021-01-18 17:21:37 +01:00
dbgLogger ( ` transitioning from ${ initialBrightness } to ${ targetBrightness } ${ transition ? 'in ' + transition + ' seconds' : '' } . ` ) ;
vrbLogger ( ` delta brightness ${ deltaBrightness } , steps ${ transitionSteps } , interval ${ transitionInterval } ms ` ) ;
2021-01-13 01:47:05 +01:00
2021-01-01 20:15:00 +01:00
const dtStart = new Date ( ) ;
let nSteps = 0 ;
2021-01-02 10:04:24 +01:00
this . bleDeviceTransitionTimers [ deviceId ] = setInterval ( ( ) => {
2021-01-18 17:21:37 +01:00
let tElapsedMs = new Date ( ) . getTime ( ) - dtStart . getTime ( ) ;
2021-01-01 20:15:00 +01:00
let tElapsed = tElapsedMs / 1000 ;
2021-01-13 01:47:05 +01:00
2021-01-01 20:15:00 +01:00
if ( tElapsed > transition || tElapsed < 0 ) {
tElapsed = transition ;
2020-01-17 14:50:58 +00:00
}
2021-01-01 20:15:00 +01:00
let newBrightness = parseInt ( initialBrightness + deltaBrightness * tElapsed / transition ) ;
2020-01-17 14:50:58 +00:00
2021-01-01 20:15:00 +01:00
if ( tElapsed === transition ) {
nSteps ++ ;
2021-01-02 10:04:24 +01:00
this . _clearDeviceTransitionTimer ( deviceId ) ;
2021-01-01 20:15:00 +01:00
newBrightness = targetBrightness ;
2021-01-18 17:21:37 +01:00
dbgLogger ( ` Queueing finalize ${ deviceName } ( ${ deviceId } ) transition from ${ initialBrightness } to ${ targetBrightness } in ${ tElapsedMs } ms. Done steps ${ nSteps } . Average interval ${ tElapsedMs / ( nSteps || 1 ) } ms. ` ) ;
this . _setBrightness ( deviceId , newBrightness , true , deviceName ) ;
} else {
2021-01-01 20:15:00 +01:00
nSteps ++ ;
2021-01-18 17:21:37 +01:00
vrbLogger ( ` Queueing dim transition for ${ deviceName } ( ${ deviceId } ) to ${ newBrightness } . Total queue length ${ this . writeQueue . length } ` ) ;
this . _setBrightness ( deviceId , newBrightness , false , deviceName ) ;
2021-01-01 20:15:00 +01:00
}
} , transitionInterval ) ;
2021-01-13 01:47:05 +01:00
}
2021-01-01 20:15:00 +01:00
else {
if ( transition && isDimmable ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( ` Could not transition light change. Either initial value is unknown or change is too small. Requested from ${ initialBrightness } to ${ targetBrightness } ` )
2021-01-01 20:15:00 +01:00
}
2021-01-18 17:21:37 +01:00
this . _setBrightness ( deviceId , targetBrightness , true , deviceName ) ;
2021-01-01 20:15:00 +01:00
}
}
2021-01-18 17:21:37 +01:00
_setBrightness ( deviceId , brightness , shouldRetry , deviceName ) {
let payload = null ;
let log = '' ;
2021-01-02 10:04:24 +01:00
if ( ! brightness && brightness !== 0 ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( ` Queueing turn on ${ deviceName } ( ${ deviceId } ). No brightness specified, setting DIM to previous. ` ) ;
payload = Buffer . from ( ( deviceId ) . toString ( 16 ) . padStart ( 2 , '0' ) + '0110009701' , 'hex' ) ;
log = 'ON' ;
2021-01-13 01:47:05 +01:00
}
2021-01-01 20:15:00 +01:00
else {
if ( brightness <= 0 ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( ` Queueing turn off ${ deviceId } ` ) ;
payload = Buffer . from ( ( deviceId ) . toString ( 16 ) . padStart ( 2 , '0' ) + '0110009700' , 'hex' ) ;
log = 'OFF' ;
}
2021-01-01 20:15:00 +01:00
else {
if ( brightness > 255 ) {
brightness = 255 ;
}
2021-01-13 01:47:05 +01:00
2021-01-18 17:21:37 +01:00
dbgLogger ( ` Queueing ${ deviceId } set brightness to ${ brightness } ` ) ;
const brightnessVal = ( brightness << 8 ) | brightness ;
payload = Buffer . from ( ( deviceId ) . toString ( 16 ) . padStart ( 2 , '0' ) + '0110009801' + ( brightnessVal ) . toString ( 16 ) . padStart ( 4 , '0' ) , 'hex' ) ;
log = ` DIM ${ brightness } ` ;
2021-01-01 20:15:00 +01:00
}
2020-01-17 14:50:58 +00:00
}
2021-01-18 17:21:37 +01:00
this . writeQueue . unshift ( { deviceId , log , shouldRetry , payload } ) ;
2020-02-29 15:54:08 +00:00
}
triggerScene ( sceneIndex ) {
2021-01-18 17:21:37 +01:00
const sceneName = this . _getDeviceName ( sceneIndex ) ;
infLogger ( ` Triggering scene ${ sceneName } ( ${ sceneIndex } ). Scene name might be misleading if there is a device with the same numeric id. ` ) ;
2020-02-29 15:54:08 +00:00
this . sceneManager . executeScene ( sceneIndex , this ) ;
2020-01-17 14:50:58 +00:00
}
2020-01-19 20:08:48 +00:00
async authenticate ( ) {
2021-01-18 17:21:37 +01:00
infLogger ( 'authenticate()' ) ;
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
try {
2021-01-18 17:21:37 +01:00
dbgLogger ( 'Sending challenge to device' ) ;
2020-01-19 20:08:48 +00:00
await this . characteristics . auth . WriteValue ( [ 0 ] , { } ) ;
2021-01-18 17:21:37 +01:00
dbgLogger ( 'Reading response from device' ) ;
2020-01-19 20:08:48 +00:00
const challenge = await this . characteristics . auth . ReadValue ( { } ) ;
2020-01-20 21:32:46 +00:00
const response = this . _createChallengeResponse ( this . cryptoKey , Buffer . from ( challenge ) ) ;
2021-01-18 17:21:37 +01:00
dbgLogger ( 'Responding to authenticate' ) ;
2020-01-19 20:08:48 +00:00
await this . characteristics . auth . WriteValue ( [ ... response ] , { } ) ;
2020-06-12 11:15:00 +02:00
} catch ( err ) {
2021-01-18 17:21:37 +01:00
errLogger ( 'Failed to authenticate: ' , err ) ;
2020-01-19 20:08:48 +00:00
}
2020-01-21 14:24:02 +00:00
// auth done, start ping
2021-01-13 19:43:11 +01:00
this . startPing ( ) ;
this . startWriteQueue ( ) ;
2020-01-21 14:24:02 +00:00
// After we've authenticated, we need to hook up the event listener
// for changes to lastData.
this . characteristics . lastDataProperties . on ( 'PropertiesChanged' , this . onLastDataUpdated . bind ( this ) ) ;
this . characteristics . lastData . StartNotify ( ) ;
2020-01-17 14:50:58 +00:00
}
2021-01-13 01:47:05 +01:00
async throttledInit ( delay ) {
2021-01-18 17:21:37 +01:00
if ( this . initInProgress ) {
dbgLogger ( 'ThrottledInit already in progress. Skipping this call and returning existing promise.' )
return this . initInProgress ;
2021-01-13 01:47:05 +01:00
}
2021-01-18 17:21:37 +01:00
this . initInProgress = new Promise ( ( resolve ) => setTimeout ( async ( ) => {
const result = await this . init ( ) . catch ( ( err ) => { errLogger ( 'TrottledInit exception calling init(). Will re-throw.' , err ) ; throw err ; } ) ;
this . initInProgress = null ;
2021-01-13 01:47:05 +01:00
resolve ( result )
} , delay ) )
2021-01-18 17:21:37 +01:00
return this . initInProgress ;
2021-01-13 01:47:05 +01:00
}
2021-01-18 17:21:37 +01:00
async write ( data ) {
2021-01-13 01:47:05 +01:00
if ( ! data || ! this . plejdService || ! this . characteristics . data ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( 'data, plejdService or characteristics not available. Cannot write()' ) ;
2020-01-24 10:20:39 +01:00
return ;
}
2020-01-19 20:08:48 +00:00
try {
2021-01-18 17:21:37 +01:00
vrbLogger ( ` Sending ${ data . length } byte(s) of data to Plejd ` , data ) ;
2020-01-19 20:08:48 +00:00
const encryptedData = this . _encryptDecrypt ( this . cryptoKey , this . plejdService . addr , data ) ;
await this . characteristics . data . WriteValue ( [ ... encryptedData ] , { } ) ;
2021-01-18 17:21:37 +01:00
return true ;
2020-06-12 11:15:00 +02:00
} catch ( err ) {
2020-03-03 15:59:10 +01:00
if ( err . message === 'In Progress' ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( 'Write failed due to \'In progress\' ' , err ) ;
} else {
dbgLogger ( 'Write failed ' , err ) ;
2020-03-03 15:59:10 +01:00
}
2021-01-13 01:47:05 +01:00
await this . throttledInit ( this . connectionTimeout * 1000 ) ;
2021-01-18 17:21:37 +01:00
return false ;
2020-01-19 20:08:48 +00:00
}
2020-01-17 14:50:58 +00:00
}
2021-01-13 19:43:11 +01:00
startPing ( ) {
2021-01-18 17:21:37 +01:00
infLogger ( 'startPing()' ) ;
2020-01-17 14:50:58 +00:00
clearInterval ( this . pingRef ) ;
this . pingRef = setInterval ( async ( ) => {
2021-01-18 17:21:37 +01:00
vrbLogger ( 'ping' ) ;
2020-01-19 20:08:48 +00:00
await this . ping ( ) ;
2020-01-20 21:32:46 +00:00
} , 3000 ) ;
2020-01-17 14:50:58 +00:00
}
onPingSuccess ( nr ) {
2021-01-18 17:21:37 +01:00
vrbLogger ( 'pong: ' + nr ) ;
2020-01-17 14:50:58 +00:00
}
2020-01-19 20:08:48 +00:00
async onPingFailed ( error ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( 'onPingFailed(' + error + ')' ) ;
infLogger ( 'ping failed, reconnecting.' ) ;
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
clearInterval ( this . pingRef ) ;
await this . init ( ) ;
2020-01-17 14:50:58 +00:00
}
2020-01-19 20:08:48 +00:00
async ping ( ) {
2021-01-18 17:21:37 +01:00
vrbLogger ( 'ping()' ) ;
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
var ping = crypto . randomBytes ( 1 ) ;
let pong = null ;
try {
await this . characteristics . ping . WriteValue ( [ ... ping ] , { } ) ;
pong = await this . characteristics . ping . ReadValue ( { } ) ;
2020-06-12 11:15:00 +02:00
} catch ( err ) {
2021-01-18 17:21:37 +01:00
errLogger ( 'writing to plejd: ' , err ) ;
2020-01-19 20:08:48 +00:00
this . emit ( 'pingFailed' , 'write error' ) ;
2020-01-17 14:50:58 +00:00
return ;
}
2020-01-19 20:08:48 +00:00
if ( ( ( ping [ 0 ] + 1 ) & 0xff ) !== pong [ 0 ] ) {
2021-01-18 17:21:37 +01:00
errLogger ( 'plejd ping failed' ) ;
2020-01-19 20:08:48 +00:00
this . emit ( 'pingFailed' , 'plejd ping failed ' + ping [ 0 ] + ' - ' + pong [ 0 ] ) ;
return ;
}
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
this . emit ( 'pingSuccess' , pong [ 0 ] ) ;
2020-01-17 14:50:58 +00:00
}
2021-01-13 19:43:11 +01:00
startWriteQueue ( ) {
2021-01-18 17:21:37 +01:00
infLogger ( 'startWriteQueue()' ) ;
2021-01-13 19:43:11 +01:00
clearTimeout ( this . writeQueueRef ) ;
2020-02-29 15:54:08 +00:00
2020-03-03 16:22:30 +01:00
this . writeQueueRef = setTimeout ( ( ) => this . runWriteQueue ( ) , this . writeQueueWaitTime ) ;
2020-03-03 15:59:10 +01:00
}
async runWriteQueue ( ) {
2021-01-18 17:21:37 +01:00
try {
while ( this . writeQueue . length > 0 ) {
const queueItem = this . writeQueue . pop ( ) ;
const deviceName = this . _getDeviceName ( queueItem . deviceId ) ;
dbgLogger ( ` Write queue: Processing ${ deviceName } ( ${ queueItem . deviceId } ). Command ${ queueItem . log } . Total queue length: ${ this . writeQueue . length } ` ) ;
if ( this . writeQueue . some ( ( item ) => item . deviceId === queueItem . deviceId ) ) {
vrbLogger ( ` Skipping ${ deviceName } ( ${ queueItem . deviceId } ) ${ queueItem . log } due to more recent command in queue. ` ) ;
continue ; // Skip commands if new ones exist for the same deviceId, but still process all messages in order
}
const success = await this . write ( queueItem . payload ) ;
if ( ! success && queueItem . shouldRetry ) {
queueItem . retryCount = ( queueItem . retryCount || 0 ) + 1 ;
dbgLogger ( 'Will retry command, count failed so far' , queueItem . retryCount ) ;
if ( queueItem . retryCount <= MAX _RETRY _COUNT ) {
this . writeQueue . push ( queueItem ) ; // Add back to top of queue to be processed next;
}
else {
errLogger ( ` Write queue: Exceeed max retry count ( ${ MAX _RETRY _COUNT } ) for ${ deviceName } ( ${ queueItem . deviceId } ). Command ${ queueItem . log } failed. ` ) ;
break ;
}
if ( queueItem . retryCount > 1 ) {
break ; // First retry directly, consecutive after writeQueueWaitTime ms
}
}
}
} catch ( e ) {
errLogger ( 'Error in writeQueue loop, values probably not written to Plejd' , e ) ;
2020-03-03 15:59:10 +01:00
}
2020-03-03 16:22:30 +01:00
this . writeQueueRef = setTimeout ( ( ) => this . runWriteQueue ( ) , this . writeQueueWaitTime ) ;
2020-02-29 15:54:08 +00:00
}
2020-01-19 20:08:48 +00:00
async _processPlejdService ( path , characteristics ) {
const proxyObject = await this . bus . getProxyObject ( BLUEZ _SERVICE _NAME , path ) ;
const service = await proxyObject . getInterface ( GATT _SERVICE _ID ) ;
const properties = await proxyObject . getInterface ( DBUS _PROP _INTERFACE ) ;
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
const uuid = ( await properties . Get ( GATT _SERVICE _ID , 'UUID' ) ) . value ;
if ( uuid !== PLEJD _SERVICE ) {
2021-01-18 17:21:37 +01:00
errLogger ( 'not a Plejd device.' ) ;
2020-01-19 20:08:48 +00:00
return null ;
}
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
const dev = ( await properties . Get ( GATT _SERVICE _ID , 'Device' ) ) . value ;
const regex = /dev_([0-9A-F_]+)$/ ;
const dirtyAddr = regex . exec ( dev ) ;
const addr = this . _reverseBuffer (
Buffer . from (
String ( dirtyAddr [ 1 ] )
2020-06-12 11:15:00 +02:00
. replace ( /\-/g , '' )
. replace ( /\_/g , '' )
. replace ( /\:/g , '' ) , 'hex'
2020-01-19 20:08:48 +00:00
)
) ;
for ( const chPath of characteristics ) {
const chProxyObject = await this . bus . getProxyObject ( BLUEZ _SERVICE _NAME , chPath ) ;
const ch = await chProxyObject . getInterface ( GATT _CHRC _ID ) ;
const prop = await chProxyObject . getInterface ( DBUS _PROP _INTERFACE ) ;
const chUuid = ( await prop . Get ( GATT _CHRC _ID , 'UUID' ) ) . value ;
if ( chUuid === DATA _UUID ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( 'found DATA characteristic.' ) ;
2020-01-19 20:08:48 +00:00
this . characteristics . data = ch ;
2020-06-12 11:15:00 +02:00
} else if ( chUuid === LAST _DATA _UUID ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( 'found LAST_DATA characteristic.' ) ;
2020-01-19 20:08:48 +00:00
this . characteristics . lastData = ch ;
this . characteristics . lastDataProperties = prop ;
2020-06-12 11:15:00 +02:00
} else if ( chUuid === AUTH _UUID ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( 'found AUTH characteristic.' ) ;
2020-01-19 20:08:48 +00:00
this . characteristics . auth = ch ;
2020-06-12 11:15:00 +02:00
} else if ( chUuid === PING _UUID ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( 'found PING characteristic.' ) ;
2020-01-19 20:08:48 +00:00
this . characteristics . ping = ch ;
}
}
return {
2020-01-20 10:58:03 +00:00
addr : addr
2020-01-19 20:08:48 +00:00
} ;
2020-01-17 14:50:58 +00:00
}
2020-01-19 20:08:48 +00:00
async onDeviceConnected ( device ) {
2021-01-18 17:21:37 +01:00
infLogger ( 'onDeviceConnected()' ) ;
dbgLogger ( 'Device: ' , device ) ;
if ( ! device ) {
errLogger ( 'Device is null. Should we break/return when this happens?' ) ;
}
2020-01-19 20:08:48 +00:00
const objects = await this . objectManager . GetManagedObjects ( ) ;
2020-01-24 17:30:17 +00:00
const paths = Object . keys ( objects ) ;
2020-01-19 20:08:48 +00:00
let characteristics = [ ] ;
2020-01-24 17:30:17 +00:00
for ( const path of paths ) {
2020-01-19 20:08:48 +00:00
const interfaces = Object . keys ( objects [ path ] ) ;
if ( interfaces . indexOf ( GATT _CHRC _ID ) > - 1 ) {
characteristics . push ( path ) ;
}
2020-01-17 14:50:58 +00:00
}
2020-01-24 17:30:17 +00:00
for ( const path of paths ) {
2020-01-19 20:08:48 +00:00
const interfaces = Object . keys ( objects [ path ] ) ;
if ( interfaces . indexOf ( GATT _SERVICE _ID ) > - 1 ) {
let chPaths = [ ] ;
for ( const c of characteristics ) {
2020-01-24 17:30:17 +00:00
if ( c . startsWith ( path + '/' ) ) {
2020-01-19 20:08:48 +00:00
chPaths . push ( c ) ;
}
}
2021-01-18 17:21:37 +01:00
infLogger ( 'trying ' + chPaths . length + ' characteristics' ) ;
2020-01-17 14:50:58 +00:00
2020-01-19 20:08:48 +00:00
this . plejdService = await this . _processPlejdService ( path , chPaths ) ;
if ( this . plejdService ) {
break ;
}
}
}
if ( ! this . plejdService ) {
2021-01-18 17:21:37 +01:00
infLogger ( 'warning: wasn\'t able to connect to Plejd, will retry.' ) ;
2020-01-20 07:58:23 +00:00
this . emit ( 'connectFailed' ) ;
2020-01-17 14:50:58 +00:00
return ;
}
2020-01-24 10:20:39 +01:00
if ( ! this . characteristics . auth ) {
2021-01-18 17:21:37 +01:00
errLogger ( 'unable to enumerate characteristics.' ) ;
2020-01-24 10:20:39 +01:00
this . emit ( 'connectFailed' ) ;
return ;
}
2020-02-29 15:54:08 +00:00
this . connectedDevice = device [ 'device' ] ;
2020-01-21 14:24:02 +00:00
await this . authenticate ( ) ;
2020-01-17 14:50:58 +00:00
}
2020-01-20 10:58:03 +00:00
async onLastDataUpdated ( iface , properties , invalidated ) {
2020-01-20 07:58:23 +00:00
if ( iface !== GATT _CHRC _ID ) {
return ;
}
2020-01-20 10:58:03 +00:00
2020-01-20 07:58:23 +00:00
const changedKeys = Object . keys ( properties ) ;
if ( changedKeys . length === 0 ) {
return ;
}
2020-01-20 10:58:03 +00:00
const value = await properties [ 'Value' ] ;
if ( ! value ) {
return ;
}
2020-01-20 07:58:23 +00:00
const data = value . value ;
2020-01-19 20:08:48 +00:00
const decoded = this . _encryptDecrypt ( this . cryptoKey , this . plejdService . addr , data ) ;
2020-01-17 14:50:58 +00:00
2021-01-18 17:21:37 +01:00
const deviceId = parseInt ( decoded [ 0 ] , 10 ) ;
// What is bytes 2-3?
const cmd = decoded . toString ( 'hex' , 3 , 5 ) ;
const state = parseInt ( decoded . toString ( 'hex' , 5 , 6 ) , 10 ) ; // Overflows for command 0x001b, scene command
const data2 = parseInt ( decoded . toString ( 'hex' , 6 , 8 ) , 16 ) >> 8 ;
2020-01-17 14:50:58 +00:00
if ( decoded . length < 5 ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( 'Too short raw event ignored: ' , decoded . toString ( 'hex' ) ) ;
2020-01-17 14:50:58 +00:00
// ignore the notification since too small
return ;
}
2021-01-18 17:21:37 +01:00
const deviceName = ( logVerbose || logDebug ) ? this . _getDeviceName ( deviceId ) : '' ;
vrbLogger ( 'Raw event received: ' , decoded . toString ( 'hex' ) ) ;
vrbLogger ( ` Device ${ deviceId } , cmd ${ cmd . toString ( 'hex' ) } , state ${ state } , dim/data2 ${ data2 } ` ) ;
2020-01-17 14:50:58 +00:00
if ( cmd === BLE _CMD _DIM _CHANGE || cmd === BLE _CMD _DIM2 _CHANGE ) {
2021-01-18 17:21:37 +01:00
const dim = data2 ;
2020-01-17 14:50:58 +00:00
2021-01-18 17:21:37 +01:00
dbgLogger ( ` ${ deviceName } ( ${ deviceId } ) got state+dim update. S: ${ state } , D: ${ dim } ` ) ;
2020-06-12 11:15:00 +02:00
2021-01-18 17:21:37 +01:00
this . emit ( 'stateChanged' , deviceId , {
2020-06-12 11:15:00 +02:00
state : state ,
brightness : dim
} ) ;
2021-01-18 17:21:37 +01:00
this . plejdDevices [ deviceId ] = {
state : state ,
dim : dim
} ;
vrbLogger ( 'All states: ' , this . plejdDevices ) ;
2020-06-12 11:15:00 +02:00
} else if ( cmd === BLE _CMD _STATE _CHANGE ) {
2021-01-18 17:21:37 +01:00
dbgLogger ( ` ${ deviceName } ( ${ deviceId } ) got state update. S: ${ state } ` ) ;
this . emit ( 'stateChanged' , deviceId , {
2020-06-12 11:15:00 +02:00
state : state
} ) ;
2021-01-18 17:21:37 +01:00
this . plejdDevices [ deviceId ] = {
state : state ,
dim : 0
} ;
vrbLogger ( 'All states: ' , this . plejdDevices ) ;
2020-06-12 11:15:00 +02:00
} else if ( cmd === BLE _CMD _SCENE _TRIG ) {
2021-01-18 17:21:37 +01:00
const sceneId = parseInt ( decoded . toString ( 'hex' , 5 , 6 ) , 16 ) ;
const sceneName = this . _getDeviceName ( sceneId ) ;
2020-01-17 14:50:58 +00:00
2021-01-18 17:21:37 +01:00
dbgLogger ( ` ${ sceneName } ( ${ sceneId } ) scene triggered (device id ${ deviceId } ). Name can be misleading if there is a device with the same numeric id. ` ) ;
this . emit ( 'sceneTriggered' , deviceId , sceneId ) ;
}
else if ( cmd === '001b' ) {
// vrbLogger('Command 001b seems to be some kind of often repeating ping/mesh data');
}
else {
vrbLogger ( ` Command ${ cmd . toString ( 'hex' ) } unknown. Device ${ deviceName } ( ${ deviceId } ) ` ) ;
}
2020-01-17 14:50:58 +00:00
}
wireEvents ( ) {
2021-01-18 17:21:37 +01:00
infLogger ( 'wireEvents()' ) ;
2020-01-17 14:50:58 +00:00
const self = this ;
this . on ( 'pingFailed' , this . onPingFailed . bind ( self ) ) ;
this . on ( 'pingSuccess' , this . onPingSuccess . bind ( self ) ) ;
}
_createChallengeResponse ( key , challenge ) {
const intermediate = crypto . createHash ( 'sha256' ) . update ( xor ( key , challenge ) ) . digest ( ) ;
const part1 = intermediate . subarray ( 0 , 16 ) ;
const part2 = intermediate . subarray ( 16 ) ;
const resp = xor ( part1 , part2 ) ;
return resp ;
}
_encryptDecrypt ( key , addr , data ) {
var buf = Buffer . concat ( [ addr , addr , addr . subarray ( 0 , 4 ) ] ) ;
2021-01-01 20:15:00 +01:00
var cipher = crypto . createCipheriv ( 'aes-128-ecb' , key , '' ) ;
2020-01-17 14:50:58 +00:00
cipher . setAutoPadding ( false ) ;
var ct = cipher . update ( buf ) . toString ( 'hex' ) ;
ct += cipher . final ( ) . toString ( 'hex' ) ;
ct = Buffer . from ( ct , 'hex' ) ;
2021-01-01 20:15:00 +01:00
var output = '' ;
2020-01-17 14:50:58 +00:00
for ( var i = 0 , length = data . length ; i < length ; i ++ ) {
output += String . fromCharCode ( data [ i ] ^ ct [ i % 16 ] ) ;
}
return Buffer . from ( output , 'ascii' ) ;
}
2021-01-18 17:21:37 +01:00
_getDeviceName ( deviceId ) {
return ( this . devices . find ( d => d . id === deviceId ) || { } ) . name ;
}
2020-01-17 14:50:58 +00:00
_reverseBuffer ( src ) {
var buffer = Buffer . allocUnsafe ( src . length )
for ( var i = 0 , j = src . length - 1 ; i <= j ; ++ i , -- j ) {
buffer [ i ] = src [ j ]
buffer [ j ] = src [ i ]
}
return buffer
}
}
2021-01-13 01:47:05 +01:00
module . exports = PlejdService ;