diff --git a/lib/controller.js b/lib/controller.js index d5524586b..f21005a7d 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -1,5 +1,6 @@ const MQTT = require('./mqtt'); const Zigbee = require('./zigbee'); +const EventBus = require('./eventBus'); const State = require('./state'); const logger = require('./util/logger'); const settings = require('./util/settings'); @@ -25,6 +26,7 @@ class Controller { constructor() { this.zigbee = new Zigbee(); this.mqtt = new MQTT(); + this.eventBus = new EventBus(); this.state = new State(); this.publishEntityState = this.publishEntityState.bind(this); @@ -32,39 +34,41 @@ class Controller { // Initialize extensions. this.extensions = [ - new ExtensionEntityPublish(this.zigbee, this.mqtt, this.state, this.publishEntityState), - new ExtensionDeviceReceive(this.zigbee, this.mqtt, this.state, this.publishEntityState), - new ExtensionDeviceGroupMembership(this.zigbee, this.mqtt, this.state, this.publishEntityState), - new ExtensionDeviceConfigure(this.zigbee, this.mqtt, this.state, this.publishEntityState), - new ExtensionNetworkMap(this.zigbee, this.mqtt, this.state, this.publishEntityState), - new ExtensionBridgeConfig(this.zigbee, this.mqtt, this.state, this.publishEntityState), - new ExtensionGroups(this.zigbee, this.mqtt, this.state, this.publishEntityState), - new ExtensionDeviceBind(this.zigbee, this.mqtt, this.state, this.publishEntityState), - new ExtensionDeviceEvent(this.zigbee, this.mqtt, this.state, this.publishEntityState), + new ExtensionEntityPublish(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus), + new ExtensionDeviceReceive(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus), + new ExtensionDeviceGroupMembership( + this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, + ), + new ExtensionDeviceConfigure(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus), + new ExtensionNetworkMap(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus), + new ExtensionBridgeConfig(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus), + new ExtensionGroups(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus), + new ExtensionDeviceBind(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus), + new ExtensionDeviceEvent(this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus), ]; if (settings.get().advanced.report) { this.extensions.push(new ExtensionDeviceReport( - this.zigbee, this.mqtt, this.state, this.publishEntityState, + this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, )); } if (settings.get().homeassistant) { this.extensions.push(new ExtensionHomeAssistant( - this.zigbee, this.mqtt, this.state, this.publishEntityState, + this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, )); } /* istanbul ignore next */ if (settings.get().advanced.soft_reset_timeout !== 0) { this.extensions.push(new ExtensionSoftReset( - this.zigbee, this.mqtt, this.state, this.publishEntityState, + this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, )); } if (settings.get().advanced.availability_timeout) { this.extensions.push(new ExtensionDeviceAvailability( - this.zigbee, this.mqtt, this.state, this.publishEntityState, + this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus, )); } } diff --git a/lib/eventBus.js b/lib/eventBus.js new file mode 100644 index 000000000..274bd23a2 --- /dev/null +++ b/lib/eventBus.js @@ -0,0 +1,20 @@ +const events = require('events'); +const assert = require('assert'); + +const allowedEvents = [ + 'deviceRemoved', // Device has been removed +]; + +class EventBus extends events.EventEmitter { + emit(event, data) { + assert(allowedEvents.includes(event), `Event '${event}' not supported`); + super.emit(event, data); + } + + on(event, callback) { + assert(allowedEvents.includes(event), `Event '${event}' not supported`); + super.on(event, callback); + } +} + +module.exports = EventBus; diff --git a/lib/extension/baseExtension.js b/lib/extension/baseExtension.js index 77b77a65d..d3569a638 100644 --- a/lib/extension/baseExtension.js +++ b/lib/extension/baseExtension.js @@ -6,12 +6,14 @@ class BaseExtension { * @param {MQTT} mqtt MQTT controller * @param {State} state State controller * @param {Function} publishEntityState Method to publish device state to MQTT. + * @param {EventBus} eventBus The event bus */ - constructor(zigbee, mqtt, state, publishEntityState) { + constructor(zigbee, mqtt, state, publishEntityState, eventBus) { this.zigbee = zigbee; this.mqtt = mqtt; this.state = state; this.publishEntityState = publishEntityState; + this.eventBus = eventBus; } /** diff --git a/lib/extension/bridgeConfig.js b/lib/extension/bridgeConfig.js index 5f4b58390..b76467e3d 100644 --- a/lib/extension/bridgeConfig.js +++ b/lib/extension/bridgeConfig.js @@ -10,8 +10,8 @@ const configRegex = const allowedLogLevels = ['error', 'warn', 'info', 'debug']; class BridgeConfig extends BaseExtension { - constructor(zigbee, mqtt, state, publishEntityState) { - super(zigbee, mqtt, state, publishEntityState); + constructor(zigbee, mqtt, state, publishEntityState, eventBus) { + super(zigbee, mqtt, state, publishEntityState, eventBus); // Bind functions this.permitJoin = this.permitJoin.bind(this); @@ -285,6 +285,9 @@ class BridgeConfig extends BaseExtension { } const cleanup = () => { + // Fire event + this.eventBus.emit('deviceRemoved', {device: entity.device}); + // Remove from configuration.yaml settings.removeDevice(entity.settings.ID); diff --git a/lib/extension/deviceAvailability.js b/lib/extension/deviceAvailability.js index 65de09082..4c2515b9e 100644 --- a/lib/extension/deviceAvailability.js +++ b/lib/extension/deviceAvailability.js @@ -18,8 +18,8 @@ const Hours25 = 1000 * 60 * 60 * 25; * This extensions pings devices to check if they are online. */ class DeviceAvailability extends BaseExtension { - constructor(zigbee, mqtt, state, publishEntityState) { - super(zigbee, mqtt, state, publishEntityState); + constructor(zigbee, mqtt, state, publishEntityState, eventBus) { + super(zigbee, mqtt, state, publishEntityState, eventBus); this.availability_timeout = settings.get().advanced.availability_timeout; this.timers = {}; diff --git a/lib/extension/deviceConfigure.js b/lib/extension/deviceConfigure.js index 83f463617..e3e14b649 100644 --- a/lib/extension/deviceConfigure.js +++ b/lib/extension/deviceConfigure.js @@ -4,8 +4,8 @@ const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters'); const BaseExtension = require('./baseExtension'); class DeviceConfigure extends BaseExtension { - constructor(zigbee, mqtt, state, publishEntityState) { - super(zigbee, mqtt, state, publishEntityState); + constructor(zigbee, mqtt, state, publishEntityState, eventBus) { + super(zigbee, mqtt, state, publishEntityState, eventBus); this.configuring = new Set(); this.attempts = {}; diff --git a/lib/extension/deviceReceive.js b/lib/extension/deviceReceive.js index 8127802ea..06c5e44d1 100755 --- a/lib/extension/deviceReceive.js +++ b/lib/extension/deviceReceive.js @@ -5,8 +5,8 @@ const debounce = require('debounce'); const BaseExtension = require('./baseExtension'); class DeviceReceive extends BaseExtension { - constructor(zigbee, mqtt, state, publishEntityState) { - super(zigbee, mqtt, state, publishEntityState); + constructor(zigbee, mqtt, state, publishEntityState, eventBus) { + super(zigbee, mqtt, state, publishEntityState, eventBus); this.coordinator = null; this.elapsed = {}; this.debouncers = {}; diff --git a/lib/extension/deviceReport.js b/lib/extension/deviceReport.js index 1ae1be2a3..466d97458 100644 --- a/lib/extension/deviceReport.js +++ b/lib/extension/deviceReport.js @@ -58,8 +58,8 @@ const pollOnMessage = [ ]; class DeviceReport extends BaseExtension { - constructor(zigbee, mqtt, state, publishEntityState) { - super(zigbee, mqtt, state, publishEntityState); + constructor(zigbee, mqtt, state, publishEntityState, eventBus) { + super(zigbee, mqtt, state, publishEntityState, eventBus); this.configuring = new Set(); this.failed = new Set(); this.pollDebouncers = {}; diff --git a/lib/extension/groups.js b/lib/extension/groups.js index 982051f2a..bfcee3b87 100644 --- a/lib/extension/groups.js +++ b/lib/extension/groups.js @@ -6,8 +6,8 @@ const topicRegex = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/( const topicRegexRemoveAll = new RegExp(`^${settings.get().mqtt.base_topic}/bridge/group/remove_all$`); class Groups extends BaseExtension { - constructor(zigbee, mqtt, state, publishEntityState) { - super(zigbee, mqtt, state, publishEntityState); + constructor(zigbee, mqtt, state, publishEntityState, eventBus) { + super(zigbee, mqtt, state, publishEntityState, eventBus); this.onStateChange = this.onStateChange.bind(this); } diff --git a/lib/extension/homeassistant.js b/lib/extension/homeassistant.js index faa83d87a..4efc47671 100644 --- a/lib/extension/homeassistant.js +++ b/lib/extension/homeassistant.js @@ -1144,8 +1144,8 @@ Object.keys(mapping).forEach((key) => { * This extensions handles integration with HomeAssistant */ class HomeAssistant extends BaseExtension { - constructor(zigbee, mqtt, state, publishEntityState) { - super(zigbee, mqtt, state, publishEntityState); + constructor(zigbee, mqtt, state, publishEntityState, eventBus) { + super(zigbee, mqtt, state, publishEntityState, eventBus); // A map of all discoverd devices this.discovered = {}; @@ -1160,6 +1160,19 @@ class HomeAssistant extends BaseExtension { 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)); + } + + onDeviceRemoved(device) { + const mappedModel = zigbeeHerdsmanConverters.findByZigbeeModel(device.modelID); + if (mappedModel) { + logger.info(`Clearing Home Assistant discovery topic for '${device.ieeeAddr}'`); + mapping[mappedModel.model].forEach((config) => { + const topic = this.getDiscoveryTopic(config, device); + this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic); + }); + } } async onMQTTConnected() { @@ -1169,26 +1182,26 @@ class HomeAssistant extends BaseExtension { for (const device of this.zigbee.getClients()) { const mappedModel = zigbeeHerdsmanConverters.findByZigbeeModel(device.modelID); if (mappedModel) { - this.discover(device.ieeeAddr, mappedModel, true); + this.discover(device, mappedModel, true); } } } - discover(entityID, mappedModel, force=false) { + discover(device, mappedModel, force=false) { // Check if already discoverd and check if there are configs. - const discover = force || !this.discovered[entityID]; + const discover = force || !this.discovered[device.ieeeAddr]; if (!discover) { return; } - const entity = settings.getEntity(entityID); + const entity = settings.getEntity(device.ieeeAddr); if (!entity || (entity.type === 'device' && !mapping[mappedModel.model]) || (entity.hasOwnProperty('homeassistant') && !entity.homeassistant)) { return; } mapping[mappedModel.model].forEach((config) => { - const topic = `${config.type}/${entity.ID}/${config.object_id}/config`; + const topic = this.getDiscoveryTopic(config, device); const payload = {...config.discovery_payload}; const stateTopic = `${settings.get().mqtt.base_topic}/${entity.friendlyName}`; @@ -1328,7 +1341,7 @@ class HomeAssistant extends BaseExtension { this.mqtt.publish(topic, JSON.stringify(payload), {retain: true, qos: 0}, this.discoveryTopic); }); - this.discovered[entityID] = true; + this.discovered[device.ieeeAddr] = true; } onMQTTMessage(topic, message) { @@ -1353,10 +1366,14 @@ class HomeAssistant extends BaseExtension { onZigbeeEvent(type, data, mappedDevice, settingsDevice) { const device = data.device; if (device && mappedDevice) { - this.discover(device.ieeeAddr, mappedDevice); + this.discover(device, mappedDevice); } } + getDiscoveryTopic(config, device) { + return `${config.type}/${device.ieeeAddr}/${config.object_id}/config`; + } + // Only for homeassistant.test.js _getMapping() { return mapping; diff --git a/lib/extension/networkMap.js b/lib/extension/networkMap.js index 4e552eb8a..8a89bb726 100644 --- a/lib/extension/networkMap.js +++ b/lib/extension/networkMap.js @@ -5,8 +5,8 @@ const logger = require('../util/logger'); const BaseExtension = require('./baseExtension'); class NetworkMap extends BaseExtension { - constructor(zigbee, mqtt, state, publishEntityState) { - super(zigbee, mqtt, state, publishEntityState); + constructor(zigbee, mqtt, state, publishEntityState, eventBus) { + super(zigbee, mqtt, state, publishEntityState, eventBus); // Subscribe to topic. this.topic = `${settings.get().mqtt.base_topic}/bridge/networkmap`; diff --git a/lib/extension/softReset.js b/lib/extension/softReset.js index 31c96e743..800d128f2 100644 --- a/lib/extension/softReset.js +++ b/lib/extension/softReset.js @@ -9,8 +9,8 @@ const BaseExtension = require('./baseExtension'); * This extensions soft resets the ZNP after a certain timeout. */ class SoftReset extends BaseExtension { - constructor(zigbee, mqtt, state, publishEntityState) { - super(zigbee, mqtt, state, publishEntityState); + constructor(zigbee, mqtt, state, publishEntityState, eventBus) { + super(zigbee, mqtt, state, publishEntityState, eventBus); this.timer = null; this.timeout = utils.secondsToMilliseconds(settings.get().advanced.soft_reset_timeout); } diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index d87894a47..a05e287c6 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -22,7 +22,7 @@ describe('HomeAssistant extension', () => { it('Should have mapping for all devices supported by zigbee-herdsman-converters', () => { const missing = []; const HomeAssistant = require('../lib/extension/homeassistant'); - const ha = new HomeAssistant(null, null, null, null); + const ha = new HomeAssistant(null, null, null, null, {on: () => {}}); require('zigbee-herdsman-converters').devices.forEach((d) => { if (!ha._getMapping()[d.model]) { @@ -637,4 +637,54 @@ describe('HomeAssistant extension', () => { expect.any(Function), ); }); + + it('Should clear discovery when device is removed', async () => { + controller = new Controller(false); + await controller.start(); + await flushPromises(); + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/config/remove', 'weather_sensor'); + await flushPromises(); + + expect(MQTT.publish).toHaveBeenCalledWith( + 'homeassistant/sensor/0x0017880104e45522/temperature/config', + null, + { retain: true, qos: 0 }, + expect.any(Function), + ); + expect(MQTT.publish).toHaveBeenCalledWith( + 'homeassistant/sensor/0x0017880104e45522/humidity/config', + null, + { retain: true, qos: 0 }, + expect.any(Function), + ); + expect(MQTT.publish).toHaveBeenCalledWith( + 'homeassistant/sensor/0x0017880104e45522/pressure/config', + null, + { retain: true, qos: 0 }, + expect.any(Function), + ); + expect(MQTT.publish).toHaveBeenCalledWith( + 'homeassistant/sensor/0x0017880104e45522/battery/config', + null, + { retain: true, qos: 0 }, + expect.any(Function), + ); + expect(MQTT.publish).toHaveBeenCalledWith( + 'homeassistant/sensor/0x0017880104e45522/linkquality/config', + null, + { retain: true, qos: 0 }, + expect.any(Function), + ); + }); + + it('Should not clear discovery when unsupported device is removed', async () => { + controller = new Controller(false); + await controller.start(); + await flushPromises(); + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/config/remove', 'unsupported2'); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledTimes(1); + }); });