diff --git a/plejd/CHANGELOG.md b/plejd/CHANGELOG.md index ea70aee..767a5e1 100644 --- a/plejd/CHANGELOG.md +++ b/plejd/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog hassio-plejd Home Assistant Plejd addon +## [0.8.0-beta](https://github.com/icanos/hassio-plejd/tree/0.8.0-beta) (2021-06-14) + +[Full Changelog](https://github.com/icanos/hassio-plejd/compare/0.7.1...0.8.0-beta) + +**BREAKING - READ BELOW FIRST** + +Release 0.8 and later will break ALL EXISTING DEVICES from earlier versions. Unique mqtt id:s will change, meaning HA will create new devices. Scenes will be added as scenes not as switches. Devices will be installed to Areas named by the rooms defined in the Plejd app (can be changed) + +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, automations, scenes, etc will have to be gone though. +- If all else fails you can uninstall the Plejd addon and the Mqtt addon (which should remove all Mqtt devices after restart), re-install and get back the same device id:s as you had before. + +**Closed issues:** + +- Configuration instruction outdated [\#189](https://github.com/icanos/hassio-plejd/issues/189) +- Cant turn on lights after update [\#183](https://github.com/icanos/hassio-plejd/issues/183) +- Discovery finds lights but claims not to [\#182](https://github.com/icanos/hassio-plejd/issues/182) +- MQTTS connection problems with mqtt@~3.0.0 [\#181](https://github.com/icanos/hassio-plejd/issues/181) +- Adding repository to HACS [\#180](https://github.com/icanos/hassio-plejd/issues/180) +- WPH-01 buttons to trigger generic automations in HA [\#172](https://github.com/icanos/hassio-plejd/issues/172) +- Scene id and device id can overlap meaning mqtt commands overlap [\#161](https://github.com/icanos/hassio-plejd/issues/161) +- Add to "Tested on" section [\#122](https://github.com/icanos/hassio-plejd/issues/122) +- USB Bluetooth adapter [\#101](https://github.com/icanos/hassio-plejd/issues/101) +- Ignores devices if they have same name [\#91](https://github.com/icanos/hassio-plejd/issues/91) +- Scene does not change state [\#85](https://github.com/icanos/hassio-plejd/issues/85) + +**Merged pull requests:** + +- Added more documentation to install steps [\#201](https://github.com/icanos/hassio-plejd/pull/201) ([polyzois](https://github.com/polyzois)) +- Fix for issue discussed in \#198. [\#199](https://github.com/icanos/hassio-plejd/pull/199) ([faanskit](https://github.com/faanskit)) +- Suggested Area and fix for \#189 [\#192](https://github.com/icanos/hassio-plejd/pull/192) ([faanskit](https://github.com/faanskit)) +- Support for WPH-01 and WRT-01 added. [\#188](https://github.com/icanos/hassio-plejd/pull/188) ([faanskit](https://github.com/faanskit)) +- Refactor unique id handling throughout the addon [\#179](https://github.com/icanos/hassio-plejd/pull/179) ([SweVictor](https://github.com/SweVictor)) +- Update README.md [\#178](https://github.com/icanos/hassio-plejd/pull/178) ([zissou1](https://github.com/zissou1)) + ## [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) @@ -8,6 +49,9 @@ - Can't connect to device: TypeError: Cannot read property 'dimmable' [\#175](https://github.com/icanos/hassio-plejd/issues/175) +**Merged pull requests:** +- Release 0.7.1 [\#177](https://github.com/icanos/hassio-plejd/pull/177) ([SweVictor](https://github.com/SweVictor)) + ## [0.7.0](https://github.com/icanos/hassio-plejd/tree/0.7.0) (2021-03-23) [Full Changelog](https://github.com/icanos/hassio-plejd/compare/0.6.2...0.7.0) 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/Details.md b/plejd/Details.md new file mode 100644 index 0000000..ee49bfe --- /dev/null +++ b/plejd/Details.md @@ -0,0 +1,55 @@ +# Details regarding installation + +If you can reach your Home Assistant at [http://homeassistant.local:8123](http://homeassistant.local:8123) the links below should work. + +## Mosquitto + +Head over to Supervisor -> Add-on Store and search for `mosquitto broker`. +Install it and then start [mosquito addon link](http://homeassistant.local:8123/hassio/addon/core_mosquitto/info) + +## Add api user for Mosquito + +Add a Home Assistant user for the Plejd addon to be able to connect to Mosquito [Configuration -> Users](http://homeassistant.local:8123/config/users) +Call the user e.g. `mqtt-api-user`, set a password and save + +## Plejd + +Follow the `Easy Installation` in [README.MD](plejd/README.md) +And `Configuration Parameters` on the same page. +The only parameters needing a value are + +- site +- username +- password +- mqttUsername e.g. `mqtt-api-user` +- mqttPassword + +Now you can start the Plejd add-on + +## Where are the lights? + +Head over to [Configuration -> Integrations](http://homeassistant.local:8123/config/integrations) and click Configure on MQTT +After this step a new `Mosquito broker` should appear on the same page. If everything was setup correctly. It will list your lights under +`1 entity`/`n entities` + +## Running the Plejd add-on in VirtualBox on Windows + +If on Windows + VirtualBox or similar setup + +- Install VirtualBox extensions to get USB 2/3 +- Redirect correct USB device +- Potentially try to replace BT drivers with WinUSB using Zadig +- (Re)start VirtualBox HA machine + +## Running the Plejd add-on outside of Home Assistant Operating System ("HassOS") + +If you're planning on running this add-on outside of HassOS, you might need to turn off AppArmor in the `config.json` file. This is due to missing AppArmor configuration that is performed in HassOS (if you've manually done it, ignore this). + +Open the `config.json` file and locate `host_dbus`, after that line, insert: `"apparmor": "no",` and then restart the add-on. + +More information about available parameters can be found here: +https://developers.home-assistant.io/docs/en/hassio_addon_config.html + +## Migration from 32bit to 64 bit + +If you restore a backup from a 32bit system to a new 64bit system, use the Rebuild option in the Add-on diff --git a/plejd/DeviceRegistry.js b/plejd/DeviceRegistry.js index 5912ba8..2bfc63d 100644 --- a/plejd/DeviceRegistry.js +++ b/plejd/DeviceRegistry.js @@ -2,154 +2,250 @@ 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 = {}; + /** @private @type {import('types/DeviceRegistry').InputDevices} */ + inputDevices = {}; - 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 inputDevice {import('types/DeviceRegistry').InputDevice} */ + addInputDevice(inputDevice) { + this.inputDevices = { + ...this.inputDevices, + [inputDevice.uniqueId]: inputDevice, }; - 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 input device: ${JSON.stringify(inputDevice)}. ${ + Object.keys(this.inputDevices).length + } output devices in total.`, + ); + this.outputUniqueIdByBleOutputAddress[ + this.getUniqueBLEId(inputDevice.bleInputAddress, inputDevice.input) + ] = inputDevice.uniqueId; + } + + /** @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.outputDevices = { + ...this.outputDevices, + [outputDevice.uniqueId]: outputDevice, + }; + + logger.verbose( + `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.inputDevices = {}; + 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').InputDevice[]} + */ + getAllInputDevices() { + return Object.values(this.inputDevices); } - getDeviceBySerialNumber(serialNumber) { - return this.getDevice(this.deviceIdsBySerial[serialNumber]); + /** + * @returns {import('./types/DeviceRegistry').OutputDevice[]} + */ + getAllSceneDevices() { + return Object.values(this.sceneDevices); } - getDeviceName(deviceId) { - return (this.plejdDevices[deviceId] || {}).name; + /** @returns {import('./types/ApiSite').ApiSite} */ + getApiSite() { + return this.apiSite; } - getScene(sceneId) { - return this.sceneDevices[sceneId]; + /** + * @param {string} uniqueOutputId + */ + getOutputDevice(uniqueOutputId) { + return this.outputDevices[uniqueOutputId]; } - getSceneName(sceneId) { - return (this.sceneDevices[sceneId] || {}).name; + /** + * @param {string} uniqueInputId + */ + getInputDevice(uniqueInputId) { + return this.inputDevices[uniqueInputId]; } - getState(deviceId) { - const device = this.getDevice(deviceId) || {}; - if (device.dimmable) { - return { - state: device.state, - dim: device.dim, - }; + /** @returns {import('./types/DeviceRegistry').OutputDevice} */ + getOutputDeviceByBleOutputAddress(bleOutputAddress) { + return this.outputDevices[this.outputUniqueIdByBleOutputAddress[bleOutputAddress]]; + } + + /** @returns {import('./types/DeviceRegistry').InputDevice} */ + getInputDeviceByBleInputAddress(bleInputAddress, inputButton) { + return this.inputDevices[ + this.outputUniqueIdByBleOutputAddress[this.getUniqueBLEId(bleInputAddress, inputButton)] + ]; + } + + /** @returns {string[]} */ + getOutputDeviceIdsByRoomId(roomId) { + return this.outputDeviceUniqueIdsByRoomId[roomId]; + } + + getOutputDeviceName(uniqueOutputId) { + return (this.outputDevices[uniqueOutputId] || {}).name; + } + + getInputDeviceName(uniqueInputId) { + return (this.inputDevices[uniqueInputId] || {}).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}`; + } + + // eslint-disable-next-line class-methods-use-this + getUniqueInputId(deviceId, inputIndex) { + return `${deviceId}_I_${inputIndex}`; + } + + // eslint-disable-next-line class-methods-use-this + getUniqueBLEId(bleAdress, inputIndex) { + return `${bleAdress}_${inputIndex}`; + } + + /** @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..5be9434 100644 --- a/plejd/MqttClient.js +++ b/plejd/MqttClient.js @@ -8,18 +8,37 @@ 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 getButtonEventTopic = (/** @type {string} */ deviceId) => `${getTopicName(deviceId, MQTT_TYPES.DEVICE_AUTOMATION, TOPIC_TYPES.STATE)}`; +const getTriggerUniqueId = (/** @type { string } */ uniqueId) => `${uniqueId}_trig`; +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 +52,82 @@ 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.uniqueId}`, manufacturer: 'Plejd', model: device.typeName, name: device.name, + ...(device.roomName !== undefined ? { suggested_area: device.roomName } : {}), 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 getInputDeviceTriggerDiscoveryPayload = ( + /** @type {import('./types/DeviceRegistry').InputDevice} */ inputDevice, +) => ({ + automation_type: 'trigger', + payload: `${inputDevice.input}`, + '~': getBaseTopic(inputDevice.deviceId, MQTT_TYPES.DEVICE_AUTOMATION), + qos: 1, + topic: `~/${TOPIC_TYPES.STATE}`, + type: 'button_short_press', + subtype: `button_${inputDevice.input + 1}`, device: { - identifiers: `${device.serialNumber}_${device.id}`, + identifiers: `${inputDevice.deviceId}`, manufacturer: 'Plejd', - model: device.typeName, - name: device.name, - sw_version: device.version, + model: inputDevice.typeName, + name: inputDevice.name, }, }); -// #endregion +const getSceneDeviceTriggerhDiscoveryPayload = ( + /** @type {import('./types/DeviceRegistry').OutputDevice} */ sceneDevice, +) => ({ + automation_type: 'trigger', + '~': getBaseTopic(`${sceneDevice.uniqueId}_trig`, MQTT_TYPES.DEVICE_AUTOMATION), + qos: 1, + topic: `~/${TOPIC_TYPES.STATE}`, + type: 'scene', + subtype: 'trigger', + device: { + identifiers: `${sceneDevice.uniqueId}_trigger`, + manufacturer: 'Plejd', + model: sceneDevice.typeName, + name: sceneDevice.name, + }, +}); + +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 +135,9 @@ class MqttClient extends EventEmitter { stateChanged: 'stateChanged', }; + /** + * @param {import("DeviceRegistry")} deviceRegistry + */ constructor(deviceRegistry) { super(); @@ -86,8 +149,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 +163,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 +198,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 +267,147 @@ 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 allInputDevices = this.deviceRegistry.getAllInputDevices(); + logger.info(`Sending discovery for ${allInputDevices.length} Plejd input devices`); + allInputDevices.forEach((inputDevice) => { + logger.debug(`Sending discovery for ${inputDevice.name}`); + const inputInputPayload = getInputDeviceTriggerDiscoveryPayload(inputDevice); + logger.info( + `Discovered ${inputDevice.typeName} (${inputDevice.type}) named ${inputDevice.name} (${inputDevice.bleInputAddress} : ${inputDevice.uniqueId}).`, + ); + logger.verbose( + `Publishing ${getTopicName( + inputDevice.uniqueId, + MQTT_TYPES.DEVICE_AUTOMATION, + TOPIC_TYPES.CONFIG, + )} with payload ${JSON.stringify(inputInputPayload)}`, + ); + + this.client.publish( + getTopicName(inputDevice.uniqueId, MQTT_TYPES.DEVICE_AUTOMATION, TOPIC_TYPES.CONFIG), + JSON.stringify(inputInputPayload), + { + retain: true, + qos: 1, + }, + ); + }); + + 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 +419,49 @@ 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} deviceId + * @param {string} deviceInput + */ + buttonPressed(deviceId, deviceInput) { + logger.verbose(`Button ${deviceInput} pressed for deviceId ${deviceId}`); + this.client.publish(getButtonEventTopic(deviceId), `${deviceInput}`, { 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..323678b 100644 --- a/plejd/PlejdAddon.js +++ b/plejd/PlejdAddon.js @@ -65,63 +65,87 @@ 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); + + // since the scene doesn't get any updates on whether it's executed or not, + // we fake this by directly send the sceneTriggered back to HA in order for + // it continue to acto on the scene (for non-plejd devices). + try { + this.mqttClient.sceneTriggered(uniqueId); + } catch (err) { + logger.error('Error in PlejdService.sceneTriggered callback', err); + } + 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); } }, ); + this.plejdDeviceCommunication.on( + PlejdDeviceCommunication.EVENTS.buttonPressed, + (deviceId, deviceInput) => { + try { + this.mqttClient.buttonPressed(deviceId, deviceInput); + } catch (err) { + logger.error('Error in PlejdService.buttonPressed callback', err); + } + }, + ); + this.plejdDeviceCommunication.on(PlejdDeviceCommunication.EVENTS.sceneTriggered, (sceneId) => { try { this.mqttClient.sceneTriggered(sceneId); diff --git a/plejd/PlejdApi.js b/plejd/PlejdApi.js index 5272249..3f4a00f 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,111 +244,257 @@ 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 }; + return { + name: 'DIM-01', + type: 'light', + dimmable: true, + broadcastClicks: false, + }; case 2: - return { name: 'DIM-02', type: 'light', dimmable: true }; + return { + name: 'DIM-02', + type: 'light', + dimmable: true, + broadcastClicks: false, + }; case 3: - return { name: 'CTR-01', type: 'light', dimmable: false }; + return { + name: 'CTR-01', + type: 'light', + dimmable: false, + broadcastClicks: false, + }; case 4: - return { name: 'GWY-01', type: 'sensor', dimmable: false }; + return { + name: 'GWY-01', + type: 'sensor', + dimmable: false, + broadcastClicks: false, + }; case 5: - return { name: 'LED-10', type: 'light', dimmable: true }; + return { + name: 'LED-10', + type: 'light', + dimmable: true, + broadcastClicks: false, + }; case 6: - return { name: 'WPH-01', type: 'switch', dimmable: false }; + return { + name: 'WPH-01', + type: 'device_automation', + dimmable: false, + broadcastClicks: true, + }; case 7: - return { name: 'REL-01', type: 'switch', dimmable: false }; + return { + name: 'REL-01', + type: 'switch', + dimmable: false, + broadcastClicks: false, + }; case 8: case 9: // Unknown - return { name: '-unknown-', type: 'light', dimmable: false }; + return { + name: '-unknown-', + type: 'light', + dimmable: false, + broadcastClicks: false, + }; case 10: - return { name: '-unknown-', type: 'light', dimmable: false }; + return { + name: 'WRT-01', + type: 'device_automation', + dimmable: false, + broadcastClicks: true, + }; case 12: // Unknown - return { name: '-unknown-', type: 'light', dimmable: false }; + return { + name: '-unknown-', + type: 'light', + dimmable: false, + broadcastClicks: false, + }; case 13: - return { name: 'Generic', type: 'light', dimmable: false }; + return { + name: 'Generic', + type: 'light', + dimmable: false, + broadcastClicks: false, + }; case 14: case 15: case 16: // Unknown - return { name: '-unknown-', type: 'light', dimmable: false }; + return { + name: '-unknown-', + type: 'light', + dimmable: false, + broadcastClicks: false, + }; case 17: - return { name: 'REL-01', type: 'switch', dimmable: false }; + return { + name: 'REL-01', + type: 'switch', + dimmable: false, + broadcastClicks: false, + }; case 18: - return { name: 'REL-02', type: 'switch', dimmable: false }; + return { + name: 'REL-02', + type: 'switch', + dimmable: false, + broadcastClicks: false, + }; case 19: // Unknown - return { name: '-unknown-', type: 'light', dimmable: false }; + return { + name: '-unknown-', + type: 'light', + dimmable: false, + broadcastClicks: false, + }; case 20: - return { name: 'SPR-01', type: 'switch', dimmable: false }; + return { + name: 'SPR-01', + type: 'switch', + dimmable: false, + broadcastClicks: 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 (settings) { - const outputs = this.siteDetails.outputAddress[deviceId]; - deviceNum = outputs[settings.output]; + 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]; - // check if device is dimmable - const plejdDevice = this.siteDetails.plejdDevices.find((x) => x.deviceId === deviceId); - const deviceType = this._getDeviceType(plejdDevice.hardwareId); - const { name, type } = deviceType; - let { dimmable } = deviceType; + if (outputAddress) { + const bleOutputAddress = outputAddress[deviceOutput]; - if (settings) { - dimmable = settings.dimCurve !== 'NonDimmable'; - } + 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 newDevice = { - id: deviceNum, - name: device.title, - type, - typeName: name, - dimmable, - roomId: device.roomId, - version: plejdDevice.firmware.version, - serialNumber: plejdDevice.deviceId, - }; + const plejdDevice = this.siteDetails.plejdDevices.find( + (x) => x.deviceId === device.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]; + const dimmable = device.traits === TRAITS.DIMMABLE; + // dimmable = settings.dimCurve !== 'NonDimmable'; - this.deviceRegistry.addPlejdDevice({ - ...newDevice, - id: first, - name: `${device.title} left`, - }); + const { name: typeName, type: deviceType } = this._getDeviceType(plejdDevice); + let loadType = deviceType; + if (device.outputType === 'RELAY') { + loadType = 'switch'; + } else if (device.outputType === 'LIGHT') { + loadType = 'light'; + } - this.deviceRegistry.addPlejdDevice({ - ...newDevice, - id: second, - name: `${device.title} right`, - }); + const room = this.siteDetails.rooms.find((x) => x.roomId === device.roomId); + const roomTitle = room ? room.title : undefined; + + /** @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, + roomName: roomTitle, + state: undefined, + type: loadType, + typeName, + version: plejdDevice.firmware.version, + uniqueId: uniqueOutputId, + }; + + this.deviceRegistry.addOutputDevice(outputDevice); + } } else { - this.deviceRegistry.addPlejdDevice(newDevice); + // The device does not have an output. It can be assumed to be a WPH-01 or a WRT-01 + // Filter inputSettings for available buttons + const inputSettings = this.siteDetails.inputSettings.filter( + (x) => x.deviceId === device.deviceId, + ); + + // For each found button, register the device as an inputDevice + inputSettings.forEach((input) => { + const bleInputAddress = this.siteDetails.deviceAddress[input.deviceId]; + logger.verbose( + `Found input device (${input.deviceId}), with input ${input.input} having BLE address (${bleInputAddress})`, + ); + + const plejdDevice = this.siteDetails.plejdDevices.find( + (x) => x.deviceId === device.deviceId, + ); + + const uniqueInputId = this.deviceRegistry.getUniqueInputId(device.deviceId, input.input); + const { name: typeName, type, broadcastClicks } = this._getDeviceType(plejdDevice); + if (broadcastClicks) { + /** @type {import('types/DeviceRegistry').InputDevice} */ + const inputDevice = { + bleInputAddress, + deviceId: device.deviceId, + name: device.title, + input: input.input, + roomId: device.roomId, + type, + typeName, + version: plejdDevice.firmware.version, + uniqueId: uniqueInputId, + }; + this.deviceRegistry.addInputDevice(inputDevice); + } + }); } }); } @@ -332,39 +506,59 @@ 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: undefined, + roomName: undefined, + 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, + roomName: 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..0a69961 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'); @@ -23,6 +23,7 @@ const BLE_CMD_DIM2_CHANGE = 0x0098; const BLE_CMD_STATE_CHANGE = 0x0097; const BLE_CMD_SCENE_TRIG = 0x0021; const BLE_CMD_TIME_UPDATE = 0x001b; +const BLE_CMD_REMOTE_CLICK = 0x0016; const BLE_BROADCAST_DEVICE_ID = 0x01; const BLE_REQUEST_NO_RESPONSE = 0x0110; @@ -49,6 +50,8 @@ class PlejBLEHandler extends EventEmitter { connectedDevice = null; consecutiveWriteFails; consecutiveReconnectAttempts = 0; + /** @type {import('./DeviceRegistry')} */ + deviceRegistry; discoveryTimeout = null; plejdService = null; pingRef = null; @@ -152,21 +155,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 +202,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 +212,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 +804,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 +818,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 +873,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 +885,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,34 +896,54 @@ 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'); } } + } else if (cmd === BLE_CMD_REMOTE_CLICK) { + const inputBleAddress = state; + const inputButton = decoded.length > 7 ? decoded.readUInt8(6) : 0; + + const sourceDevice = this.deviceRegistry.getInputDeviceByBleInputAddress( + inputBleAddress, + inputButton, + ); + if (!sourceDevice) { + logger.warn( + `Scene with BLE address ${inputBleAddress} could not be found, can't process message`, + ); + return; + } + logger.verbose( + `A button (eg. WPH-01, WRT-01) ${inputButton} at BLE address ${inputBleAddress} was pressed. Unique Id is ${sourceDevice.uniqueId}`, + ); + command = COMMANDS.BUTTON_CLICK; + data = { deviceId: sourceDevice.deviceId, deviceInput: sourceDevice.input }; + this.emit(PlejBLEHandler.EVENTS.commandReceived, outputUniqueId, command, data); } else { 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 +953,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 +987,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..8626b69 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,46 +76,48 @@ 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) { this.emit(PlejdDeviceCommunication.EVENTS.sceneTriggered, data.sceneId); + } else if (command === COMMANDS.BUTTON_CLICK) { + this.emit(PlejdDeviceCommunication.EVENTS.buttonPressed, data.deviceId, data.deviceInput); } else { logger.warn(`Unknown ble command ${command}`); } @@ -118,18 +126,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 +172,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 +186,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 +208,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 +257,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 +290,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..7fbca72 100644 --- a/plejd/README.md +++ b/plejd/README.md @@ -1,9 +1,7 @@ # Hass.io Plejd add-on Hass.io add-on for Plejd home automation devices. Gives you the ability to control the Plejd home automation devices through Home Assistant. -It uses MQTT to communicate with Home Assistant and supports auto discovery of the devices in range. - -It also supports notifications so that changed made in the Plejd app are propagated to Home Assistant. +It uses MQTT to communicate with Home Assistant and supports auto discovery of the devices in range. Changed made in the Plejd app are propagated to Home Assistant. Thanks to [ha-plejd](https://github.com/klali/ha-plejd) for inspiration. @@ -21,58 +19,58 @@ To get started, make sure that the following requirements are met: ### Requirements -- A Bluetooth device (BLE), for eg. the built-in device in Raspberry Pi 4. -- An MQTT broker (the Mosquitto Hass.io add-on works perfectly well). +- A Bluetooth device (BLE), se "Tested on" section below. +- An MQTT broker (the [Mosquitto broker Home Assistant add-on](https://github.com/home-assistant/addons/blob/master/mosquitto/DOCS.md) works perfectly well). ### Tested on The add-on has been tested on the following platforms: +- Odroid-n2+ / Home Assistant Blue / Home Assistant Operating System ("HassOS") / BT ASUS USB-BT400 - Chipset: Broadcom BCM20702A1-0b05-17cb +- Raspberry Pi 4 with Home Assistant ("Hass.io") / Built-in BT +- Raspberry Pi 4 with Home Assistant ("Hass.io"/aarch64) / Built-in BT +- Raspberry Pi 3+ with Home Assistant ("Hass.io") / Built-in BT +- Intel NUC7i5BNH with Home Assistant Operating System ("HassOS") intel NUC image / Built-in BT +- Windows 10 Pro host / Oracle VirtualBox 6.1 / Home Assistant VBox image / Deltaco BT-118 with Cambridge Silicon Radio chipset / Windows + Zadig to change driver to WinUSB +- Windows 10 host / Oracle Virtualbox 6.1 / Home Assistant VBox image / ASUS BT400 - Mac OS Catalina 10.15.1 with Node v. 13.2.0 -- Raspberry Pi 4 with Hass.io -- Raspberry Pi 4 with Hass.io/aarch64 -- Intel NUC7i5BNH with HassOS intel NUC image (built-in BT) -#### Tested Plejd devices - -- DIM-01 -- DIM-02 -- LED-10 -- CTR-01 -- REL-01 -- REL-02 -- WPH-01 +Supported Plejd devices are detailed in a specific "Plejd devices" section below. ### Easy Installation -Browse to your Home Assistant installation in a web browser and click on `Hass.io` in the navigation bar to the left. +Browse to your Home Assistant installation in a web browser and click on `Supervisor` in the navigation bar to the left. -- Open the Home Assistant web console and click `Hass.io` in the menu on the left side. +- Open the Home Assistant web console and click `Supervisor` in the menu on the left side. - Click on `Add-on Store` in the top navigation bar of that page. -- Paste the URL to this repo https://github.com/icanos/hassio-plejd.git in the `Add new repository by URL` field and hit `Add`. +- Click on the three vertical dots to the far right and chose `Repositories` +- Paste the URL to this repo https://github.com/icanos/hassio-plejd.git in the `Add` field and hit `Add`. - Scroll down and you should find a Plejd add-on that can be installed. Open that and install. +- Configure hassio-plejd (see below). - Enjoy! ### Manual Installation -Browse your Hass.io installation using a tool that allows you to manage files, for eg. SMB or an SFTP client etc. +Browse your Home Assistant installation using a tool that allows you to manage files, for eg. SCP, SMB, SFTP client, etc. - Open the `/addon` directory - Create a new folder named `hassio-plejd` - Copy all files from this repository into that newly created one. -- Open the Home Assistant web console and click `Hass.io` in the menu on the left side. +- Open the Home Assistant web console and click `Supervisor` in the menu on the left side. - Click on `Add-on Store` in the top navigation bar of that page. - Click on the refresh button in the upper right corner. - A new Local Add-on should appear named Plejd. Open that and install. - Enjoy! -### Install older versions or developemnt version +### Install older versions or development version To install older versions, follow the "Manual Installation" instructions above, but copy the code from [one of the releases](https://github.com/icanos/hassio-plejd/releases). To test new functionality you can download the development version, available in the [develop branch](https://github.com/icanos/hassio-plejd/tree/develop). -### IMPORTANT INFORMATION +### More details regarding installation -#### Startup error message +Please look at [The details](./Details.md) separate document for more detailed instructions regarding Home Asssistant, Mosquitto, etc. + +### Startup error message When starting the add-on, the log displays this message: @@ -83,31 +81,22 @@ parse error: Expected string key before ':' at line 1, column 4 However, the add-on still works as expected and this is something I'm looking into, but not with that much effort yet though. -#### Running the Plejd add-on in VirtualBox on Windows +## Configuration -If on Windows + VirtualBox or similar setup +### Simple MQTT Configurations -- Install VirtualBox extensions to get USB 2/3 -- Redirect correct USB device -- Potentially try to replace BT drivers with WinUSB using Zadig -- (Re)start VirtualBox HA machine +When you are using the official Mosquitto Broker from Home Assistant Add-on store, minimal configuration is required. +Create a user in [Configuration -> Users](http://homeassistant.local:8123/config/users) named e.g. mqtt-api-user -#### Running the Plejd add-on outside of HassOS +| Parameter | Value | +| ------------ | ------------------------------------------------ | +| mqttBroker | mqtt:// | +| mqttUsername | Arbitrary Home Assistant User e.g. mqtt-api-user | +| mqttPassword | Users password | -If you're planning on running this add-on outside of HassOS, you might need to turn off AppArmor in the `config.json` file. This is due to missing AppArmor configuration that is performed in HassOS (if you've manually done it, ignore this). +### Advanced MQTT Configurations -Open the `config.json` file and locate `host_dbus`, after that line, insert: `"apparmor": "no",` and then restart the add-on. - -More information about available parameters can be found here: -https://developers.home-assistant.io/docs/en/hassio_addon_config.html - -#### Migration from 32bit to 64 bit - -If you restore a backup from a 32bit system to a new 64bit system, use the Rebuild option in the Add-on - -### Configuration - -You need to add the MQTT integration to Home Assistant either by going to Configuration -> Integrations and clicking the Add Integration button, or by adding the following to your `configuration.yaml` file: +For more advanced instllations, you need to add the MQTT integration to Home Assistant either by going to Configuration -> Integrations and clicking the Add Integration button, or by adding the following to your `configuration.yaml` file: ``` mqtt: @@ -120,6 +109,8 @@ mqtt: The above is used to notify the add-on when Home Assistant has started successfully and let the add-on send the discovery response (containing information about all Plejd devices found). +### Configuration Parameters + The plugin needs you to configure some settings before working. You find these on the Add-on page after you've installed it. | Parameter | Value | @@ -127,7 +118,7 @@ The plugin needs you to configure some settings before working. You find these o | site | Name of your Plejd site, the name is displayed in the Plejd app (top bar). | | username | Username of your Plejd account, this is used to fetch the crypto key and devices from the Plejd API. | | password | Password of your Plejd account, this is used to fetch the crypto key and devices from the Plejd API. | -| mqttBroker | URL of the MQTT Broker, eg. mqtt://localhost | +| mqttBroker | URL of the MQTT Broker, eg. mqtt:// | | mqttUsername | Username of the MQTT broker | | mqttPassword | Password of the MQTT broker | | includeRoomsAsLights | Adds all rooms as lights, making it possible to turn on/off lights by room instead. Setting this to false will ignore all rooms. | @@ -136,6 +127,49 @@ The plugin needs you to configure some settings before working. You find these o | connectionTimeout | Number of seconds to wait when scanning and connecting. Might need to be tweaked on platforms other than RPi 4. Defaults to: 2 seconds. | | writeQueueWaitTime | Wait time between message sent to Plejd over BLE, defaults to 400. If that doesn't work, try changing the value higher in steps of 50. | +## Plejd devices and corresponding Home Assistant devices + +Plejd output devices typically appears as either lights or switches in Home Assistant depending on how they are configured. + +| Device | Plejd Configuration | Home Assistant Role | Comment | +| ------ | ------------------- | ------------------- | --------------------------------------------------------------------- | +| CTR-01 | Relay, Light | Light | | +| CTR-01 | Relay, Other | Switch | | +| REL-01 | Relay, Light | Light | | +| REL-01 | Relay, Other | Switch | | +| REL-02 | Relay, Light | Light | | +| REL-02 | Relay, Other | Switch | | +| SPR-01 | Relay, Light | Light | Not tested, not supported | +| SPR-01 | Relay, Other | Switch | Not tested, not supported | +| DIM-01 | - | Light | | +| DIM-02 | - | Light | | +| LED-10 | - | Light | | +| DAL-01 | - | - | Not tested, not supported | +| WPH-01 | - | Device Automation | type:button_short_press, subtype:button_1, button_2,button_3,button_4 | +| WRT-01 | - | Device Automation | type:button_short_press, subtype:button_1 | +| GWY-01 | - | - | | +| RTR-01 | - | - | | +| Scene | - | Scene | | +| Scene | - | Device Automation | type:scene, subtype:trigger | +| Room | - | Area | Can be changed by Home Assistant | +| Room | - | Light | If includeRoomsAsLights is set to true | + +## Transitions + +Transitions from Home Assistant are supported (for dimmable devices) when transition is longer than 1 second. Plejd will do a bit of internal transitioning (default soft start is 0.1 seconds). + +This implementation will transition each device independently, meaning that brightness change might be choppy if transitioning many devices at once or a changing brightness a lot in a limited time. Hassio-plejd's communication channel seems to handle a few updates per second, this is the combined value for all devices. + +Transition points will be skipped if the queue of messages to be sent is over a certain threshold, by default equal to the number of devices in the system. Total transition time is prioritized rather than smoothness. + +Recommendations + +- Only transition a few devices at a time when possible +- Entire rooms can be transitioned efficiently after settin gincludeRoomsAsLights to true +- Expect 5-10 brightness changes per second, meaning 5 devices => 1-2 updates per device per second +- ... meaning that SLOW transitions will work well (wake-up light, gradually fade over a minute, ...), but quick ones will only work well for few devices or small relative changes in brightness +- When experiencing choppy quick transitions, turn transitioning off and let the Plejd hardware do the work instead + ## Troubleshooting If you're having issues to get the addon working, there are a few things you can look into: @@ -158,21 +192,6 @@ If you're having issues to get the addon working, there are a few things you can - One Plejd device means max one BLE connection, meaning using the Plejd app over BT will disconnect the addon BLE connection - It seems you can kick yourself out (by connecting using the app) even when you have multiple devices if the app happens to connect to the same device as the addon is using -## Transitions - -Transitions from Home Assistant are supported (for dimmable devices) when transition is longer than 1 second. Plejd will do a bit of internal transitioning (default soft start is 0.1 seconds). - -This implementation will transition each device independently, meaning that brightness change might be choppy if transitioning many devices at once or a changing brightness a lot in a limited time. Hassio-plejd's communication channel seems to handle a few updates per second, this is the combined value for all devices. - -Transition points will be skipped if the queue of messages to be sent is over a certain threshold, by default equal to the number of devices in the system. Total transition time is prioritized rather than smoothness. - -Recommendations - -- Only transition a few devices at a time when possible -- Expect 5-10 brightness changes per second, meaning 5 devices => 1-2 updates per device per second -- ... meaning that SLOW transitions will work well (wake-up light, gradually fade over a minute, ...), but quick ones will only work well for few devices or small relative changes in brightness -- When experiencing choppy quick transitions, turn transitioning off and let the Plejd hardware do the work instead - ## I want voice control! With the Google Home integration in Home Assistant, you can get voice control for your Plejd lights right away, check this out for more information: @@ -189,6 +208,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..b9b2330 100644 --- a/plejd/config.json +++ b/plejd/config.json @@ -1,6 +1,6 @@ { "name": "Plejd", - "version": "0.7.1", + "version": "0.8.0-beta", "slug": "plejd", "description": "Adds support for the Swedish home automation devices from Plejd.", "url": "https://github.com/icanos/hassio-plejd/", diff --git a/plejd/constants.js b/plejd/constants.js index 0b69b3a..1b97f67 100644 --- a/plejd/constants.js +++ b/plejd/constants.js @@ -3,6 +3,7 @@ const COMMANDS = { TURN_OFF: 'Turn off', DIM: 'Dim', TRIGGER_SCENE: 'Trigger scene', + BUTTON_CLICK: 'Button click', }; module.exports = { COMMANDS }; 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..4a6e562 --- /dev/null +++ b/plejd/types/ApiSite.d.ts @@ -0,0 +1,657 @@ +/* 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', + DirectionUp = 'DirectionUp', + DirectionDown = 'DirectionDown', + 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..2d705cc --- /dev/null +++ b/plejd/types/DeviceRegistry.d.ts @@ -0,0 +1,36 @@ +/* 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 | undefined; + roomName: string | undefined; + state: boolean | undefined; + type: string; + typeName: string; + version: string; + uniqueId: string; +} + +export type InputDevices = { [deviceIdAndOutput: string]: InputDevice }; + +export interface InputDevice { + bleInputAddress: number; + deviceId: string; + name: string; + input: number; + roomId: string; + 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" + } +}