From a813663de5cf24110f2f7c943682bc3e8d4627eb Mon Sep 17 00:00:00 2001 From: Jorge Schrauwen Date: Thu, 24 Oct 2019 22:15:40 +0200 Subject: [PATCH] Poll device when device does not support reporting. (#2122) * Support emulation of attReport Some device do not support attReport for some keys, this will emulate it. You can configure which keys that should be read when another device send a message and the configured device a bind target or in a group the message was send to. ```yaml devices: '0x0017880104259333': friendly_name: bedroom/desk_lamp retain: true debounce: 0.5 report_emulate: - brightness - color ``` Will have the brightness and color queried for example when a hue dimmer sends commands to the bulb. * Refactor polling. * Finish poll tests. * Update herdsman. * Improve test stability. --- lib/extension/deviceReport.js | 76 +++++++++++++++++++++++++++++++++++ npm-shrinkwrap.json | 36 ++++++++--------- package.json | 2 +- test/bridgeConfig.test.js | 3 +- test/deviceReport.test.js | 36 ++++++++++++++++- test/entityPublish.test.js | 4 +- test/stub/data.js | 17 ++++++++ test/stub/zigbeeHerdsman.js | 24 +++++++---- 8 files changed, 167 insertions(+), 31 deletions(-) diff --git a/lib/extension/deviceReport.js b/lib/extension/deviceReport.js index e04e4132..43721fc3 100644 --- a/lib/extension/deviceReport.js +++ b/lib/extension/deviceReport.js @@ -3,6 +3,8 @@ const logger = require('../util/logger'); const CC2530Router = zigbeeHerdsmanConverters.devices.find((d) => d.model === 'CC2530.ROUTER'); const utils = require('../util/utils'); const BaseExtension = require('./baseExtension'); +const debounce = require('debounce'); +const ZigbeeHerdsman = require('zigbee-herdsman'); const defaultConfiguration = { minimumReportInterval: 3, maximumReportInterval: 300, reportableChange: 0, @@ -28,11 +30,31 @@ const clusters = { ], }; +const pollOnMessage = [ + { + // Key is used this.pollDebouncers uniqueness + key: 1, + // On messages that have the cluster and type of below + cluster: { + manuSpecificPhilips: ['commandHueNotification'], + genLevelCtrl: [ + 'commandStep', 'commandStepWithOnOff', 'commandStop', 'commandMoveWithOnOff', 'commandStopWithOnOff', + 'commandMove', + ], + }, + // Read the following attributes + read: {cluster: 'genLevelCtrl', attributes: ['currentLevel']}, + // When the bound devices/members of group have the following manufacturerID + manufacturerID: ZigbeeHerdsman.Zcl.ManufacturerCode.Philips, + }, +]; + class DeviceReport extends BaseExtension { constructor(zigbee, mqtt, state, publishEntityState) { super(zigbee, mqtt, state, publishEntityState); this.configuring = new Set(); this.failed = new Set(); + this.pollDebouncers = {}; } async setupReporting(device) { @@ -99,6 +121,60 @@ class DeviceReport extends BaseExtension { if (this.shouldSetupReporting(mappedDevice, data.device, type)) { this.setupReporting(data.device); } + + if (type === 'message') { + this.poll(data); + } + } + + poll(message) { + /** + * This method poll bound endpoints and group members for state changes. + * + * A use case is e.g. a Hue Dimmer switch bound to a Hue bulb. + * Hue bulbs only report their on/off state. + * When dimming the bulb via the dimmer switch the state is therefore not reported. + * When we receive a message from a Hue dimmer we read the brightness from the bulb (if bound). + */ + + const polls = pollOnMessage.filter((p) => + p.cluster[message.cluster] && p.cluster[message.cluster].includes(message.type) + ); + + if (polls.length) { + let toPoll = []; + + // Add bound devices + toPoll = toPoll.concat([].concat(...message.device.endpoints.map((e) => e.binds.map((e) => e)))); + toPoll = toPoll.filter((e) => e.target.constructor.name === 'Endpoint'); + toPoll = toPoll.filter((e) => e.target.getDevice().type !== 'Coordinator'); + toPoll = toPoll.map((e) => e.target); + + // If message is published to a group, add members of the group + const group = message.groupID !== 0 ? this.zigbee.getGroupByID(message.groupID) : null; + if (group) { + toPoll = toPoll.concat(group.members); + } + + toPoll = new Set(toPoll); + + for (const endpoint of toPoll) { + for (const poll of polls) { + if (poll.manufacturerID !== endpoint.getDevice().manufacturerID) { + continue; + } + + const key = `${endpoint.deviceIeeeAddress}_${endpoint.ID}_${poll.key}`; + if (!this.pollDebouncers[key]) { + this.pollDebouncers[key] = debounce(async () => { + await endpoint.read(poll.read.cluster, poll.read.attributes); + }, 1000); + } + + this.pollDebouncers[key](); + } + } + } } } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 4ce5412a..3925fbe7 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -880,9 +880,9 @@ } }, "bser": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.0.tgz", - "integrity": "sha512-8zsjWrQkkBoLK6uxASk1nJ2SKv97ltiGDo6A3wA0/yRPz+CwmEyDo0hUrhIuukG2JHpAl3bvFIixw2/3Hi0DOg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", "dev": true, "requires": { "node-int64": "^0.4.0" @@ -1628,9 +1628,9 @@ "dev": true }, "eslint-plugin-jest": { - "version": "22.19.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-22.19.0.tgz", - "integrity": "sha512-4zUc3rh36ds0SXdl2LywT4YWA3zRe8sfLhz8bPp8qQPIKvynTTkNGwmSCMpl5d9QiZE2JxSinGF+WD8yU+O0Lg==", + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-22.20.0.tgz", + "integrity": "sha512-UwHGXaYprxwd84Wer8H7jZS+5C3LeEaU8VD7NqORY6NmPJrs+9Ugbq3wyjqO3vWtSsDaLar2sqEB8COmOZA4zw==", "dev": true, "requires": { "@typescript-eslint/experimental-utils": "^1.13.0" @@ -2717,9 +2717,9 @@ "dev": true }, "graceful-fs": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.2.tgz", - "integrity": "sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", + "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", "dev": true }, "growly": { @@ -4644,9 +4644,9 @@ "dev": true }, "react-is": { - "version": "16.10.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.10.2.tgz", - "integrity": "sha512-INBT1QEgtcCCgvccr5/86CfD71fw9EPmDxgiJX4I2Ddr6ZsV6iFXsuby+qWJPtmNuMY0zByTsG4468P7nHuNWA==", + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz", + "integrity": "sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw==", "dev": true }, "read-pkg": { @@ -5665,9 +5665,9 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, "uglify-js": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.3.tgz", - "integrity": "sha512-KfQUgOqTkLp2aZxrMbCuKCDGW9slFYu2A23A36Gs7sGzTLcRBDORdOi5E21KWHFIfkY8kzgi/Pr1cXCh0yIp5g==", + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.4.tgz", + "integrity": "sha512-9Yc2i881pF4BPGhjteCXQNaXx1DCwm3dtOyBaG2hitHjLWOczw/ki8vD1bqyT3u6K0Ms/FpCShkmfg+FtlOfYA==", "dev": true, "optional": true, "requires": { @@ -6053,9 +6053,9 @@ } }, "zigbee-herdsman": { - "version": "0.10.9", - "resolved": "https://registry.npmjs.org/zigbee-herdsman/-/zigbee-herdsman-0.10.9.tgz", - "integrity": "sha512-QcV/v1hqGYdlawvvgoZsClOI6aeXGvJVSCZkMm4xQS1SbevTXzXFP4VtcR5WlnK0a+Kl7ibXcVfnR4XCl2r5IA==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/zigbee-herdsman/-/zigbee-herdsman-0.11.1.tgz", + "integrity": "sha512-eBIEZN8cF9hlPMRDUQ8J0LBEa7obYX74pmMwvK3dBmeMfc4m3kzNYZ0Lgc+XV93VSEEe9Hj9flOuHa93KUZKIQ==", "requires": { "debug": "^4.1.1", "fast-deep-equal": "^2.0.1", diff --git a/package.json b/package.json index 6054a202..eaaca4e1 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "rimraf": "*", "semver": "*", "winston": "*", - "zigbee-herdsman": "0.10.9", + "zigbee-herdsman": "0.11.1", "zigbee-herdsman-converters": "11.1.28" }, "devDependencies": { diff --git a/test/bridgeConfig.test.js b/test/bridgeConfig.test.js index a179d91b..95a2df6c 100644 --- a/test/bridgeConfig.test.js +++ b/test/bridgeConfig.test.js @@ -170,7 +170,7 @@ describe('Bridge config', () => { await flushPromises(); expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/log'); const payload = JSON.parse(MQTT.publish.mock.calls[0][1]); - expect(payload).toStrictEqual({"message": [{"ID": 1, "friendly_name": "group_1", "retain": false, 'devices': [], optimistic: true}, {"ID": 2, "friendly_name": "group_2", "retain": false, "devices": [], optimistic: true}], "type": "groups"}); + expect(payload).toStrictEqual({"message": [{"ID": 1, "friendly_name": "group_1", "retain": false, 'devices': [], optimistic: true}, {"ID": 2, "friendly_name": "group_2", "retain": false, "devices": [], optimistic: true}, {"ID": 15071, "friendly_name": "group_tradfri_remote", "retain": false, "devices": ['bulb_color_2', 'bulb_2'], optimistic: true}], "type": "groups"}); }); it('Should allow rename devices', async () => { @@ -237,7 +237,6 @@ describe('Bridge config', () => { expect(device.removeFromNetwork).toHaveBeenCalledTimes(1); expect(controller.state[device.ieeeAddr]).toBeUndefined(); expect(settings.getDevice('bulb_color')).toBeNull(); - expect(MQTT.publish).toHaveBeenCalledTimes(1); expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/log', JSON.stringify({type: 'device_removed', message: 'bulb_color'}), diff --git a/test/deviceReport.test.js b/test/deviceReport.test.js index 7e68c9c9..1026fcc3 100644 --- a/test/deviceReport.test.js +++ b/test/deviceReport.test.js @@ -6,13 +6,17 @@ zigbeeHerdsman.returnDevices.push('0x00124b00120144ae'); zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b2'); zigbeeHerdsman.returnDevices.push('0x0017880104e45553'); zigbeeHerdsman.returnDevices.push('0x0017880104e45559'); +zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b4'); +zigbeeHerdsman.returnDevices.push('0x000b57fffec6a5b7'); const MQTT = require('./stub/mqtt'); const settings = require('../lib/util/settings'); const Controller = require('../lib/controller'); const flushPromises = () => new Promise(setImmediate); const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +jest.mock('debounce', () => jest.fn(fn => fn)); +const debounce = require('debounce'); -const mocksClear = [MQTT.publish, logger.warn, logger.debug]; +const mocksClear = [MQTT.publish, logger.warn, logger.debug, debounce]; describe('Device report', () => { let controller; @@ -163,4 +167,34 @@ describe('Device report', () => { await flushPromises(); expect(endpoint.bind).toHaveBeenCalledTimes(1); }); + + it('Should poll bounded Hue bulb when receiving message from Hue dimmer', async () => { + const remote = zigbeeHerdsman.devices.remote; + const data = {"button":3,"unknown1":3145728,"type":2,"unknown2":0,"time":1}; + const payload = {data, cluster: 'manuSpecificPhilips', device: remote, endpoint: remote.getEndpoint(2), type: 'commandHueNotification', linkquality: 10, groupID: 0}; + await zigbeeHerdsman.events.message(payload); + await flushPromises(); + expect(debounce).toHaveBeenCalledTimes(1); + expect(zigbeeHerdsman.devices.bulb_color.getEndpoint(1).read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]); + }); + + it('Should poll grouped Hue bulb when receiving message from TRADFRI remote and should', async () => { + const remote = zigbeeHerdsman.devices.tradfri_remote; + const data = {"stepmode":0,"stepsize":43,"transtime":5}; + const payload = {data, cluster: 'genLevelCtrl', device: remote, endpoint: remote.getEndpoint(1), type: 'commandStepWithOnOff', linkquality: 10, groupID: 15071}; + await zigbeeHerdsman.events.message(payload); + await flushPromises(); + expect(debounce).toHaveBeenCalledTimes(1); + expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledTimes(1); + expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledWith("genLevelCtrl", ["currentLevel"]); + + // Should also only debounce once + await zigbeeHerdsman.events.message(payload); + await flushPromises(); + expect(debounce).toHaveBeenCalledTimes(1); + expect(zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read).toHaveBeenCalledTimes(2); + + // Should only call Hue bulb, not e.g. tradfri + expect(zigbeeHerdsman.devices.bulb.getEndpoint(1).read).toHaveBeenCalledTimes(0); + }); }); diff --git a/test/entityPublish.test.js b/test/entityPublish.test.js index a0528578..021d9c88 100644 --- a/test/entityPublish.test.js +++ b/test/entityPublish.test.js @@ -353,10 +353,10 @@ describe('Entity publish', () => { it('Should create and publish to group which is in configuration.yaml but not in zigbee-herdsman', async () => { delete zigbeeHerdsman.groups.group_2; - expect(Object.values(zigbeeHerdsman.groups).length).toBe(1); + expect(Object.values(zigbeeHerdsman.groups).length).toBe(2); await MQTT.events.message('zigbee2mqtt/group_2/set', JSON.stringify({state: 'ON'})); await flushPromises(); - expect(Object.values(zigbeeHerdsman.groups).length).toBe(2); + expect(Object.values(zigbeeHerdsman.groups).length).toBe(3); expect(zigbeeHerdsman.groups.group_2.command).toHaveBeenCalledTimes(1); expect(zigbeeHerdsman.groups.group_2.command).toHaveBeenCalledWith("genOnOff", "on", {}, {}); }); diff --git a/test/stub/data.js b/test/stub/data.js index f7648023..28976fd9 100644 --- a/test/stub/data.js +++ b/test/stub/data.js @@ -55,10 +55,18 @@ function writeDefaultConfiguration() { retain: false, friendly_name: "ikea_onoff" }, + '0x000b57fffec6a5b7': { + retain: false, + friendly_name: "bulb_2" + }, "0x000b57fffec6a5b3": { retain: false, friendly_name: "bulb_color" }, + '0x000b57fffec6a5b4': { + retain: false, + friendly_name: "bulb_color_2" + }, "0x0017880104e45541": { retain: false, friendly_name: "wall_switch" @@ -118,6 +126,10 @@ function writeDefaultConfiguration() { '0x0017880104e45560': { retain: false, friendly_name: 'livolo' + }, + '0x90fd9ffffe4b64ae': { + retain: false, + friendly_name: 'tradfri_remote', } }, groups: { @@ -128,6 +140,11 @@ function writeDefaultConfiguration() { '2': { friendly_name: 'group_2', retain: false, + }, + '15071': { + friendly_name: 'group_tradfri_remote', + retain: false, + devices: ['bulb_color_2', 'bulb_2'] } } }; diff --git a/test/stub/zigbeeHerdsman.js b/test/stub/zigbeeHerdsman.js index 3142a02d..f0a8decf 100644 --- a/test/stub/zigbeeHerdsman.js +++ b/test/stub/zigbeeHerdsman.js @@ -2,11 +2,11 @@ const events = {}; const assert = require('assert'); class Group { - constructor(groupID) { + constructor(groupID, members) { this.groupID = groupID; this.command = jest.fn(); this.meta = {}; - this.members = []; + this.members = members; this.hasMember = (endpoint) => this.members.includes(endpoint); } } @@ -20,7 +20,7 @@ const clusters = { } class Endpoint { - constructor(ID, inputClusters, outputClusters, deviceIeeeAddress) { + constructor(ID, inputClusters, outputClusters, deviceIeeeAddress, binds=[]) { this.deviceIeeeAddress = deviceIeeeAddress; this.ID = ID; this.inputClusters = inputClusters; @@ -31,6 +31,7 @@ class Endpoint { this.bind = jest.fn(); this.unbind = jest.fn(); this.configureReporting = jest.fn(); + this.binds = binds; this.supportsInputCluster = (cluster) => { assert(clusters[cluster], `Undefined '${cluster}'`); return this.inputClusters.includes(clusters[cluster]); @@ -88,11 +89,17 @@ class Device { const returnDevices = []; +const bulb_color = new Device('Router', '0x000b57fffec6a5b3', 40399, 4107, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b3')], true, "Mains (single phase)", "LLC020"); +const bulb_color_2 = new Device('Router', '0x000b57fffec6a5b4', 401292, 4107, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b4')], true, "Mains (single phase)", "LLC020"); +const bulb_2 = new Device('Router', '0x000b57fffec6a5b7', 40369, 4476, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b7')], true, "Mains (single phase)", "TRADFRI bulb E27 WS opal 980lm"); + const devices = { 'coordinator': new Device('Coordinator', '0x00124b00120144ae', 0, 0, [new Endpoint(1, [], [])], false), 'bulb': new Device('Router', '0x000b57fffec6a5b2', 40369, 4476, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b2')], true, "Mains (single phase)", "TRADFRI bulb E27 WS opal 980lm"), - 'bulb_color': new Device('Router', '0x000b57fffec6a5b3', 40399, 6535, [new Endpoint(1, [0,3,4,5,6,8,768,2821,4096], [5,25,32,4096], '0x000b57fffec6a5b3')], true, "Mains (single phase)", "LLC020"), - 'remote': new Device('EndDevice', '0x0017880104e45517', 6535, 4107, [new Endpoint(1, [0], [0,3,4,6,8,5]), new Endpoint(2, [0,1,3,15,64512], [25, 6])], true, "Battery", "RWL021"), + 'bulb_color': bulb_color, + 'bulb_2': bulb_2, + 'bulb_color_2': bulb_color_2, + 'remote': new Device('EndDevice', '0x0017880104e45517', 6535, 4107, [new Endpoint(1, [0], [0,3,4,6,8,5], '0x0017880104e45517', [{target: bulb_color.endpoints[0]}]), new Endpoint(2, [0,1,3,15,64512], [25, 6])], true, "Battery", "RWL021"), 'unsupported': new Device('EndDevice', '0x0017880104e45518', 6536, 0, [new Endpoint(1, [0], [0,3,4,6,8,5])], true, "Battery", "notSupportedModelID"), 'unsupported2': new Device('EndDevice', '0x0017880104e45529', 6536, 0, [new Endpoint(1, [0], [0,3,4,6,8,5])], true, "Battery", "notSupportedModelID"), 'interviewing': new Device('EndDevice', '0x0017880104e45530', 6536, 0, [new Endpoint(1, [0], [0,3,4,6,8,5])], true, "Battery", undefined, true), @@ -119,10 +126,12 @@ const devices = { 'unsupported_router': new Device('Router', '0x0017880104e45525', 6536, 0, [new Endpoint(1, [0], [0,3,4,6,8,5])], true, "Mains (single phase)", "notSupportedModelID", false, "Boef"), 'CC2530_ROUTER': new Device('Router', '0x0017880104e45559', 6540,4151, [new Endpoint(1, [0, 6], [])], true, "Mains (single phase)", 'lumi.router'), 'LIVOLO': new Device('Router', '0x0017880104e45560', 6541,4152, [new Endpoint(6, [0, 6], [])], true, "Mains (single phase)", 'TI0001 '), + 'tradfri_remote': new Device('EndDevice', '0x90fd9ffffe4b64ae', 33906, 4476, [new Endpoint(1, [0], [0,3,4,6,8,5], '0x90fd9ffffe4b64ae')], true, "Battery", "TRADFRI remote control"), } const groups = { - 'group_1': new Group(1), + 'group_1': new Group(1, []), + 'group_tradfri_remote': new Group(15071, [bulb_color_2.endpoints[0], bulb_2.endpoints[0]]), } const mock = { @@ -153,7 +162,7 @@ const mock = { getPermitJoin: jest.fn().mockReturnValue(false), reset: jest.fn(), createGroup: jest.fn().mockImplementation((groupID) => { - const group = new Group(groupID); + const group = new Group(groupID, []); groups[`group_${groupID}`] = group return group; }) @@ -163,6 +172,7 @@ const mockConstructor = jest.fn().mockImplementation(() => mock); jest.mock('zigbee-herdsman', () => ({ Controller: mockConstructor, + Zcl: {ManufacturerCode: {Philips: 4107}}, })); module.exports = {