feat: Add api for device re-interview (#22788)

* feat: add api for device re-interview

- Adds an API that allows for the re-interview of a device. This can be
  useful a device firmware upgrade adds new device endpoints (as is the case
  when upgrading an Inovelli VZM31-SN to 2.18). Without the ability to
  re-interview, one must remove and re-add the device.

* rename from reinterview to interview

* publish devices after interview.

* only allow device ids or names, not endpoints
This commit is contained in:
Mike Ryan authored and GitHub committed 2024-05-30 20:48:34 +02:00
1 parent 9f5b5d1a76
commit b6ad6418a1
3 files changed
+119

No files matched your search

+20
View File
@@ -40,6 +40,7 @@ export default class Bridge extends Extension {
'device/options': this.deviceOptions,
'device/configure_reporting': this.deviceConfigureReporting,
'device/remove': this.deviceRemove,
'device/interview': this.deviceInterview,
'device/generate_external_definition': this.deviceGenerateExternalDefinition,
'device/rename': this.deviceRename,
'group/add': this.groupAdd,
@@ -500,6 +501,25 @@ export default class Bridge extends Extension {
}, null);
}
@bind async deviceInterview(message: string | KeyValue): Promise<MQTTResponse> {
if (typeof message !== 'object' || !message.hasOwnProperty('id')) {
throw new Error(`Invalid payload`);
}
const device = this.getEntity('device', message.id) as Device;
try {
await device.zh.interview();
} catch (error) {
throw new Error(`interview of '${device.name}' (${device.ieeeAddr}) failed: ${error}`, {cause: error});
}
// publish devices so that the front-end has up-to-date info.
await this.publishDevices();
return utils.getResponse(message, {id: message.id}, null);
}
@bind async deviceGenerateExternalDefinition(message: string | KeyValue): Promise<MQTTResponse> {
if (typeof message !== 'object' || !message.hasOwnProperty('id')) {
throw new Error(`Invalid payload`);
+98
View File
@@ -66,6 +66,7 @@ describe('Bridge', () => {
logger.setTransportsEnabled(false);
MQTT.publish.mockClear();
const device = zigbeeHerdsman.devices.bulb;
device.interview.mockClear();
device.removeFromDatabase.mockClear();
device.removeFromNetwork.mockClear();
extension.lastJoinedDeviceIeeeAddr = null;
@@ -727,6 +728,103 @@ describe('Bridge', () => {
);
});
it('Should allow interviewing a device by friendly name', async () => {
MQTT.publish.mockClear();
zigbeeHerdsman.devices.bulb.interview.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/device/interview', stringify({id: 'bulb'}));
await flushPromises();
expect(zigbeeHerdsman.devices.bulb.interview).toHaveBeenCalled();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/interview',
stringify({"data":{"id":"bulb"},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
);
// The following indicates that devices have published.
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/devices',
expect.any(String),
{retain: true, qos: 0}, expect.any(Function)
);
});
it('Should allow interviewing a device by ieeeAddr', async () => {
MQTT.publish.mockClear();
zigbeeHerdsman.devices.bulb.interview.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/device/interview', stringify({id: "0x000b57fffec6a5b2"}));
await flushPromises();
expect(zigbeeHerdsman.devices.bulb.interview).toHaveBeenCalled();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/interview',
stringify({"data":{"id":"0x000b57fffec6a5b2"},"status":"ok"}),
{retain: false, qos: 0}, expect.any(Function)
);
// The following indicates that devices have published.
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/devices',
expect.any(String),
{retain: true, qos: 0}, expect.any(Function)
);
});
it('Should throw error on invalid device interview payload', async () => {
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/device/interview', stringify({foo: 'bulb'}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/interview',
stringify({"data":{},"status":"error","error":"Invalid payload"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should throw error on non-existing device interview', async () => {
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/device/interview', stringify({id: 'bulb_not_existing'}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/interview',
stringify({"data":{},"status":"error","error":"Device 'bulb_not_existing' does not exist"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should throw error on id is device endpoint', async () => {
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/device/interview', stringify({id: 'bulb/1'}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/interview',
stringify({"data":{},"status":"error","error":"Device 'bulb/1' does not exist"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should throw error on id is a group', async () => {
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/device/interview', stringify({id: 'group_1'}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/interview',
stringify({"data":{},"status":"error","error":"Device 'group_1' does not exist"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should throw error on when interview fails', async () => {
MQTT.publish.mockClear();
zigbeeHerdsman.devices.bulb.interview.mockClear();
zigbeeHerdsman.devices.bulb.interview.mockImplementation(() => Promise.reject(new Error('something went wrong')))
MQTT.events.message('zigbee2mqtt/bridge/request/device/interview', stringify({id: 'bulb'}));
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledWith(
'zigbee2mqtt/bridge/response/device/interview',
stringify({"data":{},"status":"error","error":"interview of 'bulb' (0x000b57fffec6a5b2) failed: Error: something went wrong"}),
{retain: false, qos: 0}, expect.any(Function)
);
});
it('Should error when generate_external_definition is invalid', async () => {
MQTT.publish.mockClear();
MQTT.events.message('zigbee2mqtt/bridge/request/device/generate_external_definition', stringify({wrong: ZNCZ02LM.ieeeAddr}));
+1
View File
@@ -127,6 +127,7 @@ class Device {
this.softwareBuildID = softwareBuildID;
this.interviewCompleted = interviewCompleted;
this.modelID = modelID;
this.interview = jest.fn();
this.interviewing = interviewing;
this.meta = {};
this.ping = jest.fn();