From 969585c84e7f4789bd5eec2f7cf0dc909bedb905 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Fri, 15 Mar 2019 21:19:42 +0100 Subject: [PATCH] Allow to bind specific endpoint. #176 --- lib/extension/deviceBind.js | 84 +++++++++++-------- lib/extension/devicePublish.js | 7 +- lib/util/utils.js | 6 ++ lib/zigbee.js | 31 +++++-- test/deviceBind.test.js | 142 +++++++++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 41 deletions(-) create mode 100644 test/deviceBind.test.js diff --git a/lib/extension/deviceBind.js b/lib/extension/deviceBind.js index 829d43d10..d94e6d786 100644 --- a/lib/extension/deviceBind.js +++ b/lib/extension/deviceBind.js @@ -1,7 +1,9 @@ const settings = require('../util/settings'); const logger = require('../util/logger'); -const Queue = require('queue'); +const utils = require('../util/utils'); +const zigbeeShepherdConverters = require('zigbee-shepherd-converters'); +const postfixes = utils.getPostfixes(); const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/(bind|unbind)/.+$`); const allowedClusters = [ @@ -17,16 +19,25 @@ class DeviceBind { this.mqtt = mqtt; this.state = state; this.publishEntityState = publishEntityState; - - // Setup queue - this.queue = new Queue(); - this.queue.concurrency = 1; - this.queue.autostart = true; } onMQTTConnected() { this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/bind/+`); + this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/bind/+/+`); this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/unbind/+`); + this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/unbind/+/+`); + } + + getIDAndPostfix(topic) { + let postfix = null; + if (postfixes.find((p) => topic.endsWith(`/${p}`))) { + postfix = topic.substr(topic.lastIndexOf('/') + 1, topic.length); + + // Remove postfix from topic + topic = topic.replace(`/${postfix}`, ''); + } + + return {ID: topic, postfix}; } parseTopic(topic) { @@ -42,8 +53,21 @@ class DeviceBind { // Remove type from topic topic = topic.replace(`${type}/`, ''); + return {type, ...this.getIDAndPostfix(topic)}; + } - return {ID: topic, type}; + getEp(ID, postfix) { + let source = null; + if (postfix) { + const device = this.zigbee.getDevice(ID); + const mappedDevice = zigbeeShepherdConverters.findByZigbeeModel(device.modelId); + const epID = mappedDevice.ep(device)[postfix]; + source = this.zigbee.getEndpoint(ID, epID); + } else { + source = this.zigbee.getEndpoint(ID); + } + + return source; } onMQTTMessage(topic, message) { @@ -55,19 +79,19 @@ class DeviceBind { // Find source; can only be a device. const sourceEntity = settings.resolveEntity(topic.ID); - const source = this.zigbee.getEndpoint(sourceEntity.ID); - + const source = this.getEp(sourceEntity.ID, topic.postfix); if (!source) { logger.error(`Failed to find device '${sourceEntity.ID}'`); return false; } // Find target; can be a device or group. - const targetEntity = settings.resolveEntity(message.toString()); + const targetEntityIDPostfix= this.getIDAndPostfix(message.toString()); + const targetEntity = settings.resolveEntity(targetEntityIDPostfix.ID); let target = null; if (targetEntity.type === 'device') { - target = this.zigbee.getEndpoint(targetEntity.ID); + target = this.getEp(targetEntity.ID, targetEntityIDPostfix.postfix); if (!target) { logger.error(`Failed to find target device '${targetEntity.ID}'`); @@ -94,29 +118,25 @@ class DeviceBind { // Bind clusters.forEach((cluster) => { - this.queue.push((queueCallback) => { - logger.debug(`${topic.type}ing cluster '${cluster}' from ${sourceEntity.ID}' to '${targetEntity.ID}'`); + logger.debug(`${topic.type}ing cluster '${cluster}' from ${sourceEntity.ID}' to '${targetEntity.ID}'`); - source[topic.type](cluster, target, (error) => { - if (error) { - logger.error( - `Failed to ${topic.type} cluster '${cluster}' from ${sourceEntity.ID}' to ` + - `'${targetEntity.ID}' (${error})` - ); - } else { - logger.info( - `Successfully ${topic.type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from ` + - `${sourceEntity.ID}' to '${targetEntity.ID}'` - ); + this.zigbee[topic.type](source, cluster, target, (error) => { + if (error) { + logger.error( + `Failed to ${topic.type} cluster '${cluster}' from ${sourceEntity.ID}' to ` + + `'${targetEntity.ID}' (${error})` + ); + } else { + logger.info( + `Successfully ${topic.type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from ` + + `${sourceEntity.ID}' to '${targetEntity.ID}'` + ); - this.mqtt.log( - `device_${topic.type}`, - {from: sourceEntity.ID, to: targetEntity.ID, cluster} - ); - } - - queueCallback(); - }); + this.mqtt.log( + `device_${topic.type}`, + {from: sourceEntity.ID, to: targetEntity.ID, cluster} + ); + } }); }); diff --git a/lib/extension/devicePublish.js b/lib/extension/devicePublish.js index 69c2f2327..d2f28b166 100644 --- a/lib/extension/devicePublish.js +++ b/lib/extension/devicePublish.js @@ -2,12 +2,11 @@ const settings = require('../util/settings'); const zigbeeShepherdConverters = require('zigbee-shepherd-converters'); const logger = require('../util/logger'); +const utils = require('../util/utils'); const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/.+/(set|get)$`); -const postfixes = [ - 'left', 'right', 'center', 'bottom_left', 'bottom_right', - 'top_left', 'top_right', 'white', 'rgb', 'system', 'top', 'bottom', -]; +const postfixes = utils.getPostfixes(); + const maxDepth = 20; const groupConverters = [ diff --git a/lib/util/utils.js b/lib/util/utils.js index f92a95d2f..b971986e4 100644 --- a/lib/util/utils.js +++ b/lib/util/utils.js @@ -24,6 +24,11 @@ function toLocalISOString(dDate) { ':' + pad(tzOffset % 60); } +const postfixes = [ + 'left', 'right', 'center', 'bottom_left', 'bottom_right', + 'top_left', 'top_right', 'white', 'rgb', 'system', 'top', 'bottom', +]; + module.exports = { millisecondsToSeconds: (milliseconds) => milliseconds / 1000, secondsToMilliseconds: (seconds) => seconds * 1000, @@ -31,4 +36,5 @@ module.exports = { isIkeaTradfriDevice: (device) => ikeaTradfriManufacturerID.includes(device.manufId), isNumeric: (string) => /^\d+$/.test(string), toLocalISOString: (dDate) => toLocalISOString(dDate), + getPostfixes: () => postfixes, }; diff --git a/lib/zigbee.js b/lib/zigbee.js index 1999571eb..d68ecc478 100644 --- a/lib/zigbee.js +++ b/lib/zigbee.js @@ -294,18 +294,39 @@ class Zigbee { } } - bind(ep, cluster, target=this.getCoordinator()) { - const log = `for ${ep.device.ieeeAddr} - ${cluster}`; + bind(ep, cluster, target, callback) { + const log = ` ${ep.device.ieeeAddr} - ${cluster}`; + target = !target ? this.getCoordinator() : target; this.queue.push(ep.device.ieeeAddr, (queueCallback) => { - logger.debug(`Setup binding ${log}`); + logger.debug(`Binding ${log}`); ep.bind(cluster, target, (error) => { if (error) { - logger.error(`Failed to setup binding ${log} - (${error})`); + logger.error(`Failed to bind ${log} - (${error})`); } else { - logger.debug(`Successfully setup binding ${log}`); + logger.debug(`Successfully bound ${log}`); } + callback(error); + queueCallback(error); + }); + }); + } + + unbind(ep, cluster, target, callback) { + const log = ` ${ep.device.ieeeAddr} - ${cluster}`; + target = !target ? this.getCoordinator() : target; + + this.queue.push(ep.device.ieeeAddr, (queueCallback) => { + logger.debug(`Unbinding ${log}`); + ep.unbind(cluster, target, (error) => { + if (error) { + logger.error(`Failed to unbind ${log} - (${error})`); + } else { + logger.debug(`Successfully unbound ${log}`); + } + + callback(error); queueCallback(error); }); }); diff --git a/test/deviceBind.test.js b/test/deviceBind.test.js new file mode 100644 index 000000000..314cf9163 --- /dev/null +++ b/test/deviceBind.test.js @@ -0,0 +1,142 @@ +const DeviceBind = require('../lib/extension/deviceBind'); +const utils = require('./utils'); + +const mqtt = { + subscribe: () => {}, + log: () => {}, +}; + +const devices = { + bulb: { + getSimpleDesc: () => { + return {inClusterList: [6, 8]}; + }, + }, + remote: { + getSimpleDesc: () => { + return {outClusterList: [5, 6, 8]}; + }, + }, + switch_ep2: { + getSimpleDesc: () => { + return {outClusterList: [5, 6]}; + }, + }, + switch_ep3: { + getSimpleDesc: () => { + return {inClusterList: [5, 6]}; + }, + }, +}; + +const zigbee = { + bind: jest.fn((ep, cluster, target, callback) => { + callback(false, null); + }), + unbind: jest.fn((ep, cluster, target, callback) => { + callback(false, null); + }), + getEndpoint: (ID, ep) => { + if (ID === 'bulb') { + return devices.bulb; + } else if (ID === 'remote') { + return devices.remote; + } else if (ep == 2 && ID === 'switch_ep2') { + return devices.switch_ep2; + } else if (ep == 3 && ID === 'switch_ep3') { + return devices.switch_ep3; + } + + throw new Error(`No mock for ${ID} and ep ${ep}`); + }, + getDevice: (ID) => { + if (ID === 'switch_ep2') { + return {modelId: 'lumi.sensor_86sw2.es1'}; + } else if (ID == 'switch_ep3') { + return {modelId: 'DNCKAT_S003'}; + } + + throw new Error(`No mock for ${ID}`); + }, +}; + +describe('DeviceBind', () => { + let deviceBind; + + beforeEach(() => { + utils.stubLogger(jest); + deviceBind = new DeviceBind(zigbee, mqtt, null, null); + }); + + afterEach(() => { + zigbee.bind.mockClear(); + zigbee.unbind.mockClear(); + jest.restoreAllMocks(); + }); + + describe('Bind devices', () => { + it('Bind', async () => { + deviceBind.onMQTTMessage('zigbee2mqtt/bridge/bind/remote', 'bulb'); + expect(zigbee.bind).toHaveBeenCalledTimes(2); + expect(zigbee.bind).toHaveBeenNthCalledWith(1, + devices.remote, + 6, + devices.bulb, + expect.any(Function) + ); + expect(zigbee.bind).toHaveBeenNthCalledWith(2, + devices.remote, + 8, + devices.bulb, + expect.any(Function) + ); + }); + + it('Bind non default ep', async () => { + deviceBind.onMQTTMessage('zigbee2mqtt/bridge/bind/switch_ep2/right', 'bulb'); + expect(zigbee.bind).toHaveBeenCalledTimes(1); + expect(zigbee.bind).toHaveBeenNthCalledWith(1, + devices.switch_ep2, + 6, + devices.bulb, + expect.any(Function) + ); + }); + + it('Bind non default ep to target with non default ep', async () => { + deviceBind.onMQTTMessage('zigbee2mqtt/bridge/bind/switch_ep2/right', 'switch_ep3/right'); + expect(zigbee.bind).toHaveBeenCalledTimes(2); + expect(zigbee.bind).toHaveBeenNthCalledWith(1, + devices.switch_ep2, + 5, + devices.switch_ep3, + expect.any(Function) + ); + expect(zigbee.bind).toHaveBeenNthCalledWith(2, + devices.switch_ep2, + 6, + devices.switch_ep3, + expect.any(Function) + ); + }); + }); + + describe('Unbind devices', () => { + it('Unbind', async () => { + deviceBind.onMQTTMessage('zigbee2mqtt/bridge/unbind/remote', 'bulb'); + expect(zigbee.unbind).toHaveBeenCalledTimes(2); + expect(zigbee.unbind).toHaveBeenNthCalledWith(1, + devices.remote, + 6, + devices.bulb, + expect.any(Function) + ); + expect(zigbee.unbind).toHaveBeenNthCalledWith(2, + devices.remote, + 8, + devices.bulb, + expect.any(Function) + ); + }); + }); +});