diff --git a/lib/controller.js b/lib/controller.js index 9236ca7e..08c15280 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -19,7 +19,7 @@ const ExtensionHomeAssistant = require('./extension/homeassistant'); const ExtensionConfigure = require('./extension/configure'); const ExtensionDeviceGroupMembership = require('./extension/legacy/deviceGroupMembership'); const ExtensionBridgeLegacy = require('./extension/legacy/bridgeLegacy'); -// const ExtensionBridge = require('./extension/bridge'); +const ExtensionBridge = require('./extension/bridge'); const ExtensionGroups = require('./extension/groups'); const ExtensionAvailability = require('./extension/availability'); const ExtensionBind = require('./extension/bind'); @@ -49,10 +49,12 @@ class Controller { new ExtensionBind(...args), new ExtensionOnEvent(...args), new ExtensionOTAUpdate(...args), - // new ExtensionBridge(...args), ]; - /* istanbul ignore else */ + if (settings.get().experimental.new_api) { + this.extensions.push(new ExtensionBridge(...args)); + } + if (settings.get().advanced.legacy_api) { this.extensions.push(new ExtensionBridgeLegacy(...args)); } diff --git a/lib/extension/bridge.js b/lib/extension/bridge.js index 403208d4..85c49573 100644 --- a/lib/extension/bridge.js +++ b/lib/extension/bridge.js @@ -1,5 +1,3 @@ -/* istanbul ignore file newApi */ - const logger = require('../util/logger'); const utils = require('../util/utils'); const Extension = require('./extension'); @@ -8,22 +6,25 @@ const settings = require('../util/settings'); const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`); -class BridgeLegacy extends Extension { +class Bridge extends Extension { constructor(zigbee, mqtt, state, publishEntityState, eventBus) { super(zigbee, mqtt, state, publishEntityState, eventBus); this.requestLookup = { - 'permitjoin': this.requestPermitJoin.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), + '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), }; } async onMQTTConnected() { - this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/+`); + this.zigbee2mqttVersion = await utils.getZigbee2mqttVersion(); + this.coordinatorVersion = await this.zigbee.getCoordinatorVersion(); + + this.mqtt.subscribe(`${settings.get().mqtt.base_topic}/bridge/request/#`); await this.publishInfo(); await this.publishDevices(); await this.publishGroups(); @@ -74,23 +75,23 @@ class BridgeLegacy extends Extension { * Requests */ - async deviceRemove(message) { - return this.removeForceRemoveOrBanEntity('remove', 'device', message); - } + // async deviceRemove(message) { + // return this.removeForceRemoveOrBanEntity('remove', 'device', message); + // } - async deviceForceRemove(message) { - return this.removeForceRemoveOrBanEntity('force_remove', 'device', message); - } + // async deviceForceRemove(message) { + // return this.removeForceRemoveOrBanEntity('force_remove', 'device', message); + // } - async deviceBan(message) { - return this.removeForceRemoveOrBanEntity('ban', '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.removeForceRemoveOrBanEntity('remove', 'group', message); + // } - async requestPermitJoin(message) { + async permitJoin(message) { const value = typeof message === 'object' ? message.value : message; await this.zigbee.permitJoin(value); await this.publishInfo(); @@ -101,59 +102,57 @@ class BridgeLegacy 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 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}`); + // } - const lookup = { - ban: ['banned', 'Banning', 'ban'], - force_remove: ['force_removed', 'Force removing', 'force remove'], - remove: ['removed', 'Removing', 'remove'], - }; + // const lookup = { + // ban: ['banned', 'Banning', 'ban'], + // force_remove: ['force_removed', 'Force removing', 'force remove'], + // remove: ['removed', 'Removing', 'remove'], + // }; - try { - logger.info(`${lookup[action][1]} '${entity.settings.friendlyName}'`); - if (entity.type === 'device') { - if (action === 'ban') { - settings.banDevice(entity.settings.ID); - } + // try { + // logger.info(`${lookup[action][1]} '${entity.settings.friendlyName}'`); + // if (entity.type === 'device') { + // if (action === 'ban') { + // settings.banDevice(entity.settings.ID); + // } - action === 'force_remove' ? - await entity.device.removeFromDatabase() : await entity.device.removeFromNetwork(); - } else { - await entity.group.removeFromDatabase(); - } + // action === 'force_remove' ? + // await entity.device.removeFromDatabase() : await entity.device.removeFromNetwork(); + // } else { + // await entity.group.removeFromDatabase(); + // } - // Fire event - if (entity.type === 'device') { - this.eventBus.emit('deviceRemoved', {device: entity.device}); - } + // // Fire event + // if (entity.type === 'device') { + // this.eventBus.emit('deviceRemoved', {device: entity.device}); + // } - // Remove from configuration.yaml - entity.type === 'device' ? - settings.removeDevice(entity.settings.ID) : settings.removeGroup(entity.settings.ID); + // // Remove from configuration.yaml + // entity.type === 'device' ? + // settings.removeDevice(entity.settings.ID) : settings.removeGroup(entity.settings.ID); - // Remove from state - this.state.remove(entity.settings.ID); + // // Remove from state + // this.state.remove(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})`); - } - } + // 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})`); + // } + // } async publishInfo() { - const info = await utils.getZigbee2mqttVersion(); - const coordinator = await this.zigbee.getCoordinatorVersion(); const payload = { - version: info.version, - commit: info.commitHash, - coordinator, + version: this.zigbee2mqttVersion.version, + commit: this.zigbee2mqttVersion.commitHash, + coordinator: this.coordinatorVersion, logLevel: logger.getLevel(), permitJoin: await this.zigbee.getPermitJoin(), }; @@ -177,7 +176,7 @@ class BridgeLegacy extends Extension { type: device.type, networkAddress: device.networkAddress, supported: !!definition, - friendlyName: resolved.settings.friendlyName, + friendlyName: resolved.name, definition: definitionPayload, powerSource: device.powerSource, softwareBuildID: device.softwareBuildID, @@ -195,7 +194,7 @@ class BridgeLegacy extends Extension { const resolved = this.zigbee.resolveEntity(group); return { ID: group.groupID, - friendlyName: resolved.settings.friendlyName, + friendlyName: resolved.name, members: group.members.map((m) => { return { ieeeAddress: m.deviceIeeeAddress, @@ -209,4 +208,4 @@ class BridgeLegacy extends Extension { } } -module.exports = BridgeLegacy; +module.exports = Bridge; diff --git a/lib/util/settings.js b/lib/util/settings.js index c678f7a3..269154ce 100644 --- a/lib/util/settings.js +++ b/lib/util/settings.js @@ -41,6 +41,7 @@ const defaults = { experimental: { // json or attribute or attribute_and_json output: 'json', + new_api: false, }, advanced: { legacy_api: true, diff --git a/lib/util/utils.js b/lib/util/utils.js index c87fd118..e3694c04 100644 --- a/lib/util/utils.js +++ b/lib/util/utils.js @@ -124,7 +124,6 @@ function getObjectsProperty(objects, key, defaultValue) { return defaultValue; } -/* istanbul ignore next newApi */ function getResponse(request, data, error) { const response = {data, status: error ? 'error' : 'ok'}; if (error) response.error = error; diff --git a/lib/zigbee.js b/lib/zigbee.js index 1e885ec7..06908b39 100644 --- a/lib/zigbee.js +++ b/lib/zigbee.js @@ -149,7 +149,6 @@ class Zigbee extends events.EventEmitter { * } */ resolveEntity(key) { - /* istanbul ignore next newApi */ assert( typeof key === 'string' || typeof key === 'number' || key.constructor.name === 'Device' || key.constructor.name === 'Group', @@ -217,7 +216,7 @@ class Zigbee extends events.EventEmitter { if (!group) group = this.createGroup(entity.ID); return {type: 'group', group, settings: entity, name: entity.friendlyName}; } - } /* istanbul ignore else newApi */ else if (key.constructor.name === 'Device') { + } else if (key.constructor.name === 'Device') { const setting = settings.getEntity(key.ieeeAddr); return { type: 'device', @@ -233,7 +232,7 @@ class Zigbee extends events.EventEmitter { type: 'group', group: key, settings: setting, - name: setting.friendlyName, + name: setting ? setting.friendlyName : key.groupID, }; } } diff --git a/test/bridge.test.js b/test/bridge.test.js new file mode 100644 index 00000000..e9859205 --- /dev/null +++ b/test/bridge.test.js @@ -0,0 +1,208 @@ +const data = require('./stub/data'); +const logger = require('./stub/logger'); +const zigbeeHerdsman = require('./stub/zigbeeHerdsman'); +const MQTT = require('./stub/mqtt'); +const settings = require('../lib/util/settings'); +const Controller = require('../lib/controller'); +const flushPromises = () => new Promise(setImmediate); +const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); + +const {coordinator, bulb, unsupported} = zigbeeHerdsman.devices; +zigbeeHerdsman.returnDevices.push(coordinator.ieeeAddr); +zigbeeHerdsman.returnDevices.push(bulb.ieeeAddr); +zigbeeHerdsman.returnDevices.push(unsupported.ieeeAddr); + +describe('Bridge', () => { + let controller; + + beforeEach(async () => { + data.writeDefaultConfiguration(); + settings._reRead(); + settings.set(['advanced', 'legacy_api'], false); + settings.set(['experimental', 'new_api'], true); + data.writeDefaultState(); + logger.info.mockClear(); + logger.warn.mockClear(); + MQTT.publish.mockClear(); + controller = new Controller(); + await controller.start(); + await flushPromises(); + }); + + it('Should publish bridge info on startup', async () => { + const version = await require('../lib/util/utils').getZigbee2mqttVersion(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/info', + JSON.stringify({"version":version.version,"commit":version.commitHash,"coordinator":{"type":"z-Stack","meta":{"version":1,"revision":20190425}},"logLevel":"info","permitJoin":false}), + { retain: true, qos: 0 }, + expect.any(Function) + ); + }); + + it('Should publish devices on startup', async () => { + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/devices', + JSON.stringify([{"ieeeAddress":"0x000b57fffec6a5b2","type":"Router","networkAddress":40369,"supported":true,"friendlyName":"bulb","definition":{"model":"LED1545G12","vendor":"IKEA","description":"TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white","supports":"on/off, brightness, color temperature"},"powerSource":"Mains (single phase)","dateCode":null,"interviewing":false,"interviewCompleted":true},{"ieeeAddress":"0x0017880104e45518","type":"EndDevice","networkAddress":6536,"supported":false,"friendlyName":"0x0017880104e45518","definition":null,"powerSource":"Battery","dateCode":null,"interviewing":false,"interviewCompleted":true}]), + { retain: true, qos: 0 }, + expect.any(Function) + ); + }); + + it('Should publish devices on startup', async () => { + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/groups', + JSON.stringify([{"ID":1,"friendlyName":"group_1","members":[]},{"ID":15071,"friendlyName":"group_tradfri_remote","members":[]},{"ID":99,"friendlyName":99,"members":[]},{"ID":11,"friendlyName":"group_with_tradfri","members":[]},{"ID":2,"friendlyName":"group_2","members":[]}]), + { retain: true, qos: 0 }, + expect.any(Function) + ); + }); + + it('Should publish event when device joined', async () => { + MQTT.publish.mockClear(); + await zigbeeHerdsman.events.deviceJoined({device: zigbeeHerdsman.devices.bulb}); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledTimes(1); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/event', + JSON.stringify({"type":"deviceJoined","data":{"friendlyName":"bulb","ieeeAddress":"0x000b57fffec6a5b2"}}), + { retain: false, qos: 0 }, + expect.any(Function) + ); + }); + + it('Should publish event when device interview started', async () => { + MQTT.publish.mockClear(); + await zigbeeHerdsman.events.deviceInterview({device: zigbeeHerdsman.devices.bulb, status: 'started'}); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledTimes(1); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/event', + JSON.stringify({"type":"deviceInterview","data":{"friendlyName":"bulb","status":"started","ieeeAddress":"0x000b57fffec6a5b2"}}), + { retain: false, qos: 0 }, + expect.any(Function) + ); + }); + + it('Should publish event and devices when device interview failed', async () => { + MQTT.publish.mockClear(); + await zigbeeHerdsman.events.deviceInterview({device: zigbeeHerdsman.devices.bulb, status: 'failed'}); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledTimes(2); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/event', + JSON.stringify({"type":"deviceInterview","data":{"friendlyName":"bulb","status":"failed","ieeeAddress":"0x000b57fffec6a5b2"}}), + { retain: false, qos: 0 }, + expect.any(Function) + ); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/devices', + expect.any(String), + { retain: true, qos: 0 }, + expect.any(Function) + ); + }); + + it('Should publish event and devices when device interview successful', async () => { + MQTT.publish.mockClear(); + await zigbeeHerdsman.events.deviceInterview({device: zigbeeHerdsman.devices.bulb, status: 'successful'}); + await zigbeeHerdsman.events.deviceInterview({device: zigbeeHerdsman.devices.unsupported, status: 'successful'}); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledTimes(4); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/event', + JSON.stringify({"type":"deviceInterview","data":{"friendlyName":"bulb","status":"successful","ieeeAddress":"0x000b57fffec6a5b2","supported":true,"definition":{"model":"LED1545G12","vendor":"IKEA","description":"TRADFRI LED bulb E26/E27 980 lumen, dimmable, white spectrum, opal white","supports":"on/off, brightness, color temperature"}}}), + { retain: false, qos: 0 }, + expect.any(Function) + ); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/event', + JSON.stringify({"type":"deviceInterview","data":{"friendlyName":"0x0017880104e45518","status":"successful","ieeeAddress":"0x0017880104e45518","supported":false,"definition":null}}), + { retain: false, qos: 0 }, + expect.any(Function) + ); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/devices', + expect.any(String), + { retain: true, qos: 0 }, + expect.any(Function) + ); + }); + + it('Should publish event and devices when device leaves', async () => { + MQTT.publish.mockClear(); + await zigbeeHerdsman.events.deviceLeave({ieeeAddr: zigbeeHerdsman.devices.bulb.ieeeAddr}); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledTimes(2); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/event', + JSON.stringify({"type":"deviceLeave","data":{"ieeeAddress":"0x000b57fffec6a5b2"}}), + { retain: false, qos: 0 }, + expect.any(Function) + ); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/devices', + expect.any(String), + { retain: true, qos: 0 }, + expect.any(Function) + ); + }); + + it('Should allow permit join', async () => { + zigbeeHerdsman.permitJoin.mockClear(); + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/permitJoin', 'true'); + await flushPromises(); + expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1); + expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledWith(true); + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/permitJoin', + JSON.stringify({"data":{"value":true},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + + zigbeeHerdsman.permitJoin.mockClear(); + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/permitJoin', JSON.stringify({"value": false})); + await flushPromises(); + expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1); + expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledWith(false); + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/permitJoin', + JSON.stringify({"data":{"value":false},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should put transaction in response when request is done with transaction', async () => { + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/permitJoin', JSON.stringify({"value": false, "transaction": 22})); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/permitJoin', + JSON.stringify({"data":{"value":false},"status":"ok", "transaction": 22}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Should put erorr in response when request fails', async () => { + zigbeeHerdsman.permitJoin.mockImplementationOnce(() => {throw new Error('Failed to connect to adapter')}); + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/permitJoin', JSON.stringify({"value": false})); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/permitJoin', + JSON.stringify({"data":{},"status":"error","error": "Failed to connect to adapter"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Coverage satisfaction', async () => { + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/random', JSON.stringify({"value": false})); + const device = zigbeeHerdsman.devices.bulb; + await zigbeeHerdsman.events.message({data: {onOff: 1}, cluster: 'genOnOff', device, endpoint: device.getEndpoint(1), type: 'attributeReport', linkquality: 10}); + await flushPromises(); + }); +});