Skip to content

Commit

Permalink
feat: Add api for device re-interview (#22788)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
justfalter authored May 30, 2024
1 parent 9f5b5d1 commit b6ad641
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 0 deletions.
20 changes: 20 additions & 0 deletions lib/extension/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`);
Expand Down
98 changes: 98 additions & 0 deletions test/bridge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}));
Expand Down
1 change: 1 addition & 0 deletions test/stub/zigbeeHerdsman.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down

0 comments on commit b6ad641

Please sign in to comment.