From d81f5575ff13a16386486a350d46df34f11f669d Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Sun, 12 Jul 2020 21:56:11 +0200 Subject: [PATCH] Bind for new api. https://github.com/Koenkk/zigbee2mqtt/issues/3281 --- lib/extension/bind.js | 150 ++++++++++++++++++++------------ test/bind.test.js | 195 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 279 insertions(+), 66 deletions(-) diff --git a/lib/extension/bind.js b/lib/extension/bind.js index 9ed55b8a3..4c18a317b 100644 --- a/lib/extension/bind.js +++ b/lib/extension/bind.js @@ -1,7 +1,8 @@ const settings = require('../util/settings'); const logger = require('../util/logger'); -const assert = require('assert'); +const utils = require('../util/utils'); const legacyTopicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/(bind|unbind)/.+$`); +const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/request/device/(bind|unbind)`); const Extension = require('./extension'); const clusters = ['genScenes', 'genOnOff', 'genLevelCtrl', 'lightingColorCtrl', 'closuresWindowCovering']; @@ -21,6 +22,12 @@ class Bind extends Extension { this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/bind/#`); this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/unbind/#`); } + + /* istanbul ignore else */ + if (settings.get().experimental.new_api) { + this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/device/bind`); + this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/device/unbind`); + } } parseMQTTMessage(topic, message) { @@ -33,6 +40,11 @@ class Bind extends Extension { type = topic.split('/')[0]; sourceKey = topic.replace(`${type}/`, ''); targetKey = message; + } else if (settings.get().experimental.new_api && topic.match(topicRegex)) { + type = topic.endsWith('unbind') ? 'unbind' : 'bind'; + message = JSON.parse(message); + sourceKey = message.from; + targetKey = message.to; } return {type, sourceKey, targetKey}; @@ -42,78 +54,104 @@ class Bind extends Extension { const {type, sourceKey, targetKey} = this.parseMQTTMessage(topic, message); if (!type) return null; - // Find source; can only be a device and target + let error = null; const source = this.zigbee.resolveEntity(sourceKey); - assert(source != null && source.type === 'device', 'Source undefined or not a device'); const target = targetKey === 'default_bind_group' ? defaultBindGroup : this.zigbee.resolveEntity(targetKey); - assert(target != null, 'Target is unknown'); + const responseData = {from: sourceKey, to: targetKey}; - const sourceName = source.settings.friendlyName; - const targetName = targetKey === 'default_bind_group' ? targetKey : target.settings.friendlyName; - let attemptedToBindSomething = false; + if (!source || source.type !== 'device') { + error = `Source device '${sourceKey}' does not exist`; + } else if (!target) { + error = `Target device or group '${targetKey}' does not exist`; + } else { + const sourceName = source.settings.friendlyName; + const targetName = targetKey === 'default_bind_group' ? targetKey : target.settings.friendlyName; + const successfulClusters = []; + const failedClusters = []; + const attemptedClusters = []; - // Find which clusters are supported by both the source and target. - // Groups are assumed to support all clusters. - for (const cluster of clusters) { - const targetValid = target.type === 'group' || target.type === 'group_number' || - target.device.type === 'Coordinator' || target.endpoint.supportsInputCluster(cluster); + // Find which clusters are supported by both the source and target. + // Groups are assumed to support all clusters. + for (const cluster of clusters) { + const targetValid = target.type === 'group' || target.type === 'group_number' || + target.device.type === 'Coordinator' || target.endpoint.supportsInputCluster(cluster); - if (source.endpoint.supportsOutputCluster(cluster) && targetValid) { - logger.debug(`${type}ing cluster '${cluster}' from '${sourceName}' to '${targetName}'`); - attemptedToBindSomething = true; - try { - let bindTarget = null; - if (target.type === 'group') bindTarget = target.group; - else if (target.type === 'group_number') bindTarget = target.ID; - else bindTarget = target.endpoint; + if (source.endpoint.supportsOutputCluster(cluster) && targetValid) { + logger.debug(`${type}ing cluster '${cluster}' from '${sourceName}' to '${targetName}'`); + attemptedClusters.push(cluster); - if (type === 'bind') { - await source.endpoint.bind(cluster, bindTarget); - } else { - await source.endpoint.unbind(cluster, bindTarget); - } + try { + let bindTarget = null; + if (target.type === 'group') bindTarget = target.group; + else if (target.type === 'group_number') bindTarget = target.ID; + else bindTarget = target.endpoint; - logger.info( - `Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from ` + - `'${sourceName}' to '${targetName}'`, - ); + if (type === 'bind') { + await source.endpoint.bind(cluster, bindTarget); + } else { + await source.endpoint.unbind(cluster, bindTarget); + } - /* istanbul ignore else */ - if (settings.get().advanced.legacy_api) { - this.mqtt.publish( - 'bridge/log', - JSON.stringify({type: `device_${type}`, - message: {from: sourceName, to: targetName, cluster}}), + successfulClusters.push(cluster); + logger.info( + `Successfully ${type === 'bind' ? 'bound' : 'unbound'} cluster '${cluster}' from ` + + `'${sourceName}' to '${targetName}'`, ); - } - } catch (error) { - logger.error( - `Failed to ${type} cluster '${cluster}' from '${sourceName}' to ` + - `'${targetName}' (${error})`, - ); - /* istanbul ignore else */ - if (settings.get().advanced.legacy_api) { - this.mqtt.publish( - 'bridge/log', - JSON.stringify({type: `device_${type}_failed`, - message: {from: sourceName, to: targetName, cluster}}), + /* istanbul ignore else */ + if (settings.get().advanced.legacy_api) { + this.mqtt.publish( + 'bridge/log', + JSON.stringify({type: `device_${type}`, + message: {from: sourceName, to: targetName, cluster}}), + ); + } + } catch (error) { + failedClusters.push(cluster); + logger.error( + `Failed to ${type} cluster '${cluster}' from '${sourceName}' to ` + + `'${targetName}' (${error})`, ); + + /* istanbul ignore else */ + if (settings.get().advanced.legacy_api) { + this.mqtt.publish( + 'bridge/log', + JSON.stringify({type: `device_${type}_failed`, + message: {from: sourceName, to: targetName, cluster}}), + ); + } } } } + + if (attemptedClusters.length === 0) { + logger.error(`Nothing to ${type} from '${sourceName}' to '${targetName}'`); + error = `Nothing to ${type}`; + + /* istanbul ignore else */ + if (settings.get().advanced.legacy_api) { + this.mqtt.publish( + 'bridge/log', + JSON.stringify({type: `device_${type}_failed`, message: {from: sourceName, to: targetName}}), + ); + } + } else if (failedClusters.length === attemptedClusters.length) { + error = `Failed to ${type}`; + } + + responseData[`clusters`] = successfulClusters; + responseData[`failed`] = failedClusters; } - if (!attemptedToBindSomething) { - logger.error(`Nothing to ${type} from '${sourceName}' to '${targetName}'`); + const triggeredViaLegacyApi = topic.match(legacyTopicRegex); + if (!triggeredViaLegacyApi) { + const response = utils.getResponse(message, responseData, error); + await this.mqtt.publish(`bridge/response/device/${type}`, JSON.stringify(response)); + } - /* istanbul ignore else */ - if (settings.get().advanced.legacy_api) { - this.mqtt.publish( - 'bridge/log', - JSON.stringify({type: `device_${type}_failed`, message: {from: sourceName, to: targetName}}), - ); - } + if (error) { + logger.error(error); } } } diff --git a/test/bind.test.js b/test/bind.test.js index 9386b5d55..8eaec812f 100644 --- a/test/bind.test.js +++ b/test/bind.test.js @@ -14,6 +14,7 @@ describe('Bind', () => { endpoint.read.mockClear(); endpoint.write.mockClear(); endpoint.configureReporting.mockClear(); + endpoint.bind = jest.fn(); endpoint.bind.mockClear(); endpoint.unbind.mockClear(); } @@ -23,6 +24,7 @@ describe('Bind', () => { data.writeDefaultConfiguration(); settings._reRead(); data.writeEmptyState(); + settings.set(['experimental', 'new_api'], true); controller = new Controller(); await controller.start(); await flushPromises(); @@ -31,10 +33,184 @@ describe('Bind', () => { }); it('Should subscribe to topics', async () => { + expect(MQTT.subscribe).toHaveBeenCalledWith('zigbee2mqtt/bridge/request/device/bind'); + expect(MQTT.subscribe).toHaveBeenCalledWith('zigbee2mqtt/bridge/request/device/unbind'); expect(MQTT.subscribe).toHaveBeenCalledWith('zigbee2mqtt/bridge/bind/#'); }); it('Should bind', async () => { + const device = zigbeeHerdsman.devices.remote; + const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); + const endpoint = device.getEndpoint(1); + mockClear(device); + MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', JSON.stringify({from: 'remote', to: 'bulb_color'})); + await flushPromises(); + expect(endpoint.bind).toHaveBeenCalledTimes(3); + expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); + expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); + expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/bind', + JSON.stringify({"data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should log error when there is nothing to bind', async () => { + const device = zigbeeHerdsman.devices.bulb_color; + const endpoint = device.getEndpoint(1); + mockClear(device); + logger.error.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', JSON.stringify({from: 'remote', to: 'button'})); + await flushPromises(); + expect(endpoint.bind).toHaveBeenCalledTimes(0); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/bind', + JSON.stringify({"data":{"from":"remote","to":"button","clusters":[],"failed":[]},"status":"error","error":"Nothing to bind"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should unbind', async () => { + const device = zigbeeHerdsman.devices.remote; + const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); + const endpoint = device.getEndpoint(1); + mockClear(device); + MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', JSON.stringify({from: 'remote', to: 'bulb_color'})); + await flushPromises(); + expect(endpoint.unbind).toHaveBeenCalledTimes(3); + expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target); + expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target); + expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/unbind', + JSON.stringify({"data":{"from":"remote","to":"bulb_color","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should unbind coordinator', async () => { + const device = zigbeeHerdsman.devices.remote; + const target = zigbeeHerdsman.devices.coordinator.getEndpoint(1); + const endpoint = device.getEndpoint(1); + mockClear(device); + endpoint.unbind.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', JSON.stringify({from: 'remote', to: 'Coordinator'})); + await flushPromises(); + expect(endpoint.unbind).toHaveBeenCalledTimes(3); + expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", target); + expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", target); + expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", target); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/unbind', + JSON.stringify({"data":{"from":"remote","to":"Coordinator","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should bind to groups', async () => { + const device = zigbeeHerdsman.devices.remote; + const target = zigbeeHerdsman.groups.group_1; + const endpoint = device.getEndpoint(1); + mockClear(device); + MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', JSON.stringify({from: 'remote', to: 'group_1'})); + await flushPromises(); + expect(endpoint.bind).toHaveBeenCalledTimes(3); + expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); + expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); + expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/bind', + JSON.stringify({"data":{"from":"remote","to":"group_1","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should bind to group by number', async () => { + const device = zigbeeHerdsman.devices.remote; + const target = zigbeeHerdsman.groups.group_1; + const endpoint = device.getEndpoint(1); + mockClear(device); + MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', JSON.stringify({from: 'remote', to: '1'})); + await flushPromises(); + expect(endpoint.bind).toHaveBeenCalledTimes(3); + expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); + expect(endpoint.bind).toHaveBeenCalledWith("genLevelCtrl", target); + expect(endpoint.bind).toHaveBeenCalledWith("genScenes", target); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/bind', + JSON.stringify({"data":{"from":"remote","to":"1","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should log when bind fails', async () => { + logger.error.mockClear(); + const device = zigbeeHerdsman.devices.remote; + const endpoint = device.getEndpoint(1); + mockClear(device); + endpoint.bind.mockImplementation(() => {throw new Error('failed')}); + MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', JSON.stringify({from: 'remote', to: 'bulb_color'})); + await flushPromises(); + expect(endpoint.bind).toHaveBeenCalledTimes(3); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/bind', + JSON.stringify({"data":{"from":"remote","to":"bulb_color","clusters":[],"failed":["genScenes","genOnOff","genLevelCtrl"]},"status":"error","error":"Failed to bind"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should bind from non default endpoints', async () => { + const device = zigbeeHerdsman.devices.remote; + const target = zigbeeHerdsman.devices.QBKG03LM.getEndpoint(3); + const endpoint = device.getEndpoint(2); + mockClear(device); + MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', JSON.stringify({from: 'remote/ep2', to: 'wall_switch_double/right'})); + await flushPromises(); + expect(endpoint.bind).toHaveBeenCalledTimes(1); + expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/bind', + JSON.stringify({"data":{"from":"remote/ep2","to":"wall_switch_double/right","clusters":["genOnOff"],"failed":[]},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should bind to default endpoint returned by endpoints()', async () => { + const device = zigbeeHerdsman.devices.remote; + const target = zigbeeHerdsman.devices.QBKG04LM.getEndpoint(2); + const endpoint = device.getEndpoint(2); + mockClear(device); + MQTT.events.message('zigbee2mqtt/bridge/request/device/bind', JSON.stringify({from: 'remote/ep2', to: 'wall_switch'})); + await flushPromises(); + expect(endpoint.bind).toHaveBeenCalledTimes(1); + expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/bind', + JSON.stringify({"data":{"from":"remote/ep2","to":"wall_switch","clusters":["genOnOff"],"failed":[]},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should unbind from default_bind_group', async () => { + const device = zigbeeHerdsman.devices.remote; + const target = 'default_bind_group'; + const endpoint = device.getEndpoint(1); + mockClear(device); + MQTT.events.message('zigbee2mqtt/bridge/request/device/unbind', JSON.stringify({from: 'remote', to: target})); + await flushPromises(); + expect(endpoint.unbind).toHaveBeenCalledTimes(3); + expect(endpoint.unbind).toHaveBeenCalledWith("genOnOff", 901); + expect(endpoint.unbind).toHaveBeenCalledWith("genLevelCtrl", 901); + expect(endpoint.unbind).toHaveBeenCalledWith("genScenes", 901); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/unbind', + JSON.stringify({"data":{"from":"remote","to":"default_bind_group","clusters":["genScenes","genOnOff","genLevelCtrl"],"failed":[]},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Legacy api: Should bind', async () => { const device = zigbeeHerdsman.devices.remote; const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); const endpoint = device.getEndpoint(1); @@ -54,7 +230,7 @@ describe('Bind', () => { expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'bulb_color', cluster: 'genLevelCtrl'}}); }); - it('Should log error when there is nothing to bind', async () => { + it('Legacy api: Should log error when there is nothing to bind', async () => { const device = zigbeeHerdsman.devices.bulb_color; const endpoint = device.getEndpoint(1); mockClear(device); @@ -65,7 +241,7 @@ describe('Bind', () => { expect(logger.error).toHaveBeenCalledWith(`Nothing to bind from 'remote' to 'button'`); }); - it('Should unbind', async () => { + it('Legacy api: Should unbind', async () => { const device = zigbeeHerdsman.devices.remote; const target = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); const endpoint = device.getEndpoint(1); @@ -85,7 +261,7 @@ describe('Bind', () => { expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'bulb_color', cluster: 'genLevelCtrl'}}); }); - it('Should unbind coordinator', async () => { + it('Legacy api: Should unbind coordinator', async () => { const device = zigbeeHerdsman.devices.remote; const target = zigbeeHerdsman.devices.coordinator.getEndpoint(1); const endpoint = device.getEndpoint(1); @@ -106,7 +282,7 @@ describe('Bind', () => { expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_unbind', message: {from: 'remote', to: 'Coordinator', cluster: 'genLevelCtrl'}}); }); - it('Should bind to groups', async () => { + it('Legacy api: Should bind to groups', async () => { const device = zigbeeHerdsman.devices.remote; const target = zigbeeHerdsman.groups.group_1; const endpoint = device.getEndpoint(1); @@ -126,7 +302,7 @@ describe('Bind', () => { expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genLevelCtrl'}}); }); - it('Should bind to group by number', async () => { + it('Legacy api: Should bind to group by number', async () => { const device = zigbeeHerdsman.devices.remote; const target = zigbeeHerdsman.groups.group_1; const endpoint = device.getEndpoint(1); @@ -146,7 +322,7 @@ describe('Bind', () => { expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({type: 'device_bind', message: {from: 'remote', to: 'group_1', cluster: 'genLevelCtrl'}}); }); - it('Should log when bind fails', async () => { + it('Legacy api: Should log when bind fails', async () => { logger.error.mockClear(); const device = zigbeeHerdsman.devices.remote; const endpoint = device.getEndpoint(1); @@ -154,12 +330,11 @@ describe('Bind', () => { endpoint.bind.mockImplementationOnce(() => {throw new Error('failed')}); MQTT.events.message('zigbee2mqtt/bridge/bind/remote', 'bulb_color'); await flushPromises(); - expect(logger.error).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenCalledWith("Failed to bind cluster 'genScenes' from 'remote' to 'bulb_color' (Error: failed)"); expect(endpoint.bind).toHaveBeenCalledTimes(3); }); - it('Should bind from non default endpoints', async () => { + it('Legacy api: Should bind from non default endpoints', async () => { const device = zigbeeHerdsman.devices.remote; const target = zigbeeHerdsman.devices.QBKG03LM.getEndpoint(3); const endpoint = device.getEndpoint(2); @@ -170,7 +345,7 @@ describe('Bind', () => { expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); }); - it('Should bind to default endpoint returned by endpoints()', async () => { + it('Legacy api: Should bind to default endpoint returned by endpoints()', async () => { const device = zigbeeHerdsman.devices.remote; const target = zigbeeHerdsman.devices.QBKG04LM.getEndpoint(2); const endpoint = device.getEndpoint(2); @@ -181,7 +356,7 @@ describe('Bind', () => { expect(endpoint.bind).toHaveBeenCalledWith("genOnOff", target); }); - it('Should unbind from default_bind_group', async () => { + it('Legacy api: Should unbind from default_bind_group', async () => { const device = zigbeeHerdsman.devices.remote; const target = 'default_bind_group'; const endpoint = device.getEndpoint(1);