fix: OnEvent fixes (#27063)

This commit is contained in:
Nerivec
2025-04-10 21:08:04 +02:00
committed by GitHub
parent 8e547d06ec
commit 80502da80f
5 changed files with 237 additions and 135 deletions
+7 -5
View File
@@ -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);
}
}
}
+40 -20
View File
@@ -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<void> {
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<string | number>`
});
});
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<void> {
@@ -38,12 +55,15 @@ export default class OnEvent extends Extension {
}
}
private async callOnEvent(device: Device, type: zhc.OnEventType, data: KeyValue): Promise<void> {
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<typeof onEvent>[0], data: Parameters<typeof onEvent>[1]): Promise<void> {
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;
+2 -2
View File
@@ -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<string | number>;
meta: {zclTransactionSequenceNumber?: number; manufacturerCode?: number; frameControl?: ZHFrameControl};
+51 -50
View File
@@ -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<number /* group ID */, Group> = new Map();
private deviceLookup: Map<string /* IEEE address */, Device> = new Map();
constructor(eventBus: EventBus) {
this.eventBus = eventBus;
}
async start(): Promise<'reset' | 'resumed' | 'restored'> {
async start(): Promise<StartResult> {
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<void> {
async resolveDevicesDefinitions(ignoreCache = false): Promise<void> {
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<Group> {
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<boolean> {
@@ -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);
}
}
+137 -58
View File
@@ -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<typeof zhc.onEvent>;
let deviceOnEventSpy: MockInstance<DefinitionOnEvent>;
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);
});
});