Clear Home Assistant MQTT discovery on device remove. #2678

This commit is contained in:
Koen Kanters 2020-01-09 21:47:19 +01:00
parent 3a463b0782
commit cb0b0b5af9
13 changed files with 136 additions and 40 deletions

View File

@ -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,
));
}
}

20
lib/eventBus.js Normal file
View File

@ -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;

View File

@ -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;
}
/**

View File

@ -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);

View File

@ -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 = {};

View File

@ -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 = {};

View File

@ -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 = {};

View File

@ -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 = {};

View File

@ -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);
}

View File

@ -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;

View File

@ -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`;

View File

@ -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);
}

View File

@ -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);
});
});