const dbus = require('dbus-next'); const crypto = require('crypto'); const xor = require('buffer-xor'); const EventEmitter = require('events'); const Configuration = require('./Configuration'); const constants = require('./constants'); const Logger = require('./Logger'); const { COMMANDS } = constants; const logger = Logger.getLogger('plejd-ble'); // UUIDs const BLE_UUID_SUFFIX = '6085-4726-be45-040c957391b5'; const PLEJD_SERVICE = `31ba0001-${BLE_UUID_SUFFIX}`; const DATA_UUID = `31ba0004-${BLE_UUID_SUFFIX}`; const LAST_DATA_UUID = `31ba0005-${BLE_UUID_SUFFIX}`; const AUTH_UUID = `31ba0009-${BLE_UUID_SUFFIX}`; const PING_UUID = `31ba000a-${BLE_UUID_SUFFIX}`; const BLE_CMD_DIM_CHANGE = 0x00c8; const BLE_CMD_DIM2_CHANGE = 0x0098; const BLE_CMD_STATE_CHANGE = 0x0097; const BLE_CMD_SCENE_TRIG = 0x0021; const BLE_CMD_TIME_UPDATE = 0x001b; const BLE_BROADCAST_DEVICE_ID = 0x01; const BLE_REQUEST_NO_RESPONSE = 0x0110; const BLE_REQUEST_RESPONSE = 0x0102; // const BLE_REQUEST_READ_VALUE = 0x0103; const BLUEZ_SERVICE_NAME = 'org.bluez'; const DBUS_OM_INTERFACE = 'org.freedesktop.DBus.ObjectManager'; const DBUS_PROP_INTERFACE = 'org.freedesktop.DBus.Properties'; 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'; const delay = (timeout) => new Promise((resolve) => setTimeout(resolve, timeout)); class PlejBLEHandler extends EventEmitter { adapter; adapterProperties; config; bleDevices = []; bus = null; connectedDevice = null; consecutiveWriteFails; consecutiveReconnectAttempts = 0; discoveryTimeout = null; plejdService = null; pingRef = null; requestCurrentPlejdTimeRef = null; reconnectInProgress = false; emergencyReconnectTimeout = null; // Refer to BLE-states.md regarding the internal BLE/bluez state machine of Bluetooth states // These states refer to the state machine of this file static STATES = ['MAIN_INIT', 'GET_ADAPTER_PROXY']; static EVENTS = { connected: 'connected', reconnecting: 'reconnecting', commandReceived: 'commandReceived', writeFailed: 'writeFailed', writeSuccess: 'writeSuccess', }; constructor(deviceRegistry) { super(); logger.info('Starting Plejd BLE Handler, resetting all device states.'); this.config = Configuration.getOptions(); this.deviceRegistry = deviceRegistry; // Holds a reference to all characteristics this.characteristics = { data: null, lastData: null, lastDataProperties: null, auth: null, ping: null, }; this.bus = dbus.systemBus(); } cleanup() { logger.verbose('cleanup() - Clearing ping interval and clock update timer'); clearInterval(this.pingRef); clearTimeout(this.requestCurrentPlejdTimeRef); logger.verbose('Removing listeners to write events, bus events and objectManager...'); this.removeAllListeners(PlejBLEHandler.EVENTS.writeFailed); this.removeAllListeners(PlejBLEHandler.EVENTS.writeSuccess); if (this.bus) { this.bus.removeAllListeners('error'); this.bus.removeAllListeners('connect'); } if (this.characteristics.lastDataProperties) { this.characteristics.lastDataProperties.removeAllListeners('PropertiesChanged'); } if (this.objectManager) { this.objectManager.removeAllListeners('InterfacesAdded'); } } async init() { logger.info('init()'); this.on(PlejBLEHandler.EVENTS.writeFailed, (error) => this._onWriteFailed(error)); this.on(PlejBLEHandler.EVENTS.writeSuccess, () => this._onWriteSuccess()); this.bus.on('error', (err) => { // Uncaught error events will show UnhandledPromiseRejection logs logger.verbose(`dbus-next error event: ${err.message}`); }); this.bus.on('connect', () => { logger.verbose('dbus-next connected'); }); // this.bus also has a 'message' event that gets emitted _very_ frequently this.adapter = null; this.adapterProperties = null; this.consecutiveWriteFails = 0; this.cryptoKey = Buffer.from(this.deviceRegistry.cryptoKey.replace(/-/g, ''), 'hex'); if (this.objectManager) { this.objectManager.removeAllListeners(); } this.bleDevices = []; this.connectedDevice = null; this.characteristics = { data: null, lastData: null, lastDataProperties: null, auth: null, ping: null, }; await this._getInterface(); await this._startGetPlejdDevice(); logger.info('BLE init done, waiting for devices.'); } async sendCommand(command, deviceId, data) { let payload; let brightnessVal; switch (command) { case COMMANDS.TURN_ON: payload = this._createHexPayload(deviceId, BLE_CMD_STATE_CHANGE, '01'); break; case COMMANDS.TURN_OFF: payload = this._createHexPayload(deviceId, BLE_CMD_STATE_CHANGE, '00'); break; case COMMANDS.DIM: // eslint-disable-next-line no-bitwise brightnessVal = (data << 8) | data; payload = this._createHexPayload( deviceId, BLE_CMD_DIM2_CHANGE, `01${brightnessVal.toString(16).padStart(4, '0')}`, ); break; default: logger.error(`Unknown command ${command}`); throw new Error(`Unknown command ${command}`); } await this._write(payload); } async _initDiscoveredPlejdDevice(path) { logger.debug(`initDiscoveredPlejdDevice(). Got ${path} device`); logger.debug(`Inspecting ${path}`); try { const proxyObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, path); const device = await proxyObject.getInterface(BLUEZ_DEVICE_ID); const properties = await proxyObject.getInterface(DBUS_PROP_INTERFACE); const plejd = { path }; plejd.rssi = (await properties.Get(BLUEZ_DEVICE_ID, 'RSSI')).value; plejd.instance = device; const segments = plejd.path.split('/'); let fixedPlejdPath = segments[segments.length - 1].replace('dev_', ''); fixedPlejdPath = fixedPlejdPath.replace(/_/g, ''); plejd.device = this.deviceRegistry.getDeviceBySerialNumber(fixedPlejdPath); if (plejd.device) { logger.debug( `Discovered ${plejd.path} with rssi ${plejd.rssi} dBm, name ${plejd.device.name}`, ); this.bleDevices.push(plejd); } else { logger.warn(`Device registry does not contain device with serial ${fixedPlejdPath}`); } } catch (err) { logger.error(`Failed inspecting ${path}. `, err); } } async _inspectDevicesDiscovered() { try { if (this.bleDevices.length === 0) { logger.error('Discovery timeout elapsed, no devices found. Starting reconnect loop...'); throw new Error('Discovery timeout elapsed'); } logger.info(`Device discovery done, found ${this.bleDevices.length} Plejd devices`); const sortedDevices = this.bleDevices.sort((a, b) => b.rssi - a.rssi); // eslint-disable-next-line no-restricted-syntax for (const plejd of sortedDevices) { try { logger.verbose(`Inspecting ${plejd.path}`); if (plejd.instance) { logger.info(`Connecting to ${plejd.path}`); // eslint-disable-next-line no-await-in-loop await plejd.instance.Connect(); logger.verbose('Connected. Waiting for timeout before reading characteristics...'); // eslint-disable-next-line no-await-in-loop await delay(this.config.connectionTimeout * 1000); // eslint-disable-next-line no-await-in-loop const connectedPlejdDevice = await this._onDeviceConnected(plejd); if (connectedPlejdDevice) { break; } } } catch (err) { logger.warn('Unable to connect. ', err); } } try { logger.verbose('Stopping discovery...'); await this.adapter.StopDiscovery(); logger.verbose('Stopped BLE discovery'); } catch (err) { logger.error('Failed to stop discovery.', err); if (err.message.includes('Operation already in progress')) { logger.info( 'If you continue to get "operation already in progress" error, you can try power cycling the bluetooth adapter. Get root console access, run "bluetoothctl" => "power off" => "power on" => "exit" => restart addon.', ); try { await delay(250); logger.verbose('Power cycling...'); await this._powerCycleAdapter(); logger.verbose('Trying again...'); await this._startGetPlejdDevice(); } catch (errInner) { logger.error('Failed to retry internalInit. Starting reconnect loop', errInner); throw new Error('Failed to retry internalInit'); } } logger.error('Failed to start discovery. Make sure no other add-on is currently scanning.'); throw new Error('Failed to start discovery'); } if (!this.connectedDevice) { logger.error('Could not connect to any Plejd device. Starting reconnect loop...'); throw new Error('Could not connect to any Plejd device'); } logger.info(`BLE Connected to ${this.connectedDevice.name}`); // Connected and authenticated, request current time and start ping if (this.config.updatePlejdClock) { this._requestCurrentPlejdTime(); } else { logger.info('Plejd clock updates disabled in configuration.'); } this._startPing(); // After we've authenticated, we need to hook up the event listener // for changes to lastData. this.characteristics.lastDataProperties.on('PropertiesChanged', ( iface, properties, // invalidated (third param), ) => this._onLastDataUpdated(iface, properties)); this.characteristics.lastData.StartNotify(); this.consecutiveReconnectAttempts = 0; this.emit(PlejBLEHandler.EVENTS.connected); clearTimeout(this.emergencyReconnectTimeout); this.emergencyReconnectTimeout = null; } catch (err) { // This method is run on a timer, so errors can't e re-thrown. // Start reconnect loop if errors occur here logger.debug(`Starting reconnect loop due to ${err.message}`); this.startReconnectPeriodicallyLoop(); } } async _getInterface() { const bluez = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, '/'); this.objectManager = await bluez.getInterface(DBUS_OM_INTERFACE); // We need to find the ble interface which implements the Adapter1 interface const managedObjects = await this.objectManager.GetManagedObjects(); const managedPaths = Object.keys(managedObjects); logger.verbose(`Managed paths${JSON.stringify(managedPaths, null, 2)}`); // eslint-disable-next-line no-restricted-syntax for (const path of managedPaths) { const pathInterfaces = Object.keys(managedObjects[path]); if (pathInterfaces.indexOf(BLUEZ_ADAPTER_ID) > -1) { logger.debug(`Found BLE interface '${BLUEZ_ADAPTER_ID}' at ${path}`); try { // eslint-disable-next-line no-await-in-loop const adapterObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, path); // eslint-disable-next-line no-await-in-loop this.adapterProperties = await adapterObject.getInterface(DBUS_PROP_INTERFACE); // eslint-disable-next-line no-await-in-loop await this._powerOnAdapter(); this.adapter = adapterObject.getInterface(BLUEZ_ADAPTER_ID); // eslint-disable-next-line no-await-in-loop await this._cleanExistingConnections(managedObjects); logger.verbose(`Got adapter ${this.adapter.path}`); return this.adapter; } catch (err) { logger.error(`Failed to get interface '${BLUEZ_ADAPTER_ID}'. `, err); } } } this.adapter = null; logger.error('Unable to find a bluetooth adapter that is compatible.'); throw new Error('Unable to find a bluetooth adapter that is compatible.'); } async _powerCycleAdapter() { logger.verbose('Power cycling BLE adapter'); await this._powerOffAdapter(); await this._powerOnAdapter(); } async _powerOnAdapter() { logger.verbose('Powering on BLE adapter and waiting 5 seconds'); await this.adapterProperties.Set(BLUEZ_ADAPTER_ID, 'Powered', new dbus.Variant('b', 1)); await delay(5000); } async _powerOffAdapter() { logger.verbose('Powering off BLE adapter and waiting 30 seconds'); await this.adapterProperties.Set(BLUEZ_ADAPTER_ID, 'Powered', new dbus.Variant('b', 0)); await delay(30000); } async _cleanExistingConnections(managedObjects) { logger.verbose( `Iterating ${ Object.keys(managedObjects).length } BLE managedObjects looking for ${BLUEZ_DEVICE_ID}`, ); // eslint-disable-next-line no-restricted-syntax for (const path of Object.keys(managedObjects)) { /* eslint-disable no-await-in-loop */ try { const interfaces = Object.keys(managedObjects[path]); 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); logger.verbose(`Found ${path}`); const connected = managedObjects[path][BLUEZ_DEVICE_ID].Connected.value; if (connected) { logger.info(`disconnecting ${path}. This can take up to 180 seconds`); await device.Disconnect(); } logger.verbose(`Removing ${path} from adapter.`); await this.adapter.RemoveDevice(path); } } catch (err) { logger.error(`Error handling ${path}`, err); } /* eslint-enable no-await-in-loop */ } logger.verbose('All active BLE device connections cleaned up.'); } async _startGetPlejdDevice() { logger.verbose('Setting up interfacesAdded subscription and discovery filter'); this.objectManager.on('InterfacesAdded', (path, interfaces) => this._onInterfacesAdded(path, interfaces)); this.adapter.SetDiscoveryFilter({ UUIDs: new dbus.Variant('as', [PLEJD_SERVICE]), Transport: new dbus.Variant('s', 'le'), }); try { logger.verbose('Starting BLE discovery... This can take up to 180 seconds.'); this._scheduleInternalInit(); await this.adapter.StartDiscovery(); logger.verbose('Started BLE discovery'); } catch (err) { logger.error('Failed to start discovery.', err); if (err.message.includes('Operation already in progress')) { logger.info( 'If you continue to get "operation already in progress" error, you can try power cycling the bluetooth adapter. Get root console access, run "bluetoothctl" => "power off" => "power on" => "exit" => restart addon.', ); } throw new Error( 'Failed to start discovery. Make sure no other add-on is currently scanning.', ); } } _scheduleInternalInit() { clearTimeout(this.discoveryTimeout); this.discoveryTimeout = setTimeout( () => this._inspectDevicesDiscovered(), this.config.connectionTimeout * 1000, ); } async _onInterfacesAdded(path, interfaces) { logger.silly(`Interface added ${path}, inspecting...`); const interfaceKeys = Object.keys(interfaces); if (interfaceKeys.indexOf(BLUEZ_DEVICE_ID) > -1) { if (interfaces[BLUEZ_DEVICE_ID].UUIDs.value.indexOf(PLEJD_SERVICE) > -1) { logger.debug(`Found Plejd service on ${path}`); this.objectManager.removeAllListeners('InterfacesAdded'); await this._initDiscoveredPlejdDevice(path); } else { logger.error('Uh oh, no Plejd device!'); } } else { logger.silly('Not the right device id'); } } async _authenticate() { logger.info('authenticate()'); try { logger.debug('Sending challenge to device'); await this.characteristics.auth.WriteValue([0], {}); logger.debug('Reading response from device'); const challenge = await this.characteristics.auth.ReadValue({}); const response = this._createChallengeResponse(this.cryptoKey, Buffer.from(challenge)); logger.debug('Responding to authenticate'); await this.characteristics.auth.WriteValue([...response], {}); } catch (err) { logger.error('Failed to authenticate: ', err); throw new Error('Failed to authenticate'); } } async startReconnectPeriodicallyLoop() { logger.info('Starting reconnect loop...'); clearTimeout(this.emergencyReconnectTimeout); this.emergencyReconnectTimeout = null; await this._startReconnectPeriodicallyLoopInternal(); } async _startReconnectPeriodicallyLoopInternal() { logger.verbose('Starting internal reconnect loop...'); if (this.reconnectInProgress && !this.emergencyReconnectTimeout) { logger.debug('Reconnect already in progress. Skipping this call.'); return; } if (this.emergencyReconnectTimeout) { logger.warn( 'Restarting reconnect loop due to emergency reconnect timer elapsed. This should very rarely happen!', ); } this.reconnectInProgress = true; /* eslint-disable no-await-in-loop */ // eslint-disable-next-line no-constant-condition while (true) { try { logger.verbose('Reconnect: Clean up, emit reconnect event, wait 5s and the re-init...'); this.cleanup(); this.consecutiveReconnectAttempts++; if (this.consecutiveReconnectAttempts % 100 === 0) { logger.error('Failed reconnecting 100 times. Creating a new dbus instance...'); this.bus = dbus.systemBus(); } if (this.consecutiveReconnectAttempts % 10 === 0) { logger.warn( `Tried reconnecting ${this.consecutiveReconnectAttempts} times. Will power cycle the BLE adapter now...`, ); await this._powerCycleAdapter(); } else { logger.verbose( `Reconnect attempt ${this.consecutiveReconnectAttempts} in a row. Will power cycle every 10th time.`, ); } this.emit(PlejBLEHandler.EVENTS.reconnecting); // Emergency 2 minute timer if reconnect silently fails somewhere clearTimeout(this.emergencyReconnectTimeout); this.emergencyReconnectTimeout = setTimeout( () => this._startReconnectPeriodicallyLoopInternal(), 120 * 1000, ); await delay(5000); logger.info('Reconnecting BLE...'); await this.init(); break; } catch (err) { logger.warn('Failed reconnecting.', err); } } /* eslint-enable no-await-in-loop */ this.reconnectInProgress = false; } async _write(payload) { if (!payload || !this.plejdService || !this.characteristics.data) { logger.debug('data, plejdService or characteristics not available. Cannot write()'); throw new Error('data, plejdService or characteristics not available. Cannot write()'); } try { logger.verbose( `Sending ${payload.length} byte(s) of data to Plejd. ${payload.toString('hex')}`, ); const encryptedData = this._encryptDecrypt(this.cryptoKey, this.plejdService.addr, payload); await this.characteristics.data.WriteValue([...encryptedData], {}); await this._onWriteSuccess(); } catch (err) { await this._onWriteFailed(err); if (err.message === 'In Progress') { logger.debug("Write failed due to 'In progress' ", err); throw new Error("Write failed due to 'In progress'"); } logger.debug('Write failed ', err); throw new Error(`Write failed due to ${err.message}`); } } _startPing() { logger.info('startPing()'); clearInterval(this.pingRef); this.pingRef = setInterval(async () => { logger.silly('ping'); await this._ping(); }, 3000); } // eslint-disable-next-line class-methods-use-this _onWriteSuccess() { this.consecutiveWriteFails = 0; } async _onWriteFailed(error) { this.consecutiveWriteFails++; logger.debug(`onWriteFailed #${this.consecutiveWriteFails} in a row.`, error); logger.verbose(`Error message: ${error.message}`); let errorIndicatesDisconnected = false; if (error.message.includes('error: 0x0e')) { logger.error("'Unlikely error' (0x0e) writing to Plejd. Will retry.", error); } else if (error.message.includes('Not connected')) { logger.error("'Not connected' writing to Plejd. Plejd device is probably disconnected."); errorIndicatesDisconnected = true; } else if (error.message.includes('Method "WriteValue" with signature')) { logger.error("'Method \"WriteValue\" doesn't exist'. Plejd device is probably disconnected."); errorIndicatesDisconnected = true; } if (errorIndicatesDisconnected || this.consecutiveWriteFails >= 5) { logger.warn( `Write error indicates BLE is disconnected. Retry count ${this.consecutiveWriteFails}. Reconnecting...`, ); this.startReconnectPeriodicallyLoop(); } } async _ping() { logger.silly('ping()'); const ping = crypto.randomBytes(1); let pong = null; try { await this.characteristics.ping.WriteValue([...ping], {}); pong = await this.characteristics.ping.ReadValue({}); } catch (err) { logger.verbose(`Error pinging Plejd, calling onWriteFailed... ${err.message}`); await this._onWriteFailed(err); return; } // eslint-disable-next-line no-bitwise if (((ping[0] + 1) & 0xff) !== pong[0]) { logger.verbose('Plejd ping failed, pong contains wrong data. Calling onWriteFailed...'); await this._onWriteFailed(new Error(`plejd ping failed ${ping[0]} - ${pong[0]}`)); return; } logger.silly(`pong: ${pong[0]}`); await this._onWriteSuccess(); } async _requestCurrentPlejdTime() { if (!this.connectedDevice) { logger.warn('Cannot request current Plejd time, not connected.'); return; } logger.info('Requesting current Plejd time...'); const payload = this._createHexPayload( this.connectedDevice.id, BLE_CMD_TIME_UPDATE, '', BLE_REQUEST_RESPONSE, ); try { this._write(payload); } catch (error) { logger.warn('Failed requesting time update from Plejd'); } clearTimeout(this.requestCurrentPlejdTimeRef); this.requestCurrentPlejdTimeRef = setTimeout( () => this._requestCurrentPlejdTime(), 1000 * 3600, ); // Once per hour } async _processPlejdService(path, characteristics) { const proxyObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, path); const properties = await proxyObject.getInterface(DBUS_PROP_INTERFACE); const uuid = (await properties.Get(GATT_SERVICE_ID, 'UUID')).value; if (uuid !== PLEJD_SERVICE) { logger.error('not a Plejd device.'); return null; } 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]).replace(/-/g, '').replace(/_/g, '').replace(/:/g, ''), 'hex', ), ); // eslint-disable-next-line no-restricted-syntax for (const chPath of characteristics) { /* eslint-disable no-await-in-loop */ 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) { logger.verbose('found DATA characteristic.'); this.characteristics.data = ch; } else if (chUuid === LAST_DATA_UUID) { logger.verbose('found LAST_DATA characteristic.'); this.characteristics.lastData = ch; this.characteristics.lastDataProperties = prop; } else if (chUuid === AUTH_UUID) { logger.verbose('found AUTH characteristic.'); this.characteristics.auth = ch; } else if (chUuid === PING_UUID) { logger.verbose('found PING characteristic.'); this.characteristics.ping = ch; } /* eslint-eslint no-await-in-loop */ } return { addr, }; } async _onDeviceConnected(device) { this.connectedDevice = null; logger.info('onDeviceConnected()'); logger.debug(`Device ${device.path}, ${JSON.stringify(device.device)}`); const objects = await this.objectManager.GetManagedObjects(); const paths = Object.keys(objects); const characteristics = []; logger.verbose(`Iterating connected devices looking for ${GATT_CHRC_ID}`); // eslint-disable-next-line no-restricted-syntax for (const path of paths) { const interfaces = Object.keys(objects[path]); logger.verbose(`Interfaces ${path}: ${JSON.stringify(interfaces)}`); if (interfaces.indexOf(GATT_CHRC_ID) > -1) { characteristics.push(path); } } logger.verbose(`Characteristics found: ${JSON.stringify(characteristics)}`); // eslint-disable-next-line no-restricted-syntax for (const path of paths) { const interfaces = Object.keys(objects[path]); if (interfaces.indexOf(GATT_SERVICE_ID) > -1) { const chPaths = []; // eslint-disable-next-line no-restricted-syntax for (const c of characteristics) { if (c.startsWith(`${path}/`)) { chPaths.push(c); } } logger.verbose(`Trying ${chPaths.length} characteristics on ${path}...`); this.plejdService = await this._processPlejdService(path, chPaths); if (this.plejdService) { break; } } } if (!this.plejdService) { logger.warn("Wasn't able to connect to Plejd, will retry."); return null; } if (!this.characteristics.auth) { logger.error('unable to enumerate characteristics.'); return null; } logger.info('Connected device is a Plejd device with the right characteristics.'); this.connectedDevice = device.device; await this._authenticate(); return this.connectedDevice; } // eslint-disable-next-line no-unused-vars async _onLastDataUpdated(iface, properties) { if (iface !== GATT_CHRC_ID) { return; } const changedKeys = Object.keys(properties); if (changedKeys.length === 0) { return; } const value = await properties.Value; if (!value) { return; } const encryptedData = value.value; const decoded = this._encryptDecrypt(this.cryptoKey, this.plejdService.addr, encryptedData); if (decoded.length < 5) { if (Logger.shouldLog('debug')) { // decoded.toString() could potentially be expensive logger.verbose(`Too short raw event ignored: ${decoded.toString('hex')}`); } // ignore the notification since too small return; } const deviceId = decoded.readUInt8(0); // Bytes 2-3 is Command/Request const cmd = decoded.readUInt16BE(3); const state = decoded.length > 5 ? decoded.readUInt8(5) : 0; const dim = decoded.length > 7 ? decoded.readUInt8(7) : 0; if (Logger.shouldLog('silly')) { // Full dim level is 2 bytes, we could potentially use this const dimFull = decoded.length > 7 ? decoded.readUInt16LE(6) : 0; logger.silly(`Dim: ${dim.toString(16)}, full precision: ${dimFull.toString(16)}`); } const deviceName = this.deviceRegistry.getDeviceName(deviceId); if (Logger.shouldLog('verbose')) { // decoded.toString() could potentially be expensive logger.verbose(`Raw event received: ${decoded.toString('hex')}`); logger.verbose( `Decoded: Device ${deviceId}, cmd ${cmd.toString(16)}, state ${state}, dim ${dim}`, ); } let command; let data = {}; if (cmd === BLE_CMD_DIM_CHANGE || cmd === BLE_CMD_DIM2_CHANGE) { logger.debug(`${deviceName} (${deviceId}) got state+dim update. S: ${state}, D: ${dim}`); command = COMMANDS.DIM; data = { state, dim }; this.emit(PlejBLEHandler.EVENTS.commandReceived, deviceId, command, data); } else if (cmd === BLE_CMD_STATE_CHANGE) { logger.debug(`${deviceName} (${deviceId}) got state update. S: ${state}`); command = state ? COMMANDS.TURN_ON : COMMANDS.TURN_OFF; this.emit(PlejBLEHandler.EVENTS.commandReceived, deviceId, command, data); } else if (cmd === BLE_CMD_SCENE_TRIG) { const sceneId = state; const sceneName = this.deviceRegistry.getSceneName(sceneId); logger.debug( `${sceneName} (${sceneId}) scene triggered (device id ${deviceId}). Name can be misleading if there is a device with the same numeric id.`, ); command = COMMANDS.TRIGGER_SCENE; data = { sceneId }; this.emit(PlejBLEHandler.EVENTS.commandReceived, deviceId, command, data); } else if (cmd === BLE_CMD_TIME_UPDATE) { const now = new Date(); // Guess Plejd timezone based on HA time zone const offsetSecondsGuess = now.getTimezoneOffset() * 60 + 250; // Todo: 4 min off // Plejd reports local unix timestamp adjust to local time zone const plejdTimestampUTC = (decoded.readInt32LE(5) + offsetSecondsGuess) * 1000; const diffSeconds = Math.round((plejdTimestampUTC - now.getTime()) / 1000); if ( deviceId !== BLE_BROADCAST_DEVICE_ID || Logger.shouldLog('verbose') || Math.abs(diffSeconds) > 60 ) { const plejdTime = new Date(plejdTimestampUTC); logger.debug( `Plejd clock time update ${plejdTime.toString()}, diff ${diffSeconds} seconds`, ); if (this.config.updatePlejdClock && Math.abs(diffSeconds) > 60) { logger.warn( `Plejd clock time off by more than 1 minute. Reported time: ${plejdTime.toString()}, diff ${diffSeconds} seconds. Time will be set hourly.`, ); if (this.connectedDevice && deviceId === this.connectedDevice.id) { // Requested time sync by us const newLocalTimestamp = now.getTime() / 1000 - offsetSecondsGuess; logger.info(`Setting time to ${now.toString()}`); const payload = this._createPayload( this.connectedDevice.id, BLE_CMD_TIME_UPDATE, 10, (pl) => pl.writeInt32LE(Math.trunc(newLocalTimestamp), 5), ); try { this.write(payload); } catch (err) { logger.error( 'Failed writing new time to Plejd. Will try again in one hour or at restart.', ); } } } else if (deviceId !== BLE_BROADCAST_DEVICE_ID) { logger.info('Got time response. Plejd clock time in sync with Home Assistant time'); } } } else { logger.verbose( `Command ${cmd.toString(16)} unknown. ${decoded.toString( 'hex', )}. Device ${deviceName} (${deviceId})`, ); } } _createHexPayload( deviceId, command, hexDataString, requestResponseCommand = BLE_REQUEST_NO_RESPONSE, ) { return this._createPayload( deviceId, command, 5 + Math.ceil(hexDataString.length / 2), (payload) => payload.write(hexDataString, 5, 'hex'), requestResponseCommand, ); } // eslint-disable-next-line class-methods-use-this _createPayload( deviceId, command, bufferLength, payloadBufferAddDataFunc, requestResponseCommand = BLE_REQUEST_NO_RESPONSE, ) { const payload = Buffer.alloc(bufferLength); payload.writeUInt8(deviceId); payload.writeUInt16BE(requestResponseCommand, 1); payload.writeUInt16BE(command, 3); payloadBufferAddDataFunc(payload); return payload; } // eslint-disable-next-line class-methods-use-this _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; } // eslint-disable-next-line class-methods-use-this _encryptDecrypt(key, addr, data) { const buf = Buffer.concat([addr, addr, addr.subarray(0, 4)]); const cipher = crypto.createCipheriv('aes-128-ecb', key, ''); cipher.setAutoPadding(false); let ct = cipher.update(buf).toString('hex'); ct += cipher.final().toString('hex'); ct = Buffer.from(ct, 'hex'); let output = ''; for (let i = 0, { length } = data; i < length; i++) { // eslint-disable-next-line no-bitwise output += String.fromCharCode(data[i] ^ ct[i % 16]); } return Buffer.from(output, 'ascii'); } // eslint-disable-next-line class-methods-use-this _reverseBuffer(src) { const buffer = Buffer.allocUnsafe(src.length); for (let i = 0, j = src.length - 1; i <= j; ++i, --j) { buffer[i] = src[j]; buffer[j] = src[i]; } return buffer; } } module.exports = PlejBLEHandler;