mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-07-19 01:56:29 +00:00
Refactor HomeAssistant extension to TypeScript
This commit is contained in:
+5
-2
@@ -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));
|
||||
|
||||
+8
-1
@@ -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);}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void>;
|
||||
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<void>,
|
||||
restartCallback: () => void, addExtension: (extension: ExternalConverterClass) => void) {
|
||||
this.zigbee = zigbee;
|
||||
this.mqtt = mqtt;
|
||||
|
||||
@@ -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<string>}= {};
|
||||
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<void>,
|
||||
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<void> {
|
||||
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<void> {
|
||||
/**
|
||||
* 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<void> {
|
||||
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 = {};
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -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<void>;
|
||||
onEvent?: (type: string, data: KeyValue, device: ZHDevice, settings: KeyValue) => Promise<void>;
|
||||
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 {
|
||||
|
||||
@@ -48,6 +48,11 @@ export function capitalize(s: string): string {
|
||||
return s[0].toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
export async function getZigbee2MQTTVersionSimple(): Promise<string> {
|
||||
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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
+46
-133
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user