diff --git a/lib/eventBus.ts b/lib/eventBus.ts index 2ed2cac5..94c18bb6 100644 --- a/lib/eventBus.ts +++ b/lib/eventBus.ts @@ -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 { diff --git a/lib/extension/configure.js b/lib/extension/configure.js deleted file mode 100644 index c14c4a94..00000000 --- a/lib/extension/configure.js +++ /dev/null @@ -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; diff --git a/lib/extension/configure.ts b/lib/extension/configure.ts new file mode 100644 index 00000000..17cf2fce --- /dev/null +++ b/lib/extension/configure.ts @@ -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 { + // 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 { + 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 { + 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 { + 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; diff --git a/lib/extension/receive.ts b/lib/extension/receive.ts index 852b8f4f..e57952df 100755 --- a/lib/extension/receive.ts +++ b/lib/extension/receive.ts @@ -85,6 +85,7 @@ class Receive extends ExtensionTS { } @bind onDeviceMessage(data: EventDeviceMessage): void { + /* istanbul ignore next */ if (!data.device) return; /** diff --git a/lib/types/types.ts b/lib/types/types.ts index 763ffec8..5fea6c67 100644 --- a/lib/types/types.ts +++ b/lib/types/types.ts @@ -220,6 +220,7 @@ declare global { vendor: string exposes: unknown[] // TODO ota: unknown // TODO + configure?: (device: ZHDevice, coordinatorEndpoint: ZZHEndpoint, logger: unknown) => Promise; } interface ResolvedDevice { diff --git a/lib/zigbee.ts b/lib/zigbee.ts index c6c7717b..70e9a3dd 100644 --- a/lib/zigbee.ts +++ b/lib/zigbee.ts @@ -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); diff --git a/test/configure.test.js b/test/configure.test.js index c9cc70d7..f6b4e787 100644 --- a/test/configure.test.js +++ b/test/configure.test.js @@ -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); }); diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index e4db91ba..138c3782 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -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}), diff --git a/test/receive.test.js b/test/receive.test.js index c68aa865..1715e824 100755 --- a/test/receive.test.js +++ b/test/receive.test.js @@ -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});