diff --git a/plejd/CHANGELOG.md b/plejd/CHANGELOG.md index ea70aee..43264bc 100644 --- a/plejd/CHANGELOG.md +++ b/plejd/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog hassio-plejd Home Assistant Plejd addon +## 0.8.0-dev + +**BREAKING - READ BELOW FIRST** + +Release 0.8 will break ALL EXISTING DEVICES. Unique mqtt id:s will change, meaning HA will create new devices. Scenes will be added as scenes not as switches. + +Recommendations to minimize impact + +- Optionally install MQTT explorer to bulk-delete discovered devices. If so - start MQTT explorer, connect, restart Plejd addon and then delete from MQTT explorer +- Shut down Plejd addon, disable autostart +- Reboot HA +- Go to Configuration => Integration => MQTT. Go to entities and after that devices and remove all Plejd devices (should be listed as unavailable) +- Upgrade addon to latest version and start +- All devices should now be back. With luck they will have the same HA id:s as before so most things should work. Room assignments, icons, etc will be gone though. + ## [0.7.1](https://github.com/icanos/hassio-plejd/tree/0.7.1) (2021-03-25) [Full Changelog](https://github.com/icanos/hassio-plejd/compare/0.7.0...0.7.1) diff --git a/plejd/Configuration.js b/plejd/Configuration.js index 88a02b3..4b62689 100644 --- a/plejd/Configuration.js +++ b/plejd/Configuration.js @@ -1,9 +1,12 @@ const fs = require('fs'); class Configuration { + /** @type {import('types/Configuration').Options} */ static _options = null; + /** @type {import('types/Configuration').AddonInfo} */ static _addonInfo = null; + /** @returns Options */ static getOptions() { if (!Configuration._options) { Configuration._hydrateCache(); @@ -11,6 +14,7 @@ class Configuration { return Configuration._options; } + /** @returns AddonInfo */ static getAddonInfo() { if (!Configuration._addonInfo) { Configuration._hydrateCache(); @@ -20,10 +24,10 @@ class Configuration { static _hydrateCache() { const rawData = fs.readFileSync('/data/options.json'); - const config = JSON.parse(rawData); + const config = JSON.parse(rawData.toString()); const defaultRawData = fs.readFileSync('/plejd/config.json'); - const defaultConfig = JSON.parse(defaultRawData); + const defaultConfig = JSON.parse(defaultRawData.toString()); Configuration._options = { ...defaultConfig.options, ...config }; Configuration._addonInfo = { diff --git a/plejd/DeviceRegistry.js b/plejd/DeviceRegistry.js index 5912ba8..a031752 100644 --- a/plejd/DeviceRegistry.js +++ b/plejd/DeviceRegistry.js @@ -2,154 +2,195 @@ const Logger = require('./Logger'); const logger = Logger.getLogger('device-registry'); class DeviceRegistry { - apiSite; + /** @type {string} */ cryptoKey = null; - deviceIdsByRoom = {}; - deviceIdsBySerial = {}; + /** @private @type {Object.} */ + devices = {}; + /** @private @type {Object.} */ + outputDeviceUniqueIdsByRoomId = {}; + /** @private @type {Object.} */ + outputUniqueIdByBleOutputAddress = {}; + /** @private @type {Object.} */ + sceneUniqueIdByBleOutputAddress = {}; + + /** @private @type {import('./types/ApiSite').ApiSite} */ + apiSite; // Dictionaries of [id]: device per type - plejdDevices = {}; - roomDevices = {}; + /** @private @type {import('types/DeviceRegistry').OutputDevices} */ + outputDevices = {}; + /** @private @type {import('types/DeviceRegistry').OutputDevices} */ sceneDevices = {}; - get allDevices() { - return [ - ...Object.values(this.plejdDevices), - ...Object.values(this.roomDevices), - ...Object.values(this.sceneDevices), - ]; + /** @param device {import('./types/ApiSite').Device} */ + addPhysicalDevice(device) { + this.devices[device.deviceId] = device; } - addPlejdDevice(device) { - const added = { - ...this.plejdDevices[device.id], - ...device, - }; + /** @param outputDevice {import('types/DeviceRegistry').OutputDevice} */ + addOutputDevice(outputDevice) { + if (outputDevice.hiddenFromIntegrations || outputDevice.hiddenFromRoomList) { + logger.verbose(`Device ${outputDevice.name} is hidden and will not be included. + Hidden from room list: ${outputDevice.hiddenFromRoomList} + Hidden from integrations: ${outputDevice.hiddenFromIntegrations}`); + return; + } - this.plejdDevices = { - ...this.plejdDevices, - [added.id]: added, + this.outputDevices = { + ...this.outputDevices, + [outputDevice.uniqueId]: outputDevice, }; - 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.outputUniqueIdByBleOutputAddress[outputDevice.bleOutputAddress] = outputDevice.uniqueId; + + if (!this.outputDeviceUniqueIdsByRoomId[outputDevice.roomId]) { + this.outputDeviceUniqueIdsByRoomId[outputDevice.roomId] = []; + } + if ( + outputDevice.roomId !== outputDevice.uniqueId + && !this.outputDeviceUniqueIdsByRoomId[outputDevice.roomId].includes(outputDevice.uniqueId) + ) { + this.outputDeviceUniqueIdsByRoomId[outputDevice.roomId].push(outputDevice.uniqueId); logger.verbose( - `Added device to room ${added.roomId}: ${JSON.stringify( - this.deviceIdsByRoom[added.roomId], + `Added device to room ${outputDevice.roomId}: ${JSON.stringify( + this.outputDeviceUniqueIdsByRoomId[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; } + /** @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, }; + this.sceneUniqueIdByBleOutputAddress[scene.bleOutputAddress] = scene.uniqueId; + 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.deviceIdsBySerial = {}; - } - - clearRoomDevices() { - this.roomDevices = {}; + this.devices = {}; + this.outputDevices = {}; + this.outputDeviceUniqueIdsByRoomId = {}; + this.outputUniqueIdByBleOutputAddress = {}; } clearSceneDevices() { this.sceneDevices = {}; + this.sceneUniqueIdByBleOutputAddress = {}; } - getDevice(deviceId) { - return this.plejdDevices[deviceId] || this.roomDevices[deviceId]; + /** + * @returns {import('./types/DeviceRegistry').OutputDevice[]} + */ + getAllOutputDevices() { + return Object.values(this.outputDevices); } - getDeviceIdsByRoom(roomId) { - return this.deviceIdsByRoom[roomId]; + /** + * @returns {import('./types/DeviceRegistry').OutputDevice[]} + */ + getAllSceneDevices() { + return Object.values(this.sceneDevices); } - getDeviceBySerialNumber(serialNumber) { - return this.getDevice(this.deviceIdsBySerial[serialNumber]); + /** @returns {import('./types/ApiSite').ApiSite} */ + getApiSite() { + return this.apiSite; } - getDeviceName(deviceId) { - return (this.plejdDevices[deviceId] || {}).name; + /** + * @param {string} uniqueOutputId + */ + getOutputDevice(uniqueOutputId) { + return this.outputDevices[uniqueOutputId]; } - getScene(sceneId) { - return this.sceneDevices[sceneId]; + /** @returns {import('./types/DeviceRegistry').OutputDevice} */ + getOutputDeviceByBleOutputAddress(bleOutputAddress) { + return this.outputDevices[this.outputUniqueIdByBleOutputAddress[bleOutputAddress]]; } - getSceneName(sceneId) { - return (this.sceneDevices[sceneId] || {}).name; + /** @returns {string[]} */ + getOutputDeviceIdsByRoomId(roomId) { + return this.outputDeviceUniqueIdsByRoomId[roomId]; } - getState(deviceId) { - const device = this.getDevice(deviceId) || {}; - if (device.dimmable) { - return { - state: device.state, - dim: device.dim, - }; + getOutputDeviceName(uniqueOutputId) { + return (this.outputDevices[uniqueOutputId] || {}).name; + } + + /** + * @param {string } deviceId The physical device serial number + * @return {import('./types/ApiSite').Device} + */ + getPhysicalDevice(deviceId) { + return this.devices[deviceId]; + } + + /** + * @param {string} sceneUniqueId + */ + getScene(sceneUniqueId) { + return this.sceneDevices[sceneUniqueId]; + } + + /** + * @param {number} sceneBleAddress + */ + getSceneByBleAddress(sceneBleAddress) { + const sceneUniqueId = this.sceneUniqueIdByBleOutputAddress[sceneBleAddress]; + if (!sceneUniqueId) { + return null; } - return { - state: device.state, - }; + return this.sceneDevices[sceneUniqueId]; } - setApiSite(siteDetails) { - this.apiSite = siteDetails; + /** + * @param {string} sceneUniqueId + */ + getSceneName(sceneUniqueId) { + return (this.sceneDevices[sceneUniqueId] || {}).name; } - setState(deviceId, state, dim) { - const device = this.getDevice(deviceId) || this.addPlejdDevice({ id: deviceId }); + // eslint-disable-next-line class-methods-use-this + getUniqueOutputId(deviceId, outputIndex) { + return `${deviceId}_${outputIndex}`; + } + + /** @param apiSite {import('./types/ApiSite').ApiSite} */ + setApiSite(apiSite) { + this.apiSite = apiSite; + this.cryptoKey = apiSite.plejdMesh.cryptoKey; + } + + /** + * @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; + } + device.state = state; if (dim && device.dimmable) { device.dim = dim; diff --git a/plejd/MqttClient.js b/plejd/MqttClient.js index 0369010..1931e21 100644 --- a/plejd/MqttClient.js +++ b/plejd/MqttClient.js @@ -8,18 +8,36 @@ const startTopics = ['hass/status', 'homeassistant/status']; const logger = Logger.getLogger('plejd-mqtt'); -// #region discovery - const discoveryPrefix = 'homeassistant'; const nodeId = 'plejd'; +/** @type {import('./types/Mqtt').MQTT_TYPES} */ +const MQTT_TYPES = { + LIGHT: 'light', + SCENE: 'scene', + SWITCH: 'switch', + DEVICE_AUTOMATION: 'device_automation', +}; + +/** @type {import('./types/Mqtt').TOPIC_TYPES} */ +const TOPIC_TYPES = { + CONFIG: 'config', + STATE: 'state', + AVAILABILITY: 'availability', + COMMAND: 'set', +}; + +const getBaseTopic = (/** @type { string } */ uniqueId, /** @type { string } */ mqttDeviceType) => `${discoveryPrefix}/${mqttDeviceType}/${nodeId}/${uniqueId}`; + +const getTopicName = ( + /** @type { string } */ uniqueId, + /** @type { import('./types/Mqtt').MqttType } */ mqttDeviceType, + /** @type { import('./types/Mqtt').TopicType } */ topicType, +) => `${getBaseTopic(uniqueId, mqttDeviceType)}/${topicType}`; + +const getTriggerUniqueId = (/** @type { string } */ uniqueId) => `${uniqueId}_trigger`; +const getSceneEventTopic = (/** @type {string} */ sceneId) => `${getTopicName(getTriggerUniqueId(sceneId), MQTT_TYPES.DEVICE_AUTOMATION, TOPIC_TYPES.STATE)}`; const getSubscribePath = () => `${discoveryPrefix}/+/${nodeId}/#`; -const getPath = ({ id, type }) => `${discoveryPrefix}/${type}/${nodeId}/${id}`; -const getConfigPath = (plug) => `${getPath(plug)}/config`; -const getStateTopic = (plug) => `${getPath(plug)}/state`; -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)/, @@ -33,41 +51,63 @@ const decodeTopic = (topic) => { return matches.groups; }; -const getDiscoveryPayload = (device) => ({ - schema: 'json', +const getOutputDeviceDiscoveryPayload = ( + /** @type {import('./types/DeviceRegistry').OutputDevice} */ device, +) => ({ name: device.name, - unique_id: `light.plejd.${device.name.toLowerCase().replace(/ /g, '')}`, - state_topic: getStateTopic(device), - command_topic: getCommandTopic(device), - availability_topic: getAvailabilityTopic(device), + unique_id: device.uniqueId, + '~': getBaseTopic(device.uniqueId, device.type), + state_topic: `~/${TOPIC_TYPES.STATE}`, + command_topic: `~/${TOPIC_TYPES.COMMAND}`, + availability_topic: `~/${TOPIC_TYPES.AVAILABILITY}`, optimistic: false, - brightness: `${device.dimmable}`, + qos: 1, + retain: true, device: { - identifiers: `${device.serialNumber}_${device.id}`, + identifiers: `${device.deviceId}`, manufacturer: 'Plejd', model: device.typeName, name: device.name, sw_version: device.version, }, + ...(device.type === MQTT_TYPES.LIGHT ? { brightness: device.dimmable, schema: 'json' } : {}), }); -const getSwitchPayload = (device) => ({ - name: device.name, - state_topic: getStateTopic(device), - command_topic: getCommandTopic(device), - optimistic: false, +const getSceneDiscoveryPayload = ( + /** @type {import('./types/DeviceRegistry').OutputDevice} */ sceneDevice, +) => ({ + name: sceneDevice.name, + unique_id: sceneDevice.uniqueId, + '~': getBaseTopic(sceneDevice.uniqueId, MQTT_TYPES.SCENE), + command_topic: `~/${TOPIC_TYPES.COMMAND}`, + availability_topic: `~/${TOPIC_TYPES.AVAILABILITY}`, + payload_on: 'ON', + qos: 1, + retain: false, +}); + +const getSceneDeviceTriggerhDiscoveryPayload = ( + /** @type {import('./types/DeviceRegistry').OutputDevice} */ sceneDevice, +) => ({ + automation_type: 'trigger', + '~': getBaseTopic(sceneDevice.uniqueId, MQTT_TYPES.DEVICE_AUTOMATION), + qos: 1, + topic: `~/${TOPIC_TYPES.STATE}`, + type: 'scene', + subtype: 'trigger', device: { - identifiers: `${device.serialNumber}_${device.id}`, + identifiers: `${sceneDevice.uniqueId}`, manufacturer: 'Plejd', - model: device.typeName, - name: device.name, - sw_version: device.version, + model: sceneDevice.typeName, + name: sceneDevice.name, }, }); -// #endregion +const getMqttStateString = (/** @type {boolean} */ state) => (state ? 'ON' : 'OFF'); +const AVAILABLILITY = { ONLINE: 'online', OFFLINE: 'offline' }; class MqttClient extends EventEmitter { + /** @type {import('DeviceRegistry')} */ deviceRegistry; static EVENTS = { @@ -75,6 +115,9 @@ class MqttClient extends EventEmitter { stateChanged: 'stateChanged', }; + /** + * @param {import("DeviceRegistry")} deviceRegistry + */ constructor(deviceRegistry) { super(); @@ -86,8 +129,11 @@ class MqttClient extends EventEmitter { logger.info('Initializing MQTT connection for Plejd addon'); this.client = mqtt.connect(this.config.mqttBroker, { - username: this.config.mqttUsername, + clientId: `hassio-plejd_${Math.random().toString(16).substr(2, 8)}`, password: this.config.mqttPassword, + protocolVersion: 4, // v5 not supported by HassIO Mosquitto + queueQoSZero: true, + username: this.config.mqttUsername, }); this.client.on('error', (err) => { @@ -97,13 +143,22 @@ class MqttClient extends EventEmitter { this.client.on('connect', () => { logger.info('Connected to MQTT.'); - this.client.subscribe(startTopics, (err) => { - if (err) { - logger.error('Unable to subscribe to status topics', err); - } + this.client.subscribe( + startTopics, + // Add below when mqtt v5 is supported in Mosquitto 1.6 or 2.0 and forward + // { + // qos: 1, + // nl: true, // don't echo back messages sent + // rap: true, // retain as published - don't force retain = 0 + // }, + (err) => { + if (err) { + logger.error('Unable to subscribe to status topics', err); + } - this.emit(MqttClient.EVENTS.connected); - }); + this.emit(MqttClient.EVENTS.connected); + }, + ); this.client.subscribe(getSubscribePath(), (err) => { if (err) { @@ -123,27 +178,24 @@ class MqttClient extends EventEmitter { logger.info('Home Assistant has started. lets do discovery.'); this.emit(MqttClient.EVENTS.connected); } else { + logger.verbose(`Received mqtt message on ${topic}`); const decodedTopic = decodeTopic(topic); if (decodedTopic) { - let device = this.deviceRegistry.getDevice(decodedTopic.id); + /** @type {import('types/DeviceRegistry').OutputDevice} */ + let device; + + if (decodedTopic.type === MQTT_TYPES.SCENE) { + logger.verbose(`Getting scene ${decodedTopic.id} from registry`); + device = this.deviceRegistry.getScene(decodedTopic.id); + } else { + logger.verbose(`Getting device ${decodedTopic.id} from registry`); + device = this.deviceRegistry.getOutputDevice(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) { @@ -195,35 +247,121 @@ class MqttClient extends EventEmitter { } disconnect(callback) { - this.deviceRegistry.allDevices.forEach((device) => { - this.client.publish(getAvailabilityTopic(device), 'offline'); + logger.info('Mqtt disconnect requested. Setting all devices as unavailable in HA...'); + this.deviceRegistry.getAllOutputDevices().forEach((outputDevice) => { + const mqttType = outputDevice.type === 'switch' ? MQTT_TYPES.SWITCH : MQTT_TYPES.LIGHT; + this.client.publish( + getTopicName(outputDevice.uniqueId, mqttType, 'availability'), + AVAILABLILITY.OFFLINE, + { + retain: true, + qos: 1, + }, + ); + }); + + const allSceneDevices = this.deviceRegistry.getAllSceneDevices(); + allSceneDevices.forEach((sceneDevice) => { + this.client.publish( + getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.AVAILABILITY), + AVAILABLILITY.OFFLINE, + { + retain: true, + qos: 1, + }, + ); }); this.client.end(callback); } sendDiscoveryToHomeAssistant() { - logger.debug(`Sending discovery of ${this.deviceRegistry.allDevices.length} device(s).`); + const allOutputDevices = this.deviceRegistry.getAllOutputDevices(); + logger.info(`Sending discovery for ${allOutputDevices.length} Plejd output devices`); + allOutputDevices.forEach((outputDevice) => { + logger.debug(`Sending discovery for ${outputDevice.name}`); - this.deviceRegistry.allDevices.forEach((device) => { - logger.debug(`Sending discovery for ${device.name}`); - - const payload = device.type === 'switch' ? getSwitchPayload(device) : getDiscoveryPayload(device); + const configPayload = getOutputDeviceDiscoveryPayload(outputDevice); logger.info( - `Discovered ${device.type} (${device.typeName}) named ${device.name} with PID ${device.id}.`, + `Discovered ${outputDevice.typeName} (${outputDevice.type}) named ${outputDevice.name} (${outputDevice.bleOutputAddress} : ${outputDevice.uniqueId}).`, ); - this.client.publish(getConfigPath(device), JSON.stringify(payload)); + const mqttType = outputDevice.type === 'switch' ? MQTT_TYPES.SWITCH : MQTT_TYPES.LIGHT; + this.client.publish( + getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.CONFIG), + JSON.stringify(configPayload), + { + retain: true, + qos: 1, + }, + ); setTimeout(() => { - this.client.publish(getAvailabilityTopic(device), 'online'); + this.client.publish( + getTopicName(outputDevice.uniqueId, mqttType, TOPIC_TYPES.AVAILABILITY), + AVAILABLILITY.ONLINE, + { + retain: true, + qos: 1, + }, + ); + }, 2000); + }); + + const allSceneDevices = this.deviceRegistry.getAllSceneDevices(); + logger.info(`Sending discovery for ${allSceneDevices.length} Plejd scene devices`); + allSceneDevices.forEach((sceneDevice) => { + logger.debug(`Sending discovery for ${sceneDevice.name}`); + + const sceneConfigPayload = getSceneDiscoveryPayload(sceneDevice); + logger.info( + `Discovered ${sceneDevice.typeName} (${sceneDevice.type}) named ${sceneDevice.name} (${sceneDevice.bleOutputAddress} : ${sceneDevice.uniqueId}).`, + ); + + this.client.publish( + getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.CONFIG), + JSON.stringify(sceneConfigPayload), + { + retain: true, + qos: 1, + }, + ); + + const sceneTriggerConfigPayload = getSceneDeviceTriggerhDiscoveryPayload(sceneDevice); + + this.client.publish( + getTopicName( + getTriggerUniqueId(sceneDevice.uniqueId), + MQTT_TYPES.DEVICE_AUTOMATION, + TOPIC_TYPES.CONFIG, + ), + JSON.stringify(sceneTriggerConfigPayload), + { + retain: true, + qos: 1, + }, + ); + + setTimeout(() => { + this.client.publish( + getTopicName(sceneDevice.uniqueId, MQTT_TYPES.SCENE, TOPIC_TYPES.AVAILABILITY), + AVAILABLILITY.ONLINE, + { + retain: true, + qos: 1, + }, + ); }, 2000); }); } - updateState(deviceId, data) { - const device = this.deviceRegistry.getDevice(deviceId); + /** + * @param {string} uniqueOutputId + * @param {{ state: boolean; brightness?: number; }} data + */ + updateOutputState(uniqueOutputId, data) { + const device = this.deviceRegistry.getOutputDevice(uniqueOutputId); if (!device) { - logger.warn(`Unknown device id ${deviceId} - not handled by us.`); + logger.warn(`Unknown output id ${uniqueOutputId} - not handled by us.`); return; } @@ -235,29 +373,40 @@ class MqttClient extends EventEmitter { let payload = null; if (device.type === 'switch') { - payload = data.state === 1 ? 'ON' : 'OFF'; + payload = getMqttStateString(data.state); } else { if (device.dimmable) { payload = { - state: data.state === 1 ? 'ON' : 'OFF', + state: getMqttStateString(data.state), brightness: data.brightness, }; } else { payload = { - state: data.state === 1 ? 'ON' : 'OFF', + state: getMqttStateString(data.state), }; } payload = JSON.stringify(payload); } - this.client.publish(getStateTopic(device), payload); - this.client.publish(getAvailabilityTopic(device), 'online'); + const mqttType = device.type === 'switch' ? MQTT_TYPES.SWITCH : MQTT_TYPES.LIGHT; + this.client.publish(getTopicName(device.uniqueId, mqttType, TOPIC_TYPES.STATE), payload, { + retain: true, + qos: 1, + }); + // this.client.publish( + // getTopicName(device.uniqueId, mqttType, TOPIC_TYPES.AVAILABILITY), + // AVAILABLILITY.ONLINE, + // { retain: true, qos: 1 }, + // ); } + /** + * @param {string} sceneId + */ sceneTriggered(sceneId) { logger.verbose(`Scene triggered: ${sceneId}`); - this.client.publish(getSceneEventTopic(), JSON.stringify({ scene: sceneId })); + this.client.publish(getSceneEventTopic(sceneId), '', { qos: 1 }); } } diff --git a/plejd/PlejdAddon.js b/plejd/PlejdAddon.js index eb39c50..cdb3d60 100644 --- a/plejd/PlejdAddon.js +++ b/plejd/PlejdAddon.js @@ -65,57 +65,61 @@ class PlejdAddon extends EventEmitter { }); // subscribe to changes from HA - this.mqttClient.on(MqttClient.EVENTS.stateChanged, (device, command) => { - try { - const deviceId = device.id; + this.mqttClient.on( + MqttClient.EVENTS.stateChanged, + /** @param device {import('./types/DeviceRegistry').OutputDevice} */ + (device, command) => { + try { + const { uniqueId } = device; - 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; + if (device.typeName === 'Scene') { + // we're triggering a scene, lets do that and jump out. + // since scenes aren't "real" devices. + this.sceneManager.executeScene(uniqueId); + return; + } + + let state = false; + let commandObj = {}; + + if (typeof command === 'string') { + // switch command + state = command === 'ON'; + 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.updateOutputState(uniqueId, { + state, + }); + } else { + // eslint-disable-next-line prefer-destructuring + state = command.state === 'ON'; + commandObj = command; + } + + if (state) { + this.plejdDeviceCommunication.turnOn(uniqueId, commandObj); + } else { + this.plejdDeviceCommunication.turnOff(uniqueId, commandObj); + } + } catch (err) { + logger.error('Error in MqttClient.stateChanged callback', err); } - - 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.plejdDeviceCommunication.turnOn(deviceId, commandObj); - } else { - this.plejdDeviceCommunication.turnOff(deviceId, commandObj); - } - } catch (err) { - logger.error('Error in MqttClient.stateChanged callback', err); - } - }); + }, + ); this.mqttClient.init(); // subscribe to changes from Plejd this.plejdDeviceCommunication.on( PlejdDeviceCommunication.EVENTS.stateChanged, - (deviceId, command) => { + (uniqueOutputId, command) => { try { - this.mqttClient.updateState(deviceId, command); + this.mqttClient.updateOutputState(uniqueOutputId, command); } catch (err) { logger.error('Error in PlejdService.stateChanged callback', err); } diff --git a/plejd/PlejdApi.js b/plejd/PlejdApi.js index 5272249..b79b4fd 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.deviceRegistry.setApiSite(this.siteDetails); 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,115 @@ 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]: bleDeviceAddress} + * * `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]: bleDeviceAddress}} + * * `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]: bleDeviceAddress}} + */ _getPlejdDevices() { this.deviceRegistry.clearPlejdDevices(); this.siteDetails.devices.forEach((device) => { - const { deviceId } = device; + this.deviceRegistry.addPhysicalDevice(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 (!outputSettings) { + logger.verbose( + `No outputSettings found for ${device.title} (${device.deviceId}), assuming output 0`, + ); + } + const deviceOutput = outputSettings ? outputSettings.output : 0; + const outputAddress = this.siteDetails.outputAddress[device.deviceId]; - if (settings) { - const outputs = this.siteDetails.outputAddress[deviceId]; - deviceNum = outputs[settings.output]; + if (outputAddress) { + const bleOutputAddress = outputAddress[deviceOutput]; + + if (device.traits === TRAITS.NO_LOAD) { + logger.warn( + `Device ${device.title} (${device.deviceId}) has no load configured and will be excluded`, + ); + } else { + const uniqueOutputId = this.deviceRegistry.getUniqueOutputId( + device.deviceId, + deviceOutput, + ); + + const plejdDevice = this.siteDetails.plejdDevices.find( + (x) => x.deviceId === device.deviceId, + ); + + const dimmable = device.traits === TRAITS.DIMMABLE; + // dimmable = settings.dimCurve !== 'NonDimmable'; + + const { name: typeName, type: deviceType } = this._getDeviceType(plejdDevice); + let loadType = deviceType; + if (device.outputType === 'RELAY') { + loadType = 'switch'; + } else if (device.outputType === 'LIGHT') { + loadType = 'light'; + } + + /** @type {import('types/DeviceRegistry').OutputDevice} */ + const outputDevice = { + bleOutputAddress, + deviceId: device.deviceId, + dimmable, + hiddenFromRoomList: device.hiddenFromRoomList, + hiddenFromIntegrations: device.hiddenFromIntegrations, + name: device.title, + output: deviceOutput, + roomId: device.roomId, + state: undefined, + type: loadType, + typeName, + version: plejdDevice.firmware.version, + uniqueId: uniqueOutputId, + }; + + this.deviceRegistry.addOutputDevice(outputDevice); + } } - // 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; + // 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]; - if (settings) { - dimmable = settings.dimCurve !== 'NonDimmable'; - } + // this.deviceRegistry.addPlejdDevice({ + // ...outputDevice, + // id: first, + // name: `${device.title} left`, + // }); - 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); - } + // this.deviceRegistry.addPlejdDevice({ + // ...outputDevice, + // id: second, + // name: `${device.title} right`, + // }); + // } else { + // this.deviceRegistry.addPlejdDevice(outputDevice); + // } }); } @@ -332,39 +409,57 @@ 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, + bleOutputAddress: 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.'); } } _getSceneDevices() { + this.deviceRegistry.clearSceneDevices(); // add scenes as switches const scenes = this.siteDetails.scenes.filter((x) => x.hiddenFromSceneList === false); scenes.forEach((scene) => { const sceneNum = this.siteDetails.sceneIndex[scene.sceneId]; + /** @type {import('types/DeviceRegistry').OutputDevice} */ const newScene = { - id: sceneNum, - name: scene.title, - type: 'switch', - typeName: 'Scene', + bleOutputAddress: sceneNum, + deviceId: undefined, dimmable: false, - version: '1.0', - serialNumber: scene.objectId, + hiddenFromSceneList: scene.hiddenFromSceneList, + name: scene.title, + output: undefined, + roomId: undefined, + state: false, + type: 'scene', + typeName: 'Scene', + version: undefined, + uniqueId: scene.sceneId, }; this.deviceRegistry.addScene(newScene); diff --git a/plejd/PlejdBLEHandler.js b/plejd/PlejdBLEHandler.js index 9256324..75eeaf0 100644 --- a/plejd/PlejdBLEHandler.js +++ b/plejd/PlejdBLEHandler.js @@ -1,7 +1,7 @@ const dbus = require('dbus-next'); const crypto = require('crypto'); const xor = require('buffer-xor'); -const EventEmitter = require('events'); +const { EventEmitter } = require('events'); const Configuration = require('./Configuration'); const constants = require('./constants'); @@ -49,6 +49,8 @@ class PlejBLEHandler extends EventEmitter { connectedDevice = null; consecutiveWriteFails; consecutiveReconnectAttempts = 0; + /** @type {import('./DeviceRegistry')} */ + deviceRegistry; discoveryTimeout = null; plejdService = null; pingRef = null; @@ -152,21 +154,26 @@ class PlejBLEHandler extends EventEmitter { logger.info('BLE init done, waiting for devices.'); } - async sendCommand(command, deviceId, data) { + /** + * @param {string} command + * @param {number} bleOutputAddress + * @param {number} data + */ + async sendCommand(command, bleOutputAddress, data) { let payload; let brightnessVal; switch (command) { case COMMANDS.TURN_ON: - payload = this._createHexPayload(deviceId, BLE_CMD_STATE_CHANGE, '01'); + payload = this._createHexPayload(bleOutputAddress, BLE_CMD_STATE_CHANGE, '01'); break; case COMMANDS.TURN_OFF: - payload = this._createHexPayload(deviceId, BLE_CMD_STATE_CHANGE, '00'); + payload = this._createHexPayload(bleOutputAddress, BLE_CMD_STATE_CHANGE, '00'); break; case COMMANDS.DIM: // eslint-disable-next-line no-bitwise brightnessVal = (data << 8) | data; payload = this._createHexPayload( - deviceId, + bleOutputAddress, BLE_CMD_DIM2_CHANGE, `01${brightnessVal.toString(16).padStart(4, '0')}`, ); @@ -194,9 +201,9 @@ class PlejBLEHandler extends EventEmitter { 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); + let plejdSerialNumber = segments[segments.length - 1].replace('dev_', ''); + plejdSerialNumber = plejdSerialNumber.replace(/_/g, ''); + plejd.device = this.deviceRegistry.getPhysicalDevice(plejdSerialNumber); if (plejd.device) { logger.debug( @@ -204,7 +211,7 @@ class PlejBLEHandler extends EventEmitter { ); this.bleDevices.push(plejd); } else { - logger.warn(`Device registry does not contain device with serial ${fixedPlejdPath}`); + logger.warn(`Device registry does not contain device with serial ${plejdSerialNumber}`); } } catch (err) { logger.error(`Failed inspecting ${path}. `, err); @@ -796,7 +803,7 @@ class PlejBLEHandler extends EventEmitter { return; } - const deviceId = decoded.readUInt8(0); + const bleOutputAddress = decoded.readUInt8(0); // Bytes 2-3 is Command/Request const cmd = decoded.readUInt16BE(3); @@ -810,38 +817,52 @@ class PlejBLEHandler extends EventEmitter { logger.silly(`Dim: ${dim.toString(16)}, full precision: ${dimFull.toString(16)}`); } - const deviceName = this.deviceRegistry.getDeviceName(deviceId); + const device = this.deviceRegistry.getOutputDeviceByBleOutputAddress(bleOutputAddress); + const deviceName = device ? device.name : 'Unknown'; + const outputUniqueId = device ? device.uniqueId : null; + if (Logger.shouldLog('verbose')) { // decoded.toString() could potentially be expensive logger.verbose(`Raw event received: ${decoded.toString('hex')}`); logger.verbose( - `Decoded: Device ${deviceId}, cmd ${cmd.toString(16)}, state ${state}, dim ${dim}`, + `Decoded: Device ${outputUniqueId} (BLE address ${bleOutputAddress}), cmd ${cmd.toString( + 16, + )}, state ${state}, dim ${dim}`, ); } let command; let data = {}; if (cmd === BLE_CMD_DIM_CHANGE || cmd === BLE_CMD_DIM2_CHANGE) { - logger.debug(`${deviceName} (${deviceId}) got state+dim update. S: ${state}, D: ${dim}`); + logger.debug( + `${deviceName} (${outputUniqueId}) got state+dim update. S: ${state}, D: ${dim}`, + ); command = COMMANDS.DIM; data = { state, dim }; - this.emit(PlejBLEHandler.EVENTS.commandReceived, deviceId, command, data); + this.emit(PlejBLEHandler.EVENTS.commandReceived, outputUniqueId, command, data); } else if (cmd === BLE_CMD_STATE_CHANGE) { - logger.debug(`${deviceName} (${deviceId}) got state update. S: ${state}`); + logger.debug(`${deviceName} (${outputUniqueId}) got state update. S: ${state}`); command = state ? COMMANDS.TURN_ON : COMMANDS.TURN_OFF; - this.emit(PlejBLEHandler.EVENTS.commandReceived, deviceId, command, data); + this.emit(PlejBLEHandler.EVENTS.commandReceived, outputUniqueId, command, data); } else if (cmd === BLE_CMD_SCENE_TRIG) { - const sceneId = state; - const sceneName = this.deviceRegistry.getSceneName(sceneId); + const sceneBleAddress = state; + const scene = this.deviceRegistry.getSceneByBleAddress(sceneBleAddress); + + if (!scene) { + logger.warn( + `Scene with BLE address ${sceneBleAddress} could not be found, can't process message`, + ); + return; + } logger.debug( - `${sceneName} (${sceneId}) scene triggered (device id ${deviceId}). Name can be misleading if there is a device with the same numeric id.`, + `${scene.name} (${sceneBleAddress}) scene triggered (device id ${outputUniqueId}).`, ); command = COMMANDS.TRIGGER_SCENE; - data = { sceneId }; - this.emit(PlejBLEHandler.EVENTS.commandReceived, deviceId, command, data); + data = { sceneId: scene.uniqueId }; + this.emit(PlejBLEHandler.EVENTS.commandReceived, outputUniqueId, command, data); } else if (cmd === BLE_CMD_TIME_UPDATE) { const now = new Date(); // Guess Plejd timezone based on HA time zone @@ -851,7 +872,7 @@ class PlejBLEHandler extends EventEmitter { const plejdTimestampUTC = (decoded.readInt32LE(5) + offsetSecondsGuess) * 1000; const diffSeconds = Math.round((plejdTimestampUTC - now.getTime()) / 1000); if ( - deviceId !== BLE_BROADCAST_DEVICE_ID + bleOutputAddress !== BLE_BROADCAST_DEVICE_ID || Logger.shouldLog('verbose') || Math.abs(diffSeconds) > 60 ) { @@ -863,7 +884,7 @@ class PlejBLEHandler extends EventEmitter { logger.warn( `Plejd clock time off by more than 1 minute. Reported time: ${plejdTime.toString()}, diff ${diffSeconds} seconds. Time will be set hourly.`, ); - if (this.connectedDevice && deviceId === this.connectedDevice.id) { + if (this.connectedDevice && bleOutputAddress === this.connectedDevice.id) { // Requested time sync by us const newLocalTimestamp = now.getTime() / 1000 - offsetSecondsGuess; logger.info(`Setting time to ${now.toString()}`); @@ -874,14 +895,14 @@ class PlejBLEHandler extends EventEmitter { (pl) => pl.writeInt32LE(Math.trunc(newLocalTimestamp), 5), ); try { - this.write(payload); + this._write(payload); } catch (err) { logger.error( 'Failed writing new time to Plejd. Will try again in one hour or at restart.', ); } } - } else if (deviceId !== BLE_BROADCAST_DEVICE_ID) { + } else if (bleOutputAddress !== BLE_BROADCAST_DEVICE_ID) { logger.info('Got time response. Plejd clock time in sync with Home Assistant time'); } } @@ -889,19 +910,19 @@ class PlejBLEHandler extends EventEmitter { logger.verbose( `Command ${cmd.toString(16)} unknown. ${decoded.toString( 'hex', - )}. Device ${deviceName} (${deviceId})`, + )}. Device ${deviceName} (${bleOutputAddress}: ${outputUniqueId})`, ); } } _createHexPayload( - deviceId, + bleOutputAddress, command, hexDataString, requestResponseCommand = BLE_REQUEST_NO_RESPONSE, ) { return this._createPayload( - deviceId, + bleOutputAddress, command, 5 + Math.ceil(hexDataString.length / 2), (payload) => payload.write(hexDataString, 5, 'hex'), @@ -911,14 +932,14 @@ class PlejBLEHandler extends EventEmitter { // eslint-disable-next-line class-methods-use-this _createPayload( - deviceId, + bleOutputAddress, command, bufferLength, payloadBufferAddDataFunc, requestResponseCommand = BLE_REQUEST_NO_RESPONSE, ) { const payload = Buffer.alloc(bufferLength); - payload.writeUInt8(deviceId); + payload.writeUInt8(bleOutputAddress); payload.writeUInt16BE(requestResponseCommand, 1); payload.writeUInt16BE(command, 3); payloadBufferAddDataFunc(payload); @@ -945,12 +966,12 @@ class PlejBLEHandler extends EventEmitter { let ct = cipher.update(buf).toString('hex'); ct += cipher.final().toString('hex'); - ct = Buffer.from(ct, 'hex'); + const ctBuf = Buffer.from(ct, 'hex'); let output = ''; for (let i = 0, { length } = data; i < length; i++) { // eslint-disable-next-line no-bitwise - output += String.fromCharCode(data[i] ^ ct[i % 16]); + output += String.fromCharCode(data[i] ^ ctBuf[i % 16]); } return Buffer.from(output, 'ascii'); diff --git a/plejd/PlejdDeviceCommunication.js b/plejd/PlejdDeviceCommunication.js index e64c8cd..4321f94 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'); @@ -12,10 +12,13 @@ const MAX_RETRY_COUNT = 10; // Could be made a setting class PlejdDeviceCommunication extends EventEmitter { bleConnected; - bleDeviceTransitionTimers = {}; + bleOutputTransitionTimers = {}; plejdBleHandler; config; + /** @type {import('./DeviceRegistry')} */ deviceRegistry; + // eslint-disable-next-line max-len + /** @type {{uniqueOutputId: string, command: string, data: any, shouldRetry: boolean, retryCount?: number}[]} */ writeQueue = []; writeQueueRef = null; @@ -34,7 +37,7 @@ class PlejdDeviceCommunication extends EventEmitter { } cleanup() { - Object.values(this.bleDeviceTransitionTimers).forEach((t) => clearTimeout(t)); + Object.values(this.bleOutputTransitionTimers).forEach((t) => clearTimeout(t)); this.plejdBleHandler.cleanup(); this.plejdBleHandler.removeAllListeners(PlejBLEHandler.EVENTS.commandReceived); this.plejdBleHandler.removeAllListeners(PlejBLEHandler.EVENTS.connected); @@ -46,7 +49,10 @@ class PlejdDeviceCommunication extends EventEmitter { this.cleanup(); this.bleConnected = false; // eslint-disable-next-line max-len - this.plejdBleHandler.on(PlejBLEHandler.EVENTS.commandReceived, (deviceId, command, data) => this._bleCommandReceived(deviceId, command, data)); + this.plejdBleHandler.on( + PlejBLEHandler.EVENTS.commandReceived, + (uniqueOutputId, command, data) => this._bleCommandReceived(uniqueOutputId, command, data), + ); this.plejdBleHandler.on(PlejBLEHandler.EVENTS.connected, () => { logger.info('Bluetooth connected. Plejd BLE up and running!'); @@ -70,42 +76,42 @@ class PlejdDeviceCommunication extends EventEmitter { } } - turnOn(deviceId, command) { - const deviceName = this.deviceRegistry.getDeviceName(deviceId); + turnOn(uniqueOutputId, command) { + const deviceName = this.deviceRegistry.getOutputDeviceName(uniqueOutputId); logger.info( - `Plejd got turn on command for ${deviceName} (${deviceId}), brightness ${command.brightness}${ + `Plejd got turn on command for ${deviceName} (${uniqueOutputId}), brightness ${ + command.brightness + }${command.transition ? `, transition: ${command.transition}` : ''}`, + ); + this._transitionTo(uniqueOutputId, command.brightness, command.transition, deviceName); + } + + turnOff(uniqueOutputId, command) { + const deviceName = this.deviceRegistry.getOutputDeviceName(uniqueOutputId); + logger.info( + `Plejd got turn off command for ${deviceName} (${uniqueOutputId})${ command.transition ? `, transition: ${command.transition}` : '' }`, ); - this._transitionTo(deviceId, command.brightness, command.transition, deviceName); + this._transitionTo(uniqueOutputId, 0, command.transition, deviceName); } - turnOff(deviceId, command) { - const deviceName = this.deviceRegistry.getDeviceName(deviceId); - logger.info( - `Plejd got turn off command for ${deviceName} (${deviceId})${ - command.transition ? `, transition: ${command.transition}` : '' - }`, - ); - this._transitionTo(deviceId, 0, command.transition, deviceName); - } - - _bleCommandReceived(deviceId, command, data) { + _bleCommandReceived(uniqueOutputId, command, data) { try { if (command === COMMANDS.DIM) { - this.deviceRegistry.setState(deviceId, data.state, data.dim); - this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, deviceId, { - state: data.state, + this.deviceRegistry.setOutputState(uniqueOutputId, data.state, data.dim); + this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, uniqueOutputId, { + state: !!data.state, brightness: data.dim, }); } else if (command === COMMANDS.TURN_ON) { - this.deviceRegistry.setState(deviceId, 1); - this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, deviceId, { + this.deviceRegistry.setOutputState(uniqueOutputId, true); + this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, uniqueOutputId, { state: 1, }); } else if (command === COMMANDS.TURN_OFF) { - this.deviceRegistry.setState(deviceId, 0); - this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, deviceId, { + this.deviceRegistry.setOutputState(uniqueOutputId, false); + this.emit(PlejdDeviceCommunication.EVENTS.stateChanged, uniqueOutputId, { state: 0, }); } else if (command === COMMANDS.TRIGGER_SCENE) { @@ -118,18 +124,18 @@ class PlejdDeviceCommunication extends EventEmitter { } } - _clearDeviceTransitionTimer(deviceId) { - if (this.bleDeviceTransitionTimers[deviceId]) { - clearInterval(this.bleDeviceTransitionTimers[deviceId]); + _clearDeviceTransitionTimer(uniqueOutputId) { + if (this.bleOutputTransitionTimers[uniqueOutputId]) { + clearInterval(this.bleOutputTransitionTimers[uniqueOutputId]); } } - _transitionTo(deviceId, targetBrightness, transition, deviceName) { - const device = this.deviceRegistry.getDevice(deviceId); + _transitionTo(uniqueOutputId, targetBrightness, transition, deviceName) { + const device = this.deviceRegistry.getOutputDevice(uniqueOutputId); const initialBrightness = device ? device.state && device.dim : null; - this._clearDeviceTransitionTimer(deviceId); + this._clearDeviceTransitionTimer(uniqueOutputId); - const isDimmable = this.deviceRegistry.getDevice(deviceId).dimmable; + const isDimmable = this.deviceRegistry.getOutputDevice(uniqueOutputId).dimmable; if ( transition > 1 @@ -164,7 +170,7 @@ class PlejdDeviceCommunication extends EventEmitter { let nSteps = 0; - this.bleDeviceTransitionTimers[deviceId] = setInterval(() => { + this.bleOutputTransitionTimers[uniqueOutputId] = setInterval(() => { const tElapsedMs = new Date().getTime() - dtStart.getTime(); let tElapsed = tElapsedMs / 1000; @@ -178,20 +184,20 @@ class PlejdDeviceCommunication extends EventEmitter { if (tElapsed === transition) { nSteps++; - this._clearDeviceTransitionTimer(deviceId); + this._clearDeviceTransitionTimer(uniqueOutputId); newBrightness = targetBrightness; logger.debug( - `Queueing finalize ${deviceName} (${deviceId}) transition from ${initialBrightness} to ${targetBrightness} in ${tElapsedMs}ms. Done steps ${nSteps}. Average interval ${ + `Queueing finalize ${deviceName} (${uniqueOutputId}) transition from ${initialBrightness} to ${targetBrightness} in ${tElapsedMs}ms. Done steps ${nSteps}. Average interval ${ tElapsedMs / (nSteps || 1) } ms.`, ); - this._setBrightness(deviceId, newBrightness, true, deviceName); + this._setBrightness(uniqueOutputId, newBrightness, true, deviceName); } else { nSteps++; logger.verbose( - `Queueing dim transition for ${deviceName} (${deviceId}) to ${newBrightness}. Total queue length ${this.writeQueue.length}`, + `Queueing dim transition for ${deviceName} (${uniqueOutputId}) to ${newBrightness}. Total queue length ${this.writeQueue.length}`, ); - this._setBrightness(deviceId, newBrightness, false, deviceName); + this._setBrightness(uniqueOutputId, newBrightness, false, deviceName); } }, transitionInterval); } else { @@ -200,34 +206,34 @@ class PlejdDeviceCommunication extends EventEmitter { `Could not transition light change. Either initial value is unknown or change is too small. Requested from ${initialBrightness} to ${targetBrightness}`, ); } - this._setBrightness(deviceId, targetBrightness, true, deviceName); + this._setBrightness(uniqueOutputId, targetBrightness, true, deviceName); } } - _setBrightness(deviceId, brightness, shouldRetry, deviceName) { + _setBrightness(unqiueOutputId, brightness, shouldRetry, deviceName) { if (!brightness && brightness !== 0) { logger.debug( - `Queueing turn on ${deviceName} (${deviceId}). No brightness specified, setting DIM to previous.`, + `Queueing turn on ${deviceName} (${unqiueOutputId}). No brightness specified, setting DIM to previous.`, ); - this._appendCommandToWriteQueue(deviceId, COMMANDS.TURN_ON, null, shouldRetry); + this._appendCommandToWriteQueue(unqiueOutputId, COMMANDS.TURN_ON, null, shouldRetry); } else if (brightness <= 0) { - logger.debug(`Queueing turn off ${deviceId}`); - this._appendCommandToWriteQueue(deviceId, COMMANDS.TURN_OFF, null, shouldRetry); + logger.debug(`Queueing turn off ${unqiueOutputId}`); + this._appendCommandToWriteQueue(unqiueOutputId, COMMANDS.TURN_OFF, null, shouldRetry); } else { if (brightness > 255) { // eslint-disable-next-line no-param-reassign brightness = 255; } - logger.debug(`Queueing ${deviceId} set brightness to ${brightness}`); + logger.debug(`Queueing ${unqiueOutputId} set brightness to ${brightness}`); // eslint-disable-next-line no-bitwise - this._appendCommandToWriteQueue(deviceId, COMMANDS.DIM, brightness, shouldRetry); + this._appendCommandToWriteQueue(unqiueOutputId, COMMANDS.DIM, brightness, shouldRetry); } } - _appendCommandToWriteQueue(deviceId, command, data, shouldRetry) { + _appendCommandToWriteQueue(uniqueOutputId, command, data, shouldRetry) { this.writeQueue.unshift({ - deviceId, + uniqueOutputId, command, data, shouldRetry, @@ -249,28 +255,29 @@ class PlejdDeviceCommunication extends EventEmitter { return; } const queueItem = this.writeQueue.pop(); - const deviceName = this.deviceRegistry.getDeviceName(queueItem.deviceId); + const device = this.deviceRegistry.getOutputDevice(queueItem.uniqueOutputId); + logger.debug( - `Write queue: Processing ${deviceName} (${queueItem.deviceId}). Command ${ + `Write queue: Processing ${device.name} (${queueItem.uniqueOutputId}). Command ${ queueItem.command }${queueItem.data ? ` ${queueItem.data}` : ''}. Total queue length: ${ this.writeQueue.length }`, ); - if (this.writeQueue.some((item) => item.deviceId === queueItem.deviceId)) { + if (this.writeQueue.some((item) => item.uniqueOutputId === queueItem.uniqueOutputId)) { logger.verbose( - `Skipping ${deviceName} (${queueItem.deviceId}) ` + `Skipping ${device.name} (${queueItem.uniqueOutputId}) ` + `${queueItem.command} due to more recent command in queue.`, ); - // Skip commands if new ones exist for the same deviceId + // Skip commands if new ones exist for the same uniqueOutputId // still process all messages in order } else { /* eslint-disable no-await-in-loop */ try { await this.plejdBleHandler.sendCommand( queueItem.command, - queueItem.deviceId, + device.bleOutputAddress, queueItem.data, ); } catch (err) { @@ -281,7 +288,7 @@ class PlejdDeviceCommunication extends EventEmitter { this.writeQueue.push(queueItem); // Add back to top of queue to be processed next; } else { logger.error( - `Write queue: Exceeed max retry count (${MAX_RETRY_COUNT}) for ${deviceName} (${queueItem.deviceId}). Command ${queueItem.command} failed.`, + `Write queue: Exceeed max retry count (${MAX_RETRY_COUNT}) for ${device.name} (${queueItem.uniqueOutputId}). Command ${queueItem.command} failed.`, ); break; } diff --git a/plejd/README.md b/plejd/README.md index dec014d..336ea57 100644 --- a/plejd/README.md +++ b/plejd/README.md @@ -189,6 +189,11 @@ The code in this project follows the [Airbnb JavaScript guide](https://github.co For a nice developer experience it is very convenient to have `eslint` and `prettier` installed in your favorite editor (such as VS Code) and use the "format on save" option (or invoke formatting by Alt+Shift+F in VS Code). Any code issues should appear in the problems window inside the editor, as well as when running the command above. +For partial type hinting you can run + +- `npm install --global typings` +- `typings install` + When contributing, please do so by forking the repo and then using pull requests towards the dev branch. ### Logs diff --git a/plejd/Scene.js b/plejd/Scene.js index e0bff71..b22f304 100644 --- a/plejd/Scene.js +++ b/plejd/Scene.js @@ -1,18 +1,20 @@ const SceneStep = require('./SceneStep'); class Scene { - constructor(idx, scene, steps) { + /** + * @param {import('./DeviceRegistry')} deviceRegistry + * @param {number} idx + * @param {import("./types/ApiSite").Scene} scene + */ + constructor(deviceRegistry, idx, scene) { this.id = idx; this.title = scene.title; this.sceneId = scene.sceneId; - const sceneSteps = steps.filter((x) => x.sceneId === scene.sceneId); - this.steps = []; - - // eslint-disable-next-line no-restricted-syntax - for (const step of sceneSteps) { - this.steps.push(new SceneStep(step)); - } + this.steps = deviceRegistry + .getApiSite() + .sceneSteps.filter((step) => step.sceneId === scene.sceneId) + .map((step) => new SceneStep(step)); } } diff --git a/plejd/SceneManager.js b/plejd/SceneManager.js index e5047cc..54dc256 100644 --- a/plejd/SceneManager.js +++ b/plejd/SceneManager.js @@ -3,45 +3,52 @@ const Scene = require('./Scene'); const logger = Logger.getLogger('scene-manager'); class SceneManager { + /** @private @type {import('./DeviceRegistry')} */ deviceRegistry; - plejdBle; + /** @private @type {import('./PlejdDeviceCommunication')} */ + plejdDeviceCommunication; + /** @private @type {Object.} */ scenes; - constructor(deviceRegistry, plejdBle) { + constructor(deviceRegistry, plejdDeviceCommunication) { this.deviceRegistry = deviceRegistry; - this.plejdBle = plejdBle; + this.plejdDeviceCommunication = plejdDeviceCommunication; this.scenes = {}; } init() { - const scenes = this.deviceRegistry.apiSite.scenes.filter( - (x) => x.hiddenFromSceneList === false, - ); + const scenes = this.deviceRegistry + .getApiSite() + .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); + const sceneBleAddress = this.deviceRegistry.getApiSite().sceneIndex[scene.sceneId]; + this.scenes[scene.sceneId] = new Scene(this.deviceRegistry, sceneBleAddress, scene); }); } - executeScene(sceneId) { - const scene = this.scenes[sceneId]; + /** + * @param {string} sceneUniqueId + */ + executeScene(sceneUniqueId) { + const scene = this.scenes[sceneUniqueId]; if (!scene) { - logger.info(`Scene with id ${sceneId} not found`); + logger.info(`Scene with id ${sceneUniqueId} not found`); logger.verbose(`Scenes: ${JSON.stringify(this.scenes, null, 2)}`); return; } scene.steps.forEach((step) => { - const device = this.deviceRegistry.getDeviceBySerialNumber(step.deviceId); + const uniqueId = this.deviceRegistry.getUniqueOutputId(step.deviceId, step.output); + const device = this.deviceRegistry.getOutputDevice(uniqueId); if (device) { if (device.dimmable && step.state) { - this.plejdBle.turnOn(device.id, { brightness: step.brightness }); + this.plejdDeviceCommunication.turnOn(uniqueId, { brightness: step.brightness }); } else if (!device.dimmable && step.state) { - this.plejdBle.turnOn(device.id, {}); + this.plejdDeviceCommunication.turnOn(uniqueId, {}); } else if (!step.state) { - this.plejdBle.turnOff(device.id, {}); + this.plejdDeviceCommunication.turnOff(uniqueId, {}); } } }); diff --git a/plejd/SceneStep.js b/plejd/SceneStep.js index 6456997..3ccc06f 100644 --- a/plejd/SceneStep.js +++ b/plejd/SceneStep.js @@ -1,7 +1,11 @@ class SceneStep { + /** + * @param {import("./types/ApiSite").SceneStep} step + */ constructor(step) { this.sceneId = step.sceneId; this.deviceId = step.deviceId; + this.output = step.output; this.state = step.state === 'On' ? 1 : 0; this.brightness = step.value; } diff --git a/plejd/config.json b/plejd/config.json index 361eda4..3b0e94f 100644 --- a/plejd/config.json +++ b/plejd/config.json @@ -1,6 +1,6 @@ { "name": "Plejd", - "version": "0.7.1", + "version": "0.8.0-dev", "slug": "plejd", "description": "Adds support for the Swedish home automation devices from Plejd.", "url": "https://github.com/icanos/hassio-plejd/", diff --git a/plejd/jsconfig.json b/plejd/jsconfig.json new file mode 100644 index 0000000..9599f8f --- /dev/null +++ b/plejd/jsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "checkJs": true, + "module": "commonjs", + "moduleResolution": "node", + "target": "es6" + }, + "exclude": ["node_modules", "rootfs"] +} diff --git a/plejd/package.json b/plejd/package.json index 6ba45e5..c2e504a 100644 --- a/plejd/package.json +++ b/plejd/package.json @@ -3,17 +3,17 @@ "@abandonware/bluetooth-hci-socket": "~0.5.3-7", "axios": "~0.21.1", "buffer-xor": "~2.0.2", - "dbus-next": "~0.9.1", + "dbus-next": "~0.9.2", "fs": "0.0.1-security", "jspack": "~0.0.4", - "mqtt": "~3.0.0", + "mqtt": "~4.2.6", "winston": "~3.3.3" }, "devDependencies": { "babel-eslint": "~10.1.0", - "eslint": "~7.18.0", + "eslint": "~7.23.0", "eslint-config-airbnb": "~18.2.1", - "eslint-config-prettier": "~7.2.0", + "eslint-config-prettier": "~8.1.0", "eslint-plugin-import": "~2.22.1", "eslint-plugin-prettier": "~3.3.1", "prettier": "~2.2.1" diff --git a/plejd/types/ApiSite.d.ts b/plejd/types/ApiSite.d.ts new file mode 100644 index 0000000..856d056 --- /dev/null +++ b/plejd/types/ApiSite.d.ts @@ -0,0 +1,655 @@ +/* eslint-disable camelcase */ +/* eslint-disable no-use-before-define */ + +export interface CachedSite { + siteId: string; + siteDetails: ApiSite; + sessionToken: string; + dtCache: string; +} + +export interface ApiSite { + site: SiteDetailsSite; + plejdMesh: PlejdMesh; + rooms: Room[]; + scenes: Scene[]; + devices: Device[]; + plejdDevices: PlejdDevice[]; + gateways: Gateway[]; + resourceSets: ResourceSet[]; + timeEvents: TimeEvent[]; + sceneSteps: SceneStep[]; + astroEvents: AstroEvent[]; + inputSettings: InputSetting[]; + outputSettings: OutputSetting[]; + stateTimers: StateTimers; + sitePermission: SitePermission; + inputAddress: { [key: string]: { [key: string]: number } }; + outputAddress: { [key: string]: OutputAddress }; + deviceAddress: { [key: string]: number }; + outputGroups: { [key: string]: OutputGroup }; + roomAddress: { [key: string]: number }; + sceneIndex: { [key: string]: number }; + images: Images; + deviceLimit: number; +} + +export interface AstroEvent { + dirtyDevices?: any[]; + dirtyRemovedDevices?: any[]; + deviceId: string; + siteId: string; + sceneId: string; + fadeTime: number; + activated: boolean; + astroEventId: string; + index: number; + sunriseOffset: number; + sunsetOffset: number; + pauseStart: string; + pauseEnd: string; + createdAt: Date; + updatedAt: Date; + dirtyRemove?: boolean; + ACL: AstroEventACL; + targetDevices: AstroEventTargetDevice[]; + objectId: string; + __type: AstroEventType; + className: string; +} + +export interface AstroEventACL {} + +export enum AstroEventType { + Object = 'Object', +} + +export interface AstroEventTargetDevice { + deviceId: string; + index: number; +} + +export interface Device { + deviceId: string; + siteId: string; + roomId: string; + title: string; + traits: number; + hardware?: Hardware; + hiddenFromRoomList: boolean; + createdAt: Date; + updatedAt: Date; + outputType: OutputType; + ACL: AstroEventACL; + objectId: string; + __type: AstroEventType; + className: DeviceClassName; + hiddenFromIntegrations?: boolean; +} + +export enum DeviceClassName { + Device = 'Device', +} + +export interface Hardware { + createdAt: Date; + updatedAt: Date; + name: Name; + hardwareId: string; + minSupportedFirmware: PlejdMeshClass; + latestFirmware: PlejdMeshClass; + brand: Brand; + type: Type; + image: Image; + requiredAccountType: RequiredAccountType[]; + numberOfDevices: number; + predefinedLoad: PredefinedLoad; + supportedFirmware: PredefinedLoad; + ACL: AstroEventACL; + objectId: HardwareObjectID; + __type: AstroEventType; + className: HardwareClassName; +} + +export enum Brand { + PlejdLight = 'Plejd Light', +} + +export enum HardwareClassName { + Hardware = 'Hardware', +} + +export interface Image { + __type: ImageType; + name: string; + url: string; +} + +export enum ImageType { + File = 'File', +} + +export interface PlejdMeshClass { + __type: InstallerType; + className: SiteClassName; + objectId: ObjectID; +} + +export enum InstallerType { + Pointer = 'Pointer', +} + +export enum SiteClassName { + DimCurve = 'DimCurve', + Firmware = 'Firmware', + PlejdMesh = 'PlejdMesh', + Site = 'Site', + User = '_User', + UserProfile = 'UserProfile', +} + +export enum ObjectID { + BBBJO2Cufm = 'BBBJO2cufm', + D4Dw87Hq21 = 'D4DW87HQ21', + FCrrS1NJHH = 'FCrrS1nJHH', + GX1W4P06QS = 'gX1W4p06QS', + Ndlvzgh4Df = 'ndlvzgh4df', + UHoKQLuXqZ = 'uHoKQLuXqZ', + VfHiawBPA8 = 'vfHiawBPA8', + WgAFPloWjK = 'wgAfPloWjK', + YkyNDotBNa = 'YkyNDotBNa', +} + +export enum Name { + Ctr01 = 'CTR-01', + Dim01 = 'DIM-01', +} + +export enum HardwareObjectID { + R3Gfd6ACAu = 'R3gfd6ACAu', + XjslOltgvi = 'xjslOltgvi', +} + +export interface PredefinedLoad { + __type: SupportedFirmwareType; + className: PredefinedLoadClassName; +} + +export enum SupportedFirmwareType { + Relation = 'Relation', +} + +export enum PredefinedLoadClassName { + DimCurve = 'DimCurve', + Firmware = 'Firmware', + PredefinedLoad = 'PredefinedLoad', +} + +export enum RequiredAccountType { + Installer = 'installer', +} + +export enum Type { + Controller = 'Controller', + LEDDimmer = 'LED Dimmer', +} + +export enum OutputType { + Light = 'LIGHT', + Relay = 'RELAY', +} + +export interface Gateway { + title: string; + deviceId: string; + siteId: string; + hardwareId: string; + installer: ObjectID; + firmware: number; + firmwareObject: Firmware; + dirtyInstall: boolean; + dirtyUpdate: boolean; + createdAt: Date; + updatedAt: Date; + factoryKey: string; + resourceSetId: string; + ACL: AstroEventACL; + objectId: string; + __type: AstroEventType; + className: string; +} + +export interface Firmware { + notes: Notes; + createdAt: Date; + updatedAt: Date; + data: Image; + metaData: Image; + version: Version; + buildTime: number; + firmwareApi: string; + ACL: AstroEventACL; + objectId: FirmwareObjectObjectID; + __type: AstroEventType; + className: SiteClassName; +} + +export enum Notes { + Ctr01 = 'CTR-01', + Ctr20ReleaseCandidate1 = 'Ctr 2.0 Release candidate 1', + Dim20ReleaseCandidate1 = 'Dim 2.0 Release candidate 1', + Dim221ReleaseCandidate = 'Dim 2.2.1 Release Candidate', + GWY10ReleaseCandidate = 'GWY 1.0 Release Candidate', +} + +export enum FirmwareObjectObjectID { + BBBJO2Cufm = 'BBBJO2cufm', + E6YxfREDuF = 'E6yxfREDuF', + JYSZ0EvyCU = 'JYSZ0EvyCU', + Ndlvzgh4Df = 'ndlvzgh4df', + RlglTfVHDe = 'rlglTfVHDe', +} + +export enum Version { + The12 = '1.2', + The20 = '2.0', + The221 = '2.2.1', + The304 = '3.0.4', +} + +export interface Images { + '2afc6c6e-7a26-466a-b8ec-febbca90f5f7': string; +} + +export interface InputSetting { + deviceId: string; + input: number; + siteId: string; + dimSpeed: number; + buttonType: ButtonType; + createdAt: Date; + updatedAt: Date; + ACL: AstroEventACL; + objectId: string; + __type: AstroEventType; + className: InputSettingClassName; + doubleClick?: string; + singleClick?: null; + doubleSidedDirectionButton?: boolean; +} + +export enum ButtonType { + PushButton = 'PushButton', + RotateMesh = 'RotateMesh', + Scene = 'Scene', +} + +export enum InputSettingClassName { + PlejdDeviceInputSetting = 'PlejdDeviceInputSetting', +} + +export interface OutputAddress { + '0': number; +} + +export interface OutputGroup { + '0': number[]; +} + +export interface OutputSetting { + deviceId: string; + output: number; + deviceParseId: string; + siteId: string; + predefinedLoad: OutputSettingPredefinedLoad; + createdAt: Date; + updatedAt: Date; + dimMin: number; + dimMax: number; + dimStart: number; + outputStartTime: number; + outputSpeed: number; + bootState: BootState; + dimCurve: DimCurve; + curveLogarithm: number; + curveSinusCompensation: number; + curveRectification: boolean; + output_0_10V_Mode?: Output0_10_VMode; + zeroCrossing?: Output0_10_VMode; + minimumRelayOffTime?: number; + ACL: AstroEventACL; + objectId: string; + __type: AstroEventType; + className: OutputSettingClassName; + ledCurrent?: number; + ledVoltage?: number; + relayConfig?: Output0_10_VMode; +} + +export enum BootState { + UseLast = 'UseLast', +} + +export enum OutputSettingClassName { + PlejdDeviceOutputSetting = 'PlejdDeviceOutputSetting', +} + +export enum DimCurve { + LinearLogarithmicSlidingProportion = 'LinearLogarithmicSlidingProportion', + NonDimmable = 'NonDimmable', +} + +export enum Output0_10_VMode { + Unknown = 'Unknown', +} + +export interface OutputSettingPredefinedLoad { + updatedAt: Date; + createdAt: Date; + loadType: string; + predefinedLoadData: string; + defaultDimCurve: PlejdMeshClass; + description_en?: DescriptionEn; + title_en?: TitleEn; + title_sv?: TitleSv; + description_sv?: DescriptionSv; + titleKey: string; + descriptionKey: string; + allowedDimCurves: PredefinedLoad; + ACL: PredefinedLoadACL; + objectId: string; + __type: AstroEventType; + className: PredefinedLoadClassName; + supportMessage?: SupportMessage; + filters?: Filters; +} + +export interface PredefinedLoadACL { + '*': Empty; +} + +export interface Empty { + read: boolean; +} + +export enum DescriptionEn { + OnOff = 'On / Off', + OnlySwitchingOffOn = 'Only switching off/on', + The230VDimmableLEDLightSourceMax100VA = '230V dimmable LED light source - Max 100VA', + The230VIncandescentHalogenElectronicTransformatorMax300W = '230V Incandescent / Halogen, Electronic transformator - Max 300W', + WithoutRelay = 'Without relay', +} + +export enum DescriptionSv { + EndastBrytningAVPå = 'Endast brytning av/på', + ReläbrytningAVPå = 'Reläbrytning av/på', + The230VDimbarLEDLjuskällaMax100VA = '230V dimbar LED ljuskälla - Max 100VA', + The230VDimbarLEDLjuskällaMax200VA = '230V dimbar LED ljuskälla - Max 200VA', + The230VHalogenGlödljusElektroniskTransformatorMax300W = '230V Halogen / Glödljus, Elektronisk transformator - Max 300W', + UtanReläbrytning = 'Utan reläbrytning', +} + +export interface Filters { + allowedCountriesFilter: AllowedCountriesFilter; +} + +export interface AllowedCountriesFilter { + countryCodes: CountryCode[]; +} + +export enum CountryCode { + Fi = 'FI', + No = 'NO', + SE = 'SE', +} + +export enum SupportMessage { + PredefinedLoadNonDimmableSupportMessageHTML = 'PredefinedLoadNonDimmableSupportMessageHTML', +} + +export enum TitleEn { + IncandescentHalogen = 'Incandescent / Halogen', + LEDTrailingEdgeCommon = 'LED Trailing Edge (Common)', + LeadingEdge = 'Leading edge', + NonDimmableLEDLightSourceMax200VA = 'Non-dimmable LED light source (Max 200VA)', + RelayOnly = 'Relay only', + The010V = '0-10V', +} + +export enum TitleSv { + EjDimbarLEDLjuskällaMax200VA = 'Ej dimbar LED-ljuskälla (Max 200VA)', + HalogenGlödljus = 'Halogen / Glödljus', + LEDBakkantVanligast = 'LED Bakkant (Vanligast)', + LEDFramkant = 'LED Framkant', + Reläfunktion = 'Reläfunktion', + The010V = '0-10V', +} + +export interface PlejdDevice { + deviceId: string; + installer: PlejdMeshClass; + dirtyInstall: boolean; + dirtyUpdate: boolean; + dirtyClock: boolean; + hardwareId: string; + faceplateId: string; + firmware: Firmware; + createdAt: Date; + updatedAt: Date; + coordinates: Coordinates; + dirtySettings: boolean; + diagnostics: string; + siteId: string; + predefinedLoad: OutputSettingPredefinedLoad; + ACL: AstroEventACL; + objectId: string; + __type: AstroEventType; + className: PlejdDeviceClassName; +} + +export enum PlejdDeviceClassName { + PlejdDevice = 'PlejdDevice', +} + +export interface Coordinates { + __type: CoordinatesType; + latitude: number; + longitude: number; +} + +export enum CoordinatesType { + GeoPoint = 'GeoPoint', +} + +export interface PlejdMesh { + siteId: string; + plejdMeshId: string; + meshKey: string; + cryptoKey: string; + createdAt: Date; + updatedAt: Date; + site: PlejdMeshClass; + ACL: AstroEventACL; + objectId: ObjectID; + __type: AstroEventType; + className: SiteClassName; +} + +export interface ResourceSet { + scopes: string[]; + remoteAccessUsers: string[]; + name: string; + type: string; + createdAt: Date; + updatedAt: Date; + ACL: AstroEventACL; + objectId: string; + __type: AstroEventType; + className: string; +} + +export interface Room { + siteId: string; + roomId: string; + title: string; + category: string; + imageHash: number; + createdAt: Date; + updatedAt: Date; + ACL: AstroEventACL; + objectId: string; + __type: AstroEventType; + className: RoomClassName; +} + +export enum RoomClassName { + Room = 'Room', +} + +export interface SceneStep { + sceneId: string; + siteId: string; + deviceId: string; + state: State; + value: number; + output: number; + createdAt: Date; + updatedAt: Date; + ACL: AstroEventACL; + objectId: string; + __type: AstroEventType; + className: SceneStepClassName; + dirty?: boolean; + dirtyRemoved?: boolean; +} + +export enum SceneStepClassName { + SceneStep = 'SceneStep', +} + +export enum State { + Off = 'Off', + On = 'On', +} + +export interface Scene { + title: string; + sceneId: string; + siteId: string; + hiddenFromSceneList: boolean; + settings: string; + createdAt: Date; + updatedAt: Date; + ACL: AstroEventACL; + objectId: string; + __type: AstroEventType; + className: ButtonType; +} + +export interface SiteDetailsSite { + installers: ObjectID[]; + title: string; + siteId: string; + version: number; + createdAt: Date; + updatedAt: Date; + plejdMesh: PlejdMeshClass; + coordinates: Coordinates; + astroTable: AstroTable; + deviceAstroTable: DeviceAstroTable; + zipCode: string; + city: string; + country: string; + previousOwners: string[]; + ACL: AstroEventACL; + objectId: ObjectID; + __type: AstroEventType; + className: SiteClassName; +} + +export interface AstroTable { + sunrise: string[]; + sunset: string[]; +} + +export interface DeviceAstroTable { + sunrise: number[]; + sunset: number[]; +} + +export interface SitePermission { + siteId: string; + userId: ObjectID; + user: User; + isOwner: boolean; + isInstaller: boolean; + isUser: boolean; + site: SiteDetailsSite; + createdAt: Date; + updatedAt: Date; + ACL: AstroEventACL; + objectId: string; + __type: AstroEventType; + className: string; +} + +export interface User { + profileName: string; + isInstaller: boolean; + email: string; + locale: string; + username: string; + emailVerified: boolean; + createdAt: Date; + updatedAt: Date; + profile: PlejdMeshClass; + _failed_login_count: number; + hasIntegration: boolean; + ACL: UserACL; + objectId: ObjectID; + __type: AstroEventType; + className: SiteClassName; +} + +export interface UserACL { + gX1W4p06QS: GX1W4P06QS; +} + +export interface GX1W4P06QS { + read: boolean; + write: boolean; +} + +export interface StateTimers { + SafetyTimer: any[]; +} + +export interface TimeEvent { + dirtyDevices?: any[]; + dirtyRemovedDevices?: any[]; + scheduledDays: number[]; + deviceId: string; + siteId: string; + sceneId: string; + fadeTime: number; + activated: boolean; + timeEventId: string; + startTimeIndex: number; + endTimeIndex: number; + startTime: string; + endTime: string; + createdAt: Date; + updatedAt: Date; + dirtyRemove?: boolean; + ACL: AstroEventACL; + targetDevices: TimeEventTargetDevice[]; + objectId: string; + __type: AstroEventType; + className: string; +} + +export interface TimeEventTargetDevice { + deviceId: string; + startTimeIndex: number; + endTimeIndex: number; +} diff --git a/plejd/types/Configuration.d.ts b/plejd/types/Configuration.d.ts new file mode 100644 index 0000000..016d19b --- /dev/null +++ b/plejd/types/Configuration.d.ts @@ -0,0 +1,50 @@ +/* eslint-disable no-use-before-define */ + +export interface AddonInfo { + name: string; + version: string; + slug: string; + description: string; + url: string; + arch: string[]; + startup: string; + boot: string; + host_network: boolean; + host_dbus: boolean; + apparmor: boolean; +} + +export interface Configuration extends AddonInfo { + options: Options; + schema: Schema; +} + +export interface Options { + site: string; + username: string; + password: string; + mqttBroker: string; + mqttUsername: string; + mqttPassword: string; + includeRoomsAsLights: boolean; + preferCachedApiResponse: boolean; + updatePlejdClock: boolean; + logLevel: string; + connectionTimeout: number; + writeQueueWaitTime: number; +} + +export interface Schema { + site: string; + username: string; + password: string; + mqttBroker: string; + mqttUsername: string; + mqttPassword: string; + includeRoomsAsLights: string; + preferCachedApiResponse: string; + updatePlejdClock: string; + logLevel: string; + connectionTimeout: string; + writeQueueWaitTime: string; +} diff --git a/plejd/types/DeviceRegistry.d.ts b/plejd/types/DeviceRegistry.d.ts new file mode 100644 index 0000000..b93c509 --- /dev/null +++ b/plejd/types/DeviceRegistry.d.ts @@ -0,0 +1,21 @@ +/* eslint-disable no-use-before-define */ + +export type OutputDevices = { [deviceIdAndOutput: string]: OutputDevice }; + +export interface OutputDevice { + bleOutputAddress: number; + deviceId: string; + dim?: number; + dimmable: boolean; + hiddenFromRoomList?: boolean; + hiddenFromIntegrations?: boolean; + hiddenFromSceneList?: boolean; + name: string; + output: number; + roomId: string; + state: boolean | undefined; + type: string; + typeName: string; + version: string; + uniqueId: string; +} diff --git a/plejd/types/Mqtt.d.ts b/plejd/types/Mqtt.d.ts new file mode 100644 index 0000000..3e3dc7a --- /dev/null +++ b/plejd/types/Mqtt.d.ts @@ -0,0 +1,25 @@ +/* eslint-disable no-use-before-define */ + +export type TopicType = 'config' | 'state' | 'availability' | 'set'; +export type TOPIC_TYPES = { [key: string]: TopicType }; + +export type MqttType = 'light' | 'scene' | 'switch' | 'device_automation'; +export type MQTT_TYPES = { [key: string]: MqttType }; + +export interface OutputDevice { + bleOutputAddress: number; + deviceId: string; + dim?: number; + dimmable: boolean; + hiddenFromRoomList?: boolean; + hiddenFromIntegrations?: boolean; + hiddenFromSceneList?: boolean; + name: string; + output: number; + roomId: string; + state: boolean | undefined; + type: string; + typeName: string; + version: string; + uniqueId: string; +} diff --git a/plejd/types/PlejdApi.d.ts b/plejd/types/PlejdApi.d.ts new file mode 100644 index 0000000..3376b65 --- /dev/null +++ b/plejd/types/PlejdApi.d.ts @@ -0,0 +1,11 @@ +/* eslint-disable no-use-before-define */ + +import { ApiSite } from './ApiSite.d.ts'; + +export type PlejdApi = { + config: any; + deviceRegistry: any; + sessionToken: string; + siteId: string; + siteDetails: ApiSite; +}; diff --git a/plejd/typings.json b/plejd/typings.json new file mode 100644 index 0000000..0fd4334 --- /dev/null +++ b/plejd/typings.json @@ -0,0 +1,5 @@ +{ + "globalDependencies": { + "node": "registry:dt/node#7.0.0+20170322231424" + } +}