From 274109bc85439b750ef218765e2b65288c290cd4 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Wed, 29 Jul 2020 23:10:03 +0200 Subject: [PATCH] Allow to disable Home Assistant via MQTT. #3281 (#4007) * Initial * Updates --- lib/controller.js | 32 +++++++++++++++-- lib/eventBus.js | 18 +++++++++- lib/extension/availability.js | 1 + lib/extension/bridge.js | 16 ++++++++- lib/extension/extension.js | 4 ++- lib/extension/homeassistant.js | 6 ++-- lib/extension/onEvent.js | 1 + test/bridge.test.js | 65 ++++++++++++++++++++++++++++++++-- 8 files changed, 132 insertions(+), 11 deletions(-) diff --git a/lib/controller.js b/lib/controller.js index 6b02978a0..0e08d205e 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -9,6 +9,7 @@ const utils = require('./util/utils'); const fs = require('fs'); const data = require('./util/data'); const path = require('path'); +const assert = require('assert'); // Extensions const ExtensionPublish = require('./extension/publish'); @@ -28,6 +29,13 @@ const ExtensionOnEvent = require('./extension/onEvent'); const ExtensionOTAUpdate = require('./extension/otaUpdate'); const ExtensionExternalConverters = require('./extension/externalConverters'); +const AllExtensions = [ + ExtensionPublish, ExtensionReceive, ExtensionNetworkMap, ExtensionSoftReset, ExtensionHomeAssistant, + ExtensionConfigure, ExtensionDeviceGroupMembership, ExtensionBridgeLegacy, ExtensionBridge, ExtensionGroups, + ExtensionAvailability, ExtensionBind, ExtensionReport, ExtensionOnEvent, ExtensionOTAUpdate, + ExtensionExternalConverters, +]; + class Controller { constructor() { this.zigbee = new Zigbee(); @@ -36,6 +44,7 @@ class Controller { this.state = new State(this.eventBus); this.publishEntityState = this.publishEntityState.bind(this); + this.enableDisableExtension = this.enableDisableExtension.bind(this); this.onZigbeeAdapterDisconnected = this.onZigbeeAdapterDisconnected.bind(this); // Initialize extensions. @@ -53,7 +62,7 @@ class Controller { ]; if (settings.get().experimental.new_api) { - this.extensions.push(new ExtensionBridge(...args)); + this.extensions.push(new ExtensionBridge(...args, this.enableDisableExtension)); } if (settings.get().advanced.legacy_api) { @@ -161,6 +170,23 @@ class Controller { await this.callExtensionMethod('onMQTTConnected', []); } + async enableDisableExtension(enable, name) { + if (!enable) { + const extension = this.extensions.find((e) => e.constructor.name === name); + if (extension) { + await this.callExtensionMethod('stop', [], [extension]); + this.extensions.splice(this.extensions.indexOf(extension), 1); + } + } else { + const Extension = AllExtensions.find((e) => e.name === name); + assert(Extension, `Extension '${name}' does not exist`); + const extension = new Extension(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus); + this.extensions.push(extension); + this.callExtensionMethod('onZigbeeStarted', [], [extension]); + this.callExtensionMethod('onMQTTConnected', [], [extension]); + } + } + async stop() { // Call extensions await this.callExtensionMethod('stop', []); @@ -343,8 +369,8 @@ class Controller { } } - async callExtensionMethod(method, parameters) { - for (const extension of this.extensions) { + async callExtensionMethod(method, parameters, extensions=null) { + for (const extension of extensions || this.extensions) { if (extension[method]) { try { await extension[method](...parameters); diff --git a/lib/eventBus.js b/lib/eventBus.js index 512c4b13a..4aa9e17e8 100644 --- a/lib/eventBus.js +++ b/lib/eventBus.js @@ -11,15 +11,31 @@ const allowedEvents = [ ]; class EventBus extends events.EventEmitter { + constructor() { + super(); + this.callbackByExtension = {}; + } + emit(event, data) { assert(allowedEvents.includes(event), `Event '${event}' not supported`); super.emit(event, data); } - on(event, callback) { + on(event, callback, extension=null) { assert(allowedEvents.includes(event), `Event '${event}' not supported`); + if (extension) { + if (!this.callbackByExtension[extension]) this.callbackByExtension[extension] = []; + this.callbackByExtension[extension].push({event, callback}); + } + super.on(event, callback); } + + removeListenersExtension(extension) { + for (const entry of this.callbackByExtension[extension] || []) { + super.removeListener(entry.event, entry.callback); + } + } } module.exports = EventBus; diff --git a/lib/extension/availability.js b/lib/extension/availability.js index 839c47fe5..af83d5a59 100644 --- a/lib/extension/availability.js +++ b/lib/extension/availability.js @@ -124,6 +124,7 @@ class Availability extends Extension { } async stop() { + super.stop(); for (const timer of Object.values(this.timers)) { clearTimeout(timer); } diff --git a/lib/extension/bridge.js b/lib/extension/bridge.js index da29fd9e2..34df9ed64 100644 --- a/lib/extension/bridge.js +++ b/lib/extension/bridge.js @@ -8,8 +8,9 @@ const Transport = require('winston-transport'); const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`); class Bridge extends Extension { - constructor(zigbee, mqtt, state, publishEntityState, eventBus) { + constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension) { super(zigbee, mqtt, state, publishEntityState, eventBus); + this.enableDisableExtension = enableDisableExtension; this.lastJoinedDeviceIeeeAddr = null; this.setupMQTTLogging(); @@ -23,6 +24,7 @@ class Bridge extends Extension { 'group/rename': this.groupRename.bind(this), 'permit_join': this.permitJoin.bind(this), 'config/last_seen': this.configLastSeen.bind(this), + 'config/homeassistant': this.configHomeAssistant.bind(this), 'config/elapsed': this.configElapsed.bind(this), 'config/log_level': this.configLogLevel.bind(this), 'touchlink/factory_reset': this.touchlinkFactoryReset.bind(this), @@ -170,6 +172,18 @@ class Bridge extends Extension { return utils.getResponse(message, {value}, null); } + configHomeAssistant(message) { + const allowed = [true, false]; + const value = this.getValue(message); + if (!allowed.includes(value)) { + throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`); + } + + this.enableDisableExtension(value, 'HomeAssistant'); + settings.set(['homeassistant'], value); + return utils.getResponse(message, {value}, null); + } + configElapsed(message) { const allowed = [true, false]; const value = this.getValue(message); diff --git a/lib/extension/extension.js b/lib/extension/extension.js index 3aa9e3445..c3a0f69ed 100644 --- a/lib/extension/extension.js +++ b/lib/extension/extension.js @@ -46,7 +46,9 @@ class Extension { /** * Is called once the extension has to stop */ - // stop() {} + stop() { + this.eventBus.removeListenersExtension(this.constructor.name); + } } module.exports = Extension; diff --git a/lib/extension/homeassistant.js b/lib/extension/homeassistant.js index e406f4c5a..b5d01b74f 100644 --- a/lib/extension/homeassistant.js +++ b/lib/extension/homeassistant.js @@ -1824,9 +1824,9 @@ class HomeAssistant extends Extension { this.discoveryTopic = settings.get().advanced.homeassistant_discovery_topic; this.statusTopic = settings.get().advanced.homeassistant_status_topic; - this.eventBus.on('deviceRemoved', (data) => this.onDeviceRemoved(data.device)); - this.eventBus.on('publishEntityState', (data) => this.onPublishEntityState(data)); - this.eventBus.on('deviceRenamed', (data) => this.onDeviceRenamed(data.device)); + this.eventBus.on('deviceRemoved', (data) => this.onDeviceRemoved(data.device), this.constructor.name); + this.eventBus.on('publishEntityState', (data) => this.onPublishEntityState(data), this.constructor.name); + this.eventBus.on('deviceRenamed', (data) => this.onDeviceRenamed(data.device), this.constructor.name); for (const definition of utils.getExternalConvertersDefinitions(settings)) { if (definition.hasOwnProperty('homeassistant')) { diff --git a/lib/extension/onEvent.js b/lib/extension/onEvent.js index f473d9321..4d05129e1 100644 --- a/lib/extension/onEvent.js +++ b/lib/extension/onEvent.js @@ -19,6 +19,7 @@ class OnEvent extends Extension { } async stop() { + super.stop(); for (const device of this.zigbee.getClients()) { const resolvedEntity = this.zigbee.resolveEntity(device); this.callOnEvent(resolvedEntity, 'stop', {}); diff --git a/test/bridge.test.js b/test/bridge.test.js index aaf7f0aac..35de421f4 100644 --- a/test/bridge.test.js +++ b/test/bridge.test.js @@ -6,10 +6,11 @@ const settings = require('../lib/util/settings'); const Controller = require('../lib/controller'); const flushPromises = () => new Promise(setImmediate); -const {coordinator, bulb, unsupported} = zigbeeHerdsman.devices; +const {coordinator, bulb, unsupported, WXKG11LM} = zigbeeHerdsman.devices; zigbeeHerdsman.returnDevices.push(coordinator.ieeeAddr); zigbeeHerdsman.returnDevices.push(bulb.ieeeAddr); zigbeeHerdsman.returnDevices.push(unsupported.ieeeAddr); +zigbeeHerdsman.returnDevices.push(WXKG11LM.ieeeAddr); describe('Bridge', () => { let controller; @@ -46,7 +47,7 @@ describe('Bridge', () => { it('Should publish devices on startup', async () => { expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/devices', - JSON.stringify([{"ieee_address":"0x000b57fffec6a5b2","type":"Router","network_address":40369,"supported":true,"friendly_name":"bulb","definition":{"model":"LED1545G12","vendor":"IKEA","description":"TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white","supports":"on/off, brightness, color temperature"},"power_source":"Mains (single phase)","date_code":null,"interviewing":false,"interview_completed":true},{"ieee_address":"0x0017880104e45518","type":"EndDevice","network_address":6536,"supported":false,"friendly_name":"0x0017880104e45518","definition":null,"power_source":"Battery","date_code":null,"interviewing":false,"interview_completed":true}]), + JSON.stringify([{"ieee_address":"0x000b57fffec6a5b2","type":"Router","network_address":40369,"supported":true,"friendly_name":"bulb","definition":{"model":"LED1545G12","vendor":"IKEA","description":"TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white","supports":"on/off, brightness, color temperature"},"power_source":"Mains (single phase)","date_code":null,"interviewing":false,"interview_completed":true},{"ieee_address":"0x0017880104e45518","type":"EndDevice","network_address":6536,"supported":false,"friendly_name":"0x0017880104e45518","definition":null,"power_source":"Battery","date_code":null,"interviewing":false,"interview_completed":true},{"ieee_address":"0x0017880104e45520","type":"EndDevice","network_address":6537,"supported":true,"friendly_name":"button","definition":{"model":"WXKG11LM","vendor":"Xiaomi","description":"Aqara wireless switch","supports":"single, double click (and triple, quadruple, hold, release depending on model)"},"power_source":"Battery","date_code":null,"interviewing":false,"interview_completed":true}]), { retain: true, qos: 0 }, expect.any(Function) ); @@ -531,6 +532,66 @@ describe('Bridge', () => { ); }); + it('Should allow to enable/disable Home Assistant extension', async () => { + // Test if disabled intially + const device = zigbeeHerdsman.devices.WXKG11LM; + settings.set(['devices', device.ieeeAddr, 'legacy'], false); + const payload = {data: {onOff: 1}, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; + await zigbeeHerdsman.events.message(payload); + expect(settings.get().homeassistant).toBeFalsy(); + expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/button/action', 'single', {retain: false, qos: 0}, expect.any(Function)); + + // Disable when already disabled should go OK + MQTT.events.message('zigbee2mqtt/bridge/request/config/homeassistant', JSON.stringify({value: false})); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/config/homeassistant', + JSON.stringify({"data":{"value":false},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + expect(settings.get().homeassistant).toBeFalsy(); + + // Enable + MQTT.events.message('zigbee2mqtt/bridge/request/config/homeassistant', JSON.stringify({value: true})); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/config/homeassistant', + JSON.stringify({"data":{"value":true},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + expect(settings.get().homeassistant).toBeTruthy(); + MQTT.publish.mockClear(); + await zigbeeHerdsman.events.message(payload); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/button/action', 'single', {retain: false, qos: 0}, expect.any(Function)); + + // Disable + MQTT.events.message('zigbee2mqtt/bridge/request/config/homeassistant', JSON.stringify({value: false})); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/config/homeassistant', + JSON.stringify({"data":{"value":false},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + expect(settings.get().homeassistant).toBeFalsy(); + MQTT.publish.mockClear(); + await zigbeeHerdsman.events.message(payload); + await flushPromises(); + expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/button/action', 'single', {retain: false, qos: 0}, expect.any(Function)); + }); + + it('Should fail to set Home Assistant when invalid type', async () => { + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/config/homeassistant', 'invalid_one'); + await flushPromises(); + expect(settings.get().homeassistant).toBeFalsy(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/config/homeassistant', + JSON.stringify({"data":{},"status":"error","error":"'invalid_one' is not an allowed value, allowed: true,false"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + it('Should allow to set last_seen', async () => { MQTT.publish.mockClear(); MQTT.events.message('zigbee2mqtt/bridge/request/config/last_seen', 'ISO_8601');