From 17a6fff3ce2848c08d350ca74f1e64809e66608e Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Thu, 14 Feb 2019 18:13:51 +0100 Subject: [PATCH] Publish group state. #764 --- lib/controller.js | 3 +- lib/extension/devicePublish.js | 82 +++++++++++++++++++++++++++++----- lib/util/utils.js | 10 ++++- test/devicePublish.test.js | 42 ++++++++++++++++- 4 files changed, 122 insertions(+), 15 deletions(-) diff --git a/lib/controller.js b/lib/controller.js index 0dbbd9133..1c9fc0b41 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -242,7 +242,6 @@ class Controller { } const entitySettings = entity.type === 'device' ? settings.getDevice(entityID) : settings.getGroup(entityID); - const friendlyName = entitySettings ? entitySettings.friendly_name : entityID; const options = { retain: entitySettings ? entitySettings.retain : false, qos: entitySettings && entitySettings.qos ? entitySettings.qos : 0, @@ -252,7 +251,7 @@ class Controller { messagePayload.device = this.getDeviceInfoForMqtt(entityID); } - this.mqtt.publish(friendlyName, JSON.stringify(messagePayload), options); + this.mqtt.publish(entity.friendlyName, JSON.stringify(messagePayload), options); } getDeviceInfoForMqtt(ieeeAddr) { diff --git a/lib/extension/devicePublish.js b/lib/extension/devicePublish.js index ab0924b8a..31a81a3dc 100644 --- a/lib/extension/devicePublish.js +++ b/lib/extension/devicePublish.js @@ -9,11 +9,51 @@ const postfixes = ['left', 'right', 'center', 'bottom_left', 'bottom_right', 'to const maxDepth = 20; const groupConverters = [ + { + from: (converted) => { + return {state: converted.cmd.toUpperCase()}; + }, + to: zigbeeShepherdConverters.toZigbeeConverters.on_off, + }, + { + from: (converted) => { + return {brightness: Number(converted.zclData.level)}; + }, + to: zigbeeShepherdConverters.toZigbeeConverters.light_brightness, + }, + { + from: (converted) => { + return {color_temp: Number(converted.zclData.colortemp)}; + }, + to: zigbeeShepherdConverters.toZigbeeConverters.light_colortemp, + }, + { + from: (converted) => { + if (converted.zclData.hasOwnProperty('colorx') && converted.zclData.hasOwnProperty('colory')) { + return { + color: { + x: converted.zclData.colorx / 65535, + y: converted.zclData.colory / 65535, + }, + }; + } + }, + to: zigbeeShepherdConverters.toZigbeeConverters.light_color, + }, + { + from: () => null, + to: zigbeeShepherdConverters.toZigbeeConverters.ignore_transition, + }, +]; + +const stateConverters = [ zigbeeShepherdConverters.toZigbeeConverters.on_off, + zigbeeShepherdConverters.toZigbeeConverters.livolo_switch_on_off, +]; + +const brightnessConverters = [ + zigbeeShepherdConverters.toZigbeeConverters.gledopto_light_brightness, zigbeeShepherdConverters.toZigbeeConverters.light_brightness, - zigbeeShepherdConverters.toZigbeeConverters.light_colortemp, - zigbeeShepherdConverters.toZigbeeConverters.light_color, - zigbeeShepherdConverters.toZigbeeConverters.ignore_transition, ]; class DevicePublish { @@ -61,6 +101,31 @@ class DevicePublish { return {type: type, ID: ID, postfix: postfix}; } + handlePublished(entity, topic, converter, converted) { + if (entity.type === 'device' && topic.type === 'set') { + // Devices do not report when they go off, this ensures state (on/off) is always in sync. + // Brightness converters also control the state. (do a moveToLevelWithOnOff) + if (stateConverters.includes(converter) || brightnessConverters.includes(converter)) { + const msg = {}; + const _key = topic.postfix ? `state_${topic.postfix}` : 'state'; + + if (stateConverters.includes(converter)) { + msg[_key] = converted.cmd.toUpperCase(); + } else if (brightnessConverters.includes(converter)) { + msg[_key] = 'ON'; + } + + this.publishEntityState(entity.ID, msg, true); + } + } else if (entity.type === 'group' && topic.type === 'set') { + // As a group doesn't confirm it's state, we mock the state here. + const payload = groupConverters.find((g) => g.to === converter).from(converted); + if (payload) { + this.publishEntityState(entity.ID, payload, true); + } + } + } + onMQTTMessage(topic, message) { topic = this.parseTopic(topic); @@ -99,7 +164,7 @@ class DevicePublish { converters = model.toZigbee; } else if (entity.type === 'group') { - converters = groupConverters; + converters = groupConverters.map((g) => g.to); } // Convert the MQTT message to a Zigbee message. @@ -145,13 +210,8 @@ class DevicePublish { converted.cfg, endpoint, (error, rsp) => { - // Devices do not report when they go off, this ensures state (on/off) is always in sync. - if (entity.type === 'device' && topic.type === 'set' && - !error && (key.startsWith('state') || key.startsWith('brightness'))) { - const msg = {}; - const _key = topic.postfix ? `state_${topic.postfix}` : 'state'; - msg[_key] = key.startsWith('brightness') ? 'ON' : json['state']; - this.publishEntityState(device.ieeeAddr, msg, true); + if (!error) { + this.handlePublished(entity, topic, converter, converted); } } ); diff --git a/lib/util/utils.js b/lib/util/utils.js index 313ec432f..ddb20719f 100644 --- a/lib/util/utils.js +++ b/lib/util/utils.js @@ -7,21 +7,29 @@ const ikeaTradfriManufacturerID = [4476]; // An entity can be either a group or a device. function resolveEntity(ID) { let type = null; + let friendlyName = null; if (settings.getIeeeAddrByFriendlyName(ID)) { // Check if the ID is a friendly_name of a device. + friendlyName = ID; ID = settings.getIeeeAddrByFriendlyName(ID); type = 'device'; } else if (settings.getGroupIDByFriendlyName(ID)) { // Check if the ID is a friendly_name of a group. + friendlyName = ID; ID = Number(settings.getGroupIDByFriendlyName(ID)); type = 'group'; + } else if (settings.getGroup(ID)) { + friendlyName = settings.getGroup(ID).friendly_name; + ID = Number(ID); + type = 'group'; } else { // By default it is a device with ID as ID. type = 'device'; + friendlyName = ID; } - return {ID: ID, type: type}; + return {ID: ID, type: type, friendlyName: friendlyName}; } module.exports = { diff --git a/test/devicePublish.test.js b/test/devicePublish.test.js index 270a51720..25c627449 100644 --- a/test/devicePublish.test.js +++ b/test/devicePublish.test.js @@ -16,6 +16,9 @@ const zigbee = { }), }; +const publishEntityState = sandbox.stub().callsFake((entityID, payload, cache) => { +}); + const cfg = { default: { manufSpec: 0, @@ -28,7 +31,7 @@ describe('DevicePublish', () => { beforeEach(() => { utils.stubLogger(sandbox); - devicePublish = new DevicePublish(zigbee, mqtt, null, () => {}); + devicePublish = new DevicePublish(zigbee, mqtt, null, publishEntityState); }); afterEach(() => { @@ -38,6 +41,7 @@ describe('DevicePublish', () => { describe('Parse topic', () => { it('Should publish messages to zigbee devices', () => { zigbee.publish.resetHistory(); + publishEntityState.resetHistory(); zigbee.getDevice = sinon.fake.returns({modelId: 'TRADFRI bulb E27 CWS opal 600lm'}); devicePublish.onMQTTMessage('zigbee2mqtt/0x12345678/set', JSON.stringify({brightness: '200'})); chai.assert.isTrue(zigbee.publish.calledOnce); @@ -49,6 +53,10 @@ describe('DevicePublish', () => { chai.assert.deepEqual(zigbee.publish.getCall(0).args[5], {level: '200', transtime: 0}); chai.assert.deepEqual(zigbee.publish.getCall(0).args[6], cfg.default); chai.assert.deepEqual(zigbee.publish.getCall(0).args[7], null); + chai.assert.isTrue(publishEntityState.calledOnce); + chai.assert.strictEqual(publishEntityState.getCall(0).args[0], '0x12345678'); + chai.assert.deepEqual(publishEntityState.getCall(0).args[1], {state: 'ON'}); + chai.assert.strictEqual(publishEntityState.getCall(0).args[2], true); }); it('Should publish messages to zigbee devices when brightness is in %', () => { @@ -97,6 +105,7 @@ describe('DevicePublish', () => { }); it('Should publish messages to zigbee devices with color_temp in %', () => { + publishEntityState.resetHistory(); zigbee.publish.resetHistory(); zigbee.getDevice = sinon.fake.returns({modelId: 'TRADFRI bulb E27 WS opal 980lm'}); devicePublish.onMQTTMessage('zigbee2mqtt/0x12345678/set', JSON.stringify({color_temp_percent: '100'})); @@ -109,6 +118,7 @@ describe('DevicePublish', () => { chai.assert.deepEqual(zigbee.publish.getCall(0).args[5], {colortemp: '500', transtime: 0}); chai.assert.deepEqual(zigbee.publish.getCall(0).args[6], cfg.default); chai.assert.deepEqual(zigbee.publish.getCall(0).args[7], null); + chai.assert.isTrue(publishEntityState.notCalled); }); it('Should publish messages to zigbee devices with non-default ep', () => { @@ -128,6 +138,7 @@ describe('DevicePublish', () => { it('Should publish messages to zigbee devices with non-default ep and postfix', () => { zigbee.publish.resetHistory(); + publishEntityState.resetHistory(); zigbee.getDevice = sinon.fake.returns({modelId: 'lumi.ctrl_neutral2'}); devicePublish.onMQTTMessage('zigbee2mqtt/0x12345678/right/set', JSON.stringify({state: 'OFF'})); chai.assert.isTrue(zigbee.publish.calledOnce); @@ -139,6 +150,10 @@ describe('DevicePublish', () => { chai.assert.deepEqual(zigbee.publish.getCall(0).args[5], {}); chai.assert.deepEqual(zigbee.publish.getCall(0).args[6], cfg.default); chai.assert.strictEqual(zigbee.publish.getCall(0).args[7], 3); + chai.assert.isTrue(publishEntityState.calledOnce); + chai.assert.strictEqual(publishEntityState.getCall(0).args[0], '0x12345678'); + chai.assert.deepEqual(publishEntityState.getCall(0).args[1], {state_right: 'OFF'}); + chai.assert.strictEqual(publishEntityState.getCall(0).args[2], true); }); it('Should publish messages to zigbee gledopto with [11,13]', () => { @@ -234,6 +249,7 @@ describe('DevicePublish', () => { it('Should publish messages to groups', () => { sandbox.stub(settings, 'getGroupIDByFriendlyName').callsFake(() => '1'); zigbee.publish.resetHistory(); + publishEntityState.resetHistory(); devicePublish.onMQTTMessage('zigbee2mqtt/group/group_1/set', JSON.stringify({state: 'ON'})); chai.assert.isTrue(zigbee.publish.calledOnce); chai.assert.strictEqual(zigbee.publish.getCall(0).args[0], 1); @@ -244,6 +260,30 @@ describe('DevicePublish', () => { chai.assert.deepEqual(zigbee.publish.getCall(0).args[5], {}); chai.assert.deepEqual(zigbee.publish.getCall(0).args[6], cfg.default); chai.assert.deepEqual(zigbee.publish.getCall(0).args[7], null); + chai.assert.isTrue(publishEntityState.calledOnce); + chai.assert.strictEqual(publishEntityState.getCall(0).args[0], 1); + chai.assert.deepEqual(publishEntityState.getCall(0).args[1], {state: 'ON'}); + chai.assert.strictEqual(publishEntityState.getCall(0).args[2], true); + }); + + it('Should publish messages to groups with brightness_percent', () => { + sandbox.stub(settings, 'getGroupIDByFriendlyName').callsFake(() => '1'); + zigbee.publish.resetHistory(); + publishEntityState.resetHistory(); + devicePublish.onMQTTMessage('zigbee2mqtt/group/group_1/set', JSON.stringify({brightness_percent: 50})); + chai.assert.isTrue(zigbee.publish.calledOnce); + chai.assert.strictEqual(zigbee.publish.getCall(0).args[0], 1); + chai.assert.strictEqual(zigbee.publish.getCall(0).args[1], 'group'); + chai.assert.strictEqual(zigbee.publish.getCall(0).args[2], 'genLevelCtrl'); + chai.assert.strictEqual(zigbee.publish.getCall(0).args[3], 'moveToLevelWithOnOff'); + chai.assert.strictEqual(zigbee.publish.getCall(0).args[4], 'functional'); + chai.assert.deepEqual(zigbee.publish.getCall(0).args[5], {level: '127', transtime: 0}); + chai.assert.deepEqual(zigbee.publish.getCall(0).args[6], cfg.default); + chai.assert.deepEqual(zigbee.publish.getCall(0).args[7], null); + chai.assert.isTrue(publishEntityState.calledOnce); + chai.assert.strictEqual(publishEntityState.getCall(0).args[0], 1); + chai.assert.deepEqual(publishEntityState.getCall(0).args[1], {brightness: 127}); + chai.assert.strictEqual(publishEntityState.getCall(0).args[2], true); }); });