Refactor configure extension to TypeScript.

This commit is contained in:
Koen Kanters
2021-09-19 11:08:16 +02:00
parent 27b36b316c
commit 48de2fa647
9 changed files with 164 additions and 184 deletions
+8 -5
View File
@@ -13,9 +13,9 @@ declare global {
type EventDeviceAnnounce = { device: Device };
type EventDeviceInterview = { device: Device, status: 'started' | 'successful' | 'failed' };
type EventDeviceJoined = { device: Device };
type EventReportingDisabled = { device: ZHDevice }; // TODO zhdevice -> device
type EventDeviceLeave = { ieeeAddr: string };
type EventGroupMembersChanged = unknown; // TODO fill
type EventDevicesChanged = unknown; // TODO fill
type EventPublishEntityState = {
// TODO: remove resolved entity, replace by Device | Group and remove ieeeAddr
messagePayload: KeyValue, entity: ResolvedEntity, stateChangeReason: 'publishDebounce', payload: KeyValue,
@@ -103,10 +103,13 @@ export default class EventBus {
public onGroupMembersChanged(key: ListenerKey, callback: (data: EventGroupMembersChanged) => void): void {
this.on('groupMembersChanged', callback, key);}
// public emitDevicesChanged(data: EventDevicesChanged): void {
// this.emitter.emit('devicesChanged', data);}
public onDevicesChanged(key: ListenerKey, callback: (data: EventDevicesChanged) => void): void {
this.on('devicesChanged', callback, key);}
public emitDevicesChanged(): void {this.emitter.emit('devicesChanged');}
public onDevicesChanged(key: ListenerKey, callback: () => void): void {this.on('devicesChanged', callback, key);}
// public emitReportingDisabled(data: EventReportingDisabled): void {
// this.emitter.emit('reportingDisabled', data);}
public onReportingDisabled(key: ListenerKey, callback: (data: EventReportingDisabled) => void): void {
this.on('reportingDisabled', callback, key);}
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
private on(event: string, callback: (...args: any[]) => void, key: ListenerKey): void {
-157
View File
@@ -1,157 +0,0 @@
const settings = require('../util/settings');
const utils = require('../util/utils');
const logger = require('../util/logger');
const Extension = require('./extension');
const stringify = require('json-stable-stringify-without-jsonify');
const zhc = require('zigbee-herdsman-converters');
/**
* This extension calls the zigbee-herdsman-converters definition configure() method
*/
class Configure extends Extension {
constructor(zigbee, mqtt, state, publishEntityState, eventBus) {
super(zigbee, mqtt, state, publishEntityState, eventBus);
this.configuring = new Set();
this.onReportingDisabled = this.onReportingDisabled.bind(this);
this.attempts = {};
this.topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`;
this.legacyTopic = `${settings.get().mqtt.base_topic}/bridge/configure`;
this.eventBus.on(`reportingDisabled`, this.onReportingDisabled, this.constructor.name);
}
onReportingDisabled(data) {
// Disabling reporting unbinds some cluster which could be bound by configure, re-setup.
const device = data.device;
const resolvedEntity = this.zigbee.resolveEntityLegacy(device);
if (resolvedEntity.device.meta && resolvedEntity.device.meta.hasOwnProperty('configured')) {
delete device.meta.configured;
device.save();
}
if (this.shouldConfigure(resolvedEntity)) {
this.configure(resolvedEntity, 'reporting_disabled');
}
}
shouldConfigure(resolvedEntity, event) {
if (!resolvedEntity || !resolvedEntity.definition || !resolvedEntity.definition.configure) {
return false;
}
if (resolvedEntity.device.meta &&
resolvedEntity.device.meta.hasOwnProperty('configured') &&
resolvedEntity.device.meta.configured === zhc.getConfigureKey(resolvedEntity.definition)) {
return false;
}
if (resolvedEntity.device.interviewing === true) {
return false;
}
// Only configure end devices when a message is received, otherwise it will likely fails as they are sleeping.
if (resolvedEntity.device.type === 'EndDevice' && event !== 'message_received') {
return false;
}
return true;
}
async onMQTTMessage(topic, message) {
if (topic === this.legacyTopic) {
const resolvedEntity = this.zigbee.resolveEntityLegacy(message);
if (!resolvedEntity || resolvedEntity.type !== 'device') {
logger.error(`Device '${message}' does not exist`);
return;
}
if (!resolvedEntity.definition || !resolvedEntity.definition.configure) {
logger.warn(`Skipping configure of '${resolvedEntity.name}', device does not require this.`);
return;
}
this.configure(resolvedEntity, true);
} else if (topic === this.topic) {
message = utils.parseJSON(message, message);
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message;
let error = null;
const resolvedEntity = this.zigbee.resolveEntityLegacy(ID);
if (!resolvedEntity || resolvedEntity.type !== 'device') {
error = `Device '${ID}' does not exist`;
} else if (!resolvedEntity.definition || !resolvedEntity.definition.configure) {
error = `Device '${resolvedEntity.name}' cannot be configured`;
} else {
try {
await this.configure(resolvedEntity, true, true);
} catch (e) {
error = `Failed to configure (${e.message})`;
}
}
const response = utils.getResponse(message, {id: ID}, error);
await this.mqtt.publish(`bridge/response/device/configure`, stringify(response));
}
}
async onZigbeeStarted() {
this.coordinatorEndpoint = this.zigbee.getDevicesByTypeLegacy('Coordinator')[0].getEndpoint(1);
for (const device of this.zigbee.getClientsLegacy()) {
const resolvedEntity = this.zigbee.resolveEntityLegacy(device);
if (this.shouldConfigure(resolvedEntity, 'started')) {
await this.configure(resolvedEntity);
}
}
}
onZigbeeEvent(type, data, resolvedEntity) {
const device = data.device;
if (type === 'deviceJoined' && device.meta.hasOwnProperty('configured')) {
delete device.meta.configured;
device.save();
}
if (this.shouldConfigure(resolvedEntity, 'message_received')) {
this.configure(resolvedEntity);
}
}
async configure(resolvedEntity, force=false, thowError=false) {
const device = resolvedEntity.device;
if (this.configuring.has(device.ieeeAddr) || (this.attempts[device.ieeeAddr] >= 3 && !force)) {
return false;
}
this.configuring.add(device.ieeeAddr);
if (!this.attempts.hasOwnProperty(device.ieeeAddr)) {
this.attempts[device.ieeeAddr] = 0;
}
logger.info(`Configuring '${resolvedEntity.name}'`);
try {
await resolvedEntity.definition.configure(device, this.coordinatorEndpoint, logger);
logger.info(`Successfully configured '${resolvedEntity.name}'`);
device.meta.configured = zhc.getConfigureKey(resolvedEntity.definition);
device.save();
this.eventBus.emit(`devicesChanged`);
} catch (error) {
this.attempts[device.ieeeAddr]++;
const attempt = this.attempts[device.ieeeAddr];
const msg = `Failed to configure '${resolvedEntity.name}', attempt ${attempt} (${error.stack})`;
logger.error(msg);
if (thowError) {
throw error;
}
}
this.configuring.delete(device.ieeeAddr);
}
}
module.exports = Configure;
+140
View File
@@ -0,0 +1,140 @@
import * as settings from '../util/settings';
import * as utils from '../util/utils';
import logger from '../util/logger';
// @ts-ignore
import stringify from 'json-stable-stringify-without-jsonify';
// @ts-ignore
import zhc from 'zigbee-herdsman-converters';
import ExtensionTS from './extensionts';
import bind from 'bind-decorator';
import Device from '../model/device';
/**
* This extension calls the zigbee-herdsman-converters definition configure() method
*/
class Configure extends ExtensionTS {
private configuring = new Set();
private attempts: {[s: string]: number} = {};
private topic = `${settings.get().mqtt.base_topic}/bridge/request/device/configure`;
private legacyTopic = `${settings.get().mqtt.base_topic}/bridge/configure`;
@bind private async onReportingDisabled(data: EventReportingDisabled): Promise<void> {
// Disabling reporting unbinds some cluster which could be bound by configure, re-setup.
const device = this.zigbee.resolveEntity(data.device) as Device; // TODO assert device
if (device.zhDevice.meta?.hasOwnProperty('configured')) {
delete device.zhDevice.meta.configured;
device.zhDevice.save();
}
await this.configure(device, 'reporting_disabled');
}
// TODO remove trailing _
@bind private async onMQTTMessage_(data: EventMQTTMessage): Promise<void> {
if (data.topic === this.legacyTopic) {
const device = this.zigbee.resolveEntity(data.message);
if (!device || !(device instanceof Device)) {
logger.error(`Device '${data.message}' does not exist`);
return;
}
if (!device.definition || !device.definition.configure) {
logger.warn(`Skipping configure of '${device.name}', device does not require this.`);
return;
}
this.configure(device, 'mqtt_message', true);
} else if (data.topic === this.topic) {
const message = utils.parseJSON(data.message, data.message);
const ID = typeof message === 'object' && message.hasOwnProperty('id') ? message.id : message;
let error = null;
const device = this.zigbee.resolveEntity(ID);
if (!device || !(device instanceof Device)) {
error = `Device '${ID}' does not exist`;
} else if (!device.definition || !device.definition.configure) {
error = `Device '${device.name}' cannot be configured`;
} else {
try {
await this.configure(device, 'mqtt_message', true, true);
} catch (e) {
error = `Failed to configure (${e.message})`;
}
}
const response = utils.getResponse(message, {id: ID}, error);
await this.mqtt.publish(`bridge/response/device/configure`, stringify(response));
}
}
override async start(): Promise<void> {
for (const device of this.zigbee.getClients()) {
await this.configure(device, 'started');
}
this.eventBus.onDeviceJoined(this, (data) => {
if (data.device.zhDevice.meta.hasOwnProperty('configured')) {
delete data.device.zhDevice.meta.configured;
data.device.zhDevice.save();
}
this.configure(data.device, 'zigbee_event');
});
this.eventBus.onLastSeenChanged(this, (data) => this.configure(data.device, 'zigbee_event'));
this.eventBus.onMQTTMessage(this, this.onMQTTMessage_);
this.eventBus.onReportingDisabled(this, this.onReportingDisabled);
}
private async configure(device: Device, event: 'started' | 'zigbee_event' | 'reporting_disabled' | 'mqtt_message',
force=false, thowError=false): Promise<boolean> {
if (!force) {
if (!device.definition?.configure || device.zhDevice.interviewing) {
return;
}
if (device.zhDevice.meta?.hasOwnProperty('configured') &&
device.zhDevice.meta.configured === zhc.getConfigureKey(device.definition)) {
return;
}
// Only configure end devices when it is active, otherwise it will likely fails as they are sleeping.
if (device.zhDevice.type === 'EndDevice' && event !== 'zigbee_event') {
return;
}
}
if (this.configuring.has(device.ieeeAddr) || (this.attempts[device.ieeeAddr] >= 3 && !force)) {
return false;
}
this.configuring.add(device.ieeeAddr);
if (!this.attempts.hasOwnProperty(device.ieeeAddr)) {
this.attempts[device.ieeeAddr] = 0;
}
logger.info(`Configuring '${device.name}'`);
try {
await device.definition.configure(device.zhDevice, this.zigbee.getFirstCoordinatorEndpoint(), logger);
logger.info(`Successfully configured '${device.name}'`);
device.zhDevice.meta.configured = zhc.getConfigureKey(device.definition);
device.zhDevice.save();
this.eventBus.emitDevicesChanged();
this.eventBus.emit(`devicesChanged`);
} catch (error) {
this.attempts[device.ieeeAddr]++;
const attempt = this.attempts[device.ieeeAddr];
const msg = `Failed to configure '${device.name}', attempt ${attempt} (${error.stack})`;
logger.error(msg);
if (thowError) {
throw error;
}
}
this.configuring.delete(device.ieeeAddr);
}
}
module.exports = Configure;
+1
View File
@@ -85,6 +85,7 @@ class Receive extends ExtensionTS {
}
@bind onDeviceMessage(data: EventDeviceMessage): void {
/* istanbul ignore next */
if (!data.device) return;
/**
+1
View File
@@ -220,6 +220,7 @@ declare global {
vendor: string
exposes: unknown[] // TODO
ota: unknown // TODO
configure?: (device: ZHDevice, coordinatorEndpoint: ZZHEndpoint, logger: unknown) => Promise<void>;
}
interface ResolvedDevice {
+1 -1
View File
@@ -213,7 +213,7 @@ export default class Zigbee {
resolveEntity(key: ZHDevice | string): Device | Group {
const ID = typeof key === 'string' ? key : key.ieeeAddr;
const entitySettings = settings.getEntity(ID);
if (!entitySettings) return undefined;
if (!entitySettings && !(typeof key === 'object' && key.type === 'Coordinator')) return undefined;
if (typeof key === 'object') {
return this.addDeviceToResolvedEntitiesLookup(key.ieeeAddr);
+11 -19
View File
@@ -113,10 +113,8 @@ describe('Configure', () => {
it('Should not configure twice', async () => {
expectBulbConfigured();
const device = zigbeeHerdsman.devices.bulb;
const endpoint = device.getEndpoint(1);
mockClear(device);
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
expectBulbNotConfigured();
});
@@ -124,10 +122,8 @@ describe('Configure', () => {
it('Should configure on zigbee message when not configured yet', async () => {
const device = zigbeeHerdsman.devices.bulb;
delete device.meta.configured;
const endpoint = device.getEndpoint(1);
mockClear(device);
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
expectBulbConfigured();
});
@@ -202,8 +198,7 @@ describe('Configure', () => {
device.interviewing = true;
const endpoint = device.getEndpoint(1);
mockClear(device);
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
expectRemoteNotConfigured();
device.interviewing = false;
@@ -215,8 +210,7 @@ describe('Configure', () => {
device.interviewCompleted = false;
const endpoint = device.getEndpoint(1);
mockClear(device);
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
expectRemoteConfigured();
device.interviewCompleted = true;
@@ -228,11 +222,10 @@ describe('Configure', () => {
const endpoint = device.getEndpoint(1);
endpoint.bind.mockImplementationOnce(async () => await wait(500));
mockClear(device);
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
await zigbeeHerdsman.events.message(payload);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(1);
});
@@ -244,20 +237,19 @@ describe('Configure', () => {
const endpoint = device.getEndpoint(1);
mockClear(device);
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
const payload = {data: {zclVersion: 1}, cluster: 'genBasic', device, endpoint, type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
await zigbeeHerdsman.events.message(payload);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
await zigbeeHerdsman.events.message(payload);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
await zigbeeHerdsman.events.message(payload);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
endpoint.bind.mockImplementationOnce(async () => {throw new Error('BLA')});
await zigbeeHerdsman.events.message(payload);
await zigbeeHerdsman.events.lastSeenChanged({device});
await flushPromises();
expect(endpoint.bind).toHaveBeenCalledTimes(3);
});
+1 -1
View File
@@ -822,7 +822,7 @@ describe('HomeAssistant extension', () => {
MQTT.publish.mockClear();
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/weather_sensor',
stringify({"battery":null,"humidity":null,"linkquality":null,"pressure":null,"temperature":-0.85,"voltage":null}),
+1 -1
View File
@@ -71,7 +71,7 @@ describe('Receive', () => {
const payload = {data, cluster: 'msTemperatureMeasurement', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10};
await zigbeeHerdsman.events.message(payload);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(3);
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/weather_sensor');
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: -0.85});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({"qos": 1, "retain": false});