diff --git a/plejd/.eslintrc.js b/plejd/.eslintrc.js index 15a3195..ff88ace 100644 --- a/plejd/.eslintrc.js +++ b/plejd/.eslintrc.js @@ -23,6 +23,7 @@ module.exports = { function getRules() { return { + 'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], // Allows modification of properties passed to functions. // Notably used in array.forEach(e => {e.prop = val;}) 'no-param-reassign': ['error', { props: false }], diff --git a/plejd/CHANGELOG.md b/plejd/CHANGELOG.md index bb82c4b..9200036 100644 --- a/plejd/CHANGELOG.md +++ b/plejd/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog hassio-plejd Home Assistant Plejd addon +### [0.6.1](https://github.com/icanos/hassio-plejd/tree/0.6.1) (2021-02-20) + +[Full Changelog](https://github.com/icanos/hassio-plejd/compare/0.6.0...0.6.1) + +**Implemented enhancements:** + +- Feature Request: Support setting the Plejd Network System Clock [\#130](https://github.com/icanos/hassio-plejd/issues/130) + +**Closed issues:** + +- Set Plejd devices' clock hourly [\#165](https://github.com/icanos/hassio-plejd/issues/165) + +### [0.6.0](https://github.com/icanos/hassio-plejd/tree/0.6.0) (2021-01-30) + +[Full Changelog](https://github.com/icanos/hassio-plejd/compare/0.5.1...0.6.0) + +**Implemented enhancements:** + +- Code restructure testing/input/code review [\#158](https://github.com/icanos/hassio-plejd/issues/158) +- Offline mode [\#148](https://github.com/icanos/hassio-plejd/issues/148) + +**Fixed bugs:** + +- Brightness level incorrect with RTR-01 and WPH-01 [\#159](https://github.com/icanos/hassio-plejd/issues/159) + +**Closed issues:** + +- \[plejd-api\] Unable to retrieve session token response: Request failed with status code 403 Error: Request failed with status code 403 [\#162](https://github.com/icanos/hassio-plejd/issues/162) +- Can't turn on/off lights after last update [\#157](https://github.com/icanos/hassio-plejd/issues/157) +- Brightness level incorrect when changing with RTR-01 or WPH-01 [\#138](https://github.com/icanos/hassio-plejd/issues/138) +- plejd-ble reconnect attempts [\#123](https://github.com/icanos/hassio-plejd/issues/123) +- unable to retrieve session token response: Error: Request failed with status code 404 \(and 403\) [\#99](https://github.com/icanos/hassio-plejd/issues/99) +- Unable to scan BT Plejd [\#97](https://github.com/icanos/hassio-plejd/issues/97) + ### [0.5.1](https://github.com/icanos/hassio-plejd/tree/0.5.1) (2021-01-30) [Full Changelog](https://github.com/icanos/hassio-plejd/compare/0.5.0...0.5.1) @@ -19,7 +53,7 @@ **Implemented enhancements:** -- Adjust code to airbnb style guid, including eslint rules and prettier config +- Adjust code to airbnb style guide, including eslint rules and prettier config - Updated dependencies - Improved readme with info about installation, debugging, and logging diff --git a/plejd/Configuration.js b/plejd/Configuration.js index 6376bb0..88a02b3 100644 --- a/plejd/Configuration.js +++ b/plejd/Configuration.js @@ -1,14 +1,52 @@ const fs = require('fs'); class Configuration { - static _config = null; + static _options = null; + static _addonInfo = null; - static getConfiguration() { - if (!Configuration._config) { - const rawData = fs.readFileSync('/data/options.json'); - Configuration._config = JSON.parse(rawData); + static getOptions() { + if (!Configuration._options) { + Configuration._hydrateCache(); } - return Configuration._config; + return Configuration._options; + } + + static getAddonInfo() { + if (!Configuration._addonInfo) { + Configuration._hydrateCache(); + } + return Configuration._addonInfo; + } + + static _hydrateCache() { + const rawData = fs.readFileSync('/data/options.json'); + const config = JSON.parse(rawData); + + const defaultRawData = fs.readFileSync('/plejd/config.json'); + const defaultConfig = JSON.parse(defaultRawData); + + Configuration._options = { ...defaultConfig.options, ...config }; + Configuration._addonInfo = { + name: defaultConfig.name, + version: defaultConfig.version, + slug: defaultConfig.slug, + description: defaultConfig.description, + url: defaultConfig.url, + arch: defaultConfig.arch, + startup: defaultConfig.startup, + boot: defaultConfig.boot, + host_network: defaultConfig.host_network, + host_dbus: defaultConfig.host_dbus, + apparmor: defaultConfig.apparmor, + }; + + // eslint-disable-next-line no-console + console.log('Config:', { + ...Configuration._options, + username: '---scrubbed---', + password: '---scrubbed---', + mqttPassword: '---scrubbed---', + }); } } diff --git a/plejd/DeviceRegistry.js b/plejd/DeviceRegistry.js new file mode 100644 index 0000000..647e5bc --- /dev/null +++ b/plejd/DeviceRegistry.js @@ -0,0 +1,77 @@ +class DeviceRegistry { + apiSite; + cryptoKey = null; + + deviceIdsByRoom = {}; + deviceIdsBySerial = {}; + + // Dictionaries of [id]: device per type + plejdDevices = {}; + roomDevices = {}; + sceneDevices = {}; + + get allDevices() { + return [ + ...Object.values(this.plejdDevices), + ...Object.values(this.roomDevices), + ...Object.values(this.sceneDevices), + ]; + } + + addPlejdDevice(device) { + this.plejdDevices[device.id] = device; + this.deviceIdsBySerial[device.serialNumber] = device.id; + if (!this.deviceIdsByRoom[device.roomId]) { + this.deviceIdsByRoom[device.roomId] = []; + } + this.deviceIdsByRoom[device.roomId].push(device.id); + } + + addScene(scene) { + this.sceneDevices[scene.id] = scene; + } + + setApiSite(siteDetails) { + this.apiSite = siteDetails; + } + + clearPlejdDevices() { + this.plejdDevices = {}; + this.deviceIdsByRoom = {}; + this.deviceIdsBySerial = {}; + } + + addRoomDevice(device) { + this.roomDevices[device.id] = device; + } + + clearRoomDevices() { + this.roomDevices = {}; + } + + clearSceneDevices() { + this.sceneDevices = {}; + } + + getDevice(deviceId) { + return this.plejdDevices[deviceId]; + } + + getDeviceBySerialNumber(serialNumber) { + return this.plejdDevices[this.deviceIdsBySerial[serialNumber]]; + } + + getDeviceName(deviceId) { + return (this.plejdDevices[deviceId] || {}).name; + } + + getScene(sceneId) { + return this.sceneDevices[sceneId]; + } + + getSceneName(sceneId) { + return (this.sceneDevices[sceneId] || {}).name; + } +} + +module.exports = DeviceRegistry; diff --git a/plejd/Dockerfile b/plejd/Dockerfile index 8c84189..9f558a9 100644 --- a/plejd/Dockerfile +++ b/plejd/Dockerfile @@ -1,75 +1,77 @@ -ARG BUILD_FROM=hassioaddons/base:8.0.6 -FROM $BUILD_FROM - -ENV LANG C.UTF-8 - -# Set shell -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -# Copy data for add-on -COPY ./config.json /plejd/ -COPY ./Configuration.js /plejd/ -COPY ./Logger.js /plejd/ -COPY ./main.js /plejd/ -COPY ./MqttClient.js /plejd/ -COPY ./package.json /plejd/ -COPY ./PlejdApi.js /plejd/ -COPY ./PlejdService.js /plejd/ -COPY ./Scene.js /plejd/ -COPY ./SceneManager.js /plejd/ -COPY ./SceneStep.js /plejd/ - -ARG BUILD_ARCH - -# Install Node -RUN apk add --no-cache jq -RUN \ - apk add --no-cache --virtual .build-dependencies \ - g++ \ - gcc \ - libc-dev \ - linux-headers \ - make \ - python3 \ - bluez \ - eudev-dev \ - \ - && apk add --no-cache \ - git \ - nodejs \ - npm \ - dbus-dev \ - glib-dev \ - \ - && npm config set unsafe-perm true - -WORKDIR /plejd -RUN npm install \ - --no-audit \ - --no-update-notifier \ - --unsafe-perm - -# Copy root filesystem -COPY rootfs / - -# Build arguments -ARG BUILD_DATE -ARG BUILD_REF -ARG BUILD_VERSION - -# Labels -LABEL \ - io.hass.name="Plejd" \ - io.hass.description="Adds support for the Swedish home automation devices from Plejd." \ - io.hass.arch="${BUILD_ARCH}" \ - io.hass.type="addon" \ - io.hass.version=${BUILD_VERSION} \ - maintainer="Marcus Westin " \ - org.label-schema.description="Adds support for the Swedish home automation devices from Plejd." \ - org.label-schema.build-date=${BUILD_DATE} \ - org.label-schema.name="Plejd" \ - org.label-schema.schema-version="1.0" \ - org.label-schema.usage="https://github.com/icanos/hassio-plejd/tree/master/README.md" \ - org.label-schema.vcs-ref=${BUILD_REF} \ - org.label-schema.vcs-url="https://github.com/icanos/hassio-plejd" - +ARG BUILD_FROM=hassioaddons/base:8.0.6 +FROM $BUILD_FROM + +ENV LANG C.UTF-8 + +# Set shell +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# Copy data for add-on +COPY ./config.json /plejd/ +COPY ./Configuration.js /plejd/ +COPY ./DeviceRegistry.js /plejd/ +COPY ./Logger.js /plejd/ +COPY ./main.js /plejd/ +COPY ./MqttClient.js /plejd/ +COPY ./package.json /plejd/ +COPY ./PlejdAddon.js /plejd/ +COPY ./PlejdApi.js /plejd/ +COPY ./PlejdBLEHandler.js /plejd/ +COPY ./Scene.js /plejd/ +COPY ./SceneManager.js /plejd/ +COPY ./SceneStep.js /plejd/ + +ARG BUILD_ARCH + +# Install Node +RUN apk add --no-cache jq +RUN \ + apk add --no-cache --virtual .build-dependencies \ + g++ \ + gcc \ + libc-dev \ + linux-headers \ + make \ + python3 \ + bluez \ + eudev-dev \ + \ + && apk add --no-cache \ + git \ + nodejs \ + npm \ + dbus-dev \ + glib-dev \ + \ + && npm config set unsafe-perm true + +WORKDIR /plejd +RUN npm install \ + --no-audit \ + --no-update-notifier \ + --unsafe-perm + +# Copy root filesystem +COPY rootfs / + +# Build arguments +ARG BUILD_DATE +ARG BUILD_REF +ARG BUILD_VERSION + +# Labels +LABEL \ + io.hass.name="Plejd" \ + io.hass.description="Adds support for the Swedish home automation devices from Plejd." \ + io.hass.arch="${BUILD_ARCH}" \ + io.hass.type="addon" \ + io.hass.version=${BUILD_VERSION} \ + maintainer="Marcus Westin " \ + org.label-schema.description="Adds support for the Swedish home automation devices from Plejd." \ + org.label-schema.build-date=${BUILD_DATE} \ + org.label-schema.name="Plejd" \ + org.label-schema.schema-version="1.0" \ + org.label-schema.usage="https://github.com/icanos/hassio-plejd/tree/master/README.md" \ + org.label-schema.vcs-ref=${BUILD_REF} \ + org.label-schema.vcs-url="https://github.com/icanos/hassio-plejd" + diff --git a/plejd/Logger.js b/plejd/Logger.js index bf908b5..19bf436 100644 --- a/plejd/Logger.js +++ b/plejd/Logger.js @@ -25,20 +25,35 @@ const logFormat = printf((info) => { /** Winston-based logger */ class Logger { + static shouldLogLookup = {}; + constructor() { throw new Error('Please call createLogger instead'); } + static getLogLevel() { + const config = Configuration.getOptions(); + // eslint-disable-next-line max-len + const level = (config.logLevel && LEVELS.find((l) => l.startsWith(config.logLevel[0].toLowerCase()))) + || 'info'; + return level; + } + + static shouldLog(logLevel) { + if (!Logger.shouldLogLookup[logLevel]) { + // eslint-disable-next-line max-len + Logger.shouldLogLookup[logLevel] = Logger.logLevels().levels[logLevel] <= Logger.logLevels().levels[Logger.getLogLevel()]; + } + return Logger.shouldLogLookup[logLevel]; + } + /** Created logger will follow Winston createLogger, but * - add module name to logger * - swap debug/verbose levels and omit http to mimic HA standard * Levels (in order): error, warn, info, debug, verbose, silly * */ static getLogger(moduleName) { - const config = Configuration.getConfiguration(); - // eslint-disable-next-line max-len - const level = (config.logLevel && LEVELS.find((l) => l.startsWith(config.logLevel[0].toLowerCase()))) - || 'info'; + const level = Logger.getLogLevel(); const logger = winston.createLogger({ format: combine( diff --git a/plejd/MqttClient.js b/plejd/MqttClient.js index f0ceca5..de0b09d 100644 --- a/plejd/MqttClient.js +++ b/plejd/MqttClient.js @@ -1,5 +1,7 @@ const EventEmitter = require('events'); const mqtt = require('mqtt'); + +const Configuration = require('./Configuration'); const Logger = require('./Logger'); const startTopics = ['hass/status', 'homeassistant/status']; @@ -19,6 +21,18 @@ const getAvailabilityTopic = (plug) => `${getPath(plug)}/availability`; const getCommandTopic = (plug) => `${getPath(plug)}/set`; const getSceneEventTopic = () => 'plejd/event/scene'; +const decodeTopicRegexp = new RegExp( + /(?[^[]+)\/(?.+)\/plejd\/(?.+)\/(?config|state|availability|set|scene)/, +); + +const decodeTopic = (topic) => { + const matches = decodeTopicRegexp.exec(topic); + if (!matches) { + return null; + } + return matches.groups; +}; + const getDiscoveryPayload = (device) => ({ schema: 'json', name: device.name, @@ -54,23 +68,25 @@ const getSwitchPayload = (device) => ({ // #endregion class MqttClient extends EventEmitter { - constructor(mqttBroker, username, password) { + deviceRegistry; + + constructor(deviceRegistry) { super(); - this.mqttBroker = mqttBroker; - this.username = username; - this.password = password; - this.deviceMap = {}; - this.devices = []; + this.config = Configuration.getOptions(); + this.deviceRegistry = deviceRegistry; } init() { logger.info('Initializing MQTT connection for Plejd addon'); - const self = this; - this.client = mqtt.connect(this.mqttBroker, { - username: this.username, - password: this.password, + this.client = mqtt.connect(this.config.mqttBroker, { + username: this.config.mqttUsername, + password: this.config.mqttPassword, + }); + + this.client.on('error', (err) => { + logger.warn('Error emitted from mqtt client', err); }); this.client.on('connect', () => { @@ -81,7 +97,7 @@ class MqttClient extends EventEmitter { logger.error('Unable to subscribe to status topics'); } - self.emit('connected'); + this.emit('connected'); }); this.client.subscribe(getSubscribePath(), (err) => { @@ -93,32 +109,70 @@ class MqttClient extends EventEmitter { this.client.on('close', () => { logger.verbose('Warning: mqtt channel closed event, reconnecting...'); - self.reconnect(); + this.reconnect(); }); this.client.on('message', (topic, message) => { - // const command = message.toString(); - const command = message.toString().substring(0, 1) === '{' - ? JSON.parse(message.toString()) - : message.toString(); - if (startTopics.includes(topic)) { logger.info('Home Assistant has started. lets do discovery.'); - self.emit('connected'); - } else if (topic.includes('set')) { - logger.verbose(`Got mqtt command on ${topic} - ${message}`); - const device = self.devices.find((x) => getCommandTopic(x) === topic); - if (device) { - self.emit('stateChanged', device, command); + this.emit('connected'); + } else { + const decodedTopic = decodeTopic(topic); + if (decodedTopic) { + let device = this.deviceRegistry.getDevice(decodedTopic.id); + + const messageString = message.toString(); + const isJsonMessage = messageString.startsWith('{'); + const command = isJsonMessage ? JSON.parse(messageString) : messageString; + + if ( + !isJsonMessage + && messageString === 'ON' + && this.deviceRegistry.getScene(decodedTopic.id) + ) { + // Guess that id that got state command without dim value belongs to Scene, not Device + // This guess could very well be wrong depending on the installation... + logger.warn( + `Device id ${decodedTopic.id} belongs to both scene and device, guessing Scene is what should be set to ON.` + + 'OFF commands still sent to device.', + ); + device = this.deviceRegistry.getScene(decodedTopic.id); + } + const deviceName = device ? device.name : ''; + + switch (decodedTopic.command) { + case 'set': + logger.verbose( + `Got mqtt SET command for ${decodedTopic.type}, ${deviceName} (${decodedTopic.id}): ${messageString}`, + ); + + if (device) { + this.emit('stateChanged', device, command); + } else { + logger.warn( + `Device for topic ${topic} not found! Can happen if HA calls previously existing devices.`, + ); + } + break; + case 'state': + case 'config': + case 'availability': + logger.verbose( + `Sent mqtt ${decodedTopic.command} command for ${ + decodedTopic.type + }, ${deviceName} (${decodedTopic.id}). ${ + decodedTopic.command === 'availability' ? messageString : '' + }`, + ); + break; + default: + logger.verbose(`Warning: Unknown command ${decodedTopic.command} in decoded topic`); + } } else { - logger.warn( - `Device for topic ${topic} not found! Can happen if HA calls previously existing devices.`, + logger.verbose( + `Warning: Got unrecognized mqtt command on '${topic}': ${message.toString()}`, ); } - } else if (topic.includes('state')) { - logger.verbose(`State update sent over mqtt to HA ${topic} - ${message}`); - } else { - logger.verbose(`Warning: Got unrecognized mqtt command on ${topic} - ${message}`); } }); } @@ -128,19 +182,16 @@ class MqttClient extends EventEmitter { } disconnect(callback) { - this.devices.forEach((device) => { + this.deviceRegistry.allDevices.forEach((device) => { this.client.publish(getAvailabilityTopic(device), 'offline'); }); this.client.end(callback); } - discover(devices) { - this.devices = devices; + sendDiscoveryToHomeAssistant() { + logger.debug(`Sending discovery of ${this.deviceRegistry.allDevices.length} device(s).`); - const self = this; - logger.debug(`Sending discovery of ${devices.length} device(s).`); - - devices.forEach((device) => { + this.deviceRegistry.allDevices.forEach((device) => { logger.debug(`Sending discovery for ${device.name}`); const payload = device.type === 'switch' ? getSwitchPayload(device) : getDiscoveryPayload(device); @@ -148,17 +199,15 @@ class MqttClient extends EventEmitter { `Discovered ${device.type} (${device.typeName}) named ${device.name} with PID ${device.id}.`, ); - self.deviceMap[device.id] = payload.unique_id; - - self.client.publish(getConfigPath(device), JSON.stringify(payload)); + this.client.publish(getConfigPath(device), JSON.stringify(payload)); setTimeout(() => { - self.client.publish(getAvailabilityTopic(device), 'online'); + this.client.publish(getAvailabilityTopic(device), 'online'); }, 2000); }); } updateState(deviceId, data) { - const device = this.devices.find((x) => x.id === deviceId); + const device = this.deviceRegistry.getDevice(deviceId); if (!device) { logger.warn(`Unknown device id ${deviceId} - not handled by us.`); @@ -193,9 +242,9 @@ class MqttClient extends EventEmitter { this.client.publish(getAvailabilityTopic(device), 'online'); } - sceneTriggered(scene) { - logger.verbose(`Scene triggered: ${scene}`); - this.client.publish(getSceneEventTopic(), JSON.stringify({ scene })); + sceneTriggered(sceneId) { + logger.verbose(`Scene triggered: ${sceneId}`); + this.client.publish(getSceneEventTopic(), JSON.stringify({ scene: sceneId })); } } diff --git a/plejd/PlejdAddon.js b/plejd/PlejdAddon.js new file mode 100644 index 0000000..9f2a18a --- /dev/null +++ b/plejd/PlejdAddon.js @@ -0,0 +1,136 @@ +const EventEmitter = require('events'); + +const Configuration = require('./Configuration'); +const Logger = require('./Logger'); +const PlejdApi = require('./PlejdApi'); +// const PlejdBLE = require('./PlejdBLE'); +const PlejdBLEHandler = require('./PlejdBLEHandler'); +const MqttClient = require('./MqttClient'); +const SceneManager = require('./SceneManager'); +const DeviceRegistry = require('./DeviceRegistry'); + +const logger = Logger.getLogger('plejd-main'); + +class PlejdAddon extends EventEmitter { + bleInitTimeout; + config; + deviceRegistry; + plejdApi; + plejdBLEHandler; + mqttClient; + sceneManager; + + constructor() { + super(); + + this.config = Configuration.getOptions(); + this.deviceRegistry = new DeviceRegistry(); + + this.plejdApi = new PlejdApi(this.deviceRegistry); + this.plejdBLEHandler = new PlejdBLEHandler(this.deviceRegistry); + this.sceneManager = new SceneManager(this.deviceRegistry, this.plejdBLEHandler); + this.mqttClient = new MqttClient(this.deviceRegistry); + } + + async init() { + logger.info('Main Plejd addon init()...'); + + await this.plejdApi.init(); + this.sceneManager.init(); + + ['SIGINT', 'SIGHUP', 'SIGTERM'].forEach((signal) => { + process.on(signal, () => { + this.mqttClient.disconnect(() => process.exit(0)); + }); + }); + + this.mqttClient.on('connected', () => { + try { + logger.verbose('connected to mqtt.'); + this.mqttClient.sendDiscoveryToHomeAssistant(); + } catch (err) { + logger.error('Error in MqttClient.connected callback in main.js', err); + } + }); + + // subscribe to changes from HA + this.mqttClient.on('stateChanged', (device, command) => { + try { + const deviceId = device.id; + + if (device.typeName === 'Scene') { + // we're triggering a scene, lets do that and jump out. + // since scenes aren't "real" devices. + this.sceneManager.executeScene(device.id); + return; + } + + let state = 'OFF'; + let commandObj = {}; + + if (typeof command === 'string') { + // switch command + state = command; + commandObj = { + state, + }; + + // since the switch doesn't get any updates on whether it's on or not, + // we fake this by directly send the updateState back to HA in order for + // it to change state. + this.mqttClient.updateState(deviceId, { + state: state === 'ON' ? 1 : 0, + }); + } else { + // eslint-disable-next-line prefer-destructuring + state = command.state; + commandObj = command; + } + + if (state === 'ON') { + this.plejdBLEHandler.turnOn(deviceId, commandObj); + } else { + this.plejdBLEHandler.turnOff(deviceId, commandObj); + } + } catch (err) { + logger.error('Error in MqttClient.stateChanged callback in main.js', err); + } + }); + + this.mqttClient.init(); + + this.plejdBLEHandler.on('connected', () => { + logger.info('Bluetooth connected. Plejd BLE up and running!'); + }); + this.plejdBLEHandler.on('reconnecting', () => { + logger.info('Bluetooth reconnecting...'); + }); + + // subscribe to changes from Plejd + this.plejdBLEHandler.on('stateChanged', (deviceId, command) => { + try { + this.mqttClient.updateState(deviceId, command); + } catch (err) { + logger.error('Error in PlejdService.stateChanged callback in main.js', err); + } + }); + + this.plejdBLEHandler.on('sceneTriggered', (deviceId, sceneId) => { + try { + this.mqttClient.sceneTriggered(sceneId); + } catch (err) { + logger.error('Error in PlejdService.sceneTriggered callback in main.js', err); + } + }); + + try { + await this.plejdBLEHandler.init(); + } catch (err) { + logger.error('Failed init() of BLE. Starting reconnect loop.'); + await this.plejdBLEHandler.startReconnectPeriodicallyLoop(); + } + logger.info('Main init done'); + } +} + +module.exports = PlejdAddon; diff --git a/plejd/PlejdApi.js b/plejd/PlejdApi.js index 39bc44d..3803ea8 100644 --- a/plejd/PlejdApi.js +++ b/plejd/PlejdApi.js @@ -1,5 +1,7 @@ -const axios = require('axios'); -const EventEmitter = require('events'); +const axios = require('axios').default; +const fs = require('fs'); + +const Configuration = require('./Configuration'); const Logger = require('./Logger'); const API_APP_ID = 'zHtVqXt8k4yFyk2QGmgp48D9xZr2G94xWYnF4dak'; @@ -10,271 +12,207 @@ const API_SITE_DETAILS_URL = 'functions/getSiteById'; const logger = Logger.getLogger('plejd-api'); -class PlejdApi extends EventEmitter { - constructor(siteName, username, password, includeRoomsAsLights) { - super(); +class PlejdApi { + config; + deviceRegistry; + sessionToken; + siteId; + siteDetails; - this.includeRoomsAsLights = includeRoomsAsLights; - this.siteName = siteName; - this.username = username; - this.password = password; - - this.sessionToken = ''; - this.site = null; + constructor(deviceRegistry) { + this.config = Configuration.getOptions(); + this.deviceRegistry = deviceRegistry; } - login() { + async init() { + logger.info('init()'); + const cache = await this.getCachedCopy(); + const cacheExists = cache && cache.siteId && cache.siteDetails && cache.sessionToken; + + logger.debug(`Prefer cache? ${this.config.preferCachedApiResponse}`); + logger.debug(`Cache exists? ${cacheExists ? `Yes, created ${cache.dtCache}` : 'No'}`); + + if (this.config.preferCachedApiResponse && cacheExists) { + logger.info( + `Cache preferred. Skipping api requests and setting api data to response from ${cache.dtCache}`, + ); + logger.silly(`Cached response: ${JSON.stringify(cache, null, 2)}`); + this.siteId = cache.siteId; + this.siteDetails = cache.siteDetails; + this.sessionToken = cache.sessionToken; + } else { + try { + await this.login(); + await this.getSites(); + await this.getSiteDetails(); + this.saveCachedCopy(); + } catch (err) { + if (cacheExists) { + logger.warn('Failed to get api response, using cached copy instead'); + this.siteId = cache.siteId; + this.siteDetails = cache.siteDetails; + this.sessionToken = cache.sessionToken; + } else { + logger.error('Api request failed, no cached fallback available', err); + throw err; + } + } + } + this.deviceRegistry.setApiSite(this.siteDetails); + this.deviceRegistry.cryptoKey = this.siteDetails.plejdMesh.cryptoKey; + + this.getDevices(); + } + + // eslint-disable-next-line class-methods-use-this + async getCachedCopy() { + logger.info('Getting cached api response from disk'); + + try { + const rawData = await fs.promises.readFile('/data/cachedApiResponse.json'); + const cachedCopy = JSON.parse(rawData); + + return cachedCopy; + } catch (err) { + logger.warn('No cached api response could be read. This is normal on the first run', err); + return null; + } + } + + async saveCachedCopy() { + logger.info('Saving cached copy'); + try { + const rawData = JSON.stringify({ + siteId: this.siteId, + siteDetails: this.siteDetails, + sessionToken: this.sessionToken, + dtCache: new Date().toISOString(), + }); + await fs.promises.writeFile('/data/cachedApiResponse.json', rawData); + } catch (err) { + logger.error('Failed to save cache of api response', err); + } + } + + async login() { logger.info('login()'); - logger.info(`logging into ${this.siteName}`); - const self = this; + logger.info(`logging into ${this.config.site}`); - const instance = axios.create({ - baseURL: API_BASE_URL, - headers: { - 'X-Parse-Application-Id': API_APP_ID, - 'Content-Type': 'application/json', - }, - }); + logger.debug(`sending POST to ${API_BASE_URL}${API_LOGIN_URL}`); - return new Promise((resolve, reject) => { - logger.debug(`sending POST to ${API_BASE_URL}${API_LOGIN_URL}`); + try { + const response = await this._getAxiosInstance().post(API_LOGIN_URL, { + username: this.config.username, + password: this.config.password, + }); - instance - .post(API_LOGIN_URL, { - username: this.username, - password: this.password, - }) - .then((response) => { - logger.info('got session token response'); - self.sessionToken = response.data.sessionToken; + logger.info('got session token response'); + this.sessionToken = response.data.sessionToken; - if (!self.sessionToken) { - logger.error('No session token received'); - reject(new Error('no session token received.')); - } + if (!this.sessionToken) { + logger.error('No session token received'); + throw new Error('API: No session token received.'); + } + } catch (error) { + if (error.response.status === 400) { + logger.error('Server returned status 400. probably invalid credentials, please verify.'); + } else if (error.response.status === 403) { + logger.error( + 'Server returned status 403, forbidden. Plejd service does this sometimes, despite correct credentials. Possibly throttling logins. Waiting a long time often fixes this.', + ); + } else { + logger.error('Unable to retrieve session token response: ', error); + } + logger.verbose(`Error details: ${JSON.stringify(error.response, null, 2)}`); - resolve(); - }) - .catch((error) => { - if (error.response.status === 400) { - logger.error( - 'Server returned status 400. probably invalid credentials, please verify.', - ); - } else { - logger.error('Unable to retrieve session token response: ', error); - } - - reject(new Error(`unable to retrieve session token response: ${error}`)); - }); - }); + throw new Error(`API: Unable to retrieve session token response: ${error}`); + } } - getSites() { + async getSites() { logger.info('Get all Plejd sites for account...'); - const self = this; - const instance = axios.create({ - baseURL: API_BASE_URL, - headers: { - 'X-Parse-Application-Id': API_APP_ID, - 'X-Parse-Session-Token': this.sessionToken, - 'Content-Type': 'application/json', - }, - }); + logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_LIST_URL}`); - return new Promise((resolve, reject) => { - logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_LIST_URL}`); + try { + const response = await this._getAxiosInstance().post(API_SITE_LIST_URL); - instance - .post(API_SITE_LIST_URL) - .then((response) => { - logger.info('got site list response'); - const site = response.data.result.find((x) => x.site.title === self.siteName); + const sites = response.data.result; + logger.info( + `Got site list response with ${sites.length}: ${sites.map((s) => s.site.title).join(', ')}`, + ); + logger.silly('All sites found:'); + logger.silly(JSON.stringify(sites, null, 2)); - if (!site) { - logger.error(`error: failed to find a site named ${self.siteName}`); - reject(new Error(`failed to find a site named ${self.siteName}`)); - return; - } + const site = sites.find((x) => x.site.title === this.config.site); - resolve(site); - }) - .catch((error) => { - logger.error('error: unable to retrieve list of sites. error: ', error); - return reject(new Error(`plejd-api: unable to retrieve list of sites. error: ${error}`)); - }); - }); + if (!site) { + logger.error(`Failed to find a site named ${this.config.site}`); + throw new Error(`API: Failed to find a site named ${this.config.site}`); + } + + logger.info(`Site found matching configuration name ${this.config.site}`); + logger.silly(JSON.stringify(site, null, 2)); + this.siteId = site.site.siteId; + } catch (error) { + logger.error('error: unable to retrieve list of sites. error: ', error); + throw new Error(`API: unable to retrieve list of sites. error: ${error}`); + } } - getSite(siteId) { - logger.info('Get site details...'); - const self = this; + async getSiteDetails() { + logger.info(`Get site details for ${this.siteId}...`); - const instance = axios.create({ - baseURL: API_BASE_URL, - headers: { - 'X-Parse-Application-Id': API_APP_ID, - 'X-Parse-Session-Token': this.sessionToken, - 'Content-Type': 'application/json', - }, - }); + logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_DETAILS_URL}`); - return new Promise((resolve, reject) => { - logger.debug(`sending POST to ${API_BASE_URL}${API_SITE_DETAILS_URL}`); + try { + const response = await this._getAxiosInstance().post(API_SITE_DETAILS_URL, { + siteId: this.siteId, + }); - instance - .post(API_SITE_DETAILS_URL, { siteId }) - .then((response) => { - logger.info('got site details response'); - if (response.data.result.length === 0) { - const msg = `no site with ID ${siteId} was found.`; - logger.error(`error: ${msg}`); - reject(msg); - return; - } + logger.info('got site details response'); - self.site = response.data.result[0]; - self.cryptoKey = self.site.plejdMesh.cryptoKey; + if (response.data.result.length === 0) { + logger.error(`No site with ID ${this.siteId} was found.`); + throw new Error(`API: No site with ID ${this.siteId} was found.`); + } - resolve(self.cryptoKey); - }) - .catch((error) => { - logger.error('error: unable to retrieve the crypto key. error: ', error); - return reject(new Error(`plejd-api: unable to retrieve the crypto key. error: ${error}`)); - }); - }); + this.siteDetails = response.data.result[0]; + + logger.info(`Site details for site id ${this.siteId} found`); + logger.silly(JSON.stringify(this.siteDetails, null, 2)); + + if (!this.siteDetails.plejdMesh.cryptoKey) { + throw new Error('API: No crypto key set for site'); + } + } catch (error) { + logger.error(`Unable to retrieve site details for ${this.siteId}. error: `, error); + throw new Error(`API: Unable to retrieve site details. error: ${error}`); + } } getDevices() { - const devices = []; + logger.info('Getting devices from site details response...'); - logger.verbose(JSON.stringify(this.site)); + this._getPlejdDevices(); + this._getRoomDevices(); + this._getSceneDevices(); + } - const roomDevices = {}; + _getAxiosInstance() { + const headers = { + 'X-Parse-Application-Id': API_APP_ID, + 'Content-Type': 'application/json', + }; - for (let i = 0; i < this.site.devices.length; i++) { - const device = this.site.devices[i]; - const { deviceId } = device; - - const settings = this.site.outputSettings.find((x) => x.deviceParseId === device.objectId); - let deviceNum = this.site.deviceAddress[deviceId]; - - if (settings) { - const outputs = this.site.outputAddress[deviceId]; - deviceNum = outputs[settings.output]; - } - - // check if device is dimmable - const plejdDevice = this.site.plejdDevices.find((x) => x.deviceId === deviceId); - const deviceType = this._getDeviceType(plejdDevice.hardwareId); - const { name, type } = deviceType; - let { dimmable } = deviceType; - - if (settings) { - dimmable = settings.dimCurve !== 'NonDimmable'; - } - - const newDevice = { - id: deviceNum, - name: device.title, - type, - typeName: name, - dimmable, - version: plejdDevice.firmware.version, - serialNumber: plejdDevice.deviceId, - }; - - if (newDevice.typeName === 'WPH-01') { - // WPH-01 is special, it has two buttons which needs to be - // registered separately. - const inputs = this.site.inputAddress[deviceId]; - const first = inputs[0]; - const second = inputs[1]; - - let switchDevice = { - id: first, - name: `${device.title} knapp vä`, - type, - typeName: name, - dimmable, - version: plejdDevice.firmware.version, - serialNumber: plejdDevice.deviceId, - }; - - if (roomDevices[device.roomId]) { - roomDevices[device.roomId].push(switchDevice); - } else { - roomDevices[device.roomId] = [switchDevice]; - } - devices.push(switchDevice); - - switchDevice = { - id: second, - name: `${device.title} knapp hö`, - type, - typeName: name, - dimmable, - version: plejdDevice.firmware.version, - serialNumber: plejdDevice.deviceId, - }; - - if (roomDevices[device.roomId]) { - roomDevices[device.roomId].push(switchDevice); - } else { - roomDevices[device.roomId] = [switchDevice]; - } - devices.push(switchDevice); - } else { - if (roomDevices[device.roomId]) { - roomDevices[device.roomId].push(newDevice); - } else { - roomDevices[device.roomId] = [newDevice]; - } - - devices.push(newDevice); - } + if (this.sessionToken) { + headers['X-Parse-Session-Token'] = this.sessionToken; } - if (this.includeRoomsAsLights) { - logger.debug('includeRoomsAsLights is set to true, adding rooms too.'); - for (let i = 0; i < this.site.rooms.length; i++) { - const room = this.site.rooms[i]; - const { roomId } = room; - const roomAddress = this.site.roomAddress[roomId]; - - const newDevice = { - id: roomAddress, - name: room.title, - type: 'light', - typeName: 'Room', - dimmable: roomDevices[roomId].filter((x) => x.dimmable).length > 0, - }; - - devices.push(newDevice); - } - logger.debug('includeRoomsAsLights done.'); - } - - // add scenes as switches - const scenes = this.site.scenes.filter((x) => x.hiddenFromSceneList === false); - - // eslint-disable-next-line no-restricted-syntax - for (const scene of scenes) { - const sceneNum = this.site.sceneIndex[scene.sceneId]; - const newScene = { - id: sceneNum, - name: scene.title, - type: 'switch', - typeName: 'Scene', - dimmable: false, - version: '1.0', - serialNumber: scene.objectId, - }; - - devices.push(newScene); - } - - return devices; + return axios.create({ + baseURL: API_BASE_URL, + headers, + }); } // eslint-disable-next-line class-methods-use-this @@ -324,6 +262,111 @@ class PlejdApi extends EventEmitter { throw new Error(`Unknown device type with id ${hardwareId}`); } } + + _getPlejdDevices() { + this.deviceRegistry.clearPlejdDevices(); + + this.siteDetails.devices.forEach((device) => { + const { deviceId } = device; + + const settings = this.siteDetails.outputSettings.find( + (x) => x.deviceParseId === device.objectId, + ); + + let deviceNum = this.siteDetails.deviceAddress[deviceId]; + + if (settings) { + const outputs = this.siteDetails.outputAddress[deviceId]; + deviceNum = outputs[settings.output]; + } + + // check if device is dimmable + const plejdDevice = this.siteDetails.plejdDevices.find((x) => x.deviceId === deviceId); + const deviceType = this._getDeviceType(plejdDevice.hardwareId); + const { name, type } = deviceType; + let { dimmable } = deviceType; + + if (settings) { + dimmable = settings.dimCurve !== 'NonDimmable'; + } + + const newDevice = { + id: deviceNum, + name: device.title, + type, + typeName: name, + dimmable, + roomId: device.roomId, + version: plejdDevice.firmware.version, + serialNumber: plejdDevice.deviceId, + }; + + if (newDevice.typeName === 'WPH-01') { + // WPH-01 is special, it has two buttons which needs to be + // registered separately. + const inputs = this.siteDetails.inputAddress[deviceId]; + const first = inputs[0]; + const second = inputs[1]; + + this.deviceRegistry.addPlejdDevice({ + ...newDevice, + id: first, + name: `${device.title} left`, + }); + + this.deviceRegistry.addPlejdDevice({ + ...newDevice, + id: second, + name: `${device.title} right`, + }); + } else { + this.deviceRegistry.addPlejdDevice(newDevice); + } + }); + } + + _getRoomDevices() { + if (this.config.includeRoomsAsLights) { + logger.debug('includeRoomsAsLights is set to true, adding rooms too.'); + this.siteDetails.rooms.forEach((room) => { + const { roomId } = room; + const roomAddress = this.siteDetails.roomAddress[roomId]; + + const newDevice = { + id: roomAddress, + name: room.title, + type: 'light', + typeName: 'Room', + dimmable: this.deviceIdsByRoom[roomId].some( + (deviceId) => this.plejdDevices[deviceId].dimmable, + ), + }; + + this.deviceRegistry.addRoomDevice(newDevice); + }); + logger.debug('includeRoomsAsLights done.'); + } + } + + _getSceneDevices() { + // add scenes as switches + const scenes = this.siteDetails.scenes.filter((x) => x.hiddenFromSceneList === false); + + scenes.forEach((scene) => { + const sceneNum = this.siteDetails.sceneIndex[scene.sceneId]; + const newScene = { + id: sceneNum, + name: scene.title, + type: 'switch', + typeName: 'Scene', + dimmable: false, + version: '1.0', + serialNumber: scene.objectId, + }; + + this.deviceRegistry.addScene(newScene); + }); + } } module.exports = PlejdApi; diff --git a/plejd/PlejdBLEHandler.js b/plejd/PlejdBLEHandler.js new file mode 100644 index 0000000..bef0441 --- /dev/null +++ b/plejd/PlejdBLEHandler.js @@ -0,0 +1,1030 @@ +const dbus = require('dbus-next'); +const crypto = require('crypto'); +const xor = require('buffer-xor'); +const EventEmitter = require('events'); +const Logger = require('./Logger'); + +const Configuration = require('./Configuration'); + +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 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 MAX_TRANSITION_STEPS_PER_SECOND = 5; // Could be made a setting +const MAX_RETRY_COUNT = 5; // Could be made a setting + +const delay = (timeout) => new Promise((resolve) => setTimeout(resolve, timeout)); + +class PlejBLEHandler extends EventEmitter { + adapter; + adapterProperties; + config; + bleDevices = []; + bleDeviceTransitionTimers = {}; + bus = null; + connectedDevice = null; + consecutiveWriteFails; + deviceRegistry; + discoveryTimeout = null; + plejdService = null; + plejdDevices = {}; + pingRef = null; + writeQueue = []; + writeQueueRef = null; + reconnectInProgress = false; + + // 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', 'reconnecting', 'sceneTriggered', 'stateChanged']; + + 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.on('writeFailed', (error) => this.onWriteFailed(error)); + this.on('writeSuccess', () => this.onWriteSuccess()); + } + + async init() { + logger.info('init()'); + + this.bus = dbus.systemBus(); + 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 _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); + + logger.debug(`Discovered ${plejd.path} with rssi ${plejd.rssi}, name ${plejd.device.name}`); + // Todo: Connect should probably be done here + this.bleDevices.push(plejd); + } 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}`); + this.emit('connected'); + + // 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(); + this.startWriteQueue(); + + // 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(); + } 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() { + await this._powerOffAdapter(); + await this._powerOnAdapter(); + } + + async _powerOnAdapter() { + await this.adapterProperties.Set(BLUEZ_ADAPTER_ID, 'Powered', new dbus.Variant('b', 1)); + await delay(1000); + } + + async _powerOffAdapter() { + await this.adapterProperties.Set(BLUEZ_ADAPTER_ID, 'Powered', new dbus.Variant('b', 0)); + await delay(1000); + } + + 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 [adapter, dev, service, characteristic] = path.split('/').slice(3); + 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}`); + + await this._initDiscoveredPlejdDevice(path); + } else { + logger.error('Uh oh, no Plejd device!'); + } + } else { + logger.silly('Not the right device id'); + } + } + + turnOn(deviceId, command) { + const deviceName = this.deviceRegistry.getDeviceName(deviceId); + logger.info( + `Plejd got turn on command for ${deviceName} (${deviceId}), brightness ${command.brightness}${ + command.transition ? `, transition: ${command.transition}` : '' + }`, + ); + this._transitionTo(deviceId, command.brightness, command.transition, deviceName); + } + + turnOff(deviceId, command) { + const deviceName = this.deviceRegistry.getDeviceName(deviceId); + logger.info( + `Plejd got turn off command for ${deviceName} (${deviceId})${ + command.transition ? `, transition: ${command.transition}` : '' + }`, + ); + this._transitionTo(deviceId, 0, command.transition, deviceName); + } + + _clearDeviceTransitionTimer(deviceId) { + if (this.bleDeviceTransitionTimers[deviceId]) { + clearInterval(this.bleDeviceTransitionTimers[deviceId]); + } + } + + _transitionTo(deviceId, targetBrightness, transition, deviceName) { + const initialBrightness = this.plejdDevices[deviceId] + ? this.plejdDevices[deviceId].state && this.plejdDevices[deviceId].dim + : null; + this._clearDeviceTransitionTimer(deviceId); + + const isDimmable = this.deviceRegistry.getDevice(deviceId).dimmable; + + 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; + + logger.debug( + `transitioning from ${initialBrightness} to ${targetBrightness} ${ + transition ? `in ${transition} seconds` : '' + }.`, + ); + logger.verbose( + `delta brightness ${deltaBrightness}, steps ${transitionSteps}, interval ${transitionInterval} ms`, + ); + + const dtStart = new Date(); + + let nSteps = 0; + + this.bleDeviceTransitionTimers[deviceId] = setInterval(() => { + const tElapsedMs = new Date().getTime() - dtStart.getTime(); + let tElapsed = tElapsedMs / 1000; + + if (tElapsed > transition || tElapsed < 0) { + tElapsed = transition; + } + + let newBrightness = Math.round( + initialBrightness + (deltaBrightness * tElapsed) / transition, + ); + + if (tElapsed === transition) { + nSteps++; + this._clearDeviceTransitionTimer(deviceId); + newBrightness = targetBrightness; + logger.debug( + `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 { + nSteps++; + logger.verbose( + `Queueing dim transition for ${deviceName} (${deviceId}) to ${newBrightness}. Total queue length ${this.writeQueue.length}`, + ); + this._setBrightness(deviceId, newBrightness, false, deviceName); + } + }, transitionInterval); + } else { + if (transition && isDimmable) { + logger.debug( + `Could not transition light change. Either initial value is unknown or change is too small. Requested from ${initialBrightness} to ${targetBrightness}`, + ); + } + this._setBrightness(deviceId, targetBrightness, true, deviceName); + } + } + + _setBrightness(deviceId, brightness, shouldRetry, deviceName) { + let payload = null; + let log = ''; + + if (!brightness && brightness !== 0) { + logger.debug( + `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'; + } else if (brightness <= 0) { + logger.debug(`Queueing turn off ${deviceId}`); + payload = Buffer.from(`${deviceId.toString(16).padStart(2, '0')}0110009700`, 'hex'); + log = 'OFF'; + } else { + if (brightness > 255) { + // eslint-disable-next-line no-param-reassign + brightness = 255; + } + + logger.debug(`Queueing ${deviceId} set brightness to ${brightness}`); + // eslint-disable-next-line no-bitwise + 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}`; + } + this.writeQueue.unshift({ + deviceId, + log, + shouldRetry, + payload, + }); + } + + 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.verbose('startReconnectPeriodicallyLoop'); + if (this.reconnectInProgress) { + logger.debug('Reconnect already in progress. Skipping this call.'); + return; + } + clearInterval(this.pingRef); + clearTimeout(this.writeQueueRef); + this.reconnectInProgress = true; + + /* eslint-disable no-await-in-loop */ + // eslint-disable-next-line no-constant-condition + while (true) { + try { + await delay(5000); + this.emit('reconnecting'); + 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(data) { + if (!data || !this.plejdService || !this.characteristics.data) { + logger.debug('data, plejdService or characteristics not available. Cannot write()'); + return false; + } + + try { + logger.verbose(`Sending ${data.length} byte(s) of data to Plejd. ${data.toString('hex')}`); + const encryptedData = this._encryptDecrypt(this.cryptoKey, this.plejdService.addr, data); + await this.characteristics.data.WriteValue([...encryptedData], {}); + await this.onWriteSuccess(); + return true; + } catch (err) { + if (err.message === 'In Progress') { + logger.debug("Write failed due to 'In progress' ", err); + } else { + logger.debug('Write failed ', err); + } + await this.onWriteFailed(err); + return false; + } + } + + 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; + } + logger.verbose(`Made it ${errorIndicatesDisconnected} || ${this.consecutiveWriteFails >= 5}`); + + 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() { + logger.info('Requesting current Plejd clock time...'); + + // Eg: 0b0102001b: 0b: id, 0102: read, 001b: time + const payload = Buffer.from( + `${this.connectedDevice.id.toString(16).padStart(2, '0')}0102${BLE_CMD_TIME_UPDATE.toString( + 16, + ).padStart(4, '0')}`, + 'hex', + ); + this.writeQueue.unshift({ + deviceId: this.connectedDevice.id, + log: 'RequestTime', + shouldRetry: true, + payload, + }); + setTimeout(() => this._requestCurrentPlejdTime(), 1000 * 3600); // Once per hour + } + + startWriteQueue() { + logger.info('startWriteQueue()'); + clearTimeout(this.writeQueueRef); + + this.writeQueueRef = setTimeout(() => this.runWriteQueue(), this.config.writeQueueWaitTime); + } + + async runWriteQueue() { + try { + while (this.writeQueue.length > 0) { + const queueItem = this.writeQueue.pop(); + const deviceName = this.deviceRegistry.getDeviceName(queueItem.deviceId); + logger.debug( + `Write queue: Processing ${deviceName} (${queueItem.deviceId}). Command ${queueItem.log}. Total queue length: ${this.writeQueue.length}`, + ); + + if (this.writeQueue.some((item) => item.deviceId === queueItem.deviceId)) { + logger.verbose( + `Skipping ${deviceName} (${queueItem.deviceId}) ` + + `${queueItem.log} due to more recent command in queue.`, + ); + // Skip commands if new ones exist for the same deviceId + // still process all messages in order + } else { + // eslint-disable-next-line no-await-in-loop + const success = await this.write(queueItem.payload); + if (!success && queueItem.shouldRetry) { + queueItem.retryCount = (queueItem.retryCount || 0) + 1; + logger.debug(`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 { + logger.error( + `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) { + logger.error('Error in writeQueue loop, values probably not written to Plejd', e); + } + + this.writeQueueRef = setTimeout(() => this.runWriteQueue(), this.config.writeQueueWaitTime); + } + + 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 data = value.value; + const decoded = this._encryptDecrypt(this.cryptoKey, this.plejdService.addr, data); + + 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}`, + ); + } + + if (cmd === BLE_CMD_DIM_CHANGE || cmd === BLE_CMD_DIM2_CHANGE) { + logger.debug(`${deviceName} (${deviceId}) got state+dim update. S: ${state}, D: ${dim}`); + + this.emit('stateChanged', deviceId, { + state, + brightness: dim, + }); + + this.plejdDevices[deviceId] = { + state, + dim, + }; + logger.silly(`All states: ${JSON.stringify(this.plejdDevices, null, 2)}`); + } else if (cmd === BLE_CMD_STATE_CHANGE) { + logger.debug(`${deviceName} (${deviceId}) got state update. S: ${state}`); + this.emit('stateChanged', deviceId, { + state, + }); + this.plejdDevices[deviceId] = { + state, + dim: 0, + }; + logger.silly(`All states: ${JSON.stringify(this.plejdDevices, null, 2)}`); + } 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.`, + ); + + this.emit('sceneTriggered', deviceId, sceneId); + } else if (cmd === BLE_CMD_TIME_UPDATE) { + const now = new Date(); + // Guess Plejd timezone based on HA time zone + const offsetSecondsGuess = now.getTimezoneOffset() * 60; + + // 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) { + const newLocalTimestamp = now.getTime() / 1000 - offsetSecondsGuess; + logger.info(`Setting time to ${now.toString()}`); + const payload = Buffer.alloc(10); + // E.g: 00 0110 001b 38df2360 00 + // 00: set?, 0110: don't respond, 001b: time command, 38df236000: the time + payload.write('000110001b', 0, 'hex'); + payload.writeInt32LE(Math.trunc(newLocalTimestamp), 5); + payload.write('00', 9, 'hex'); + this.writeQueue.unshift({ + deviceId: this.connectedDevice.id, + log: 'SetTime', + shouldRetry: true, + payload, + }); + } + } 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})`, + ); + } + } + + // 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; diff --git a/plejd/PlejdService.js b/plejd/PlejdService.js deleted file mode 100644 index c941a1b..0000000 --- a/plejd/PlejdService.js +++ /dev/null @@ -1,807 +0,0 @@ -const dbus = require('dbus-next'); -const crypto = require('crypto'); -const xor = require('buffer-xor'); -const EventEmitter = require('events'); -const Logger = require('./Logger'); - -const logger = Logger.getLogger('plejd-ble'); - -// UUIDs -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'; - -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'; -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 MAX_TRANSITION_STEPS_PER_SECOND = 5; // Could be made a setting -const MAX_RETRY_COUNT = 5; // Could be made a setting - -class PlejdService extends EventEmitter { - constructor(cryptoKey, devices, sceneManager, connectionTimeout, writeQueueWaitTime) { - super(); - - logger.info('Starting Plejd BLE, resetting all device states.'); - - this.cryptoKey = Buffer.from(cryptoKey.replace(/-/g, ''), 'hex'); - - this.sceneManager = sceneManager; - this.connectedDevice = null; - this.plejdService = null; - this.bleDevices = []; - this.bleDeviceTransitionTimers = {}; - this.plejdDevices = {}; - this.devices = devices; - this.connectEventHooked = false; - this.connectionTimeout = connectionTimeout; - this.writeQueueWaitTime = writeQueueWaitTime; - this.writeQueue = []; - this.writeQueueRef = null; - this.initInProgress = null; - - // Holds a reference to all characteristics - this.characteristics = { - data: null, - lastData: null, - lastDataProperties: null, - auth: null, - ping: null, - }; - - this.bus = dbus.systemBus(); - this.adapter = null; - - logger.debug('wiring events and waiting for BLE interface to power up.'); - this.wireEvents(); - } - - async init() { - if (this.objectManager) { - this.objectManager.removeAllListeners(); - } - - this.bleDevices = []; - this.connectedDevice = null; - - this.characteristics = { - data: null, - lastData: null, - lastDataProperties: null, - auth: null, - ping: null, - }; - - clearInterval(this.pingRef); - clearTimeout(this.writeQueueRef); - logger.info('init()'); - - 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 result = await this._getInterface(managedObjects, BLUEZ_ADAPTER_ID); - - if (result) { - this.adapter = result[1]; - } - - if (!this.adapter) { - logger.error('Unable to find a bluetooth adapter that is compatible.'); - return Promise.reject(new Error('Unable to find a bluetooth adapter that is compatible.')); - } - - // eslint-disable-next-line no-restricted-syntax - for (const path of Object.keys(managedObjects)) { - /* eslint-disable no-await-in-loop */ - 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); - - const connected = managedObjects[path][BLUEZ_DEVICE_ID].Connected.value; - - if (connected) { - logger.info(`disconnecting ${path}`); - await device.Disconnect(); - } - - await this.adapter.RemoveDevice(path); - } - /* eslint-enable no-await-in-loop */ - } - - this.objectManager.on('InterfacesAdded', this.onInterfacesAdded.bind(this)); - - this.adapter.SetDiscoveryFilter({ - UUIDs: new dbus.Variant('as', [PLEJD_SERVICE]), - Transport: new dbus.Variant('s', 'le'), - }); - - try { - await this.adapter.StartDiscovery(); - } catch (err) { - logger.error('Failed to start discovery. Make sure no other add-on is currently scanning.'); - return Promise.reject( - new Error('Failed to start discovery. Make sure no other add-on is currently scanning.'), - ); - } - return new Promise((resolve) => setTimeout( - () => resolve( - this._internalInit().catch((err) => { - logger.error('InternalInit exception! Will rethrow.', err); - throw err; - }), - ), - this.connectionTimeout * 1000, - )); - } - - async _internalInit() { - logger.debug(`Got ${this.bleDevices.length} device(s).`); - - // eslint-disable-next-line no-restricted-syntax - for (const plejd of this.bleDevices) { - /* eslint-disable no-await-in-loop */ - logger.debug(`Inspecting ${plejd.path}`); - - 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); - - 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.devices.find((x) => x.serialNumber === fixedPlejdPath); - - logger.debug(`Discovered ${plejd.path} with rssi ${plejd.rssi}`); - } catch (err) { - logger.error(`Failed inspecting ${plejd.path}. `, err); - } - /* eslint-enable no-await-in-loop */ - } - - const sortedDevices = this.bleDevices.sort((a, b) => b.rssi - a.rssi); - let connectedDevice = null; - - // eslint-disable-next-line no-restricted-syntax - for (const plejd of sortedDevices) { - try { - if (plejd.instance) { - logger.info(`Connecting to ${plejd.path}`); - // eslint-disable-next-line no-await-in-loop - await plejd.instance.Connect(); - connectedDevice = plejd; - break; - } - } catch (err) { - logger.error('Warning: unable to connect, will retry. ', err); - } - } - - setTimeout(async () => { - await this.onDeviceConnected(connectedDevice); - await this.adapter.StopDiscovery(); - }, this.connectionTimeout * 1000); - } - - async _getInterface(managedObjects, iface) { - const managedPaths = Object.keys(managedObjects); - - // eslint-disable-next-line no-restricted-syntax - for (const path of managedPaths) { - const pathInterfaces = Object.keys(managedObjects[path]); - if (pathInterfaces.indexOf(iface) > -1) { - logger.debug(`Found BLE interface '${iface}' at ${path}`); - try { - // eslint-disable-next-line no-await-in-loop - const adapterObject = await this.bus.getProxyObject(BLUEZ_SERVICE_NAME, path); - return [path, adapterObject.getInterface(iface), adapterObject]; - } catch (err) { - logger.error(`Failed to get interface '${iface}'. `, err); - } - } - } - - return null; - } - - async onInterfacesAdded(path, interfaces) { - // const [adapter, dev, service, characteristic] = path.split('/').slice(3); - 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.bleDevices.push({ - path, - }); - } else { - logger.error('Uh oh, no Plejd device!'); - } - } - } - - turnOn(deviceId, command) { - const deviceName = this._getDeviceName(deviceId); - logger.info( - `Plejd got turn on command for ${deviceName} (${deviceId}), brightness ${command.brightness}${ - command.transition ? `, transition: ${command.transition}` : '' - }`, - ); - this._transitionTo(deviceId, command.brightness, command.transition, deviceName); - } - - turnOff(deviceId, command) { - const deviceName = this._getDeviceName(deviceId); - logger.info( - `Plejd got turn off command for ${deviceName} (${deviceId})${ - command.transition ? `, transition: ${command.transition}` : '' - }`, - ); - this._transitionTo(deviceId, 0, command.transition, deviceName); - } - - _clearDeviceTransitionTimer(deviceId) { - if (this.bleDeviceTransitionTimers[deviceId]) { - clearInterval(this.bleDeviceTransitionTimers[deviceId]); - } - } - - _transitionTo(deviceId, targetBrightness, transition, deviceName) { - const initialBrightness = this.plejdDevices[deviceId] - ? this.plejdDevices[deviceId].state && this.plejdDevices[deviceId].dim - : null; - this._clearDeviceTransitionTimer(deviceId); - - const isDimmable = this.devices.find((d) => d.id === deviceId).dimmable; - - 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; - - logger.debug( - `transitioning from ${initialBrightness} to ${targetBrightness} ${ - transition ? `in ${transition} seconds` : '' - }.`, - ); - logger.verbose( - `delta brightness ${deltaBrightness}, steps ${transitionSteps}, interval ${transitionInterval} ms`, - ); - - const dtStart = new Date(); - - let nSteps = 0; - - this.bleDeviceTransitionTimers[deviceId] = setInterval(() => { - const tElapsedMs = new Date().getTime() - dtStart.getTime(); - let tElapsed = tElapsedMs / 1000; - - if (tElapsed > transition || tElapsed < 0) { - tElapsed = transition; - } - - let newBrightness = Math.round( - initialBrightness + (deltaBrightness * tElapsed) / transition, - ); - - if (tElapsed === transition) { - nSteps++; - this._clearDeviceTransitionTimer(deviceId); - newBrightness = targetBrightness; - logger.debug( - `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 { - nSteps++; - logger.verbose( - `Queueing dim transition for ${deviceName} (${deviceId}) to ${newBrightness}. Total queue length ${this.writeQueue.length}`, - ); - this._setBrightness(deviceId, newBrightness, false, deviceName); - } - }, transitionInterval); - } else { - if (transition && isDimmable) { - logger.debug( - `Could not transition light change. Either initial value is unknown or change is too small. Requested from ${initialBrightness} to ${targetBrightness}`, - ); - } - this._setBrightness(deviceId, targetBrightness, true, deviceName); - } - } - - _setBrightness(deviceId, brightness, shouldRetry, deviceName) { - let payload = null; - let log = ''; - - if (!brightness && brightness !== 0) { - logger.debug( - `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'; - } else if (brightness <= 0) { - logger.debug(`Queueing turn off ${deviceId}`); - payload = Buffer.from(`${deviceId.toString(16).padStart(2, '0')}0110009700`, 'hex'); - log = 'OFF'; - } else { - if (brightness > 255) { - // eslint-disable-next-line no-param-reassign - brightness = 255; - } - - logger.debug(`Queueing ${deviceId} set brightness to ${brightness}`); - // eslint-disable-next-line no-bitwise - 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}`; - } - this.writeQueue.unshift({ - deviceId, - log, - shouldRetry, - payload, - }); - } - - triggerScene(sceneIndex) { - const sceneName = this._getDeviceName(sceneIndex); - logger.info( - `Triggering scene ${sceneName} (${sceneIndex}). Scene name might be misleading if there is a device with the same numeric id.`, - ); - this.sceneManager.executeScene(sceneIndex, this); - } - - 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); - } - - // auth done, start ping - this.startPing(); - this.startWriteQueue(); - - // 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(); - } - - async throttledInit(delay) { - if (this.initInProgress) { - logger.debug( - 'ThrottledInit already in progress. Skipping this call and returning existing promise.', - ); - return this.initInProgress; - } - this.initInProgress = new Promise((resolve) => setTimeout(async () => { - const result = await this.init().catch((err) => { - logger.error('TrottledInit exception calling init(). Will re-throw.', err); - throw err; - }); - this.initInProgress = null; - resolve(result); - }, delay)); - return this.initInProgress; - } - - async write(data) { - if (!data || !this.plejdService || !this.characteristics.data) { - logger.debug('data, plejdService or characteristics not available. Cannot write()'); - return false; - } - - try { - logger.verbose(`Sending ${data.length} byte(s) of data to Plejd. ${data.toString('hex')}`); - const encryptedData = this._encryptDecrypt(this.cryptoKey, this.plejdService.addr, data); - await this.characteristics.data.WriteValue([...encryptedData], {}); - return true; - } catch (err) { - if (err.message === 'In Progress') { - logger.debug("Write failed due to 'In progress' ", err); - } else { - logger.debug('Write failed ', err); - } - await this.throttledInit(this.connectionTimeout * 1000); - return false; - } - } - - 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 - onPingSuccess(nr) { - logger.silly(`pong: ${nr}`); - } - - async onPingFailed(error) { - logger.debug(`onPingFailed(${error})`); - logger.info('ping failed, reconnecting.'); - - clearInterval(this.pingRef); - return this.init().catch((err) => { - logger.error('onPingFailed exception calling init(). Will swallow error.', err); - }); - } - - 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.error('Error writing to plejd: ', err); - this.emit('pingFailed', 'write error'); - return; - } - - // eslint-disable-next-line no-bitwise - if (((ping[0] + 1) & 0xff) !== pong[0]) { - logger.error('Plejd ping failed'); - this.emit('pingFailed', `plejd ping failed ${ping[0]} - ${pong[0]}`); - return; - } - - this.emit('pingSuccess', pong[0]); - } - - startWriteQueue() { - logger.info('startWriteQueue()'); - clearTimeout(this.writeQueueRef); - - this.writeQueueRef = setTimeout(() => this.runWriteQueue(), this.writeQueueWaitTime); - } - - async runWriteQueue() { - try { - while (this.writeQueue.length > 0) { - const queueItem = this.writeQueue.pop(); - const deviceName = this._getDeviceName(queueItem.deviceId); - logger.debug( - `Write queue: Processing ${deviceName} (${queueItem.deviceId}). Command ${queueItem.log}. Total queue length: ${this.writeQueue.length}`, - ); - - if (this.writeQueue.some((item) => item.deviceId === queueItem.deviceId)) { - logger.verbose( - `Skipping ${deviceName} (${queueItem.deviceId}) ` - + `${queueItem.log} due to more recent command in queue.`, - ); - // Skip commands if new ones exist for the same deviceId - // still process all messages in order - } else { - // eslint-disable-next-line no-await-in-loop - const success = await this.write(queueItem.payload); - if (!success && queueItem.shouldRetry) { - queueItem.retryCount = (queueItem.retryCount || 0) + 1; - logger.debug(`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 { - logger.error( - `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) { - logger.error('Error in writeQueue loop, values probably not written to Plejd', e); - } - - this.writeQueueRef = setTimeout(() => this.runWriteQueue(), this.writeQueueWaitTime); - } - - 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.debug('found DATA characteristic.'); - this.characteristics.data = ch; - } else if (chUuid === LAST_DATA_UUID) { - logger.debug('found LAST_DATA characteristic.'); - this.characteristics.lastData = ch; - this.characteristics.lastDataProperties = prop; - } else if (chUuid === AUTH_UUID) { - logger.debug('found AUTH characteristic.'); - this.characteristics.auth = ch; - } else if (chUuid === PING_UUID) { - logger.debug('found PING characteristic.'); - this.characteristics.ping = ch; - } - /* eslint-eslint no-await-in-loop */ - } - - return { - addr, - }; - } - - async onDeviceConnected(device) { - logger.info('onDeviceConnected()'); - logger.debug(`Device: ${device}`); - if (!device) { - logger.error('Device is null. Should we break/return when this happens?'); - } - - const objects = await this.objectManager.GetManagedObjects(); - const paths = Object.keys(objects); - const characteristics = []; - - // eslint-disable-next-line no-restricted-syntax - for (const path of paths) { - const interfaces = Object.keys(objects[path]); - if (interfaces.indexOf(GATT_CHRC_ID) > -1) { - characteristics.push(path); - } - } - - // 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.info(`trying ${chPaths.length} characteristics`); - - this.plejdService = await this._processPlejdService(path, chPaths); - if (this.plejdService) { - break; - } - } - } - - if (!this.plejdService) { - logger.info("warning: wasn't able to connect to Plejd, will retry."); - this.emit('connectFailed'); - return; - } - - if (!this.characteristics.auth) { - logger.error('unable to enumerate characteristics.'); - this.emit('connectFailed'); - return; - } - - this.connectedDevice = device.device; - await this.authenticate(); - } - - // eslint-disable-next-line no-unused-vars - async onLastDataUpdated(iface, properties, invalidated) { - 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 data = value.value; - const decoded = this._encryptDecrypt(this.cryptoKey, this.plejdService.addr, data); - - 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 - // eslint-disable-next-line no-bitwise - const data2 = parseInt(decoded.toString('hex', 6, 8), 16) >> 8; - - if (decoded.length < 5) { - logger.debug(`Too short raw event ignored: ${decoded.toString('hex')}`); - // ignore the notification since too small - return; - } - - const deviceName = this._getDeviceName(deviceId); - logger.verbose(`Raw event received: ${decoded.toString('hex')}`); - logger.verbose( - `Device ${deviceId}, cmd ${cmd.toString('hex')}, state ${state}, dim/data2 ${data2}`, - ); - - if (cmd === BLE_CMD_DIM_CHANGE || cmd === BLE_CMD_DIM2_CHANGE) { - const dim = data2; - - logger.debug(`${deviceName} (${deviceId}) got state+dim update. S: ${state}, D: ${dim}`); - - this.emit('stateChanged', deviceId, { - state, - brightness: dim, - }); - - this.plejdDevices[deviceId] = { - state, - dim, - }; - logger.verbose(`All states: ${JSON.stringify(this.plejdDevices)}`); - } else if (cmd === BLE_CMD_STATE_CHANGE) { - logger.debug(`${deviceName} (${deviceId}) got state update. S: ${state}`); - this.emit('stateChanged', deviceId, { - state, - }); - this.plejdDevices[deviceId] = { - state, - dim: 0, - }; - logger.verbose(`All states: ${this.plejdDevices}`); - } else if (cmd === BLE_CMD_SCENE_TRIG) { - const sceneId = parseInt(decoded.toString('hex', 5, 6), 16); - const sceneName = this._getDeviceName(sceneId); - - logger.debug( - `${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') { - logger.silly('Command 001b seems to be some kind of often repeating ping/mesh data'); - } else { - logger.verbose(`Command ${cmd.toString('hex')} unknown. Device ${deviceName} (${deviceId})`); - } - } - - wireEvents() { - logger.info('wireEvents()'); - const self = this; - - this.on('pingFailed', this.onPingFailed.bind(self)); - this.on('pingSuccess', this.onPingSuccess.bind(self)); - } - - // 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'); - } - - _getDeviceName(deviceId) { - return (this.devices.find((d) => d.id === deviceId) || {}).name; - } - - // 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 = PlejdService; diff --git a/plejd/README.md b/plejd/README.md index ef101f5..3a16cf0 100644 --- a/plejd/README.md +++ b/plejd/README.md @@ -67,7 +67,7 @@ Browse your Hass.io installation using a tool that allows you to manage files, f ### Install older versions or developemnt version -To install older versions, follow the "Manual Installation" instructions above, but copy the code from [one of the releases](https://github.com/icanos/hassio-plejd/releases). To test new functionality you can download the development version, available in the [develop branch](https://github.com/icanos/hassio-plejd/tree/feature/develop). +To install older versions, follow the "Manual Installation" instructions above, but copy the code from [one of the releases](https://github.com/icanos/hassio-plejd/releases). To test new functionality you can download the development version, available in the [develop branch](https://github.com/icanos/hassio-plejd/tree/develop). ### IMPORTANT INFORMATION @@ -121,20 +121,21 @@ The above is used to notify the add-on when Home Assistant has started successfu The plugin needs you to configure some settings before working. You find these on the Add-on page after you've installed it. -| Parameter | Value | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| site | Name of your Plejd site, the name is displayed in the Plejd app (top bar). | -| username | Username of your Plejd account, this is used to fetch the crypto key and devices from the Plejd API. | -| password | Password of your Plejd account, this is used to fetch the crypto key and devices from the Plejd API. | -| mqttBroker | URL of the MQTT Broker, eg. mqtt://localhost | -| mqttUsername | Username of the MQTT broker | -| mqttPassword | Password of the MQTT broker | -| includeRoomsAsLights | Adds all rooms as lights, making it possible to turn on/off lights by room instead. Setting this to false will ignore all rooms. | -| logLevel | Minimim log level. Supported values are `error`, `warn`, `info`, `debug`, `verbose`, `silly` with increasing amount of logging. Do not log more than `info` for production purposes. | -| connectionTimeout | Number of seconds to wait when scanning and connecting. Might need to be tweaked on platforms other than RPi 4. Defaults to: 2 seconds. | -| writeQueueWaitTime | Wait time between message sent to Plejd over BLE, defaults to 400. If that doesn't work, try changing the value higher in steps of 50. | +| Parameter | Value | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| site | Name of your Plejd site, the name is displayed in the Plejd app (top bar). | +| username | Username of your Plejd account, this is used to fetch the crypto key and devices from the Plejd API. | +| password | Password of your Plejd account, this is used to fetch the crypto key and devices from the Plejd API. | +| mqttBroker | URL of the MQTT Broker, eg. mqtt://localhost | +| mqttUsername | Username of the MQTT broker | +| mqttPassword | Password of the MQTT broker | +| includeRoomsAsLights | Adds all rooms as lights, making it possible to turn on/off lights by room instead. Setting this to false will ignore all rooms. | +| updatePlejdClock | Hourly update Plejd devices' clock if out of sync. Clock is used for time-based scenes. Not recommended if you have a Plejd gateway. Clock updates may flicker scene-controlled devices. | +| logLevel | Minimim log level. Supported values are `error`, `warn`, `info`, `debug`, `verbose`, `silly` with increasing amount of logging. Do not log more than `info` for production purposes. | +| connectionTimeout | Number of seconds to wait when scanning and connecting. Might need to be tweaked on platforms other than RPi 4. Defaults to: 2 seconds. | +| writeQueueWaitTime | Wait time between message sent to Plejd over BLE, defaults to 400. If that doesn't work, try changing the value higher in steps of 50. | -## Having issues to get the addon working? +## Troubleshooting If you're having issues to get the addon working, there are a few things you can look into: diff --git a/plejd/SceneManager.js b/plejd/SceneManager.js index ee3bc18..a30e58b 100644 --- a/plejd/SceneManager.js +++ b/plejd/SceneManager.js @@ -1,48 +1,54 @@ -/* eslint-disable max-classes-per-file */ const EventEmitter = require('events'); +const Logger = require('./Logger'); const Scene = require('./Scene'); +const logger = Logger.getLogger('scene-manager'); class SceneManager extends EventEmitter { - constructor(site, devices) { + deviceRegistry; + plejdBle; + scenes; + + constructor(deviceRegistry, plejdBle) { super(); - this.site = site; - this.scenes = []; - this.devices = devices; - - this.init(); + this.deviceRegistry = deviceRegistry; + this.plejdBle = plejdBle; + this.scenes = {}; } init() { - const scenes = this.site.scenes.filter((x) => x.hiddenFromSceneList === false); - // eslint-disable-next-line no-restricted-syntax - for (const scene of scenes) { - const idx = this.site.sceneIndex[scene.sceneId]; - this.scenes.push(new Scene(idx, scene, this.site.sceneSteps)); - } + const scenes = this.deviceRegistry.apiSite.scenes.filter( + (x) => x.hiddenFromSceneList === false, + ); + + this.scenes = {}; + scenes.forEach((scene) => { + const idx = this.deviceRegistry.apiSite.sceneIndex[scene.sceneId]; + this.scenes[idx] = new Scene(idx, scene, this.deviceRegistry.apiSite.sceneSteps); + }); } - executeScene(sceneIndex, ble) { - const scene = this.scenes.find((x) => x.id === sceneIndex); + executeScene(sceneId) { + const scene = this.scenes[sceneId]; if (!scene) { + logger.info(`Scene with id ${sceneId} not found`); + logger.verbose(`Scenes: ${JSON.stringify(this.scenes, null, 2)}`); return; } - // eslint-disable-next-line no-restricted-syntax - for (const step of scene.steps) { - const device = this.devices.find((x) => x.serialNumber === step.deviceId); + scene.steps.forEach((step) => { + const device = this.deviceRegistry.getDeviceBySerialNumber(step.deviceId); if (device) { if (device.dimmable && step.state) { - ble.turnOn(device.id, { brightness: step.brightness }); + this.plejdBle.turnOn(device.id, { brightness: step.brightness }); } else if (!device.dimmable && step.state) { - ble.turnOn(device.id, {}); + this.plejdBle.turnOn(device.id, {}); } else if (!step.state) { - ble.turnOff(device.id, {}); + this.plejdBle.turnOff(device.id, {}); } } - } + }); } } module.exports = SceneManager; -/* eslint-disable */ diff --git a/plejd/config.json b/plejd/config.json index aea6676..8deb919 100644 --- a/plejd/config.json +++ b/plejd/config.json @@ -1,6 +1,6 @@ { "name": "Plejd", - "version": "0.5.1", + "version": "0.6.1", "slug": "plejd", "description": "Adds support for the Swedish home automation devices from Plejd.", "url": "https://github.com/icanos/hassio-plejd/", @@ -18,6 +18,8 @@ "mqttUsername": "", "mqttPassword": "", "includeRoomsAsLights": false, + "preferCachedApiResponse": false, + "updatePlejdClock": false, "logLevel": "info", "connectionTimeout": 2, "writeQueueWaitTime": 400 @@ -30,6 +32,8 @@ "mqttUsername": "str", "mqttPassword": "str", "includeRoomsAsLights": "bool", + "preferCachedApiResponse": "bool", + "updatePlejdClock": "bool", "logLevel": "list(error|warn|info|debug|verbose|silly)", "connectionTimeout": "int", "writeQueueWaitTime": "int" diff --git a/plejd/main.js b/plejd/main.js index 1136140..ce03914 100644 --- a/plejd/main.js +++ b/plejd/main.js @@ -1,144 +1,28 @@ -const PlejdApi = require('./PlejdApi'); -const MqttClient = require('./MqttClient'); - -const Logger = require('./Logger'); -const PlejdService = require('./PlejdService'); -const SceneManager = require('./SceneManager'); const Configuration = require('./Configuration'); - -const logger = Logger.getLogger('plejd-main'); - -const version = '0.5.1'; +const Logger = require('./Logger'); +const PlejdAddon = require('./PlejdAddon'); async function main() { - logger.info(`Starting Plejd add-on v. ${version}`); + try { + // eslint-disable-next-line no-console + console.log('Starting Plejd addon and reading configuration...'); - const config = Configuration.getConfiguration(); + const addonInfo = Configuration.getAddonInfo(); + const logger = Logger.getLogger('plejd-main'); - if (!config.connectionTimeout) { - config.connectionTimeout = 2; + logger.info(`Plejd add-on, version ${addonInfo.version}`); + logger.verbose(`Addon info: ${JSON.stringify(addonInfo)}`); + + const addon = new PlejdAddon(); + + await addon.init(); + + logger.info('main() finished'); + } catch (err) { + // eslint-disable-next-line no-console + console.log('Catastrophic error. Resetting entire addon in 1 minute', err); + setTimeout(() => main(), 60000); } - - const plejdApi = new PlejdApi( - config.site, - config.username, - config.password, - config.includeRoomsAsLights, - ); - const client = new MqttClient(config.mqttBroker, config.mqttUsername, config.mqttPassword); - - ['SIGINT', 'SIGHUP', 'SIGTERM'].forEach((signal) => { - process.on(signal, () => { - client.disconnect(() => process.exit(0)); - }); - }); - - plejdApi.login().then(() => { - // load all sites and find the one that we want (from config) - plejdApi.getSites().then((site) => { - // load the site and retrieve the crypto key - plejdApi.getSite(site.site.siteId).then((cryptoKey) => { - // parse all devices from the API - const devices = plejdApi.getDevices(); - - client.on('connected', () => { - try { - logger.verbose('connected to mqtt.'); - client.discover(devices); - } catch (err) { - logger.error('Error in MqttClient.connected callback in main.js', err); - } - }); - - client.init(); - - // init the BLE interface - const sceneManager = new SceneManager(plejdApi.site, devices); - const plejd = new PlejdService( - cryptoKey, - devices, - sceneManager, - config.connectionTimeout, - config.writeQueueWaitTime, - ); - plejd.on('connectFailed', () => { - logger.verbose('Were unable to connect, will retry connection in 10 seconds.'); - setTimeout(() => { - plejd - .init() - .catch((e) => logger.error('Error in init() from connectFailed in main.js', e)); - }, 10000); - }); - - plejd.init(); - - plejd.on('authenticated', () => { - logger.verbose('plejd: connected via bluetooth.'); - }); - - // subscribe to changes from Plejd - plejd.on('stateChanged', (deviceId, command) => { - try { - client.updateState(deviceId, command); - } catch (err) { - logger.error('Error in PlejdService.stateChanged callback in main.js', err); - } - }); - - plejd.on('sceneTriggered', (deviceId, scene) => { - try { - client.sceneTriggered(scene); - } catch (err) { - logger.error('Error in PlejdService.sceneTriggered callback in main.js', err); - } - }); - - // subscribe to changes from HA - client.on('stateChanged', (device, command) => { - try { - const deviceId = device.id; - - if (device.typeName === 'Scene') { - // we're triggering a scene, lets do that and jump out. - // since scenes aren't "real" devices. - plejd.triggerScene(device.id); - return; - } - - let state = 'OFF'; - let commandObj = {}; - - if (typeof command === 'string') { - // switch command - state = command; - commandObj = { - state, - }; - - // since the switch doesn't get any updates on whether it's on or not, - // we fake this by directly send the updateState back to HA in order for - // it to change state. - client.updateState(deviceId, { - state: state === 'ON' ? 1 : 0, - }); - } else { - // eslint-disable-next-line prefer-destructuring - state = command.state; - commandObj = command; - } - - if (state === 'ON') { - plejd.turnOn(deviceId, commandObj); - } else { - plejd.turnOff(deviceId, commandObj); - } - } catch (err) { - logger.error('Error in MqttClient.stateChanged callback in main.js', err); - } - }); - }); - }); - }); } main(); diff --git a/plejd/package.json b/plejd/package.json index 71665b9..6ba45e5 100644 --- a/plejd/package.json +++ b/plejd/package.json @@ -1,13 +1,12 @@ { "dependencies": { - "@abandonware/bluetooth-hci-socket": "0.5.3-3", + "@abandonware/bluetooth-hci-socket": "~0.5.3-7", "axios": "~0.21.1", "buffer-xor": "~2.0.2", "dbus-next": "~0.9.1", "fs": "0.0.1-security", "jspack": "~0.0.4", "mqtt": "~3.0.0", - "sleep": "~6.1.0", "winston": "~3.3.3" }, "devDependencies": { diff --git a/plejd/test/test.ble.bluez.js b/plejd/test/test.ble.bluez.js index d6fa398..acd9022 100644 --- a/plejd/test/test.ble.bluez.js +++ b/plejd/test/test.ble.bluez.js @@ -1,6 +1,6 @@ -const PlejdService = require('../PlejdService'); +const PlejdBLE = require('../PlejdBLEHandler'); const cryptoKey = ''; -const plejd = new PlejdService(cryptoKey, true); +const plejd = new PlejdBLE(cryptoKey, true); plejd.init();