diff --git a/lib/extension/bridge.js b/lib/extension/bridge.js index 85c49573c..2f5dc5789 100644 --- a/lib/extension/bridge.js +++ b/lib/extension/bridge.js @@ -13,10 +13,8 @@ class Bridge extends Extension { this.requestLookup = { 'permitjoin': this.permitJoin.bind(this), - // 'device/remove': this.deviceRemove.bind(this), - // 'device/forceremove': this.deviceForceRemove.bind(this), - // 'device/ban': this.deviceBan.bind(this), - // 'group/remove': this.groupRemove.bind(this), + 'device/remove': this.deviceRemove.bind(this), + 'group/remove': this.groupRemove.bind(this), }; } @@ -33,7 +31,7 @@ class Bridge extends Extension { async onMQTTMessage(topic, message) { const match = topic.match(requestRegex); if (match && this.requestLookup[match[1].toLowerCase()]) { - message = JSON.parse(message); + try {message = JSON.parse(message);} catch {} try { const response = await this.requestLookup[match[1].toLowerCase()](message); await this.mqtt.publish(`bridge/response/${match[1]}`, JSON.stringify(response)); @@ -75,21 +73,13 @@ class Bridge extends Extension { * Requests */ - // async deviceRemove(message) { - // return this.removeForceRemoveOrBanEntity('remove', 'device', message); - // } + async deviceRemove(message) { + return this.removeEntity('device', message); + } - // async deviceForceRemove(message) { - // return this.removeForceRemoveOrBanEntity('force_remove', 'device', message); - // } - - // async deviceBan(message) { - // return this.removeForceRemoveOrBanEntity('ban', 'device', message); - // } - - // async groupRemove(message) { - // return this.removeForceRemoveOrBanEntity('remove', 'group', message); - // } + async groupRemove(message) { + return this.removeEntity('group', message); + } async permitJoin(message) { const value = typeof message === 'object' ? message.value : message; @@ -102,51 +92,67 @@ class Bridge extends Extension { * Utils */ - // async removeForceRemoveOrBanEntity(action, entityType, message) { - // const ID = typeof message === 'object' ? message.ID : message.trim(); - // const entity = this.zigbee.resolveEntity(ID); - // if (!entity || entity.type !== entityType) { - // throw new Error(`${ID} is not a ${entityType}`); - // } + async removeEntity(entityType, message) { + const ID = typeof message === 'object' ? message.ID : message.trim(); + const entity = this.zigbee.resolveEntity(ID); + if (!entity || entity.type !== entityType) { + throw new Error(`${utils.capitalize(entityType)} '${ID}' does not exist`); + } - // const lookup = { - // ban: ['banned', 'Banning', 'ban'], - // force_remove: ['force_removed', 'Force removing', 'force remove'], - // remove: ['removed', 'Removing', 'remove'], - // }; + let ban = false; + let force = false; + let banForceLog = ''; - // try { - // logger.info(`${lookup[action][1]} '${entity.settings.friendlyName}'`); - // if (entity.type === 'device') { - // if (action === 'ban') { - // settings.banDevice(entity.settings.ID); - // } + if (entityType === 'device' && typeof message === 'object') { + ban = !!message.ban; + force = !!message.force; + banForceLog = ` (ban: ${ban}, force: ${force})`; + } - // action === 'force_remove' ? - // await entity.device.removeFromDatabase() : await entity.device.removeFromNetwork(); - // } else { - // await entity.group.removeFromDatabase(); - // } + try { + logger.info(`Removing ${entity.type} '${entity.settings.friendlyName}'${banForceLog}`); + if (entity.type === 'device') { + if (ban) { + settings.banDevice(entity.settings.ID); + } - // // Fire event - // if (entity.type === 'device') { - // this.eventBus.emit('deviceRemoved', {device: entity.device}); - // } + if (force) { + await entity.device.removeFromDatabase() + } else { + await entity.device.removeFromNetwork(); + } + } else { + await entity.group.removeFromDatabase(); + } - // // Remove from configuration.yaml - // entity.type === 'device' ? - // settings.removeDevice(entity.settings.ID) : settings.removeGroup(entity.settings.ID); + // Fire event + if (entity.type === 'device') { + this.eventBus.emit('deviceRemoved', {device: entity.device}); + } - // // Remove from state - // this.state.remove(entity.settings.ID); + // Remove from configuration.yaml + if (entity.type === 'device') { + settings.removeDevice(entity.settings.ID); + } else { + settings.removeGroup(entity.settings.ID); + } - // logger.info(`Successfully ${lookup[action][0]} ${entity.settings.friendlyName}`); - // entity.type === 'device' ? this.publishDevices() : this.publishGroups(); - // return utils.getResponse(message, {ID}, null); - // } catch (error) { - // throw new Error(`Failed to ${lookup[action][2]} ${entity.settings.friendlyName} (${error})`); - // } - // } + // Remove from state + this.state.remove(entity.settings.ID); + + logger.info(`Successfully removed ${entity.type} '${entity.settings.friendlyName}'${banForceLog}`); + + if (entity.type === 'device') { + this.publishDevices(); + return utils.getResponse(message, {ID, ban: ban, force: force}, null); + } else { + this.publishGroups(); + return utils.getResponse(message, {ID}, null); + } + } catch (error) { + throw new Error(`Failed to remove ${entity.type} '${entity.settings.friendlyName}'${banForceLog} (${error})`); + } + } async publishInfo() { const payload = { diff --git a/lib/util/utils.js b/lib/util/utils.js index e3694c042..59e1fce79 100644 --- a/lib/util/utils.js +++ b/lib/util/utils.js @@ -44,6 +44,10 @@ function flatten(arr) { }, []); } +function capitalize(s) { + return s[0].toUpperCase() + s.slice(1); +} + const forceEndDevice = flatten( ['QBKG03LM', 'QBKG04LM', 'ZNMS13LM', 'ZNMS12LM'] .map((model) => zigbeeHerdsmanConverters.devices.find((d) => d.model === model)) @@ -150,4 +154,5 @@ module.exports = { formatDate: (date, type) => formatDate(date, type), equalsPartial, getResponse, + capitalize, }; diff --git a/test/bridge.test.js b/test/bridge.test.js index 3b197bfd3..65a23a699 100644 --- a/test/bridge.test.js +++ b/test/bridge.test.js @@ -24,6 +24,9 @@ describe('Bridge', () => { logger.info.mockClear(); logger.warn.mockClear(); MQTT.publish.mockClear(); + const device = zigbeeHerdsman.devices.bulb; + device.removeFromDatabase.mockClear(); + device.removeFromNetwork.mockClear(); controller = new Controller(); await controller.start(); await flushPromises(); @@ -205,4 +208,111 @@ describe('Bridge', () => { await zigbeeHerdsman.events.message({data: {onOff: 1}, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}); await flushPromises(); }); + + it('Should allow to remove device by string', async () => { + const device = zigbeeHerdsman.devices.bulb; + controller.state.state = {'0x000b57fffec6a5b3': {brightness: 100}}; + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', 'bulb'); + await flushPromises(); + expect(controller.state[device.ieeeAddr]).toBeUndefined(); + expect(device.removeFromNetwork).toHaveBeenCalledTimes(1); + expect(device.removeFromDatabase).not.toHaveBeenCalled(); + expect(settings.getDevice('bulb')).toBeNull(); + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function)); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/remove', + JSON.stringify({"data":{"ID": "bulb","ban":false,"force":false},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + expect(settings.get().ban).toStrictEqual([]); + }); + + it('Should allow to remove device by object ID', async () => { + const device = zigbeeHerdsman.devices.bulb; + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', JSON.stringify({ID: "bulb"})); + await flushPromises(); + expect(device.removeFromNetwork).toHaveBeenCalledTimes(1); + expect(device.removeFromDatabase).not.toHaveBeenCalled(); + expect(settings.getDevice('bulb')).toBeNull(); + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function)); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/remove', + JSON.stringify({"data":{"ID": "bulb","ban":false,"force":false},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should allow to force remove device', async () => { + const device = zigbeeHerdsman.devices.bulb; + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', JSON.stringify({ID: "bulb", force: true})); + await flushPromises(); + expect(device.removeFromDatabase).toHaveBeenCalledTimes(1); + expect(device.removeFromNetwork).not.toHaveBeenCalled(); + expect(settings.getDevice('bulb')).toBeNull(); + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function)); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/remove', + JSON.stringify({"data":{"ID": "bulb","ban":false,"force":true},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should allow to ban device', async () => { + const device = zigbeeHerdsman.devices.bulb; + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', JSON.stringify({ID: "bulb", ban: true, force: true})); + await flushPromises(); + expect(device.removeFromDatabase).toHaveBeenCalledTimes(1); + expect(settings.getDevice('bulb')).toBeNull(); + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/devices', expect.any(String), expect.any(Object), expect.any(Function)); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/remove', + JSON.stringify({"data":{"ID": "bulb","ban":true,"force":true},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + expect(settings.get().ban).toStrictEqual(["0x000b57fffec6a5b2"]); + }); + + it('Should allow to remove group', async () => { + const group = zigbeeHerdsman.groups.group_1; + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/group/remove', 'group_1'); + await flushPromises(); + expect(group.removeFromDatabase).toHaveBeenCalledTimes(1); + expect(settings.getGroup('group_1')).toBeNull(); + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/groups', expect.any(String), expect.any(Object), expect.any(Function)); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/group/remove', + JSON.stringify({"data":{"ID": "group_1"},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should throw error on removing non-existing device', async () => { + const device = zigbeeHerdsman.devices.bulb; + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', JSON.stringify({ID: "non-existing-device"})); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/remove', + JSON.stringify({"data":{},"status":"error","error":"Device 'non-existing-device' does not exist"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should throw error when remove device fails', async () => { + const device = zigbeeHerdsman.devices.bulb; + MQTT.publish.mockClear(); + device.removeFromNetwork.mockImplementationOnce(() => {throw new Error('device timeout')}) + MQTT.events.message('zigbee2mqtt/bridge/request/device/remove', JSON.stringify({ID: "bulb"})); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/device/remove', + JSON.stringify({"data":{},"status":"error","error":"Failed to remove device 'bulb' (ban: false, force: false) (Error: device timeout)"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); });