diff --git a/lib/extension/bind.ts b/lib/extension/bind.ts index a0267e325..e9f897d67 100755 --- a/lib/extension/bind.ts +++ b/lib/extension/bind.ts @@ -575,12 +575,14 @@ export default class Bind extends Extension { } } - // If message is published to a group, add members of the group - const group = data.groupID && data.groupID !== 0 && this.zigbee.groupByID(data.groupID); + if (data.groupID && data.groupID !== 0) { + // If message is published to a group, add members of the group + const group = this.zigbee.groupByID(data.groupID); - if (group) { - for (const member of group.zh.members) { - toPoll.add(member); + if (group) { + for (const member of group.zh.members) { + toPoll.add(member); + } } } diff --git a/lib/extension/onEvent.ts b/lib/extension/onEvent.ts index 9ccbea442..f28eedd65 100644 --- a/lib/extension/onEvent.ts +++ b/lib/extension/onEvent.ts @@ -1,4 +1,4 @@ -import * as zhc from 'zigbee-herdsman-converters'; +import {onEvent} from 'zigbee-herdsman-converters'; import utils from '../util/utils'; import Extension from './extension'; @@ -9,25 +9,42 @@ import Extension from './extension'; export default class OnEvent extends Extension { override async start(): Promise { for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) { + // don't await, in case of repeated failures this would hold startup this.callOnEvent(device, 'start', {}).catch(utils.noop); } - this.eventBus.onDeviceMessage(this, (data) => this.callOnEvent(data.device, 'message', this.convertData(data))); - this.eventBus.onDeviceJoined(this, (data) => this.callOnEvent(data.device, 'deviceJoined', this.convertData(data))); - this.eventBus.onDeviceInterview(this, (data) => this.callOnEvent(data.device, 'deviceInterview', this.convertData(data))); - this.eventBus.onDeviceAnnounce(this, (data) => this.callOnEvent(data.device, 'deviceAnnounce', this.convertData(data))); - this.eventBus.onDeviceNetworkAddressChanged(this, (data) => - this.callOnEvent(data.device, 'deviceNetworkAddressChanged', this.convertData(data)), - ); - this.eventBus.onEntityOptionsChanged(this, async (data) => { - if (data.entity.isDevice()) { - await this.callOnEvent(data.entity, 'deviceOptionsChanged', data).then(() => this.eventBus.emitDevicesChanged()); + this.eventBus.onDeviceMessage(this, async (data) => { + await this.callOnEvent(data.device, 'message', { + endpoint: data.endpoint, + meta: data.meta, + cluster: typeof data.cluster === 'string' ? data.cluster : /* v8 ignore next */ undefined, // XXX: ZH typing is wrong? + type: data.type, + data: data.data, // XXX: typing is a bit convoluted: ZHC has `KeyValueAny` here while Z2M has `KeyValue | Array` + }); + }); + this.eventBus.onDeviceJoined(this, async (data) => { + await this.callOnEvent(data.device, 'deviceJoined', {}); + }); + this.eventBus.onDeviceLeave(this, async (data) => { + if (data.device) { + await this.callOnEvent(data.device, 'stop', {}); + } + }); + this.eventBus.onDeviceInterview(this, async (data) => { + await this.callOnEvent(data.device, 'deviceInterview', {}); + }); + this.eventBus.onDeviceAnnounce(this, async (data) => { + await this.callOnEvent(data.device, 'deviceAnnounce', {}); + }); + this.eventBus.onDeviceNetworkAddressChanged(this, async (data) => { + await this.callOnEvent(data.device, 'deviceNetworkAddressChanged', {}); + }); + this.eventBus.onEntityOptionsChanged(this, async (data) => { + if (data.entity.isDevice()) { + await this.callOnEvent(data.entity, 'deviceOptionsChanged', {}); + this.eventBus.emitDevicesChanged(); } }); - } - - private convertData(data: KeyValue): KeyValue { - return {...data, device: data.device.zh}; } override async stop(): Promise { @@ -38,12 +55,15 @@ export default class OnEvent extends Extension { } } - private async callOnEvent(device: Device, type: zhc.OnEventType, data: KeyValue): Promise { - if (device.options.disabled) return; - const state = this.state.get(device); - const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(data.device); + private async callOnEvent(device: Device, type: Parameters[0], data: Parameters[1]): Promise { + if (device.options.disabled) { + return; + } - await zhc.onEvent(type, data, device.zh, {deviceExposesChanged}); + const state = this.state.get(device); + const deviceExposesChanged = (): void => this.eventBus.emitExposesAndDevicesChanged(device); + + await onEvent(type, data, device.zh, {deviceExposesChanged}); if (device.definition?.onEvent) { const options: KeyValue = device.options; diff --git a/lib/types/types.d.ts b/lib/types/types.d.ts index 71e4963f4..65832dff4 100644 --- a/lib/types/types.d.ts +++ b/lib/types/types.d.ts @@ -71,7 +71,7 @@ declare global { type EntityOptionsChanged = {entity: Device | Group; from: KeyValue; to: KeyValue}; type ExposesChanged = {device: Device}; type Reconfigure = {device: Device}; - type DeviceLeave = {ieeeAddr: string; name: string}; + type DeviceLeave = {ieeeAddr: string; name: string; device?: Device}; type GroupMembersChanged = {group: Group; action: 'remove' | 'add' | 'remove_all'; endpoint: zh.Endpoint; skipDisableReporting: boolean}; type PublishEntityState = {entity: Group | Device; message: KeyValue; stateChangeReason?: StateChangeReason; payload: KeyValue}; type DeviceMessage = { @@ -79,7 +79,7 @@ declare global { device: Device; endpoint: zh.Endpoint; linkquality: number; - groupID: number; + groupID: number; // XXX: should this be `?` cluster: string | number; data: KeyValue | Array; meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl}; diff --git a/lib/zigbee.ts b/lib/zigbee.ts index 8d326350e..2d7a672bf 100644 --- a/lib/zigbee.ts +++ b/lib/zigbee.ts @@ -1,4 +1,5 @@ import type {Events as ZHEvents} from 'zigbee-herdsman'; +import type {StartResult} from 'zigbee-herdsman/dist/adapter/tstype'; import {randomInt} from 'node:crypto'; @@ -14,20 +15,20 @@ import logger from './util/logger'; import * as settings from './util/settings'; import utils from './util/utils'; -const entityIDRegex = new RegExp(`^(.+?)(?:/([^/]+))?$`); +const entityIDRegex = /^(.+?)(?:\/([^/]+))?$/; export default class Zigbee { // @ts-expect-error initialized in start private herdsman: Controller; private eventBus: EventBus; - private groupLookup: {[s: number]: Group} = {}; - private deviceLookup: {[s: string]: Device} = {}; + private groupLookup: Map = new Map(); + private deviceLookup: Map = new Map(); constructor(eventBus: EventBus) { this.eventBus = eventBus; } - async start(): Promise<'reset' | 'resumed' | 'restored'> { + async start(): Promise { const infoHerdsman = await utils.getDependencyVersion('zigbee-herdsman'); logger.info(`Starting zigbee-herdsman (${infoHerdsman.version})`); const panId = settings.get().advanced.pan_id; @@ -63,12 +64,12 @@ export default class Zigbee { `Using zigbee-herdsman with settings: '${stringify(JSON.stringify(herdsmanSettings).replaceAll(JSON.stringify(herdsmanSettings.network.networkKey), '"HIDDEN"'))}'`, ); - let startResult; + let startResult: StartResult; try { this.herdsman = new Controller(herdsmanSettings); startResult = await this.herdsman.start(); } catch (error) { - logger.error(`Error while starting zigbee-herdsman`); + logger.error('Error while starting zigbee-herdsman'); throw error; } @@ -109,18 +110,17 @@ export default class Zigbee { this.herdsman.on('deviceLeave', (data: ZHEvents.DeviceLeavePayload) => { const name = settings.getDevice(data.ieeeAddr)?.friendly_name || data.ieeeAddr; logger.warning(`Device '${name}' left the network`); - this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name}); + this.eventBus.emitDeviceLeave({ieeeAddr: data.ieeeAddr, name, device: this.deviceLookup.get(data.ieeeAddr)}); }); this.herdsman.on('message', async (data: ZHEvents.MessagePayload) => { const device = this.resolveDevice(data.device.ieeeAddr)!; await device.resolveDefinition(); - logger.debug( - () => - `Received Zigbee message from '${device.name}', type '${data.type}', ` + - `cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}` + - (data['groupID'] !== undefined ? ` with groupID ${data.groupID}` : ``) + - (device.zh.type === 'Coordinator' ? `, ignoring since it is from coordinator` : ``), - ); + logger.debug(() => { + const groupId = data.groupID !== undefined ? ` with groupID ${data.groupID}` : ''; + const fromCoord = device.zh.type === 'Coordinator' ? ', ignoring since it is from coordinator' : ''; + + return `Received Zigbee message from '${device.name}', type '${data.type}', cluster '${data.cluster}', data '${stringify(data.data)}' from endpoint ${data.endpoint.ID}${groupId}${fromCoord}`; + }); if (device.zh.type === 'Coordinator') return; this.eventBus.emitDeviceMessage({...data, device}); }); @@ -165,9 +165,7 @@ export default class Zigbee { logger.info(`Device '${name}' is supported, identified as: ${vendor} ${description} (${model})`); } else { logger.warning( - `Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name ` + - `'${data.device.zh.manufacturerName}' is NOT supported, ` + - `please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`, + `Device '${name}' with Zigbee model '${data.device.zh.modelID}' and manufacturer name '${data.device.zh.manufacturerName}' is NOT supported, please follow https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html`, ); } } else if (data.status === 'failed') { @@ -241,55 +239,57 @@ export default class Zigbee { await this.herdsman.permitJoin(time, device?.zh); } - async resolveDevicesDefinitions(ignoreCache: boolean = false): Promise { + async resolveDevicesDefinitions(ignoreCache = false): Promise { for (const device of this.devicesIterator(utils.deviceNotCoordinator)) { await device.resolveDefinition(ignoreCache); } } @bind private resolveDevice(ieeeAddr: string): Device | undefined { - if (!this.deviceLookup[ieeeAddr]) { + if (!this.deviceLookup.has(ieeeAddr)) { const device = this.herdsman.getDeviceByIeeeAddr(ieeeAddr); if (device) { - this.deviceLookup[ieeeAddr] = new Device(device); + this.deviceLookup.set(ieeeAddr, new Device(device)); } } - const device = this.deviceLookup[ieeeAddr]; + const device = this.deviceLookup.get(ieeeAddr); if (device && !device.zh.isDeleted) { device.ensureInSettings(); return device; } } - private resolveGroup(groupID: number): Group { + private resolveGroup(groupID: number): Group | undefined { const group = this.herdsman.getGroupByID(Number(groupID)); - if (group && !this.groupLookup[groupID]) { - this.groupLookup[groupID] = new Group(group, this.resolveDevice); + if (group && !this.groupLookup.has(groupID)) { + this.groupLookup.set(groupID, new Group(group, this.resolveDevice)); } - return this.groupLookup[groupID]; + return this.groupLookup.get(groupID); } resolveEntity(key: string | number | zh.Device): Device | Group | undefined { if (typeof key === 'object') { return this.resolveDevice(key.ieeeAddr); - } else if (typeof key === 'string' && key.toLowerCase() === 'coordinator') { + } + + if (typeof key === 'string' && key.toLowerCase() === 'coordinator') { return this.resolveDevice(this.herdsman.getDevicesByType('Coordinator')[0].ieeeAddr); - } else { - const settingsDevice = settings.getDevice(key.toString()); + } - if (settingsDevice) { - return this.resolveDevice(settingsDevice.ID); - } + const settingsDevice = settings.getDevice(key.toString()); - const groupSettings = settings.getGroup(key); + if (settingsDevice) { + return this.resolveDevice(settingsDevice.ID); + } - if (groupSettings) { - const group = this.resolveGroup(groupSettings.ID); - // If group does not exist, create it (since it's already in configuration.yaml) - return group ? group : this.createGroup(groupSettings.ID); - } + const groupSettings = settings.getGroup(key); + + if (groupSettings) { + const group = this.resolveGroup(groupSettings.ID); + // If group does not exist, create it (since it's already in configuration.yaml) + return group ? group : this.createGroup(groupSettings.ID); } } @@ -339,13 +339,13 @@ export default class Zigbee { } for (const group of this.herdsman.getGroupsIterator(groupPredicate)) { - yield this.resolveGroup(group.groupID); + yield this.resolveGroup(group.groupID)!; } } *groupsIterator(predicate?: (value: zh.Group) => boolean): Generator { for (const group of this.herdsman.getGroupsIterator(predicate)) { - yield this.resolveGroup(group.groupID); + yield this.resolveGroup(group.groupID)!; } } @@ -363,21 +363,22 @@ export default class Zigbee { if (passlist.includes(ieeeAddr)) { logger.info(`Accepting joining device which is on passlist '${ieeeAddr}'`); return true; - } else { - logger.info(`Rejecting joining not in passlist device '${ieeeAddr}'`); - return false; } - } else if (blocklist.length > 0) { + + logger.info(`Rejecting joining not in passlist device '${ieeeAddr}'`); + return false; + } + + if (blocklist.length > 0) { if (blocklist.includes(ieeeAddr)) { logger.info(`Rejecting joining device which is on blocklist '${ieeeAddr}'`); return false; - } else { - logger.info(`Accepting joining not in blocklist device '${ieeeAddr}'`); - return true; } - } else { - return true; + + logger.info(`Accepting joining not in blocklist device '${ieeeAddr}'`); } + + return true; } async touchlinkFactoryResetFirst(): Promise { @@ -402,7 +403,7 @@ export default class Zigbee { createGroup(ID: number): Group { this.herdsman.createGroup(ID); - return this.resolveGroup(ID); + return this.resolveGroup(ID)!; } deviceByNetworkAddress(networkAddress: number): Device | undefined { @@ -410,7 +411,7 @@ export default class Zigbee { return device && this.resolveDevice(device.ieeeAddr); } - groupByID(ID: number): Group { + groupByID(ID: number): Group | undefined { return this.resolveGroup(ID); } } diff --git a/test/extensions/onEvent.test.ts b/test/extensions/onEvent.test.ts index d929e6fb2..e41a2fd14 100644 --- a/test/extensions/onEvent.test.ts +++ b/test/extensions/onEvent.test.ts @@ -1,101 +1,180 @@ import * as data from '../mocks/data'; import {mockLogger} from '../mocks/logger'; import {mockMQTTPublishAsync} from '../mocks/mqtt'; -import {flushPromises, getZhcBaseDefinitions} from '../mocks/utils'; -import {devices, events as mockZHEvents} from '../mocks/zigbeeHerdsman'; +import {flushPromises} from '../mocks/utils'; +import {devices, events as mockZHEvents, returnDevices} from '../mocks/zigbeeHerdsman'; -import {MockInstance} from 'vitest'; +import type {MockInstance} from 'vitest'; +import type {OnEvent as DefinitionOnEvent} from 'zigbee-herdsman-converters/lib/types'; + +import type Device from '../../lib/model/device'; import * as zhc from 'zigbee-herdsman-converters'; import {Controller} from '../../lib/controller'; +import OnEvent from '../../lib/extension/onEvent'; import * as settings from '../../lib/util/settings'; -const mockOnEvent = vi.spyOn(zhc, 'onEvent'); -const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug, mockOnEvent]; +const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.debug]; + +returnDevices.push(devices.bulb.ieeeAddr, devices.LIVOLO.ieeeAddr); describe('Extension: OnEvent', () => { let controller: Controller; - let mockLivoloOnEvent: MockInstance; + let onEventSpy: MockInstance; + let deviceOnEventSpy: MockInstance; + + const getZ2MDevice = (zhDevice: unknown): Device => { + // @ts-expect-error private + return controller.zigbee.resolveEntity(zhDevice)! as Device; + }; + + const clearOnEventSpies = (): void => { + onEventSpy.mockClear(); + deviceOnEventSpy.mockClear(); + }; beforeAll(async () => { - const livoloDefinition = (await getZhcBaseDefinitions()).find((d) => d.zigbeeModel?.includes(devices.LIVOLO.modelID!))!; - mockLivoloOnEvent = vi.spyOn(livoloDefinition, 'onEvent'); - }); - - beforeEach(async () => { vi.useFakeTimers(); data.writeDefaultConfiguration(); settings.reRead(); + controller = new Controller(vi.fn(), vi.fn()); await controller.start(); await flushPromises(); + + onEventSpy = vi.spyOn(zhc, 'onEvent'); + deviceOnEventSpy = vi.spyOn(getZ2MDevice(devices.LIVOLO).definition!, 'onEvent'); }); beforeEach(async () => { - // @ts-expect-error private - controller.state.state = {}; - data.writeDefaultConfiguration(); - settings.reRead(); - mocksClear.forEach((m) => m.mockClear()); + for (const mock of mocksClear) { + mock.mockClear(); + } + + await controller.removeExtension(controller.getExtension('OnEvent')!); + clearOnEventSpies(); + await controller.addExtension(new OnEvent(...controller.extensionArgs)); }); afterAll(async () => { - await controller?.stop(); + await controller.stop(); await flushPromises(); vi.useRealTimers(); }); - it('Should call with start event', async () => { - expect(mockLivoloOnEvent).toHaveBeenCalledTimes(1); - const call = mockLivoloOnEvent.mock.calls[0]; - expect(call[0]).toBe('start'); - expect(call[1]).toStrictEqual({}); - expect(call[2]).toBe(devices.LIVOLO); - expect(call[3]).toStrictEqual(settings.getDevice(devices.LIVOLO.ieeeAddr)); - expect(call[4]).toStrictEqual({}); - }); - - it('Should call with stop event', async () => { - mockLivoloOnEvent.mockClear(); - await controller.stop(); - await flushPromises(); - expect(mockLivoloOnEvent).toHaveBeenCalledTimes(1); - const call = mockLivoloOnEvent.mock.calls[0]; - expect(call[0]).toBe('stop'); - expect(call[1]).toStrictEqual({}); - expect(call[2]).toBe(devices.LIVOLO); - }); - - it('Should call with zigbee event', async () => { - mockLivoloOnEvent.mockClear(); - await mockZHEvents.deviceAnnounce({device: devices.LIVOLO}); - await flushPromises(); - expect(mockLivoloOnEvent).toHaveBeenCalledTimes(1); - expect(mockLivoloOnEvent).toHaveBeenCalledWith( - 'deviceAnnounce', - {device: devices.LIVOLO}, + it('starts & stops', async () => { + expect(onEventSpy).toHaveBeenCalledTimes(2); + expect(deviceOnEventSpy).toHaveBeenCalledTimes(1); + expect(deviceOnEventSpy).toHaveBeenNthCalledWith( + 1, + 'start', + {}, devices.LIVOLO, settings.getDevice(devices.LIVOLO.ieeeAddr), {}, - { - deviceExposesChanged: expect.any(Function), - }, + {deviceExposesChanged: expect.any(Function)}, ); - // Test deviceExposesChanged - mockMQTTPublishAsync.mockClear(); - console.log(mockLivoloOnEvent.mock.calls[0][5].deviceExposesChanged()); - expect(mockMQTTPublishAsync.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/bridge/devices'); + await controller.stop(); + + expect(onEventSpy).toHaveBeenCalledTimes(4); + expect(deviceOnEventSpy).toHaveBeenCalledTimes(2); + expect(deviceOnEventSpy).toHaveBeenNthCalledWith( + 2, + 'stop', + {}, + devices.LIVOLO, + settings.getDevice(devices.LIVOLO.ieeeAddr), + {}, + {deviceExposesChanged: expect.any(Function)}, + ); }); - it('Should call index onEvent with zigbee event', async () => { - mockOnEvent.mockClear(); + it('calls on device events', async () => { + clearOnEventSpies(); await mockZHEvents.deviceAnnounce({device: devices.LIVOLO}); await flushPromises(); - expect(mockOnEvent).toHaveBeenCalledTimes(1); - expect(zhc.onEvent).toHaveBeenCalledWith('deviceAnnounce', {device: devices.LIVOLO}, devices.LIVOLO, { - deviceExposesChanged: expect.any(Function), + + expect(deviceOnEventSpy).toHaveBeenCalledTimes(1); + expect(deviceOnEventSpy).toHaveBeenNthCalledWith( + 1, + 'deviceAnnounce', + {}, + devices.LIVOLO, + settings.getDevice(devices.LIVOLO.ieeeAddr), + {}, + {deviceExposesChanged: expect.any(Function)}, + ); + + const emitExposesAndDevicesChangedSpy = vi.spyOn( + // @ts-expect-error protected + controller.getExtension('OnEvent')!.eventBus, + 'emitExposesAndDevicesChanged', + ); + + deviceOnEventSpy.mock.calls[0][5]!.deviceExposesChanged(); + + expect(emitExposesAndDevicesChangedSpy).toHaveBeenCalledTimes(1); + expect(emitExposesAndDevicesChangedSpy).toHaveBeenCalledWith(getZ2MDevice(devices.LIVOLO)); + + await mockZHEvents.deviceLeave({ieeeAddr: devices.LIVOLO.ieeeAddr}); + await flushPromises(); + + expect(deviceOnEventSpy).toHaveBeenCalledTimes(2); + expect(deviceOnEventSpy).toHaveBeenNthCalledWith( + 2, + 'stop', + {}, + devices.LIVOLO, + settings.getDevice(devices.LIVOLO.ieeeAddr), + {}, + {deviceExposesChanged: expect.any(Function)}, + ); + }); + + it('calls on device message', async () => { + clearOnEventSpies(); + + await mockZHEvents.message({ + type: 'attributeReport', + device: devices.LIVOLO, + endpoint: devices.LIVOLO.endpoints[0], + linkquality: 213, + groupID: 0, + cluster: 'genBasic', + data: {zclVersion: 8}, + meta: {zclTransactionSequenceNumber: 1, manufacturerCode: devices.LIVOLO.manufacturerID}, }); + await flushPromises(); + + expect(deviceOnEventSpy).toHaveBeenCalledTimes(1); + expect(deviceOnEventSpy).toHaveBeenCalledWith( + 'message', + { + type: 'attributeReport', + endpoint: devices.LIVOLO.endpoints[0], + cluster: 'genBasic', + data: {zclVersion: 8}, + meta: {zclTransactionSequenceNumber: 1, manufacturerCode: devices.LIVOLO.manufacturerID}, + }, + devices.LIVOLO, + settings.getDevice(devices.LIVOLO.ieeeAddr), + {}, + {deviceExposesChanged: expect.any(Function)}, + ); + }); + + it('does not block startup on failure', async () => { + await controller.removeExtension(controller.getExtension('OnEvent')!); + clearOnEventSpies(); + deviceOnEventSpy.mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, 10000)); + throw new Error('Failed'); + }); + await controller.addExtension(new OnEvent(...controller.extensionArgs)); + + expect(onEventSpy).toHaveBeenCalledTimes(2); + expect(deviceOnEventSpy).toHaveBeenCalledTimes(1); }); });