diff --git a/lib/controller.js b/lib/controller.js index 74542dfb..7f09c834 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -70,6 +70,18 @@ class Controller { } async start() { + const settingsErrors = settings.validate(); + if (settingsErrors) { + logger.error(`Refusing to start, configuration.yaml is not valid, found the following errors:`); + for (const error of settingsErrors) { + logger.error(`\t - ${error}`); + } + logger.error( + `If you don't know how to solve this, read https://www.zigbee2mqtt.io/configuration/configuration.html` + ); + process.exit(1); + } + logger.info(`Logging to directory: '${logger.directory}'`); logger.cleanup(); this.state.start(); @@ -242,7 +254,7 @@ class Controller { } const options = { - retain: entity.settings.hasOwnProperty('retain') ? entity.settings.retain : false, + retain: entity.settings.retain, qos: entity.settings.hasOwnProperty('qos') ? entity.settings.qos : 0, }; diff --git a/lib/util/settings.js b/lib/util/settings.js index d2a4b728..20957ff6 100644 --- a/lib/util/settings.js +++ b/lib/util/settings.js @@ -3,6 +3,8 @@ const file = data.joinPath('configuration.yaml'); const objectAssignDeep = require(`object-assign-deep`); const path = require('path'); const yaml = require('./yaml'); +const Ajv = require('ajv'); +const ajv = new Ajv({allErrors: true}); const defaults = { whitelist: [], @@ -100,6 +102,138 @@ const defaults = { }, }; +const schema = { + type: 'object', + properties: { + homeassistant: {type: 'boolean'}, + permit_join: {type: 'boolean'}, + mqtt: { + type: 'object', + properties: { + base_topic: {type: 'string'}, + server: {type: 'string'}, + ca: {type: 'string'}, + key: {type: 'string'}, + cert: {type: 'string'}, + user: {type: 'string'}, + password: {type: 'string'}, + client_id: {type: 'string'}, + reject_unauthorized: {type: 'boolean'}, + include_device_information: {type: 'boolean'}, + }, + required: ['base_topic', 'server'], + }, + serial: { + type: 'object', + properties: { + port: {type: 'string'}, + disable_led: {type: 'boolean'}, + }, + required: ['port'], + }, + ban: {type: 'array', items: {type: 'string'}}, + whitelist: {type: 'array', items: {type: 'string'}}, + advanced: { + type: 'object', + properties: { + pan_id: {type: 'number'}, + ext_pan_id: {type: 'array', items: {type: 'number'}}, + channel: {type: 'number', minimum: 11, maximum: 26}, + cache_state: {type: 'boolean'}, + log_level: {type: 'string', enum: ['info', 'warn', 'error', 'debug']}, + log_directory: {type: 'string'}, + baudrate: {type: 'number'}, + rtscts: {type: 'boolean'}, + soft_reset_timeout: {type: 'number', minimum: 0}, + network_key: {type: 'array', items: {type: 'number'}}, + last_seen: {type: 'string', enum: ['disable', 'ISO_8601', 'ISO_8601_local', 'epoch']}, + elapsed: {type: 'boolean'}, + availability_timeout: {type: 'number', minimum: 0}, + availability_blacklist: {type: 'array', items: {type: 'string'}}, + report: {type: 'boolean'}, + homeassistant_discovery_topic: {type: 'string'}, + homeassistant_status_topic: {type: 'string'}, + }, + }, + map_options: { + type: 'object', + properties: { + graphviz: { + type: 'object', + properties: { + colors: { + type: 'object', + properties: { + fill: { + type: 'object', + properties: { + enddevice: {type: 'string'}, + coordinator: {type: 'string'}, + router: {type: 'string'}, + }, + }, + font: { + type: 'object', + properties: { + enddevice: {type: 'string'}, + coordinator: {type: 'string'}, + router: {type: 'string'}, + }, + }, + line: { + type: 'object', + properties: { + active: {type: 'string'}, + inactive: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + }, + devices: { + type: 'object', + propertyNames: { + pattern: '^0x[\\d\\w]{16}$', + }, + patternProperties: { + '^.*$': { + type: 'object', + properties: { + friendly_name: {type: 'string'}, + retain: {type: 'boolean'}, + qos: {type: 'number'}, + }, + required: ['friendly_name', 'retain'], + }, + }, + }, + groups: { + type: 'object', + propertyNames: { + pattern: '^[\\w].*$', + }, + patternProperties: { + '^.*$': { + type: 'object', + properties: { + friendly_name: {type: 'string'}, + retain: {type: 'boolean'}, + devices: {type: 'array', items: {type: 'string'}}, + optimistic: {type: 'boolean'}, + qos: {type: 'number'}, + }, + required: ['friendly_name', 'retain'], + }, + }, + }, + }, + required: ['homeassistant', 'permit_join', 'mqtt', 'serial'], +}; + + let _settings; let _settingsWithDefaults; @@ -125,6 +259,12 @@ function write() { _settingsWithDefaults = objectAssignDeep.noMutate(defaults, get()); } +function validate() { + const validate = ajv.compile(schema); + const valid = validate(_settings); + return !valid ? validate.errors.map((v) => `${v.dataPath.substring(1)} ${v.message}`) : null; +} + function read() { const s = yaml.read(file); @@ -391,6 +531,7 @@ function changeFriendlyName(IDorName, newName) { } module.exports = { + validate, get: getWithDefaults, set, getDevice, diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index d4b63de7..aa643935 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -383,9 +383,9 @@ } }, "@types/babel__generator": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.0.2.tgz", - "integrity": "sha512-NHcOfab3Zw4q5sEE2COkpfXjoE7o+PmqD9DQW4koUT3roNxwziUdXGnRndMat/LJNUtePwn1TlP4do3uoe3KZQ==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.0.tgz", + "integrity": "sha512-c1mZUu4up5cp9KROs/QAw0gTeHrw/x7m52LcnvMxxOZ03DmLwPV0MlGmlgzV3cnSdjhJOZsj7E7FHeioai+egw==", "dev": true, "requires": { "@babel/types": "^7.0.0" @@ -504,15 +504,15 @@ } }, "abab": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.1.tgz", - "integrity": "sha512-1zSbbCuoIjafKZ3mblY5ikvAb0ODUbqBnFuUb7f6uLeQhhGJ0vEV4ntmtxKLT2WgXCO94E07BjunsIw1jOMPZw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.2.tgz", + "integrity": "sha512-2scffjvioEmNz0OyDSLGWDfKCVwaKc6l9Pm9kOIREU13ClXZvHpg/nRL5xyjSSSLhOnXqft2HpsAzNEEA8cFFg==", "dev": true }, "acorn": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.0.0.tgz", - "integrity": "sha512-PaF/MduxijYYt7unVGRuds1vBC9bFxbNf+VWqhOClfdgy7RlVkQqt610ig1/yxTgsDIfW1cWDel5EBbOy3jdtQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz", + "integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==", "dev": true }, "acorn-globals": { @@ -549,7 +549,6 @@ "version": "6.10.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", - "dev": true, "requires": { "fast-deep-equal": "^2.0.1", "fast-json-stable-stringify": "^2.0.0", @@ -1407,9 +1406,9 @@ } }, "end-of-stream": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.2.tgz", - "integrity": "sha512-gUSUszrsxlDnUbUwEI9Oygyrk4ZEWtVaHQc+uZHphVeNxl+qeqMV/jDWoTkjN1RmGlZ5QWAP7o458p/JMlikQg==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.3.tgz", + "integrity": "sha512-cbNhPFS6MlYlWTGncSiDYbdqKhwWFy7kNeb1YSOG6K65i/wPTkLVCJQj0hXA4j0m5Da+hBWnqopEnu1FFelisQ==", "requires": { "once": "^1.4.0" } @@ -1930,14 +1929,12 @@ "fast-deep-equal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", - "dev": true + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" }, "fast-json-stable-stringify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", - "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", - "dev": true + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" }, "fast-levenshtein": { "version": "2.0.6", @@ -2732,9 +2729,9 @@ "dev": true }, "handlebars": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.2.1.tgz", - "integrity": "sha512-bqPIlDk06UWbVEIFoYj+LVo42WhK96J+b25l7hbFDpxrOXMphFM3fNIm+cluwg4Pk2jiLjWU5nHQY7igGE75NQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.3.1.tgz", + "integrity": "sha512-c0HoNHzDiHpBt4Kqe99N8tdLPKAnGCQ73gYMPWtAYM4PwGnf7xl8PBUHJqh9ijlzt2uQKaSRxbXRt+rZ7M2/kA==", "dev": true, "requires": { "neo-async": "^2.6.0", @@ -3779,8 +3776,7 @@ "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -4639,8 +4635,7 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "qs": { "version": "6.5.2", @@ -5755,7 +5750,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", - "dev": true, "requires": { "punycode": "^2.1.0" } diff --git a/package.json b/package.json index 088a27bf..1eaf9287 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ }, "homepage": "https://koenkk.github.io/zigbee2mqtt", "dependencies": { + "ajv": "*", "debounce": "*", "git-last-commit": "*", "js-yaml": "*", diff --git a/test/controller.test.js b/test/controller.test.js index fa0d0895..14c8a8d4 100644 --- a/test/controller.test.js +++ b/test/controller.test.js @@ -11,7 +11,7 @@ const tmp = require('tmp'); const mocksClear = [ zigbeeHerdsman.permitJoin, mockExit, MQTT.end, zigbeeHerdsman.stop, logger.debug, MQTT.publish, MQTT.connect, zigbeeHerdsman.devices.bulb_color.removeFromNetwork, - zigbeeHerdsman.devices.bulb.removeFromNetwork + zigbeeHerdsman.devices.bulb.removeFromNetwork, logger.error, ]; const fs = require('fs'); @@ -142,6 +142,15 @@ describe('Controller', () => { expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledWith(false); }); + it('Refuse to start when configuration.yaml is invalid', async () => { + settings.set(['permit_join'], 'invalid'); + await controller.start(); + expect(logger.error).toHaveBeenCalledWith('Refusing to start, configuration.yaml is not valid, found the following errors:'); + expect(logger.error).toHaveBeenCalledWith('\t - permit_join should be boolean'); + expect(mockExit).toHaveBeenCalledTimes(1); + expect(mockExit).toHaveBeenCalledWith(1); + }); + it('Start controller with permit join true', async () => { settings.set(['serial', 'disable_led'], true); await controller.start(); diff --git a/test/group.test.js b/test/group.test.js index b5416234..610dde97 100644 --- a/test/group.test.js +++ b/test/group.test.js @@ -22,7 +22,7 @@ describe('Groups', () => { }) it('Apply group updates add', async () => { - settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: ['bulb', 'bulb_color']}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['bulb', 'bulb_color']}}); zigbeeHerdsman.groups.group_1.members.push(zigbeeHerdsman.devices.bulb.getEndpoint(1)) await controller.start(); await flushPromises(); @@ -36,7 +36,7 @@ describe('Groups', () => { const endpoint = zigbeeHerdsman.devices.bulb_color.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; group.members.push(endpoint); - settings.set(['groups'], {'1': {friendly_name: 'group_1'}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false,}}); await controller.start(); await flushPromises(); expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]); @@ -47,7 +47,7 @@ describe('Groups', () => { const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; group.members.push(endpoint); - settings.set(['groups'], {'3': {friendly_name: 'group_3', devices: [device.ieeeAddr]}}); + settings.set(['groups'], {'3': {friendly_name: 'group_3', retain: false, devices: [device.ieeeAddr]}}); await controller.start(); await flushPromises(); expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]); @@ -55,7 +55,7 @@ describe('Groups', () => { it('Add non standard endpoint to group with name', async () => { const QBKG03LM = zigbeeHerdsman.devices.QBKG03LM; - settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: ['0x0017880104e45542/right']}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['0x0017880104e45542/right']}}); await controller.start(); await flushPromises(); expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([QBKG03LM.getEndpoint(3)]); @@ -63,7 +63,7 @@ describe('Groups', () => { it('Add non standard endpoint to group with number', async () => { const QBKG03LM = zigbeeHerdsman.devices.QBKG03LM; - settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: ['wall_switch_double/2']}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['wall_switch_double/2']}}); await controller.start(); await flushPromises(); expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([QBKG03LM.getEndpoint(2)]); @@ -71,7 +71,7 @@ describe('Groups', () => { it('Shouldnt crash on non-existing devices', async () => { logger.error.mockClear(); - settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: ['not_existing_bla']}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['not_existing_bla']}}); await controller.start(); await flushPromises(); expect(zigbeeHerdsman.groups.group_1.members).toStrictEqual([]); @@ -82,7 +82,7 @@ describe('Groups', () => { const device = zigbeeHerdsman.devices.bulb_color; const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; - settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: []}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: []}}); expect(group.members.length).toBe(0); await controller.start(); await flushPromises(); @@ -126,7 +126,7 @@ describe('Groups', () => { const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; group.members.push(endpoint); - settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: [device.ieeeAddr]}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}}); await controller.start(); await flushPromises(); await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'bulb_color'); @@ -141,7 +141,7 @@ describe('Groups', () => { const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; group.members.push(endpoint); - settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: ['dummy']}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: ['dummy']}}); await controller.start(); await flushPromises(); await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'bulb_color'); @@ -155,7 +155,7 @@ describe('Groups', () => { const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; group.members.push(endpoint); - settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: [`wall_switch_double/right`]}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/right`]}}); await controller.start(); await flushPromises(); await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', '0x0017880104e45542/3'); @@ -169,7 +169,7 @@ describe('Groups', () => { const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; group.members.push(endpoint); - settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: [`0x0017880104e45542/right`]}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`0x0017880104e45542/right`]}}); await controller.start(); await flushPromises(); await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', 'wall_switch_double/3'); @@ -183,7 +183,7 @@ describe('Groups', () => { const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; group.members.push(endpoint); - settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: [`wall_switch_double/3`]}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}}); await controller.start(); await flushPromises(); await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove', '0x0017880104e45542/right'); @@ -197,7 +197,7 @@ describe('Groups', () => { const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; group.members.push(endpoint); - settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: [`wall_switch_double/3`]}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}}); await controller.start(); await flushPromises(); await MQTT.events.message('zigbee2mqtt/bridge/group/remove_all', '0x0017880104e45542/right'); @@ -212,7 +212,7 @@ describe('Groups', () => { const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; group.members.push(endpoint); - settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: [`wall_switch_double/3`]}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [`wall_switch_double/3`]}}); await controller.start(); await flushPromises(); await MQTT.events.message('zigbee2mqtt/bridge/group/group_1/remove_all', '0x0017880104e45542/right'); @@ -244,7 +244,7 @@ describe('Groups', () => { const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; group.members.push(endpoint); - settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: [device.ieeeAddr]}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', retain: false, devices: [device.ieeeAddr]}}); await controller.start(); await flushPromises(); @@ -260,7 +260,7 @@ describe('Groups', () => { const endpoint = device.getEndpoint(1); const group = zigbeeHerdsman.groups.group_1; group.members.push(endpoint); - settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: [device.ieeeAddr], optimistic: false}}); + settings.set(['groups'], {'1': {friendly_name: 'group_1', devices: [device.ieeeAddr], optimistic: false, retain: false}}); await controller.start(); await flushPromises(); diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index 868ba3a1..8c5f067f 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -171,6 +171,7 @@ describe('HomeAssistant extension', () => { temperature_precision: 1, pressure_precision: 2, friendly_name: 'weather_sensor', + retain: false, }) controller = new Controller(false); @@ -276,6 +277,7 @@ describe('HomeAssistant extension', () => { } }, friendly_name: 'weather_sensor', + retain: false, }) controller = new Controller(false); diff --git a/test/stub/data.js b/test/stub/data.js index d21cff7a..de867d59 100644 --- a/test/stub/data.js +++ b/test/stub/data.js @@ -36,6 +36,7 @@ function writeDefaultConfiguration() { }, "0x0017880104e45522": { qos: 1, + retain: false, friendly_name: "weather_sensor" }, "0x0017880104e45523": {