diff --git a/plejd/DeviceRegistry.js b/plejd/DeviceRegistry.js index 5912ba8..e1fb753 100644 --- a/plejd/DeviceRegistry.js +++ b/plejd/DeviceRegistry.js @@ -2,125 +2,94 @@ const Logger = require('./Logger'); const logger = Logger.getLogger('device-registry'); class DeviceRegistry { - apiSite; + /** @type {string} */ cryptoKey = null; - deviceIdsByRoom = {}; - deviceIdsBySerial = {}; + outputDeviceIdByRoomId = {}; + outputDeviceIdByBLEIndex = {}; // Dictionaries of [id]: device per type - plejdDevices = {}; - roomDevices = {}; + /** @type {import('types/DeviceRegistry').OutputDevices} */ + outputDevices = {}; + /** @type {import('types/DeviceRegistry').OutputDevices} */ sceneDevices = {}; - get allDevices() { - return [ - ...Object.values(this.plejdDevices), - ...Object.values(this.roomDevices), - ...Object.values(this.sceneDevices), - ]; + // eslint-disable-next-line class-methods-use-this + getUniqueOutputId(deviceId, outputIndex) { + return `${deviceId}_${outputIndex}`; } - addPlejdDevice(device) { - const added = { - ...this.plejdDevices[device.id], - ...device, + /** @param outputDevice {import('types/DeviceRegistry').OutputDevice} */ + addOutputDevice(outputDevice) { + this.outputDevices = { + ...this.outputDevices, + [outputDevice.uniqueId]: outputDevice, }; - this.plejdDevices = { - ...this.plejdDevices, - [added.id]: added, - }; - - this.deviceIdsBySerial[added.serialNumber] = added.id; - logger.verbose( - `Added/updated device: ${JSON.stringify(added)}. ${ - Object.keys(this.plejdDevices).length - } plejd devices in total.`, + `Added/updated output device: ${JSON.stringify(outputDevice)}. ${ + Object.keys(this.outputDevices).length + } output devices in total.`, ); - if (added.roomId) { - if (!this.deviceIdsByRoom[added.roomId]) { - this.deviceIdsByRoom[added.roomId] = []; - } - const room = this.deviceIdsByRoom[added.roomId]; - if (!room.includes(added.id)) { - this.deviceIdsByRoom[added.roomId] = [...room, added.id]; - } + this.outputDeviceIdByBLEIndex[outputDevice.bleDeviceIndex] = outputDevice.uniqueId; + + if (!this.outputDeviceIdByRoomId[outputDevice.roomId]) { + this.outputDeviceIdByRoomId[outputDevice.roomId] = []; + } + if ( + outputDevice.roomId !== outputDevice.uniqueId + && !this.outputDeviceIdByRoomId[outputDevice.roomId].includes(outputDevice.roomId) + ) { + this.outputDeviceIdByRoomId[outputDevice.roomId].push(outputDevice.roomId); logger.verbose( - `Added device to room ${added.roomId}: ${JSON.stringify( - this.deviceIdsByRoom[added.roomId], + `Added device to room ${outputDevice.roomId}: ${JSON.stringify( + this.outputDeviceIdByRoomId[outputDevice.roomId], )}`, ); } - return added; - } - - addRoomDevice(device) { - const added = { - ...this.roomDevices[device.id], - ...device, - }; - this.roomDevices = { - ...this.roomDevices, - [added.id]: added, - }; - - logger.verbose( - `Added/updated room device: ${JSON.stringify(added)}. ${ - Object.keys(this.roomDevices).length - } room devices total.`, - ); - return added; + if (outputDevice.hiddenFromIntegrations || outputDevice.hiddenFromRoomList) { + logger.verbose(`Device is hidden and should possibly not be included. + Hidden from room list: ${outputDevice.hiddenFromRoomList} + Hidden from integrations: ${outputDevice.hiddenFromIntegrations}`); + } } + /** @param scene {import('types/DeviceRegistry').OutputDevice} */ addScene(scene) { - const added = { - ...this.sceneDevices[scene.id], - ...scene, - }; this.sceneDevices = { ...this.sceneDevices, - [added.id]: added, + [scene.uniqueId]: scene, }; logger.verbose( - `Added/updated scene: ${JSON.stringify(added)}. ${ + `Added/updated scene: ${JSON.stringify(scene)}. ${ Object.keys(this.sceneDevices).length } scenes in total.`, ); - return added; } clearPlejdDevices() { - this.plejdDevices = {}; - this.deviceIdsByRoom = {}; + this.outputDevices = {}; + this.outputDeviceIdByRoomId = {}; this.deviceIdsBySerial = {}; } - clearRoomDevices() { - this.roomDevices = {}; - } - clearSceneDevices() { this.sceneDevices = {}; } - getDevice(deviceId) { - return this.plejdDevices[deviceId] || this.roomDevices[deviceId]; + getOutputDevice(uniqueOutputId) { + return this.outputDevices[uniqueOutputId]; } - getDeviceIdsByRoom(roomId) { - return this.deviceIdsByRoom[roomId]; + /** @returns {string[]} */ + getOutputDeviceIdsByRoomId(roomId) { + return this.outputDeviceIdByRoomId[roomId]; } - getDeviceBySerialNumber(serialNumber) { - return this.getDevice(this.deviceIdsBySerial[serialNumber]); - } - - getDeviceName(deviceId) { - return (this.plejdDevices[deviceId] || {}).name; + getOutputDeviceName(uniqueOutputId) { + return (this.outputDevices[uniqueOutputId] || {}).name; } getScene(sceneId) { @@ -131,25 +100,20 @@ class DeviceRegistry { return (this.sceneDevices[sceneId] || {}).name; } - getState(deviceId) { - const device = this.getDevice(deviceId) || {}; - if (device.dimmable) { - return { - state: device.state, - dim: device.dim, - }; + /** + * @param {string} uniqueOutputId + * @param {boolean} state + * @param {number?} [dim] + */ + setOutputState(uniqueOutputId, state, dim) { + const device = this.getOutputDevice(uniqueOutputId); + if (!device) { + logger.warn( + `Trying to set state for ${uniqueOutputId} which is not in the list of known outputs.`, + ); + return; } - return { - state: device.state, - }; - } - setApiSite(siteDetails) { - this.apiSite = siteDetails; - } - - setState(deviceId, state, dim) { - const device = this.getDevice(deviceId) || this.addPlejdDevice({ id: deviceId }); device.state = state; if (dim && device.dimmable) { device.dim = dim; diff --git a/plejd/PlejdApi.js b/plejd/PlejdApi.js index 5272249..d3a485c 100644 --- a/plejd/PlejdApi.js +++ b/plejd/PlejdApi.js @@ -10,15 +10,33 @@ const API_LOGIN_URL = 'login'; const API_SITE_LIST_URL = 'functions/getSiteList'; const API_SITE_DETAILS_URL = 'functions/getSiteById'; +const TRAITS = { + NO_LOAD: 0, + NON_DIMMABLE: 9, + DIMMABLE: 11, +}; + const logger = Logger.getLogger('plejd-api'); class PlejdApi { + /** @private @type {import('types/Configuration').Options} */ config; + + /** @private @type {import('DeviceRegistry')} */ deviceRegistry; + + /** @private @type {string} */ sessionToken; + + /** @private @type {string} */ siteId; + + /** @private @type {import('types/ApiSite').ApiSite} */ siteDetails; + /** + * @param {import("./DeviceRegistry")} deviceRegistry + */ constructor(deviceRegistry) { this.config = Configuration.getOptions(); this.deviceRegistry = deviceRegistry; @@ -58,19 +76,19 @@ class PlejdApi { } } } - this.deviceRegistry.setApiSite(this.siteDetails); this.deviceRegistry.cryptoKey = this.siteDetails.plejdMesh.cryptoKey; this.getDevices(); } + /** @returns {Promise} */ // 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); + const cachedCopy = JSON.parse(rawData.toString()); return cachedCopy; } catch (err) { @@ -82,12 +100,14 @@ class PlejdApi { async saveCachedCopy() { logger.info('Saving cached copy'); try { - const rawData = JSON.stringify({ + /** @type {import('types/ApiSite').CachedSite} */ + const cachedSite = { siteId: this.siteId, siteDetails: this.siteDetails, sessionToken: this.sessionToken, dtCache: new Date().toISOString(), - }); + }; + const rawData = JSON.stringify(cachedSite); await fs.promises.writeFile('/data/cachedApiResponse.json', rawData); } catch (err) { logger.error('Failed to save cache of api response', err); @@ -194,6 +214,14 @@ class PlejdApi { getDevices() { logger.info('Getting devices from site details response...'); + if (this.siteDetails.gateways && this.siteDetails.gateways.length) { + this.siteDetails.gateways.forEach((gwy) => { + logger.info(`Plejd gateway '${gwy.title}' found on site`); + }); + } else { + logger.info('No Plejd gateway found on site'); + } + this._getPlejdDevices(); this._getRoomDevices(); this._getSceneDevices(); @@ -216,8 +244,11 @@ class PlejdApi { } // eslint-disable-next-line class-methods-use-this - _getDeviceType(hardwareId) { - switch (parseInt(hardwareId, 10)) { + _getDeviceType(plejdDevice) { + // Type name is also sometimes available in device.hardware.name + // (maybe only when GWY-01 is present?) + + switch (parseInt(plejdDevice.hardwareId, 10)) { case 1: case 11: return { name: 'DIM-01', type: 'light', dimmable: true }; @@ -259,69 +290,106 @@ class PlejdApi { case 20: return { name: 'SPR-01', type: 'switch', dimmable: false }; default: - throw new Error(`Unknown device type with id ${hardwareId}`); + throw new Error(`Unknown device type with id ${plejdDevice.hardwareId}`); } } + /** + * Plejd API properties parsed + * + * * `devices` - physical Plejd devices, duplicated for devices with multiple outputs + * devices: [{deviceId, title, objectId, ...}, {...}] + * * `deviceAddress` - BLE address of each physical device + * deviceAddress: {[deviceId]: bleDeviceId} + * * `outputSettings` - lots of info about load settings, also links devices to output index + * outputSettings: [{deviceId, output, deviceParseId, ...}] //deviceParseId === objectId above + * * `outputAddress`: BLE address of [0] main output and [n] other output (loads) + * outputAddress: {[deviceId]: {[output]: bleDeviceId}} + * * `inputSettings` - detailed settings for inputs (buttons, RTR-01, ...), scenes triggered, ... + * inputSettings: [{deviceId, input, ...}] //deviceParseId === objectId above + * * `inputAddress` - Links inputs to what BLE device they control, or 255 for unassigned/scene + * inputAddress: {[deviceId]: {[input]: bleDeviceId}} + */ _getPlejdDevices() { this.deviceRegistry.clearPlejdDevices(); this.siteDetails.devices.forEach((device) => { - const { deviceId } = device; - - const settings = this.siteDetails.outputSettings.find( + const outputSettings = this.siteDetails.outputSettings.find( (x) => x.deviceParseId === device.objectId, ); - let deviceNum = this.siteDetails.deviceAddress[deviceId]; + if (device.traits === TRAITS.NO_LOAD) { + logger.warn( + `Device ${device.title} (${device.deviceId}) has no load configured and will be excluded`, + ); + } else if (outputSettings) { + const uniqueOutputId = this.deviceRegistry.getUniqueOutputId( + device.deviceId, + outputSettings.output, + ); - if (settings) { - const outputs = this.siteDetails.outputAddress[deviceId]; - deviceNum = outputs[settings.output]; - } + const bleDeviceIndex = this.siteDetails.outputAddress[device.deviceId][ + outputSettings.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; + const plejdDevice = this.siteDetails.plejdDevices.find( + (x) => x.deviceId === device.deviceId, + ); - if (settings) { - dimmable = settings.dimCurve !== 'NonDimmable'; - } + const dimmable = device.traits === TRAITS.DIMMABLE; + // 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, - }; + const { name: typeName, type } = this._getDeviceType(plejdDevice); - 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]; + /** @type {import('types/DeviceRegistry').OutputDevice} */ + const outputDevice = { + bleDeviceIndex, + deviceId: device.deviceId, + dimmable, + hiddenFromRoomList: device.hiddenFromRoomList, + hiddenFromIntegrations: device.hiddenFromIntegrations, + name: device.title, + output: outputSettings.output, + roomId: device.roomId, + state: undefined, + type, + typeName, + version: plejdDevice.firmware.version, + uniqueId: uniqueOutputId, + }; - this.deviceRegistry.addPlejdDevice({ - ...newDevice, - id: first, - name: `${device.title} left`, - }); - - this.deviceRegistry.addPlejdDevice({ - ...newDevice, - id: second, - name: `${device.title} right`, - }); + this.deviceRegistry.addOutputDevice(outputDevice); } else { - this.deviceRegistry.addPlejdDevice(newDevice); + logger.warn( + `No outputSettings found for ${device.title} (${device.deviceId}), device will not be included`, + ); + logger.verbose( + 'Fallback cound potentially be implemented by assuming default deviceSettings[deviceId]', + ); } + + // What should we do with inputs?! + // if (outputDevice.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({ + // ...outputDevice, + // id: first, + // name: `${device.title} left`, + // }); + + // this.deviceRegistry.addPlejdDevice({ + // ...outputDevice, + // id: second, + // name: `${device.title} right`, + // }); + // } else { + // this.deviceRegistry.addPlejdDevice(outputDevice); + // } }); } @@ -332,20 +400,31 @@ class PlejdApi { const { roomId } = room; const roomAddress = this.siteDetails.roomAddress[roomId]; - const deviceIdsByRoom = this.deviceRegistry.getDeviceIdsByRoom(roomId); + const deviceIdsByRoom = this.deviceRegistry.getOutputDeviceIdsByRoomId(roomId); const dimmable = deviceIdsByRoom - && deviceIdsByRoom.some((deviceId) => this.deviceRegistry.getDevice(deviceId).dimmable); + && deviceIdsByRoom.some( + (deviceId) => this.deviceRegistry.getOutputDevice(deviceId).dimmable, + ); + /** @type {import('types/DeviceRegistry').OutputDevice} */ const newDevice = { - id: roomAddress, + bleDeviceIndex: roomAddress, + deviceId: null, + dimmable, + hiddenFromRoomList: false, + hiddenFromIntegrations: false, name: room.title, + output: undefined, + roomId, + state: undefined, type: 'light', typeName: 'Room', - dimmable, + uniqueId: roomId, + version: undefined, }; - this.deviceRegistry.addRoomDevice(newDevice); + this.deviceRegistry.addOutputDevice(newDevice); }); logger.debug('includeRoomsAsLights done.'); } @@ -357,14 +436,20 @@ class PlejdApi { scenes.forEach((scene) => { const sceneNum = this.siteDetails.sceneIndex[scene.sceneId]; + /** @type {import('types/DeviceRegistry').OutputDevice} */ const newScene = { - id: sceneNum, + bleDeviceIndex: sceneNum, + deviceId: undefined, + dimmable: false, + hiddenFromSceneList: scene.hiddenFromSceneList, name: scene.title, + output: undefined, + roomId: undefined, + state: false, type: 'switch', typeName: 'Scene', - dimmable: false, - version: '1.0', - serialNumber: scene.objectId, + version: undefined, + uniqueId: scene.sceneId, }; this.deviceRegistry.addScene(newScene); diff --git a/plejd/PlejdDeviceCommunication.js b/plejd/PlejdDeviceCommunication.js index e64c8cd..2fa4e2a 100644 --- a/plejd/PlejdDeviceCommunication.js +++ b/plejd/PlejdDeviceCommunication.js @@ -1,4 +1,4 @@ -const EventEmitter = require('events'); +const { EventEmitter } = require('events'); const Configuration = require('./Configuration'); const constants = require('./constants'); const Logger = require('./Logger'); @@ -15,6 +15,7 @@ class PlejdDeviceCommunication extends EventEmitter { bleDeviceTransitionTimers = {}; plejdBleHandler; config; + /** @type {import('./DeviceRegistry')} */ deviceRegistry; writeQueue = []; writeQueueRef = null; @@ -71,7 +72,7 @@ class PlejdDeviceCommunication extends EventEmitter { } turnOn(deviceId, command) { - const deviceName = this.deviceRegistry.getDeviceName(deviceId); + const deviceName = this.deviceRegistry.getOutputDeviceName(deviceId); logger.info( `Plejd got turn on command for ${deviceName} (${deviceId}), brightness ${command.brightness}${ command.transition ? `, transition: ${command.transition}` : '' @@ -81,7 +82,7 @@ class PlejdDeviceCommunication extends EventEmitter { } turnOff(deviceId, command) { - const deviceName = this.deviceRegistry.getDeviceName(deviceId); + const deviceName = this.deviceRegistry.getOutputDeviceName(deviceId); logger.info( `Plejd got turn off command for ${deviceName} (${deviceId})${ command.transition ? `, transition: ${command.transition}` : '' @@ -93,18 +94,18 @@ class PlejdDeviceCommunication extends EventEmitter { _bleCommandReceived(deviceId, command, data) { try { if (command === COMMANDS.DIM) { - this.deviceRegistry.setState(deviceId, data.state, data.dim); + this.deviceRegistry.setOutputState(deviceId, data.state, data.dim); this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, deviceId, { - state: data.state, + state: !!data.state, brightness: data.dim, }); } else if (command === COMMANDS.TURN_ON) { - this.deviceRegistry.setState(deviceId, 1); + this.deviceRegistry.setOutputState(deviceId, true); this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, deviceId, { state: 1, }); } else if (command === COMMANDS.TURN_OFF) { - this.deviceRegistry.setState(deviceId, 0); + this.deviceRegistry.setOutputState(deviceId, false); this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, deviceId, { state: 0, }); @@ -125,11 +126,11 @@ class PlejdDeviceCommunication extends EventEmitter { } _transitionTo(deviceId, targetBrightness, transition, deviceName) { - const device = this.deviceRegistry.getDevice(deviceId); + const device = this.deviceRegistry.getOutputDevice(deviceId); const initialBrightness = device ? device.state && device.dim : null; this._clearDeviceTransitionTimer(deviceId); - const isDimmable = this.deviceRegistry.getDevice(deviceId).dimmable; + const isDimmable = this.deviceRegistry.getOutputDevice(deviceId).dimmable; if ( transition > 1 @@ -249,7 +250,7 @@ class PlejdDeviceCommunication extends EventEmitter { return; } const queueItem = this.writeQueue.pop(); - const deviceName = this.deviceRegistry.getDeviceName(queueItem.deviceId); + const deviceName = this.deviceRegistry.getOutputDeviceName(queueItem.deviceId); logger.debug( `Write queue: Processing ${deviceName} (${queueItem.deviceId}). Command ${ queueItem.command