From 89dea86b3ac191889796d1013084bdfd5908434e Mon Sep 17 00:00:00 2001 From: Koen Kanters Date: Wed, 22 Sep 2021 18:04:57 +0200 Subject: [PATCH] Refactor HomeAssistant extension to TypeScript --- lib/controller.js | 7 +- lib/eventBus.ts | 9 +- lib/extension/bridge.ts | 4 +- lib/extension/extensionts.ts | 4 +- .../{homeassistant.js => homeassistant.ts} | 378 +++++++++--------- lib/types/types.ts | 12 +- lib/util/utils.ts | 5 + lib/zigbee.ts | 1 + test/homeassistant.test.js | 179 +++------ 9 files changed, 274 insertions(+), 325 deletions(-) rename lib/extension/{homeassistant.js => homeassistant.ts} (81%) diff --git a/lib/controller.js b/lib/controller.js index f73491c4..b756a02d 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -91,10 +91,13 @@ class Controller { this.extensions.push(new ExtensionSoftReset(...args)); } + /* istanbul ignore else */ if (settings.get().experimental.availability_new) { this.extensions.push(new ExtensionAvailability(...args)); - } else if (settings.get().advanced.availability_timeout) { - this.extensions.push(new ExtensionAvailabilityLegacy(...args)); + } else { + if (settings.get().advanced.availability_timeout) { + this.extensions.push(new ExtensionAvailabilityLegacy(...args)); + } } this.extensions.push(new ExtensionExternalExtension(...args)); diff --git a/lib/eventBus.ts b/lib/eventBus.ts index 94c18bb6..017d7567 100644 --- a/lib/eventBus.ts +++ b/lib/eventBus.ts @@ -5,6 +5,8 @@ import ExtensionTS from './extension/extensionts'; declare global { interface EventDeviceRenamed { device: Device, homeAssisantRename: boolean, from: string, to: string } + // TODO: remove resolved entity, replace by Device + interface EventDeviceRemoved { resolvedEntity: ResolvedEntity} type EventMQTTMessage = { topic: string, message: string }; type EventMQTTMessagePublished = { topic: string, payload: string, options: {retain: boolean, qos: number} }; type EventPermitJoinChanged = ZHEvents.PermitJoinChangedPayload; @@ -15,7 +17,8 @@ declare global { type EventDeviceJoined = { device: Device }; type EventReportingDisabled = { device: ZHDevice }; // TODO zhdevice -> device type EventDeviceLeave = { ieeeAddr: string }; - type EventGroupMembersChanged = unknown; // TODO fill + // TODO: remove resolved entity, replace by Group + type EventGroupMembersChanged = { group: ResolvedEntity }; // TODO fill type EventPublishEntityState = { // TODO: remove resolved entity, replace by Device | Group and remove ieeeAddr messagePayload: KeyValue, entity: ResolvedEntity, stateChangeReason: 'publishDebounce', payload: KeyValue, @@ -54,6 +57,10 @@ export default class EventBus { public onDeviceRenamed(key: ListenerKey, callback: (data: EventDeviceRenamed) => void): void { this.on('deviceRenamed', callback, key);} + // public emitDeviceRemoved(data: EventDeviceRemoved): void {this.emitter.emit('deviceRemoved', data);} + public onDeviceRemoved(key: ListenerKey, callback: (data: EventDeviceRemoved) => void): void { + this.on('deviceRemoved', callback, key);} + public emitLastSeenChanged(data: EventLastSeenChanged): void {this.emitter.emit('lastSeenChanged', data);} public onLastSeenChanged(key: ListenerKey, callback: (data: EventLastSeenChanged) => void): void { this.on('lastSeenChanged', callback, key);} diff --git a/lib/extension/bridge.ts b/lib/extension/bridge.ts index 9261b6e5..9bec7d97 100644 --- a/lib/extension/bridge.ts +++ b/lib/extension/bridge.ts @@ -175,7 +175,7 @@ class Bridge extends ExtensionTS { } if (newSettings.hasOwnProperty('homeassistant')) { - this.enableDisableExtension(newSettings.homeassistant, 'HomeAssistant'); + await this.enableDisableExtension(newSettings.homeassistant, 'HomeAssistant'); } if (newSettings.hasOwnProperty('advanced') && newSettings.advanced.hasOwnProperty('log_level')) { @@ -283,7 +283,7 @@ class Bridge extends ExtensionTS { throw new Error(`'${value}' is not an allowed value, allowed: ${allowed}`); } - this.enableDisableExtension(value, 'HomeAssistant'); + await this.enableDisableExtension(value, 'HomeAssistant'); settings.set(['homeassistant'], value); this.publishInfo(); return utils.getResponse(message, {value}, null); diff --git a/lib/extension/extensionts.ts b/lib/extension/extensionts.ts index 2cf17e87..083cfea6 100644 --- a/lib/extension/extensionts.ts +++ b/lib/extension/extensionts.ts @@ -5,7 +5,7 @@ abstract class ExtensionTS { protected state: TempState; protected publishEntityState: PublishEntityState; protected eventBus: EventBus; - protected enableDisableExtension: (enable: boolean, name: string) => void; + protected enableDisableExtension: (enable: boolean, name: string) => Promise; protected restartCallback: () => void; protected addExtension: (extension: ExternalConverterClass) => void; @@ -22,7 +22,7 @@ abstract class ExtensionTS { * @param {addExtension} addExtension Add an extension */ constructor(zigbee: Zigbee, mqtt: MQTT, state: TempState, publishEntityState: PublishEntityState, - eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => void, + eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise, restartCallback: () => void, addExtension: (extension: ExternalConverterClass) => void) { this.zigbee = zigbee; this.mqtt = mqtt; diff --git a/lib/extension/homeassistant.js b/lib/extension/homeassistant.ts similarity index 81% rename from lib/extension/homeassistant.js rename to lib/extension/homeassistant.ts index 2036b29d..a62239c1 100644 --- a/lib/extension/homeassistant.js +++ b/lib/extension/homeassistant.ts @@ -1,11 +1,18 @@ -const settings = require('../util/settings'); -const logger = require('../util/logger'); -const utils = require('../util/utils'); -const zigbee2mqttVersion = require('../..' + '/package.json').version; -const Extension = require('./extension'); -const stringify = require('json-stable-stringify-without-jsonify'); -const zigbeeHerdsmanConverters = require('zigbee-herdsman-converters'); -const assert = require('assert'); +import * as settings from '../util/settings'; +import logger from '../util/logger'; +import * as utils from '../util/utils'; +// @ts-ignore +import stringify from 'json-stable-stringify-without-jsonify'; +// @ts-ignore +import zigbeeHerdsmanConverters from 'zigbee-herdsman-converters'; +import assert from 'assert'; +import ExtensionTS from './extensionts'; +import bind from 'bind-decorator'; +import Device from '../model/device'; +import Group from '../model/group'; + +// eslint-disable-next-line camelcase +interface DiscoveryEntry {type: string, object_id: string, discovery_payload: KeyValue} const sensorClick = { type: 'sensor', @@ -21,7 +28,7 @@ const ACCESS_SET = 0b010; const groupSupportedTypes = ['light', 'switch', 'lock', 'cover']; const defaultStatusTopic = 'homeassistant/status'; -const featurePropertyWithoutEndpoint = (feature) => { +const featurePropertyWithoutEndpoint = (feature: DefinitionExposeFeature): string => { if (feature.endpoint) { return feature.property.slice(0, -1 + -1 * feature.endpoint.length); } else { @@ -32,39 +39,58 @@ const featurePropertyWithoutEndpoint = (feature) => { /** * This extensions handles integration with HomeAssistant */ -class HomeAssistant extends Extension { - constructor(zigbee, mqtt, state, publishEntityState, eventBus) { - super(zigbee, mqtt, state, publishEntityState, eventBus); +class HomeAssistant extends ExtensionTS { + private discovered: {[s: string]: string[]} = {}; + private mapping: {[s: string]: DiscoveryEntry[]} = {}; + private discoveredTriggers : {[s: string]: Set}= {}; + private legacyApi = settings.get().advanced.legacy_api; + private discoveryTopic = settings.get().advanced.homeassistant_discovery_topic; + private statusTopic = settings.get().advanced.homeassistant_status_topic; + private entityAttributes = settings.get().advanced.homeassistant_legacy_entity_attributes; + private zigbee2MQTTVersion: string; - // A map of all discoverd devices - this.discovered = {}; - this.mapping = {}; - this.discoveredTriggers = {}; - this.legacyApi = settings.get().advanced.legacy_api; + constructor(zigbee: Zigbee, mqtt: MQTT, state: TempState, publishEntityState: PublishEntityState, + eventBus: EventBus, enableDisableExtension: (enable: boolean, name: string) => Promise, + restartCallback: () => void, addExtension: (extension: ExternalConverterClass) => void) { + super(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension); + if (settings.get().experimental.output === 'attribute') { + throw new Error('Home Assistant integration is not possible with attribute output!'); + } + } + override async start(): Promise { if (!settings.get().advanced.cache_state) { logger.warn('In order for Home Assistant integration to work properly set `cache_state: true'); } - if (settings.get().experimental.output === 'attribute') { - throw new Error('Home Assistant integration is not possible with attribute output!'); - } - - this.discoveryTopic = settings.get().advanced.homeassistant_discovery_topic; - this.statusTopic = settings.get().advanced.homeassistant_status_topic; - this.entityAttributes = settings.get().advanced.homeassistant_legacy_entity_attributes; - - this.eventBus.on('deviceRemoved', (data) => this.onDeviceRemoved(data.resolvedEntity), this.constructor.name); - this.eventBus.on('publishEntityState', (data) => this.onPublishEntityState(data), this.constructor.name); - this.eventBus.on('deviceRenamed', (data) => - this.onDeviceRenamed(data.device, data.homeAssisantRename), this.constructor.name, - ); - this.eventBus.on(`groupMembersChanged`, (d) => this.groupMembersChanged(d), this.constructor.name); - + this.zigbee2MQTTVersion = await utils.getZigbee2MQTTVersionSimple(); this.populateMapping(); + + this.eventBus.onDeviceRemoved(this, this.onDeviceRemoved); + this.eventBus.onMQTTMessage(this, this.onMQTTMessage_); + this.eventBus.onDeviceRenamed(this, this.onDeviceRenamed); + this.eventBus.onPublishEntityState(this, this.onPublishEntityState); + this.eventBus.onGroupMembersChanged(this, this.onGroupMembersChanged); + /* istanbul ignore next TODO */ + this.eventBus.onDeviceAnnounce(this, (data: EventDeviceAnnounce) => this.onZigbeeEvent(data.device)); + /* istanbul ignore next TODO */ + this.eventBus.onDeviceJoined(this, (data: EventDeviceAnnounce) => this.onZigbeeEvent(data.device)); + /* istanbul ignore next TODO */ + this.eventBus.onDeviceInterview(this, (data: EventDeviceAnnounce) => this.onZigbeeEvent(data.device)); + this.eventBus.onDeviceMessage(this, (data: EventDeviceAnnounce) => this.onZigbeeEvent(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.zigbee.getClients(), ...this.zigbee.getGroups()]) { + this.discover(entity, true); + } } - exposeToConfig(exposes, entityType, definition) { + private exposeToConfig( + exposes: DefinitionExpose[], entityType: 'device' | 'group', definition?: Definition): DiscoveryEntry[] { // For groups an array of exposes (of the same type) is passed, this is to determine e.g. what features // to use for a bulb (e.g. color_xy/color_temp) assert(entityType === 'group' || exposes.length === 1, 'Multiple exposes for device not allowed'); @@ -74,7 +100,7 @@ class HomeAssistant extends Extension { const discoveryEntries = []; const endpoint = entityType === 'device' ? exposes[0].endpoint : undefined; - const getProperty = (feature) => entityType === 'group' ? + const getProperty = (feature: DefinitionExposeFeature): string => entityType === 'group' ? featurePropertyWithoutEndpoint(feature) : feature.property; /* istanbul ignore else */ @@ -84,7 +110,7 @@ class HomeAssistant extends Extension { const hasBrightness = exposes.find((expose) => expose.features.find((e) => e.name === 'brightness')); const hasColorTemp = exposes.find((expose) => expose.features.find((e) => e.name === 'color_temp')); - const discoveryEntry = { + const discoveryEntry: DiscoveryEntry = { type: 'light', object_id: endpoint ? `light_${endpoint}` : 'light', discovery_payload: { @@ -127,7 +153,7 @@ class HomeAssistant extends Extension { } else if (firstExpose.type === 'switch') { const state = firstExpose.features.find((f) => f.name === 'state'); const property = getProperty(state); - const discoveryEntry = { + const discoveryEntry: DiscoveryEntry = { type: 'switch', object_id: endpoint ? `switch_${endpoint}` : 'switch', discovery_payload: { @@ -159,7 +185,7 @@ class HomeAssistant extends Extension { const temperature = firstExpose.features.find((f) => f.name === 'local_temperature'); assert(temperature, 'No temperature found'); - const discoveryEntry = { + const discoveryEntry: DiscoveryEntry = { type: 'climate', object_id: endpoint ? `climate_${endpoint}` : 'climate', discovery_payload: { @@ -246,7 +272,7 @@ class HomeAssistant extends Extension { assert(!endpoint, `Endpoint not supported for lock type`); const state = firstExpose.features.find((f) => f.name === 'state'); assert(state, 'No state found'); - const discoveryEntry = { + const discoveryEntry: DiscoveryEntry = { type: 'lock', object_id: 'lock', discovery_payload: { @@ -283,7 +309,7 @@ class HomeAssistant extends Extension { const position = exposes.find((expose) => expose.features.find((e) => e.name === 'position')); const hasTilt = exposes.find((expose) => expose.features.find((e) => e.name === 'tilt')); - const discoveryEntry = { + const discoveryEntry: DiscoveryEntry = { type: 'cover', object_id: endpoint ? `cover_${endpoint}` : 'cover', discovery_payload: {}, @@ -325,7 +351,7 @@ class HomeAssistant extends Extension { discoveryEntries.push(discoveryEntry); } else if (firstExpose.type === 'fan') { assert(!endpoint, `Endpoint not supported for fan type`); - const discoveryEntry = { + const discoveryEntry: DiscoveryEntry = { type: 'fan', object_id: 'fan', discovery_payload: { @@ -387,7 +413,7 @@ class HomeAssistant extends Extension { discoveryEntries.push(discoveryEntry); } else if (firstExpose.type === 'binary') { - const lookup = { + const lookup: {[s: string]: KeyValue}= { occupancy: {device_class: 'motion'}, battery_low: {device_class: 'battery'}, water_leak: {device_class: 'moisture'}, @@ -406,7 +432,7 @@ class HomeAssistant extends Extension { * https://github.com/Koenkk/zigbee2mqtt/issues/7740 */ if (firstExpose.access & ACCESS_SET) { - const discoveryEntry = { + const discoveryEntry: DiscoveryEntry = { type: 'switch', object_id: endpoint ? `switch_${firstExpose.name}_${endpoint}` : @@ -438,7 +464,7 @@ class HomeAssistant extends Extension { discoveryEntries.push(discoveryEntry); } } else if (firstExpose.type === 'numeric') { - const lookup = { + const lookup: {[s: string]: KeyValue} = { battery: {device_class: 'battery', state_class: 'measurement'}, temperature: {device_class: 'temperature', state_class: 'measurement'}, humidity: {device_class: 'humidity', state_class: 'measurement'}, @@ -518,7 +544,7 @@ class HomeAssistant extends Extension { discoveryEntries.push(discoveryEntry); } } else if (firstExpose.type === 'enum') { - const lookup = { + const lookup: {[s: string]: KeyValue} = { action: {icon: 'mdi:gesture-double-tap'}, backlight_auto_dim: {enabled_by_default: false, icon: 'mdi:brightness-auto'}, backlight_mode: {enabled_by_default: false, icon: 'mdi:lightbulb'}, @@ -572,7 +598,7 @@ class HomeAssistant extends Extension { } } else if (firstExpose.type === 'text' || firstExpose.type === 'composite') { if (firstExpose.access & ACCESS_STATE) { - const lookup = { + const lookup: {[s: string]: KeyValue} = { action: {icon: 'mdi:gesture-double-tap'}, }; @@ -593,7 +619,7 @@ class HomeAssistant extends Extension { return discoveryEntries; } - populateMapping() { + private populateMapping(): void { for (const def of zigbeeHerdsmanConverters.definitions) { this.mapping[def.model] = []; @@ -632,20 +658,21 @@ class HomeAssistant extends Extension { } } - onDeviceRemoved(resolvedEntity) { - logger.debug(`Clearing Home Assistant discovery topic for '${resolvedEntity.name}'`); - delete this.discovered[resolvedEntity.device.ieeeAddr]; - for (const config of this.getConfigs(resolvedEntity)) { - const topic = this.getDiscoveryTopic(config, resolvedEntity); + @bind onDeviceRemoved(data: EventDeviceRemoved): void { + logger.debug(`Clearing Home Assistant discovery topic for '${data.resolvedEntity.name}'`); + this.discovered[data.resolvedEntity.device.ieeeAddr]?.forEach((topic) => { this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false); - } + }); + + delete this.discovered[data.resolvedEntity.device.ieeeAddr]; } - groupMembersChanged(data) { - this.discover(data.group, true); + @bind onGroupMembersChanged(data: EventGroupMembersChanged): void { + const group = this.zigbee.resolveEntity(data.group.name); + this.discover(group, true); } - async onPublishEntityState(data) { + @bind async onPublishEntityState(data: EventPublishEntityState): Promise { /** * In case we deal with a lightEndpoint configuration Zigbee2MQTT publishes * e.g. {state_l1: ON, brightness_l1: 250} to zigbee2mqtt/mydevice. @@ -654,13 +681,14 @@ class HomeAssistant extends Extension { * Here we retrieve all the attributes with the _l1 values and republish them on * zigbee2mqtt/mydevice/l1. */ - if (data.entity.definition && this.mapping[data.entity.definition.model]) { - for (const config of this.mapping[data.entity.definition.model]) { + const entity = this.zigbee.resolveEntity(data.entity.name); + if (entity instanceof Device && this.mapping[entity.definition?.model]) { + for (const config of this.mapping[entity.definition.model]) { const match = /light_(.*)/.exec(config['object_id']); if (match) { const endpoint = match[1]; const endpointRegExp = new RegExp(`(.*)_${endpoint}`); - const payload = {}; + const payload: KeyValue = {}; for (const key of Object.keys(data.messagePayload)) { const keyMatch = endpointRegExp.exec(key); if (keyMatch) { @@ -693,84 +721,71 @@ class HomeAssistant extends Extension { * Whenever a device publish an {action: *} we discover an MQTT device trigger sensor * and republish it to zigbee2mqtt/my_devic/action */ - if (data.entity.definition) { + if (entity instanceof Device && entity.definition) { const keys = ['action', 'click'].filter((k) => data.messagePayload[k]); for (const key of keys) { const value = data.messagePayload[key].toString(); - await this.publishDeviceTriggerDiscover(data.entity, key, value); + await this.publishDeviceTriggerDiscover(entity, key, value); await this.mqtt.publish(`${data.entity.name}/${key}`, value, {}); } } } - onDeviceRenamed(device, homeAssisantRename) { - logger.debug(`Refreshing Home Assistant discovery topic for '${device.ieeeAddr}'`); - const resolvedEntity = this.zigbee.resolveEntityLegacy(device.ieeeAddr); + @bind onDeviceRenamed(data: EventDeviceRenamed): void { + logger.debug(`Refreshing Home Assistant discovery topic for '${data.device.ieeeAddr}'`); // Clear before rename so Home Assistant uses new friendly_name // https://github.com/Koenkk/zigbee2mqtt/issues/4096#issuecomment-674044916 - if (homeAssisantRename) { - for (const config of this.getConfigs(resolvedEntity)) { - const topic = this.getDiscoveryTopic(config, resolvedEntity); + if (data.homeAssisantRename) { + for (const config of this.getConfigs(data.device)) { + const topic = this.getDiscoveryTopic(config, data.device); this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false); } } - this.discover(resolvedEntity, true); + this.discover(data.device, true); - if (this.discoveredTriggers[device.ieeeAddr]) { - for (const config of this.discoveredTriggers[device.ieeeAddr]) { + if (this.discoveredTriggers[data.device.ieeeAddr]) { + for (const config of this.discoveredTriggers[data.device.ieeeAddr]) { const key = config.substring(0, config.indexOf('_')); const value = config.substring(config.indexOf('_') + 1); - this.publishDeviceTriggerDiscover(resolvedEntity, key, value, true); + this.publishDeviceTriggerDiscover(data.device, key, value, true); } } } - async onMQTTConnected() { - this.mqtt.subscribe(this.statusTopic); - this.mqtt.subscribe(defaultStatusTopic); - this.mqtt.subscribe(`${this.discoveryTopic}/#`); + private getConfigs(entity: Device | Group): DiscoveryEntry[] { + const isDevice = entity instanceof Device; + /* istanbul ignore next */ + if (!entity || (isDevice && !entity.definition) || + (isDevice && !this.mapping[entity.definition.model])) return []; - // MQTT discovery of all paired devices on startup. - for (const entity of [...this.zigbee.getClientsLegacy(), ...this.zigbee.getGroupsLegacy()]) { - const resolvedEntity = this.zigbee.resolveEntityLegacy(entity); - this.discover(resolvedEntity, true); - } - } - - getConfigs(resolvedEntity) { - if (!resolvedEntity || (resolvedEntity.type === 'device' && !resolvedEntity.definition) || - (resolvedEntity.type === 'device' && !this.mapping[resolvedEntity.definition.model])) return []; - - const isDevice = resolvedEntity.type === 'device'; - let configs; + let configs: DiscoveryEntry[]; if (isDevice) { - configs = this.mapping[resolvedEntity.definition.model].slice(); + configs = this.mapping[entity.definition.model].slice(); } else { // group - const exposesByType = {}; + const exposesByType: {[s: string]: DefinitionExpose[]} = {}; - resolvedEntity.group.members.map((m) => zigbeeHerdsmanConverters.findByDevice(m.getDevice())) - .filter((m) => m != null).forEach((definition) => { - for (const expose of definition.exposes.filter((e) => groupSupportedTypes.includes(e.type))) { - let key = expose.type; - if (['switch', 'lock', 'cover'].includes(expose.type) && expose.endpoint) { - // A device can have multiple of these types which have to discovered seperately. - // e.g. switch with property state and valve_detection. - const state = expose.features.find((f) => f.name === 'state'); - key += featurePropertyWithoutEndpoint(state.property); - } - - if (!exposesByType[key]) exposesByType[key] = []; - exposesByType[key].push(expose); + entity.membersDefinitions().forEach((definition) => { + for (const expose of definition.exposes.filter((e) => groupSupportedTypes.includes(e.type))) { + let key = expose.type; + if (['switch', 'lock', 'cover'].includes(expose.type) && expose.endpoint) { + // A device can have multiple of these types which have to discovered seperately. + // e.g. switch with property state and valve_detection. + const state = expose.features.find((f) => f.name === 'state'); + key += featurePropertyWithoutEndpoint(state); } - }); + + if (!exposesByType[key]) exposesByType[key] = []; + exposesByType[key].push(expose); + } + }); configs = [].concat(...Object.values(exposesByType) .map((exposes) => this.exposeToConfig(exposes, 'group'))); } - if (isDevice && resolvedEntity.definition.hasOwnProperty('ota')) { + if (isDevice && entity.definition.hasOwnProperty('ota')) { const updateStateSensor = { type: 'sensor', object_id: 'update_state', @@ -797,7 +812,7 @@ class HomeAssistant extends Extension { } } - if (isDevice && resolvedEntity.settings.hasOwnProperty('legacy') && !resolvedEntity.settings.legacy) { + if (isDevice && entity.settings.hasOwnProperty('legacy') && !entity.settings.legacy) { configs = configs.filter((c) => c !== sensorClick); } @@ -808,8 +823,8 @@ class HomeAssistant extends Extension { // deep clone of the config objects configs = JSON.parse(JSON.stringify(configs)); - if (resolvedEntity.settings.homeassistant) { - const s = resolvedEntity.settings.homeassistant; + if (entity.settings.homeassistant) { + const s = entity.settings.homeassistant; configs = configs.filter((config) => !s.hasOwnProperty(config.object_id) || s[config.object_id] != null); configs.forEach((config) => { const configOverride = s[config.object_id]; @@ -823,23 +838,22 @@ class HomeAssistant extends Extension { return configs; } - discover(resolvedEntity, force=false) { + private discover(entity: Device | Group, force=false): void { // Check if already discoverd and check if there are configs. - const {device, definition, group} = resolvedEntity; - const discoverKey = resolvedEntity.type === 'device' ? device.ieeeAddr : group.groupID; + const discoverKey = entity instanceof Device ? entity.ieeeAddr : entity.ID; const discover = force || !this.discovered[discoverKey]; - if (group) { - if (!discover || group.members.length === 0) return; - } else if (!discover || !device || !definition || !this.mapping[definition.model] || device.interviewing || - (resolvedEntity.settings.hasOwnProperty('homeassistant') && !resolvedEntity.settings.homeassistant)) { + if (entity instanceof Group) { + if (!discover || entity.zhGroup.members.length === 0) return; + } else if (!discover || !entity.definition || !this.mapping[entity.definition.model] || entity.interviewing || + (entity.settings.hasOwnProperty('homeassistant') && !entity.settings.homeassistant)) { return; } - const friendlyName = resolvedEntity.settings.friendlyName; - this.getConfigs(resolvedEntity).forEach((config) => { + this.discovered[discoverKey] = []; + this.getConfigs(entity).forEach((config) => { const payload = {...config.discovery_payload}; - let stateTopic = `${settings.get().mqtt.base_topic}/${friendlyName}`; + let stateTopic = `${settings.get().mqtt.base_topic}/${entity.name}`; if (payload.state_topic_postfix) { stateTopic += `/${payload.state_topic_postfix}`; delete payload.state_topic_postfix; @@ -867,8 +881,8 @@ class HomeAssistant extends Extension { } // Set (unique) name, separate by space if friendlyName contains space. - const nameSeparator = friendlyName.includes('_') ? '_' : ' '; - payload.name = friendlyName; + const nameSeparator = entity.name.includes('_') ? '_' : ' '; + payload.name = entity.name; if (config.object_id.startsWith(config.type) && config.object_id.includes('_')) { payload.name += `${nameSeparator}${config.object_id.split(/_(.+)/)[1]}`; } else if (!config.object_id.startsWith(config.type)) { @@ -876,28 +890,28 @@ class HomeAssistant extends Extension { } // Set unique_id - payload.unique_id = `${resolvedEntity.settings.ID}_${config.object_id}_${settings.get().mqtt.base_topic}`; + payload.unique_id = `${entity.settings.ID}_${config.object_id}_${settings.get().mqtt.base_topic}`; // Attributes for device registry - payload.device = this.getDevicePayload(resolvedEntity); + payload.device = this.getDevicePayload(entity); // Availability payload payload.availability = [{topic: `${settings.get().mqtt.base_topic}/bridge/state`}]; + const availabilityEnabled = + entity instanceof Device && utils.isAvailabilityEnabledForDevice(entity, settings.get()); /* istanbul ignore next */ - if (utils.isAvailabilityEnabledForDeviceLegacy(resolvedEntity, settings.get())) { + if (availabilityEnabled) { payload.availability_mode = 'all'; } /* istanbul ignore next */ - const availabilityEnabled = utils.isAvailabilityEnabledForDeviceLegacy(resolvedEntity, settings.get()) || - settings.get().advanced.availability_timeout; - if (resolvedEntity.type === 'device' && availabilityEnabled) { - payload.availability.push({topic: `${settings.get().mqtt.base_topic}/${friendlyName}/availability`}); + if (availabilityEnabled || settings.get().advanced.availability_timeout) { + payload.availability.push({topic: `${settings.get().mqtt.base_topic}/${entity.name}/availability`}); } if (payload.command_topic) { - payload.command_topic = `${settings.get().mqtt.base_topic}/${friendlyName}/`; + payload.command_topic = `${settings.get().mqtt.base_topic}/${entity.name}/`; if (payload.command_topic_prefix) { payload.command_topic += `${payload.command_topic_prefix}/`; @@ -1003,8 +1017,8 @@ class HomeAssistant extends Extension { } // Override configuration with user settings. - if (resolvedEntity.settings.hasOwnProperty('homeassistant')) { - const add = (obj) => { + if (entity.settings.hasOwnProperty('homeassistant')) { + const add = (obj: KeyValue): void => { Object.keys(obj).forEach((key) => { if (['type', 'object_id'].includes(key)) { return; @@ -1020,28 +1034,28 @@ class HomeAssistant extends Extension { }); }; - add(resolvedEntity.settings.homeassistant); + add(entity.settings.homeassistant); - if (resolvedEntity.settings.homeassistant.hasOwnProperty(config.object_id)) { - add(resolvedEntity.settings.homeassistant[config.object_id]); + if (entity.settings.homeassistant.hasOwnProperty(config.object_id)) { + add(entity.settings.homeassistant[config.object_id]); } } - const topic = this.getDiscoveryTopic(config, resolvedEntity); + const topic = this.getDiscoveryTopic(config, entity); this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0}, this.discoveryTopic, false, false); + this.discovered[discoverKey].push(topic); }); - - this.discovered[discoverKey] = true; } - onMQTTMessage(topic, message) { + @bind private onMQTTMessage_(data: EventMQTTMessage): void { const discoveryRegex = new RegExp(`${this.discoveryTopic}/(.*)/(.*)/(.*)/config`); - const discoveryMatch = topic.match(discoveryRegex); + const discoveryMatch = data.topic.match(discoveryRegex); const isDeviceAutomation = discoveryMatch && discoveryMatch[1] === 'device_automation'; if (discoveryMatch) { // Clear outdated discovery configs and remember already discoverd device_automations + let message: KeyValue = null; try { - message = JSON.parse(message); + message = JSON.parse(data.message); const baseTopic = settings.get().mqtt.base_topic + '/'; if (isDeviceAutomation && (!message.topic || !message.topic.startsWith(baseTopic))) { return; @@ -1057,13 +1071,13 @@ class HomeAssistant extends Extension { // Group discovery topic uses "ENCODEDBASETOPIC_GROUPID", device use ieeeAddr const ID = discoveryMatch[2].includes('_') ? discoveryMatch[2].split('_')[1] : discoveryMatch[2]; - const resolvedEntity = this.zigbee.resolveEntityLegacy(ID); - let clear = !resolvedEntity || resolvedEntity.type === 'device' && !resolvedEntity.definition; + const entity = this.zigbee.resolveEntity(ID); + let clear = !entity || entity instanceof Device && !entity.definition; // Only save when topic matches otherwise config is not updated when renamed by editing configuration.yaml - if (resolvedEntity) { + if (entity) { const key = `${discoveryMatch[3].substring(0, discoveryMatch[3].indexOf('_'))}`; - const triggerTopic = `${settings.get().mqtt.base_topic}/${resolvedEntity.name}/${key}`; + const triggerTopic = `${settings.get().mqtt.base_topic}/${entity.name}/${key}`; if (isDeviceAutomation && message.topic === triggerTopic) { if (!this.discoveredTriggers[ID]) { this.discoveredTriggers[ID] = new Set(); @@ -1075,23 +1089,23 @@ class HomeAssistant extends Extension { if (!clear && !isDeviceAutomation) { const type = discoveryMatch[1]; const objectID = discoveryMatch[3]; - clear = !this.getConfigs(resolvedEntity) + clear = !this.getConfigs(entity) .find((c) => c.type === type && c.object_id === objectID && - `${this.discoveryTopic}/${this.getDiscoveryTopic(c, resolvedEntity)}` === topic); + `${this.discoveryTopic}/${this.getDiscoveryTopic(c, entity)}` === data.topic); } // Device was flagged to be excluded from homeassistant discovery - clear = clear || (resolvedEntity.settings.hasOwnProperty('homeassistant') && - !resolvedEntity.settings.homeassistant); + clear = clear || (entity.settings.hasOwnProperty('homeassistant') && !entity.settings.homeassistant); if (clear) { - logger.debug(`Clearing Home Assistant config '${topic}'`); - topic = topic.substring(this.discoveryTopic.length + 1); + logger.debug(`Clearing Home Assistant config '${data.topic}'`); + const topic = data.topic.substring(this.discoveryTopic.length + 1); this.mqtt.publish(topic, null, {retain: true, qos: 0}, this.discoveryTopic, false, false); } - } else if ((topic === this.statusTopic || topic === defaultStatusTopic) && message.toLowerCase() === 'online') { + } else if ((data.topic === this.statusTopic || data.topic === defaultStatusTopic) && + data.message.toLowerCase() === 'online') { const timer = setTimeout(async () => { // Publish all device states. - for (const device of this.zigbee.getClientsLegacy()) { + for (const device of this.zigbee.getClients()) { if (this.state.exists(device.ieeeAddr)) { this.publishEntityState(device.ieeeAddr, this.state.get(device.ieeeAddr)); } @@ -1102,40 +1116,38 @@ class HomeAssistant extends Extension { } } - onZigbeeEvent(type, data, resolvedEntity) { - if (resolvedEntity && type !== 'deviceLeave' && this.mqtt.isConnected()) { - this.discover(resolvedEntity); - } + @bind onZigbeeEvent(device: Device): void { + this.discover(device); } - getDevicePayload(resolvedEntity) { - const identifierPostfix = resolvedEntity.type === 'group' ? + private getDevicePayload(entity: Device | Group): KeyValue { + const identifierPostfix = entity instanceof Group ? `zigbee2mqtt_${this.getEncodedBaseTopic()}` : 'zigbee2mqtt'; - const payload = { - identifiers: [`${identifierPostfix}_${resolvedEntity.settings.ID}`], - name: resolvedEntity.settings.friendlyName, - sw_version: `Zigbee2MQTT ${zigbee2mqttVersion}`, + const payload: KeyValue = { + identifiers: [`${identifierPostfix}_${entity.settings.ID}`], + name: entity.name, + sw_version: `Zigbee2MQTT ${this.zigbee2MQTTVersion}`, }; - if (resolvedEntity.type === 'device') { - payload.model = `${resolvedEntity.definition.description} (${resolvedEntity.definition.model})`; - payload.manufacturer = resolvedEntity.definition.vendor; + if (entity instanceof Device) { + payload.model = `${entity.definition.description} (${entity.definition.model})`; + payload.manufacturer = entity.definition.vendor; } return payload; } - adjustMessagePayloadBeforePublish(resolvedEntity, messagePayload) { + private adjustMessagePayloadBeforePublish(device: Device, messagePayload: KeyValue): void { // Set missing values of state to 'null': https://github.com/Koenkk/zigbee2mqtt/issues/6987 - if (!resolvedEntity || !resolvedEntity.definition) return null; + if (!device.definition) return null; - const add = (expose) => { + const add = (expose: DefinitionExpose | DefinitionExposeFeature): void => { if (!messagePayload.hasOwnProperty(expose.property) && expose.access & ACCESS_STATE) { messagePayload[expose.property] = null; } }; - for (const expose of resolvedEntity.definition.exposes) { + for (const expose of device.definition.exposes) { if (expose.hasOwnProperty('features')) { for (const feature of expose.features) { if (feature.name === 'state') { @@ -1158,25 +1170,23 @@ class HomeAssistant extends Extension { } } - getEncodedBaseTopic() { + private getEncodedBaseTopic(): string { return settings.get().mqtt.base_topic.split('').map((s) => s.charCodeAt(0).toString()).join(''); } - getDiscoveryTopic(config, resolvedEntity) { - const key = resolvedEntity.type === 'device' ? - resolvedEntity.device.ieeeAddr : - `${this.getEncodedBaseTopic()}_${resolvedEntity.group.groupID}`; + private getDiscoveryTopic(config: DiscoveryEntry, entity: Device | Group): string { + const key = entity instanceof Device ? entity.ieeeAddr : `${this.getEncodedBaseTopic()}_${entity.ID}`; return `${config.type}/${key}/${config.object_id}/config`; } - async publishDeviceTriggerDiscover(entity, key, value, force=false) { - const haConfig = entity.settings.homeassistant; - if (entity.settings.hasOwnProperty('homeassistant') && (haConfig == null || - (haConfig.hasOwnProperty('device_automation') && haConfig.device_automation == null))) { + private async publishDeviceTriggerDiscover(device: Device, key: string, value: string, force=false): Promise { + const haConfig = device.settings.homeassistant; + if (device.settings.hasOwnProperty('homeassistant') && (haConfig == null || + (haConfig.hasOwnProperty('device_automation') && typeof haConfig === 'object' && + haConfig.device_automation == null))) { return; } - const device = entity.device; if (!this.discoveredTriggers[device.ieeeAddr]) { this.discoveredTriggers[device.ieeeAddr] = new Set(); } @@ -1186,7 +1196,7 @@ class HomeAssistant extends Extension { return; } - const config = { + const config: DiscoveryEntry = { type: 'device_automation', object_id: `${key}_${value}`, discovery_payload: { @@ -1195,13 +1205,13 @@ class HomeAssistant extends Extension { }, }; - const topic = this.getDiscoveryTopic(config, entity); + const topic = this.getDiscoveryTopic(config, device); const payload = { ...config.discovery_payload, subtype: value, payload: value, - topic: `${settings.get().mqtt.base_topic}/${entity.name}/${key}`, - device: this.getDevicePayload(entity), + topic: `${settings.get().mqtt.base_topic}/${device.name}/${key}`, + device: this.getDevicePayload(device), }; await this.mqtt.publish(topic, stringify(payload), {retain: true, qos: 0}, this.discoveryTopic, false, false); @@ -1209,12 +1219,12 @@ class HomeAssistant extends Extension { } // Only for homeassistant.test.js - _getMapping() { + _getMapping(): {[s: string]: DiscoveryEntry[]} { return this.mapping; } - _clearDiscoveredTrigger() { - this.discoveredTriggers = new Set(); + _clearDiscoveredTrigger(): void { + this.discoveredTriggers = {}; } } diff --git a/lib/types/types.ts b/lib/types/types.ts index d6b6ef15..02a2d079 100644 --- a/lib/types/types.ts +++ b/lib/types/types.ts @@ -172,6 +172,8 @@ declare global { debounce_ignore?: string[], filtered_optimistic?: string[], icon?: string, + homeassistant?: KeyValue, + legacy?: boolean, } interface GroupSettings { @@ -181,6 +183,7 @@ declare global { optimistic?: boolean, filtered_optimistic?: string[], retrieve_state?: boolean, + homeassistant?: KeyValue, } type EntitySettings = { @@ -191,6 +194,8 @@ declare global { interface ResolvedEntity { type: 'device' | 'group', + device: ZHDevice, + name: string, } interface ToZigbeeConverterGetMeta {message: {}, mapped: Definition | Definition[]} @@ -217,6 +222,10 @@ declare global { meta: {state: KeyValue, logger: any, device: ZHDevice}) => KeyValue, } + interface DefinitionExposeFeature {name: string, endpoint?: string, property: string, value_max?: number, value_min?: number, value_off?: string, value_on?: string, value_step?: number, values: string[], access: number} + + interface DefinitionExpose {type: string, name?: string, features?: DefinitionExposeFeature[], endpoint?: string, values?: string[], value_off?: string, value_on?: string, access: number, property: string, unit?: string, value_min?: number, value_max?: number} + interface Definition { model: string endpoint?: (device: ZHDevice) => {[s: string]: number} @@ -225,7 +234,7 @@ declare global { icon?: string description: string vendor: string - exposes: {type: string, name?: string, features?: {name: string}[]}[] // TODO + exposes: DefinitionExpose[] // TODO configure?: (device: ZHDevice, coordinatorEndpoint: ZZHEndpoint, logger: unknown) => Promise; onEvent?: (type: string, data: KeyValue, device: ZHDevice, settings: KeyValue) => Promise; ota?: { @@ -254,6 +263,7 @@ declare global { get: (ID: string | number) => KeyValue | null; remove: (ID: string | number) => void; removeKey: (ID: string, keys: string[]) => void; + exists: (ID: string) => boolean; } interface ExternalConverterClass { diff --git a/lib/util/utils.ts b/lib/util/utils.ts index 1607ac1e..4755b2a8 100644 --- a/lib/util/utils.ts +++ b/lib/util/utils.ts @@ -48,6 +48,11 @@ export function capitalize(s: string): string { return s[0].toUpperCase() + s.slice(1); } +export async function getZigbee2MQTTVersionSimple(): Promise { + const packageJSON = await import('../..' + '/package.json'); + return packageJSON.version; +} + export async function getZigbee2MQTTVersion(): Promise<{commitHash: string, version: string}> { const git = await import('git-last-commit'); const packageJSON = await import('../..' + '/package.json'); diff --git a/lib/zigbee.ts b/lib/zigbee.ts index 603efe8e..499520f7 100644 --- a/lib/zigbee.ts +++ b/lib/zigbee.ts @@ -344,6 +344,7 @@ export default class Zigbee { }; const deviceOptions = settings.get().device_options; + /* istanbul ignore else */ if (typeof key === 'string' || typeof key === 'number') { if (typeof key === 'number') { key = key.toString(); diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index 138c3782..ca712788 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -8,25 +8,38 @@ const MQTT = require('./stub/mqtt'); const Controller = require('../lib/controller'); const fs = require('fs'); const path = require('path'); -const HomeAssistant = require('../lib/extension/homeassistant'); - -const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); describe('HomeAssistant extension', () => { let version; + let controller; + let extension; + + let resetExtension = async () => { + await controller.enableDisableExtension(false, 'HomeAssistant'); + MQTT.publish.mockClear(); + await controller.enableDisableExtension(true, 'HomeAssistant'); + extension = controller.extensions.find((e) => e.constructor.name === 'HomeAssistant'); + } beforeEach(async () => { + data.writeDefaultConfiguration(); + settings.reRead(); + data.writeEmptyState(); + controller.state._load(); + await resetExtension(); + }); + + beforeAll(async () => { version = await require('../lib/util/utils').getZigbee2MQTTVersion(); version = `Zigbee2MQTT ${version.version}`; + jest.useFakeTimers(); + settings.set(['homeassistant'], true); data.writeDefaultConfiguration(); settings.reRead(); data.writeEmptyState(); MQTT.publish.mockClear(); - settings.set(['homeassistant'], true); - }); - - beforeAll(async () => { - jest.useFakeTimers(); + controller = new Controller(false); + await controller.start(); }); afterAll(async () => { @@ -35,10 +48,8 @@ describe('HomeAssistant extension', () => { it('Should not have duplicate type/object_ids in a mapping', () => { const duplicated = []; - const ha = new HomeAssistant(null, null, null, null, {on: () => {}}); - require('zigbee-herdsman-converters').devices.forEach((d) => { - const mapping = ha._getMapping()[d.model]; + const mapping = extension._getMapping()[d.model]; const cfg_type_object_ids = []; mapping.forEach((c) => { @@ -54,9 +65,6 @@ describe('HomeAssistant extension', () => { }); it('Should discover devices and groups', async () => { - const controller = new Controller(false); - await controller.start(); - let payload; await flushPromises(); @@ -357,8 +365,7 @@ describe('HomeAssistant extension', () => { retain: false, }) - const controller = new Controller(false); - await controller.start(); + await resetExtension(); let payload; await flushPromises(); @@ -469,8 +476,7 @@ describe('HomeAssistant extension', () => { retain: false, }) - const controller = new Controller(false); - await controller.start(); + await resetExtension(); let payload; await flushPromises(); @@ -549,8 +555,7 @@ describe('HomeAssistant extension', () => { }, }) - const controller = new Controller(false); - await controller.start(); + await resetExtension(); let payload; await flushPromises(); @@ -592,9 +597,7 @@ describe('HomeAssistant extension', () => { retain: false, }) - const controller = new Controller(false); - await controller.start(); - + await resetExtension(); await flushPromises(); const topics = MQTT.publish.mock.calls.map((c) => c[0]); @@ -610,10 +613,7 @@ describe('HomeAssistant extension', () => { retain: false, }) - const controller = new Controller(false); - await controller.start(); - - await flushPromises(); + await resetExtension(); const topics = MQTT.publish.mock.calls.map((c) => c[0]); expect(topics).not.toContain('homeassistant/sensor/0x0017880104e45522/humidity/config') @@ -621,11 +621,7 @@ describe('HomeAssistant extension', () => { }); it('Should discover devices with fan', async () => { - const controller = new Controller(false); - await controller.start(); - let payload; - await flushPromises(); payload = { "state_topic":"zigbee2mqtt/fan", @@ -667,11 +663,7 @@ describe('HomeAssistant extension', () => { }); it('Should discover thermostat devices', async () => { - const controller = new Controller(false); - await controller.start(); - let payload; - await flushPromises(); payload = { 'availability': [{topic: 'zigbee2mqtt/bridge/state'}], @@ -727,11 +719,7 @@ describe('HomeAssistant extension', () => { }); it('Should discover devices with cover_position', async () => { - const controller = new Controller(false); - await controller.start(); - let payload; - await flushPromises(); payload = { command_topic: 'zigbee2mqtt/smart vent/set', @@ -763,11 +751,9 @@ describe('HomeAssistant extension', () => { it('Should discover devices with custom homeassistant_discovery_topic', async () => { settings.set(['advanced', 'homeassistant_discovery_topic'], 'my_custom_discovery_topic') - const controller = new Controller(false); - await controller.start(); + await resetExtension(); let payload; - await flushPromises(); payload = { 'unit_of_measurement': '°C', @@ -799,6 +785,7 @@ describe('HomeAssistant extension', () => { it('Should throw error when starting with attributes output', async () => { settings.set(['experimental', 'output'], 'attribute') + settings.set(['homeassistant'], true) expect(() => { const controller = new Controller(false); }).toThrowError('Home Assistant integration is not possible with attribute output!'); @@ -807,15 +794,12 @@ describe('HomeAssistant extension', () => { it('Should warn when starting with cache_state false', async () => { settings.set(['advanced', 'cache_state'], false); logger.warn.mockClear(); - const controller = new Controller(false); + await resetExtension(); expect(logger.warn).toHaveBeenCalledWith("In order for Home Assistant integration to work properly set `cache_state: true"); }); it('Should set missing values to null', async () => { // https://github.com/Koenkk/zigbee2mqtt/issues/6987 - const controller = new Controller(false); - await controller.start(); - await flushPromises(); const device = zigbeeHerdsman.devices.WSDCGQ11LM; const data = {measuredValue: -85} const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; @@ -832,9 +816,6 @@ describe('HomeAssistant extension', () => { }); it('Should copy hue/saturtion to h/s if present', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); const device = zigbeeHerdsman.devices.bulb_color; const data = {currentHue: 0, currentSaturation: 254} const payload = {data, cluster: 'lightingColorCtrl', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; @@ -851,9 +832,6 @@ describe('HomeAssistant extension', () => { }); it('Should not copy hue/saturtion if properties are missing', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); const device = zigbeeHerdsman.devices.bulb_color; const data = {currentX: 29991, currentY: 26872}; const payload = {data, cluster: 'lightingColorCtrl', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; @@ -870,9 +848,6 @@ describe('HomeAssistant extension', () => { }); it('Should not copy hue/saturtion if color is missing', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); const device = zigbeeHerdsman.devices.bulb_color; const data = {onOff: 1} const payload = {data, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; @@ -889,9 +864,6 @@ describe('HomeAssistant extension', () => { }); it('Shouldt discover when already discovered', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); const device = zigbeeHerdsman.devices.WSDCGQ11LM; const data = {measuredValue: -85} const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}; @@ -903,9 +875,6 @@ describe('HomeAssistant extension', () => { }); it('Should discover when not discovered yet', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); controller.extensions.find((e) => e.constructor.name === 'HomeAssistant').discovered = {}; const device = zigbeeHerdsman.devices.WSDCGQ11LM; const data = {measuredValue: -85} @@ -942,9 +911,6 @@ describe('HomeAssistant extension', () => { }); it('Shouldnt discover when device leaves', async () => { - const controller = new Controller(jest.fn(), jest.fn()); - await controller.start(); - await flushPromises(); controller.extensions.find((e) => e.constructor.name === 'HomeAssistant').discovered = {}; const device = zigbeeHerdsman.devices.bulb; const payload = {ieeeAddr: device.ieeeAddr}; @@ -955,8 +921,8 @@ describe('HomeAssistant extension', () => { it('Should send all status when home assistant comes online (default topic)', async () => { data.writeDefaultState(); - const controller = new Controller(jest.fn(), jest.fn()); - await controller.start(); + extension.state._load(); + await resetExtension(); expect(MQTT.subscribe).toHaveBeenCalledWith('homeassistant/status'); await flushPromises(); MQTT.publish.mockClear(); @@ -980,9 +946,8 @@ describe('HomeAssistant extension', () => { it('Should send all status when home assistant comes online', async () => { data.writeDefaultState(); - const controller = new Controller(jest.fn(), jest.fn()); - await controller.start(); - await flushPromises(); + extension.state._load(); + await resetExtension(); expect(MQTT.subscribe).toHaveBeenCalledWith('hass/status'); MQTT.publish.mockClear(); await MQTT.events.message('hass/status', 'online'); @@ -1005,8 +970,8 @@ describe('HomeAssistant extension', () => { it('Shouldnt send all status when home assistant comes offline', async () => { data.writeDefaultState(); - const controller = new Controller(jest.fn(), jest.fn()); - await controller.start(); + extension.state._load(); + await resetExtension(); await flushPromises(); MQTT.publish.mockClear(); await MQTT.events.message('hass/status', 'offline'); @@ -1018,9 +983,8 @@ describe('HomeAssistant extension', () => { it('Shouldnt send all status when home assistant comes online with different topic', async () => { data.writeDefaultState(); - const controller = new Controller(jest.fn(), jest.fn()); - await controller.start(); - await flushPromises(); + extension.state._load(); + await resetExtension(); MQTT.publish.mockClear(); await MQTT.events.message('hass/status_different', 'offline'); await flushPromises(); @@ -1031,11 +995,9 @@ describe('HomeAssistant extension', () => { it('Should discover devices with availability', async () => { settings.set(['advanced', 'availability_timeout'], 1) - const controller = new Controller(false); - await controller.start(); + await resetExtension(); let payload; - await flushPromises(); payload = { 'unit_of_measurement': '°C', @@ -1066,9 +1028,6 @@ describe('HomeAssistant extension', () => { }); it('Should clear discovery when device is removed', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); MQTT.publish.mockClear(); MQTT.events.message('zigbee2mqtt/bridge/config/remove', 'weather_sensor'); await flushPromises(); @@ -1106,9 +1065,6 @@ describe('HomeAssistant extension', () => { }); it('Should not clear discovery when unsupported device is removed', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); MQTT.publish.mockClear(); MQTT.events.message('zigbee2mqtt/bridge/config/remove', 'unsupported2'); await flushPromises(); @@ -1116,9 +1072,6 @@ describe('HomeAssistant extension', () => { }); it('Should refresh discovery when device is renamed', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); await MQTT.events.message('homeassistant/device_automation/0x0017880104e45522/action_double/config', stringify({topic: 'zigbee2mqtt/weather_sensor/action'})); await flushPromises(); MQTT.publish.mockClear(); @@ -1183,9 +1136,6 @@ describe('HomeAssistant extension', () => { }); it('Shouldnt refresh discovery when device is renamed and homeassistant_rename is false', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); MQTT.publish.mockClear(); MQTT.events.message('zigbee2mqtt/bridge/request/device/rename', stringify({"from": "weather_sensor", "to": "weather_sensor_renamed","homeassistant_rename":false})); await flushPromises(); @@ -1226,9 +1176,6 @@ describe('HomeAssistant extension', () => { }); it('Should discover update_available sensor when device supports it', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); const payload = { "payload_on":true, "payload_off":false, @@ -1259,10 +1206,6 @@ describe('HomeAssistant extension', () => { }); it('Should discover trigger when click is published', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); - const discovered = MQTT.publish.mock.calls.filter((c) => c[0].includes('0x0017880104e45520')).map((c) => c[0]); expect(discovered.length).toBe(5); expect(discovered).toContain('homeassistant/sensor/0x0017880104e45520/click/config'); @@ -1415,9 +1358,7 @@ describe('HomeAssistant extension', () => { settings.set(['device_options'], { homeassistant: {device_automation: null}, }) - const controller = new Controller(false); - await controller.start(); - await flushPromises(); + await resetExtension(); MQTT.publish.mockClear(); const device = zigbeeHerdsman.devices.WXKG11LM; @@ -1439,9 +1380,7 @@ describe('HomeAssistant extension', () => { friendly_name: 'weather_sensor', retain: false, }) - const controller = new Controller(false); - await controller.start(); - await flushPromises(); + await resetExtension(); const discovered = MQTT.publish.mock.calls.filter((c) => c[0].includes('0x0017880104e45520')).map((c) => c[0]); expect(discovered.length).toBe(4); @@ -1452,9 +1391,7 @@ describe('HomeAssistant extension', () => { it('Should disable Home Assistant legacy triggers', async () => { settings.set(['advanced', 'homeassistant_legacy_triggers'], false); - const controller = new Controller(false); - await controller.start(); - await flushPromises(); + await resetExtension(); const discovered = MQTT.publish.mock.calls.filter((c) => c[0].includes('0x0017880104e45520')).map((c) => c[0]); expect(discovered.length).toBe(3); @@ -1511,9 +1448,6 @@ describe('HomeAssistant extension', () => { }); it('Should republish payload to postfix topic with lightWithPostfix config', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); MQTT.publish.mockClear(); await MQTT.events.message('zigbee2mqtt/U202DST600ZB/l2/set', stringify({state: 'ON', brightness: 20})); @@ -1524,9 +1458,6 @@ describe('HomeAssistant extension', () => { }); it('Shouldnt crash in onPublishEntityState on group publish', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); logger.error.mockClear(); MQTT.publish.mockClear(); @@ -1536,9 +1467,6 @@ describe('HomeAssistant extension', () => { }); it('Should counter an action payload with an empty payload', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); MQTT.publish.mockClear(); const device = zigbeeHerdsman.devices.WXKG11LM; settings.set(['devices', device.ieeeAddr, 'legacy'], false); @@ -1560,10 +1488,7 @@ describe('HomeAssistant extension', () => { it('Load Home Assistant mapping from external converters', async () => { fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter-multiple.js'), path.join(data.mockDir, 'mock-external-converter-multiple.js')); settings.set(['external_converters'], ['mock-external-converter-multiple.js']); - const controller = new Controller(jest.fn(), jest.fn()); - const ha = controller.extensions.find((e) => e.constructor.name === 'HomeAssistant'); - await controller.start(); - await flushPromises(); + await resetExtension(); const homeassistantSwitch = { type: 'switch', @@ -1575,14 +1500,10 @@ describe('HomeAssistant extension', () => { command_topic: true, }, }; - expect(ha._getMapping()['external_converters_device_1']).toEqual([homeassistantSwitch]); + expect(extension._getMapping()['external_converters_device_1']).toEqual([homeassistantSwitch]); }); it('Should clear outdated configs', async () => { - let controller = new Controller(jest.fn(), jest.fn()); - await controller.start(); - await flushPromises(); - // Non-existing group -> clear MQTT.publish.mockClear(); await MQTT.events.message('homeassistant/light/1221051039810110150109113116116_91231/light/config', stringify({availability: [{topic: 'zigbee2mqtt/bridge/state'}]})); @@ -1655,12 +1576,8 @@ describe('HomeAssistant extension', () => { expect(MQTT.publish).toHaveBeenCalledTimes(0); // Device was flagged to be excluded from homeassistant discovery - await controller.stop(); - await flushPromises(); settings.set(['devices', '0x000b57fffec6a5b2', 'homeassistant'], null); - controller = new Controller(false); - await controller.start(); - await flushPromises(); + await resetExtension(); MQTT.publish.mockClear(); await MQTT.events.message('homeassistant/sensor/0x000b57fffec6a5b2/update_available/config', stringify({availability: [{topic: 'zigbee2mqtt/bridge/state'}]})); @@ -1674,8 +1591,7 @@ describe('HomeAssistant extension', () => { it('Should not have Home Assistant legacy entity attributes when disabled', async () => { settings.set(['advanced', 'homeassistant_legacy_entity_attributes'], false); - const controller = new Controller(false); - await controller.start(); + await resetExtension(); let payload; await flushPromises(); @@ -1708,9 +1624,6 @@ describe('HomeAssistant extension', () => { }); it('Should rediscover group when device is added to it', async () => { - const controller = new Controller(false); - await controller.start(); - await flushPromises(); MQTT.publish.mockClear(); MQTT.events.message('zigbee2mqtt/bridge/request/group/members/add', stringify({group: 'ha_discovery_group', device: 'wall_switch_double/left'})); await flushPromises();