Allow to disable Home Assistant via MQTT. #3281 (#4007)

* Initial

* Updates
This commit is contained in:
Koen Kanters
2020-07-29 23:10:03 +02:00
committed by GitHub
parent b9c52ef96e
commit 274109bc85
8 changed files with 132 additions and 11 deletions
+29 -3
View File
@@ -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);
+17 -1
View File
@@ -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;
+1
View File
@@ -124,6 +124,7 @@ class Availability extends Extension {
}
async stop() {
super.stop();
for (const timer of Object.values(this.timers)) {
clearTimeout(timer);
}
+15 -1
View File
@@ -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);
+3 -1
View File
@@ -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;
+3 -3
View File
@@ -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')) {
+1
View File
@@ -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', {});
+63 -2
View File
@@ -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');