From ef68cc328ec7780c55bb40072bfa0156cfb79d71 Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Tue, 21 May 2024 20:53:37 +0200 Subject: [PATCH] fix: Optimize Home Assistant discovery (#22701) * fix: Optimize Home Assistant discovery * update * u * u * u * fix --- lib/extension/frontend.ts | 1 + lib/extension/homeassistant.ts | 169 ++++++++++++++++++--------------- lib/mqtt.ts | 4 + test/homeassistant.test.js | 75 +++++++++++++-- test/stub/mqtt.js | 1 + 5 files changed, 169 insertions(+), 81 deletions(-) diff --git a/lib/extension/frontend.ts b/lib/extension/frontend.ts index aa1d50423d..0957ce74d8 100644 --- a/lib/extension/frontend.ts +++ b/lib/extension/frontend.ts @@ -152,6 +152,7 @@ export default class Frontend extends Extension { } @bind private onMQTTPublishMessage(data: eventdata.MQTTMessagePublished): void { + /* istanbul ignore else */ if (data.topic.startsWith(`${this.mqttBaseTopic}/`)) { // Send topic without base_topic const topic = data.topic.substring(this.mqttBaseTopic.length + 1); diff --git a/lib/extension/homeassistant.ts b/lib/extension/homeassistant.ts index 0146b17ca0..06a4236fe2 100644 --- a/lib/extension/homeassistant.ts +++ b/lib/extension/homeassistant.ts @@ -23,6 +23,13 @@ const sensorClick: DiscoveryEntry = { }, }; +interface Discovered { + mockProperties: Set, + messages: {[s: string]: {payload: string, published: boolean}}, + triggers: Set, + discovered: boolean, +} + const ACCESS_STATE = 0b001; const ACCESS_SET = 0b010; const groupSupportedTypes = ['light', 'switch', 'lock', 'cover']; @@ -106,10 +113,10 @@ class Bridge { * This extensions handles integration with HomeAssistant */ export default class HomeAssistant extends Extension { - private discovered: {[s: string]: - {topics: Set, mockProperties: Set, objectIDs: Set}} = {}; - private discoveredTriggers : {[s: string]: Set}= {}; + private discovered: {[s: string]: Discovered} = {}; private discoveryTopic = settings.get().homeassistant.discovery_topic; + private discoveryRegex = new RegExp(`${settings.get().homeassistant.discovery_topic}/(.*)/(.*)/(.*)/config`); + private discoveryRegexWoTopic = new RegExp(`(.*)/(.*)/(.*)/config`); private statusTopic = settings.get().homeassistant.status_topic; private entityAttributes = settings.get().homeassistant.legacy_entity_attributes; private zigbee2MQTTVersion: string; @@ -145,22 +152,41 @@ export default class HomeAssistant extends Extension { this.eventBus.onDeviceInterview(this, this.onZigbeeEvent); this.eventBus.onDeviceMessage(this, this.onZigbeeEvent); this.eventBus.onScenesChanged(this, this.onScenesChanged); - this.eventBus.onEntityOptionsChanged(this, (data) => this.discover(data.entity, true)); - this.eventBus.onExposesChanged(this, (data) => this.discover(data.device, true)); + this.eventBus.onEntityOptionsChanged(this, (data) => this.discover(data.entity)); + this.eventBus.onExposesChanged(this, (data) => this.discover(data.device)); this.mqtt.subscribe(this.statusTopic); this.mqtt.subscribe(defaultStatusTopic); - this.mqtt.subscribe(`${this.discoveryTopic}/#`); - // MQTT discovery of all paired devices on startup. - for (const entity of [this.bridge, ...this.zigbee.devices(false), ...this.zigbee.groups()]) { - this.discover(entity, true); - } + /** + * Prevent unecessary re-discovery of entities by waiting 5 seconds for retained discovery messages to come in. + * Any received discovery messages will not be published again. + * Unsubscribe from the discoveryTopic to prevent receiving our own messages. + */ + const discoverWait = 5; + // Discover with `published = false`, this will populate `this.discovered` without publishing the discoveries. + // This is needed for clearing outdated entries in `this.onMQTTMessage()` + [this.bridge, ...this.zigbee.devices(false), ...this.zigbee.groups()].forEach((e) => this.discover(e, false)); + logger.debug(`Discovering entities to Home Assistant in ${discoverWait}s`); + this.mqtt.subscribe(`${this.discoveryTopic}/#`); + setTimeout(() => { + this.mqtt.unsubscribe(`${this.discoveryTopic}/#`); + logger.debug(`Discovering entities to Home Assistant`); + [this.bridge, ...this.zigbee.devices(false), ...this.zigbee.groups()].forEach((e) => this.discover(e)); + }, utils.seconds(discoverWait)); // Send availability messages, this is required if the legacy_availability_payload option has been changed. this.eventBus.emitPublishAvailability(); } + private getDiscovered(entity: Device | Group | Bridge | string): Discovered { + const ID = typeof entity === 'string' ? entity : entity.ID; + if (!(ID in this.discovered)) { + this.discovered[ID] = {messages: {}, triggers: new Set(), mockProperties: new Set(), discovered: false}; + } + return this.discovered[ID]; + } + private exposeToConfig(exposes: zhc.Expose[], entityType: 'device' | 'group', allExposes: zhc.Expose[], definition?: zhc.Definition): DiscoveryEntry[] { // For groups an array of exposes (of the same type) is passed, this is to determine e.g. what features @@ -1109,8 +1135,9 @@ export default class HomeAssistant extends Extension { } @bind onDeviceRemoved(data: eventdata.DeviceRemoved): void { - logger.debug(`Clearing Home Assistant discovery topic for '${data.name}'`); - this.discovered[data.ieeeAddr]?.topics.forEach((topic) => { + logger.debug(`Clearing Home Assistant discovery for '${data.name}'`); + const discovered = this.getDiscovered(data.ieeeAddr); + Object.keys(discovered.messages).forEach((topic) => { this.mqtt.publish(topic, null, {retain: true, qos: 1}, this.discoveryTopic, false, false); }); @@ -1118,7 +1145,7 @@ export default class HomeAssistant extends Extension { } @bind onGroupMembersChanged(data: eventdata.GroupMembersChanged): void { - this.discover(data.group, true); + this.discover(data.group); } @bind async onPublishEntityState(data: eventdata.PublishEntityState): Promise { @@ -1131,8 +1158,9 @@ export default class HomeAssistant extends Extension { * zigbee2mqtt/mydevice/l1. */ const entity = this.zigbee.resolveEntity(data.entity.name); - if (entity.isDevice() && this.discovered[entity.ieeeAddr]) { - for (const objectID of this.discovered[entity.ieeeAddr].objectIDs) { + if (entity.isDevice()) { + Object.keys(this.getDiscovered(entity).messages).forEach((topic) => { + const objectID = topic.match(this.discoveryRegexWoTopic)?.[3]; const lightMatch = /^light_(.*)/.exec(objectID); const coverMatch = /^cover_(.*)/.exec(objectID); @@ -1149,11 +1177,9 @@ export default class HomeAssistant extends Extension { } } - await this.mqtt.publish( - `${data.entity.name}/${endpoint}`, stringify(payload), {}, - ); + this.mqtt.publish(`${data.entity.name}/${endpoint}`, stringify(payload), {}); } - } + }); } /** @@ -1190,20 +1216,21 @@ export default class HomeAssistant extends Extension { // Clear before rename so Home Assistant uses new friendly_name // https://github.com/Koenkk/zigbee2mqtt/issues/4096#issuecomment-674044916 if (data.homeAssisantRename) { - for (const config of this.getConfigs(data.entity)) { - const topic = this.getDiscoveryTopic(config, data.entity); + const discovered = this.getDiscovered(data.entity); + for (const topic of Object.keys(discovered.messages)) { this.mqtt.publish(topic, null, {retain: true, qos: 1}, this.discoveryTopic, false, false); } + discovered.messages = {}; // Make sure Home Assistant deletes the old entity first otherwise another one (_2) is created // https://github.com/Koenkk/zigbee2mqtt/issues/12610 await utils.sleep(2); } - this.discover(data.entity, true); + this.discover(data.entity); - if (data.entity.isDevice() && this.discoveredTriggers[data.entity.ieeeAddr]) { - for (const config of this.discoveredTriggers[data.entity.ieeeAddr]) { + if (data.entity.isDevice()) { + for (const config of this.getDiscovered(data.entity).triggers) { const key = config.substring(0, config.indexOf('_')); const value = config.substring(config.indexOf('_') + 1); this.publishDeviceTriggerDiscover(data.entity, key, value, true); @@ -1387,28 +1414,22 @@ export default class HomeAssistant extends Extension { return configs; } - private getDiscoverKey(entity: Device | Group | Bridge): string | number { - return entity.ID; - } - - private discover(entity: Device | Group | Bridge, force=false): void { - // Check if already discovered and check if there are configs. - const discoverKey = this.getDiscoverKey(entity); - const discover = force || !this.discovered[discoverKey]; - + private discover(entity: Device | Group | Bridge, publish: boolean = true): void { // Handle type differences. const isDevice = entity.isDevice(); const isGroup = entity.isGroup(); - if (isGroup && (!discover || entity.zh.members.length === 0)) { + if (isGroup && entity.zh.members.length === 0) { return; - } else if (isDevice && (!discover || !entity.definition || entity.zh.interviewing || + } else if (isDevice && (!entity.definition || entity.zh.interviewing || (entity.options.hasOwnProperty('homeassistant') && !entity.options.homeassistant))) { return; } - const lastDiscovered = this.discovered[discoverKey]; - this.discovered[discoverKey] = {topics: new Set(), mockProperties: new Set(), objectIDs: new Set()}; + const discovered = this.getDiscovered(entity); + discovered.discovered = true; + const lastDiscoverdTopics = Object.keys(discovered.messages); + const newDiscoveredTopics: Set = new Set(); this.getConfigs(entity).forEach((config) => { const payload = {...config.discovery_payload}; const baseTopic = `${settings.get().mqtt.base_topic}/${entity.name}`; @@ -1611,22 +1632,30 @@ export default class HomeAssistant extends Extension { } const topic = this.getDiscoveryTopic(config, entity); - this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 1}, this.discoveryTopic, false, false); - this.discovered[discoverKey].topics.add(topic); - this.discovered[discoverKey].objectIDs.add(config.object_id); - config.mockProperties?.forEach((mockProperty) => - this.discovered[discoverKey].mockProperties.add(mockProperty)); + const payloadStr = stringify(payload); + newDiscoveredTopics.add(topic); + + // Only discover when not discovered yet + const discoveredMessage = discovered.messages[topic]; + if (!discoveredMessage || discoveredMessage.payload !== payloadStr || !discoveredMessage.published) { + discovered.messages[topic] = {payload: payloadStr, published: publish}; + if (publish) { + this.mqtt.publish(topic, payloadStr, {retain: true, qos: 1}, this.discoveryTopic, false, false); + } + } else { + logger.debug(`Skipping discovery of '${topic}', already discovered`); + } + config.mockProperties?.forEach((mockProperty) => discovered.mockProperties.add(mockProperty)); }); - lastDiscovered?.topics?.forEach((topic) => { - if (!this.discovered[discoverKey].topics.has(topic)) { + lastDiscoverdTopics.forEach((topic) => { + if (!newDiscoveredTopics.has(topic)) { this.mqtt.publish(topic, null, {retain: true, qos: 1}, this.discoveryTopic, false, false); } }); } @bind private onMQTTMessage(data: eventdata.MQTTMessage): void { - const discoveryRegex = new RegExp(`${this.discoveryTopic}/(.*)/(.*)/(.*)/config`); - const discoveryMatch = data.topic.match(discoveryRegex); + const discoveryMatch = data.topic.match(this.discoveryRegex); const isDeviceAutomation = discoveryMatch && discoveryMatch[1] === 'device_automation'; if (discoveryMatch) { // Clear outdated discovery configs and remember already discovered device_automations @@ -1656,27 +1685,23 @@ export default class HomeAssistant extends Extension { const key = `${discoveryMatch[3].substring(0, discoveryMatch[3].indexOf('_'))}`; const triggerTopic = `${settings.get().mqtt.base_topic}/${entity.name}/${key}`; if (isDeviceAutomation && message.topic === triggerTopic) { - if (!this.discoveredTriggers[ID]) { - this.discoveredTriggers[ID] = new Set(); - } - this.discoveredTriggers[ID].add(discoveryMatch[3]); + this.getDiscovered(ID).triggers.add(discoveryMatch[3]); } } - if (!clear && !isDeviceAutomation) { - const type = discoveryMatch[1]; - const objectID = discoveryMatch[3]; - clear = !this.getConfigs(entity) - .find((c) => c.type === type && c.object_id === objectID && - `${this.discoveryTopic}/${this.getDiscoveryTopic(c, entity)}` === data.topic); + const topic = data.topic.substring(this.discoveryTopic.length + 1); + if (!clear && !isDeviceAutomation && !(topic in this.getDiscovered(entity).messages)) { + clear = true; } + // Device was flagged to be excluded from homeassistant discovery clear = clear || (entity.options.hasOwnProperty('homeassistant') && !entity.options.homeassistant); if (clear) { - logger.debug(`Clearing Home Assistant config '${data.topic}'`); - const topic = data.topic.substring(this.discoveryTopic.length + 1); + logger.debug(`Clearing outdated Home Assistant config '${data.topic}'`); this.mqtt.publish(topic, null, {retain: true, qos: 1}, this.discoveryTopic, false, false); + } else { + this.getDiscovered(entity).messages[topic] = {payload: stringify(message), published: true}; } } else if ((data.topic === this.statusTopic || data.topic === defaultStatusTopic) && data.message.toLowerCase() === 'online') { @@ -1694,17 +1719,21 @@ export default class HomeAssistant extends Extension { } @bind onZigbeeEvent(data: {device: Device}): void { - this.discover(data.device); + if (!this.getDiscovered(data.device).discovered) { + this.discover(data.device); + } } @bind async onScenesChanged(data: eventdata.ScenesChanged): Promise { // Re-trigger MQTT discovery of changed devices and groups, similar to bridge.ts // First, clear existing scene discovery topics - logger.debug(`Clearing Home Assistant scene discovery topics for '${data.entity.name}'`); - this.discovered[this.getDiscoverKey(data.entity)]?.topics.forEach((topic) => { + logger.debug(`Clearing Home Assistant scene discovery for '${data.entity.name}'`); + const discovered = this.getDiscovered(data.entity); + Object.keys(discovered.messages).forEach((topic) => { if (topic.startsWith('scene')) { this.mqtt.publish(topic, null, {retain: true, qos: 1}, this.discoveryTopic, false, false); + delete discovered.messages[topic]; } }); @@ -1715,7 +1744,7 @@ export default class HomeAssistant extends Extension { // Re-discover entity (including any new scenes). logger.debug(`Re-discovering entities with their scenes.`); - this.discover(data.entity, true); + this.discover(data.entity); } private getDevicePayload(entity: Device | Group | Bridge): KeyValue { @@ -1765,8 +1794,7 @@ export default class HomeAssistant extends Extension { } override adjustMessageBeforePublish(entity: Device | Group | Bridge, message: KeyValue): void { - const discoverKey = this.getDiscoverKey(entity); - this.discovered[discoverKey]?.mockProperties?.forEach((mockProperty) => { + this.getDiscovered(entity).mockProperties.forEach((mockProperty) => { if (!message.hasOwnProperty(mockProperty.property)) { message[mockProperty.property] = mockProperty.value; } @@ -1804,12 +1832,9 @@ export default class HomeAssistant extends Extension { return; } - if (!this.discoveredTriggers[device.ieeeAddr]) { - this.discoveredTriggers[device.ieeeAddr] = new Set(); - } - + const discovered = this.getDiscovered(device); const discoveredKey = `${key}_${value}`; - if (this.discoveredTriggers[device.ieeeAddr].has(discoveredKey) && !force) { + if (discovered.triggers.has(discoveredKey) && !force) { return; } @@ -1834,11 +1859,7 @@ export default class HomeAssistant extends Extension { }; await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 1}, this.discoveryTopic, false, false); - this.discoveredTriggers[device.ieeeAddr].add(discoveredKey); - } - - _clearDiscoveredTrigger(): void { - this.discoveredTriggers = {}; + discovered.triggers.add(discoveredKey); } private getBridgeEntity(coordinatorVersion: zh.CoordinatorVersion): Bridge { diff --git a/lib/mqtt.ts b/lib/mqtt.ts index f0c377dd66..ca2db4e113 100644 --- a/lib/mqtt.ts +++ b/lib/mqtt.ts @@ -136,6 +136,10 @@ export default class MQTT { this.client.subscribe(topic); } + unsubscribe(topic: string): void { + this.client.unsubscribe(topic); + } + @bind public onMessage(topic: string, message: Buffer): void { // Since we subscribe to zigbee2mqtt/# we also receive the message we send ourselves, skip these. if (!this.publishedTopics.has(topic)) { diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index abd2654a16..75b46dbb8e 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -15,11 +15,23 @@ describe('HomeAssistant extension', () => { let extension; let origin; - let resetExtension = async () => { + let resetExtension = async (runTimers=true) => { await controller.enableDisableExtension(false, 'HomeAssistant'); MQTT.publish.mockClear(); await controller.enableDisableExtension(true, 'HomeAssistant'); extension = controller.extensions.find((e) => e.constructor.name === 'HomeAssistant'); + if (runTimers) { + jest.runOnlyPendingTimers(); + } + } + + let resetDiscoveryPayloads = (id) => { + // Change discovered payload, otherwise it's not re-published because it's the same. + Object.values(extension.discovered[id].messages).forEach((m) => m.payload = 'changed'); + } + + let clearDiscoveredTrigger = (id) => { + extension.discovered[id].triggers = new Set(); } beforeEach(async () => { @@ -29,6 +41,7 @@ describe('HomeAssistant extension', () => { data.writeEmptyState(); controller.state.load(); await resetExtension(); + await flushPromises(); }); beforeAll(async () => { @@ -77,7 +90,6 @@ describe('HomeAssistant extension', () => { it('Should discover devices and groups', async () => { let payload; - await flushPromises(); payload = { "availability":[{"topic":"zigbee2mqtt/bridge/state"}], @@ -410,6 +422,51 @@ describe('HomeAssistant extension', () => { ); }); + it('Should not discovery devices which are already discovered', async() => { + await resetExtension(false); + const topic = 'homeassistant/sensor/0x0017880104e45522/humidity/config'; + const payload = stringify({ + 'unit_of_measurement': '%', + 'device_class': 'humidity', + 'state_class': 'measurement', + 'value_template': '{{ value_json.humidity }}', + 'state_topic': 'zigbee2mqtt/weather_sensor', + 'json_attributes_topic': 'zigbee2mqtt/weather_sensor', + 'object_id': 'weather_sensor_humidity', + 'unique_id': '0x0017880104e45522_humidity_zigbee2mqtt', + 'origin': origin, + 'enabled_by_default': true, + 'device': { + 'identifiers': ['zigbee2mqtt_0x0017880104e45522'], + 'name': 'weather_sensor', + 'sw_version': null, + 'model': 'Temperature and humidity sensor (WSDCGQ11LM)', + 'manufacturer': 'Aqara', + 'via_device': 'zigbee2mqtt_bridge_0x00124b00120144ae', + }, + 'availability': [{topic: 'zigbee2mqtt/bridge/state'}], + }); + + // Should subscribe to `homeassistant/#` to find out what devices are already discovered. + expect(MQTT.subscribe).toHaveBeenCalledWith(`homeassistant/#`); + + // Retained Home Assistant discovery message arrives + await MQTT.events.message(topic, payload); + + jest.runOnlyPendingTimers(); + + // Should unsubscribe to not receive all messages that are going to be published to `homeassistant/#` again. + expect(MQTT.unsubscribe).toHaveBeenCalledWith(`homeassistant/#`); + + expect(MQTT.publish).not.toHaveBeenCalledWith( + 'homeassistant/sensor/0x0017880104e45522/humidity/config', + expect.any(String), + expect.any(Object), + expect.any(Function), + ); + expect(logger.debug).toHaveBeenCalledWith(`Skipping discovery of 'sensor/0x0017880104e45522/humidity/config', already discovered`) + }); + it('Should discover devices with precision', async () => { settings.set(['devices', '0x0017880104e45522'], { humidity_precision: 0, @@ -1073,7 +1130,7 @@ describe('HomeAssistant extension', () => { }); it('Should discover when not discovered yet', async () => { - controller.extensions.find((e) => e.constructor.name === 'HomeAssistant').discovered = {}; + extension.discovered = {}; const device = zigbeeHerdsman.devices.WSDCGQ11LM; const data = {measuredValue: -85} const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; @@ -1111,7 +1168,7 @@ describe('HomeAssistant extension', () => { }); it('Shouldnt discover when device leaves', async () => { - controller.extensions.find((e) => e.constructor.name === 'HomeAssistant').discovered = {}; + extension.discovered = {}; const device = zigbeeHerdsman.devices.bulb; const payload = {ieeeAddr: device.ieeeAddr}; MQTT.publish.mockClear(); @@ -1121,6 +1178,7 @@ describe('HomeAssistant extension', () => { it('Should discover when options change', async () => { const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb); + resetDiscoveryPayloads(device.ieeeAddr); MQTT.publish.mockClear(); controller.eventBus.emitEntityOptionsChanged({entity: device, from: {}, to: {'test': 123}}); await flushPromises(); @@ -1629,7 +1687,7 @@ describe('HomeAssistant extension', () => { ); // Shouldn't rediscover when already discovered in previous session - controller.extensions.find((e) => e.constructor.name === 'HomeAssistant')._clearDiscoveredTrigger(); + clearDiscoveredTrigger('0x0017880104e45520'); await MQTT.events.message('homeassistant/device_automation/0x0017880104e45520/action_double/config', stringify({topic: 'zigbee2mqtt/button/action'})); await MQTT.events.message('homeassistant/device_automation/0x0017880104e45520/action_double/config', stringify({topic: 'zigbee2mqtt/button/action'})); await flushPromises(); @@ -1640,7 +1698,7 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).not.toHaveBeenCalledWith('homeassistant/device_automation/0x0017880104e45520/action_double/config', expect.any(String), expect.any(Object), expect.any(Function)); // Should rediscover when already discovered in previous session but with different name - controller.extensions.find((e) => e.constructor.name === 'HomeAssistant')._clearDiscoveredTrigger(); + clearDiscoveredTrigger('0x0017880104e45520'); await MQTT.events.message('homeassistant/device_automation/0x0017880104e45520/action_double/config', stringify({topic: 'zigbee2mqtt/button_other_name/action'})); await flushPromises(); MQTT.publish.mockClear(); @@ -1905,6 +1963,7 @@ describe('HomeAssistant extension', () => { }); it('Should rediscover group when device is added to it', async () => { + resetDiscoveryPayloads(9); MQTT.publish.mockClear(); MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'ha_discovery_group', device: 'wall_switch_double/left'})); await flushPromises(); @@ -2148,9 +2207,9 @@ describe('HomeAssistant extension', () => { }); it('Should rediscover scenes when a scene is changed', async () => { - // Device/endpoint scenes. const device = controller.zigbee.resolveEntity(zigbeeHerdsman.devices.bulb_color_2); + resetDiscoveryPayloads(device.ieeeAddr); MQTT.publish.mockClear(); controller.eventBus.emitScenesChanged({entity: device}); @@ -2194,6 +2253,7 @@ describe('HomeAssistant extension', () => { // Group scenes. const group = controller.zigbee.resolveEntity('ha_discovery_group'); + resetDiscoveryPayloads(9); MQTT.publish.mockClear(); controller.eventBus.emitScenesChanged({entity: group}); @@ -2503,6 +2563,7 @@ describe('HomeAssistant extension', () => { const device = zigbeeHerdsman.devices['BMCT-SLZ']; const data = {deviceMode: 0} const msg = {data, cluster: 'manuSpecificBosch10', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; + resetDiscoveryPayloads('0x18fc26000000cafe'); await zigbeeHerdsman.events.message(msg); const payload = { 'availability':[{'topic':'zigbee2mqtt/bridge/state'}], diff --git a/test/stub/mqtt.js b/test/stub/mqtt.js index c47d177b2e..2e2fe682f0 100644 --- a/test/stub/mqtt.js +++ b/test/stub/mqtt.js @@ -4,6 +4,7 @@ const mock = { publish: jest.fn().mockImplementation((topic, payload, options, cb) => cb()), end: jest.fn(), subscribe: jest.fn(), + unsubscribe: jest.fn(), reconnecting: false, on: jest.fn(), stream: {setMaxListeners: jest.fn()}