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/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 430d27b..19bf436 100644 --- a/plejd/Logger.js +++ b/plejd/Logger.js @@ -32,7 +32,7 @@ class Logger { } static getLogLevel() { - const config = Configuration.getConfiguration(); + const config = Configuration.getOptions(); // eslint-disable-next-line max-len const level = (config.logLevel && LEVELS.find((l) => l.startsWith(config.logLevel[0].toLowerCase()))) || 'info'; 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/PlejdService.js b/plejd/PlejdBLEHandler.js similarity index 56% rename from plejd/PlejdService.js rename to plejd/PlejdBLEHandler.js index b6f9c8c..e48b78d 100644 --- a/plejd/PlejdService.js +++ b/plejd/PlejdBLEHandler.js @@ -4,14 +4,17 @@ 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 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_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 = 0xc8; const BLE_CMD_DIM2_CHANGE = 0x98; @@ -30,27 +33,39 @@ 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) { +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, resetting all device states.'); + logger.info('Starting Plejd BLE Handler, 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; + this.config = Configuration.getOptions(); + this.deviceRegistry = deviceRegistry; // Holds a reference to all characteristics this.characteristics = { @@ -61,14 +76,29 @@ class PlejdService extends EventEmitter { ping: null, }; - this.bus = dbus.systemBus(); - this.adapter = null; - - logger.debug('wiring events and waiting for BLE interface to power up.'); - this.wireEvents(); + this.on('writeFailed', (error) => this.onWriteFailed(error)); + this.on('writeSuccess', () => this.onWriteSuccess()); } async init() { + logger.info('init()'); + + this.bus = dbus.systemBus(); + this.bus.on('connect', () => { + logger.verbose('dbus-next connected'); + }); + this.bus.on('error', (err) => { + // Uncaught error events will show UnhandledPromiseRejection logs + logger.verbose(`dbus-next error event: ${err.message}`); + }); + // 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(); } @@ -84,48 +114,224 @@ class PlejdService extends EventEmitter { ping: null, }; - clearInterval(this.pingRef); - clearTimeout(this.writeQueueRef); - logger.info('init()'); + 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, 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', ( + 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 result = await this._getInterface(managedObjects, BLUEZ_ADAPTER_ID); + const managedPaths = Object.keys(managedObjects); - if (result) { - this.adapter = result[1]; + 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); + } + } } - 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.')); - } + 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 */ - const interfaces = Object.keys(managedObjects[path]); + 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); + 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; + logger.verbose(`Found ${path}`); - if (connected) { - logger.info(`disconnecting ${path}`); - await device.Disconnect(); + 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); } - - await this.adapter.RemoveDevice(path); + } catch (err) { + logger.error(`Error handling ${path}`, err); } /* eslint-enable no-await-in-loop */ } - this.objectManager.on('InterfacesAdded', this.onInterfacesAdded.bind(this)); + 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]), @@ -133,115 +339,51 @@ class PlejdService extends EventEmitter { }); 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. 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.'), + 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.', ); } - 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; + _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}`); - this.bleDevices.push({ - 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._getDeviceName(deviceId); + const deviceName = this.deviceRegistry.getDeviceName(deviceId); logger.info( `Plejd got turn on command for ${deviceName} (${deviceId}), brightness ${command.brightness}${ command.transition ? `, transition: ${command.transition}` : '' @@ -251,7 +393,7 @@ class PlejdService extends EventEmitter { } turnOff(deviceId, command) { - const deviceName = this._getDeviceName(deviceId); + const deviceName = this.deviceRegistry.getDeviceName(deviceId); logger.info( `Plejd got turn off command for ${deviceName} (${deviceId})${ command.transition ? `, transition: ${command.transition}` : '' @@ -272,7 +414,7 @@ class PlejdService extends EventEmitter { : null; this._clearDeviceTransitionTimer(deviceId); - const isDimmable = this.devices.find((d) => d.id === deviceId).dimmable; + const isDimmable = this.deviceRegistry.getDevice(deviceId).dimmable; if ( transition > 1 @@ -386,14 +528,6 @@ class PlejdService extends EventEmitter { }); } - 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()'); @@ -407,37 +541,36 @@ class PlejdService extends EventEmitter { await this.characteristics.auth.WriteValue([...response], {}); } catch (err) { logger.error('Failed to authenticate: ', err); + throw new Error('Failed to authenticate'); } - - // 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; + async startReconnectPeriodicallyLoop() { + logger.verbose('startReconnectPeriodicallyLoop'); + if (this.reconnectInProgress) { + logger.debug('Reconnect already in progress. Skipping this call.'); + return; } - 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; + 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) { @@ -450,6 +583,7 @@ class PlejdService extends EventEmitter { 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') { @@ -457,7 +591,7 @@ class PlejdService extends EventEmitter { } else { logger.debug('Write failed ', err); } - await this.throttledInit(this.connectionTimeout * 1000); + await this.onWriteFailed(err); return false; } } @@ -473,18 +607,34 @@ class PlejdService extends EventEmitter { } // eslint-disable-next-line class-methods-use-this - onPingSuccess(nr) { - logger.silly(`pong: ${nr}`); + onWriteSuccess() { + this.consecutiveWriteFails = 0; } - async onPingFailed(error) { - logger.debug(`onPingFailed(${error})`); - logger.info('ping failed, reconnecting.'); + async onWriteFailed(error) { + this.consecutiveWriteFails++; + logger.debug(`onWriteFailed #${this.consecutiveWriteFails} in a row.`, error); + logger.verbose(`Error message: ${error.message}`); - clearInterval(this.pingRef); - return this.init().catch((err) => { - logger.error('onPingFailed exception calling init(). Will swallow error.', err); - }); + 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() { @@ -497,33 +647,34 @@ class PlejdService extends EventEmitter { 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'); + 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.error('Plejd ping failed'); - this.emit('pingFailed', `plejd ping failed ${ping[0]} - ${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; } - this.emit('pingSuccess', pong[0]); + logger.silly(`pong: ${pong[0]}`); + await this.onWriteSuccess(); } startWriteQueue() { logger.info('startWriteQueue()'); clearTimeout(this.writeQueueRef); - this.writeQueueRef = setTimeout(() => this.runWriteQueue(), this.writeQueueWaitTime); + this.writeQueueRef = setTimeout(() => this.runWriteQueue(), this.config.writeQueueWaitTime); } async runWriteQueue() { try { while (this.writeQueue.length > 0) { const queueItem = this.writeQueue.pop(); - const deviceName = this._getDeviceName(queueItem.deviceId); + const deviceName = this.deviceRegistry.getDeviceName(queueItem.deviceId); logger.debug( `Write queue: Processing ${deviceName} (${queueItem.deviceId}). Command ${queueItem.log}. Total queue length: ${this.writeQueue.length}`, ); @@ -559,7 +710,7 @@ class PlejdService extends EventEmitter { logger.error('Error in writeQueue loop, values probably not written to Plejd', e); } - this.writeQueueRef = setTimeout(() => this.runWriteQueue(), this.writeQueueWaitTime); + this.writeQueueRef = setTimeout(() => this.runWriteQueue(), this.config.writeQueueWaitTime); } async _processPlejdService(path, characteristics) { @@ -592,17 +743,17 @@ class PlejdService extends EventEmitter { const chUuid = (await prop.Get(GATT_CHRC_ID, 'UUID')).value; if (chUuid === DATA_UUID) { - logger.debug('found DATA characteristic.'); + logger.verbose('found DATA characteristic.'); this.characteristics.data = ch; } else if (chUuid === LAST_DATA_UUID) { - logger.debug('found LAST_DATA characteristic.'); + logger.verbose('found LAST_DATA characteristic.'); this.characteristics.lastData = ch; this.characteristics.lastDataProperties = prop; } else if (chUuid === AUTH_UUID) { - logger.debug('found AUTH characteristic.'); + logger.verbose('found AUTH characteristic.'); this.characteristics.auth = ch; } else if (chUuid === PING_UUID) { - logger.debug('found PING characteristic.'); + logger.verbose('found PING characteristic.'); this.characteristics.ping = ch; } /* eslint-eslint no-await-in-loop */ @@ -613,25 +764,26 @@ class PlejdService extends EventEmitter { }; } - async onDeviceConnected(device) { + async _onDeviceConnected(device) { + this.connectedDevice = null; logger.info('onDeviceConnected()'); - logger.debug(`Device: ${device}`); - if (!device) { - logger.error('Device is null. Should we break/return when this happens?'); - } + 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]); @@ -644,7 +796,7 @@ class PlejdService extends EventEmitter { } } - logger.info(`trying ${chPaths.length} characteristics`); + logger.verbose(`Trying ${chPaths.length} characteristics on ${path}...`); this.plejdService = await this._processPlejdService(path, chPaths); if (this.plejdService) { @@ -654,23 +806,25 @@ class PlejdService extends EventEmitter { } if (!this.plejdService) { - logger.info("warning: wasn't able to connect to Plejd, will retry."); - this.emit('connectFailed'); - return; + logger.warn("Wasn't able to connect to Plejd, will retry."); + return null; } if (!this.characteristics.auth) { logger.error('unable to enumerate characteristics.'); - this.emit('connectFailed'); - return; + 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, invalidated) { + async onLastDataUpdated(iface, properties) { if (iface !== GATT_CHRC_ID) { return; } @@ -691,7 +845,7 @@ class PlejdService extends EventEmitter { if (decoded.length < 5) { if (Logger.shouldLog('debug')) { // decoded.toString() could potentially be expensive - logger.debug(`Too short raw event ignored: ${decoded.toString('hex')}`); + logger.verbose(`Too short raw event ignored: ${decoded.toString('hex')}`); } // ignore the notification since too small return; @@ -705,11 +859,13 @@ class PlejdService extends EventEmitter { const dim = decoded.length > 7 ? decoded.readUInt8(7) : 0; // Bytes 8-9 are sometimes present, what are they? - const deviceName = this._getDeviceName(deviceId); + const deviceName = this.deviceRegistry.getDeviceName(deviceId); if (Logger.shouldLog('debug')) { // decoded.toString() could potentially be expensive - logger.debug(`Raw event received: ${decoded.toString('hex')}`); - logger.verbose(`Device ${deviceId}, cmd ${cmd.toString(16)}, state ${state}, dim ${dim}`); + 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) { @@ -724,7 +880,7 @@ class PlejdService extends EventEmitter { state, dim, }; - logger.verbose(`All states: ${JSON.stringify(this.plejdDevices)}`); + 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, { @@ -734,10 +890,10 @@ class PlejdService extends EventEmitter { state, dim: 0, }; - logger.verbose(`All states: ${this.plejdDevices}`); + logger.silly(`All states: ${JSON.stringify(this.plejdDevices, null, 2)}`); } else if (cmd === BLE_CMD_SCENE_TRIG) { const sceneId = state; - const sceneName = this._getDeviceName(sceneId); + 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.`, @@ -745,7 +901,9 @@ class PlejdService extends EventEmitter { this.emit('sceneTriggered', deviceId, sceneId); } else if (cmd === 0x1b) { - logger.silly('Command 001b seems to be some kind of often repeating ping/mesh data'); + logger.silly( + 'Command 001b is the time of the Plejd devices command, not implemented currently', + ); } else { logger.verbose( `Command ${cmd.toString(16)} unknown. ${decoded.toString( @@ -755,14 +913,6 @@ class PlejdService extends EventEmitter { } } - 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(); @@ -794,10 +944,6 @@ class PlejdService extends EventEmitter { 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); @@ -811,4 +957,4 @@ class PlejdService extends EventEmitter { } } -module.exports = PlejdService; +module.exports = PlejBLEHandler; diff --git a/plejd/README.md b/plejd/README.md index ef101f5..3791b1f 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 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..7cfccd9 100644 --- a/plejd/config.json +++ b/plejd/config.json @@ -1,6 +1,6 @@ { "name": "Plejd", - "version": "0.5.1", + "version": "0.6.0-dev", "slug": "plejd", "description": "Adds support for the Swedish home automation devices from Plejd.", "url": "https://github.com/icanos/hassio-plejd/", @@ -18,6 +18,7 @@ "mqttUsername": "", "mqttPassword": "", "includeRoomsAsLights": false, + "preferCachedApiResponse": false, "logLevel": "info", "connectionTimeout": 2, "writeQueueWaitTime": 400 @@ -30,6 +31,7 @@ "mqttUsername": "str", "mqttPassword": "str", "includeRoomsAsLights": "bool", + "preferCachedApiResponse": "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();