Skip to content

Commit

Permalink
Poll device when device does not support reporting. (#2122)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
sjorge authored and Koenkk committed Oct 24, 2019
1 parent ec5064b commit a813663
Show file tree
Hide file tree
Showing 8 changed files with 167 additions and 31 deletions.
76 changes: 76 additions & 0 deletions lib/extension/deviceReport.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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]();
}
}
}
}
}

Expand Down
36 changes: 18 additions & 18 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"rimraf": "*",
"semver": "*",
"winston": "*",
"zigbee-herdsman": "0.10.9",
"zigbee-herdsman": "0.11.1",
"zigbee-herdsman-converters": "11.1.28"
},
"devDependencies": {
Expand Down
3 changes: 1 addition & 2 deletions test/bridgeConfig.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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'}),
Expand Down
36 changes: 35 additions & 1 deletion test/deviceReport.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
});
4 changes: 2 additions & 2 deletions test/entityPublish.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", {}, {});
});
Expand Down
17 changes: 17 additions & 0 deletions test/stub/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -118,6 +126,10 @@ function writeDefaultConfiguration() {
'0x0017880104e45560': {
retain: false,
friendly_name: 'livolo'
},
'0x90fd9ffffe4b64ae': {
retain: false,
friendly_name: 'tradfri_remote',
}
},
groups: {
Expand All @@ -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']
}
}
};
Expand Down
24 changes: 17 additions & 7 deletions test/stub/zigbeeHerdsman.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand All @@ -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;
Expand All @@ -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]);
Expand Down Expand Up @@ -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),
Expand All @@ -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 = {
Expand Down Expand Up @@ -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;
})
Expand All @@ -163,6 +172,7 @@ const mockConstructor = jest.fn().mockImplementation(() => mock);

jest.mock('zigbee-herdsman', () => ({
Controller: mockConstructor,
Zcl: {ManufacturerCode: {Philips: 4107}},
}));

module.exports = {
Expand Down

0 comments on commit a813663

Please sign in to comment.