diff --git a/plejd/DeviceRegistry.js b/plejd/DeviceRegistry.js index a031752..2bfc63d 100644 --- a/plejd/DeviceRegistry.js +++ b/plejd/DeviceRegistry.js @@ -22,12 +22,31 @@ class DeviceRegistry { outputDevices = {}; /** @private @type {import('types/DeviceRegistry').OutputDevices} */ sceneDevices = {}; + /** @private @type {import('types/DeviceRegistry').InputDevices} */ + inputDevices = {}; /** @param device {import('./types/ApiSite').Device} */ addPhysicalDevice(device) { this.devices[device.deviceId] = device; } + /** @param inputDevice {import('types/DeviceRegistry').InputDevice} */ + addInputDevice(inputDevice) { + this.inputDevices = { + ...this.inputDevices, + [inputDevice.uniqueId]: inputDevice, + }; + + logger.verbose( + `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) { @@ -84,6 +103,7 @@ class DeviceRegistry { clearPlejdDevices() { this.devices = {}; this.outputDevices = {}; + this.inputDevices = {}; this.outputDeviceUniqueIdsByRoomId = {}; this.outputUniqueIdByBleOutputAddress = {}; } @@ -100,6 +120,13 @@ class DeviceRegistry { return Object.values(this.outputDevices); } + /** + * @returns {import('./types/DeviceRegistry').InputDevice[]} + */ + getAllInputDevices() { + return Object.values(this.inputDevices); + } + /** * @returns {import('./types/DeviceRegistry').OutputDevice[]} */ @@ -119,11 +146,25 @@ class DeviceRegistry { return this.outputDevices[uniqueOutputId]; } + /** + * @param {string} uniqueInputId + */ + getInputDevice(uniqueInputId) { + return this.inputDevices[uniqueInputId]; + } + /** @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]; @@ -133,6 +174,10 @@ class DeviceRegistry { 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} @@ -171,6 +216,16 @@ class DeviceRegistry { 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; diff --git a/plejd/MqttClient.js b/plejd/MqttClient.js index 1931e21..64586bd 100644 --- a/plejd/MqttClient.js +++ b/plejd/MqttClient.js @@ -35,6 +35,7 @@ const getTopicName = ( /** @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}_trigger`; const getSceneEventTopic = (/** @type {string} */ sceneId) => `${getTopicName(getTriggerUniqueId(sceneId), MQTT_TYPES.DEVICE_AUTOMATION, TOPIC_TYPES.STATE)}`; const getSubscribePath = () => `${discoveryPrefix}/+/${nodeId}/#`; @@ -86,6 +87,24 @@ const getSceneDiscoveryPayload = ( 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: `${inputDevice.deviceId}`, + manufacturer: 'Plejd', + model: inputDevice.typeName, + name: inputDevice.name, + }, +}); + const getSceneDeviceTriggerhDiscoveryPayload = ( /** @type {import('./types/DeviceRegistry').OutputDevice} */ sceneDevice, ) => ({ @@ -306,6 +325,32 @@ class MqttClient extends EventEmitter { }, 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) => { @@ -401,6 +446,15 @@ class MqttClient extends EventEmitter { // ); } + /** + * @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 */ diff --git a/plejd/PlejdAddon.js b/plejd/PlejdAddon.js index cdb3d60..89e8310 100644 --- a/plejd/PlejdAddon.js +++ b/plejd/PlejdAddon.js @@ -126,6 +126,17 @@ class PlejdAddon extends EventEmitter { }, ); + 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 b79b4fd..ab5cbf4 100644 --- a/plejd/PlejdApi.js +++ b/plejd/PlejdApi.js @@ -251,44 +251,124 @@ class PlejdApi { 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 ${plejdDevice.hardwareId}`); } @@ -375,30 +455,43 @@ class PlejdApi { this.deviceRegistry.addOutputDevice(outputDevice); } + } else { + // 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); + } + }); } - - // What should we do with inputs?! - // if (outputDevice.typeName === 'WPH-01') { - // // WPH-01 is special, it has two buttons which needs to be - // // registered separately. - // const inputs = this.siteDetails.inputAddress[deviceId]; - // const first = inputs[0]; - // const second = inputs[1]; - - // this.deviceRegistry.addPlejdDevice({ - // ...outputDevice, - // id: first, - // name: `${device.title} left`, - // }); - - // this.deviceRegistry.addPlejdDevice({ - // ...outputDevice, - // id: second, - // name: `${device.title} right`, - // }); - // } else { - // this.deviceRegistry.addPlejdDevice(outputDevice); - // } }); } diff --git a/plejd/PlejdBLEHandler.js b/plejd/PlejdBLEHandler.js index 75eeaf0..0a69961 100644 --- a/plejd/PlejdBLEHandler.js +++ b/plejd/PlejdBLEHandler.js @@ -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; @@ -906,6 +907,26 @@ class PlejBLEHandler extends EventEmitter { 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( diff --git a/plejd/PlejdDeviceCommunication.js b/plejd/PlejdDeviceCommunication.js index 4321f94..8626b69 100644 --- a/plejd/PlejdDeviceCommunication.js +++ b/plejd/PlejdDeviceCommunication.js @@ -116,6 +116,8 @@ class PlejdDeviceCommunication extends EventEmitter { }); } 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}`); } diff --git a/plejd/README.md b/plejd/README.md index 336ea57..e126733 100644 --- a/plejd/README.md +++ b/plejd/README.md @@ -41,7 +41,8 @@ The add-on has been tested on the following platforms: - CTR-01 - REL-01 - REL-02 -- WPH-01 +- WPH-01 (Note: Available as Device Trigger short_button_press, button_1 .. button_4) +- WRT-01 (Note: Available as Device Trigger short_button_press, button_1. Rotation/dimming not offered by the device) ### Easy Installation 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/types/ApiSite.d.ts b/plejd/types/ApiSite.d.ts index 856d056..4a6e562 100644 --- a/plejd/types/ApiSite.d.ts +++ b/plejd/types/ApiSite.d.ts @@ -280,6 +280,8 @@ export interface InputSetting { export enum ButtonType { PushButton = 'PushButton', + DirectionUp = 'DirectionUp', + DirectionDown = 'DirectionDown', RotateMesh = 'RotateMesh', Scene = 'Scene', } diff --git a/plejd/types/DeviceRegistry.d.ts b/plejd/types/DeviceRegistry.d.ts index b93c509..5ee68ae 100644 --- a/plejd/types/DeviceRegistry.d.ts +++ b/plejd/types/DeviceRegistry.d.ts @@ -19,3 +19,17 @@ export interface OutputDevice { 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; +}