diff --git a/index.js b/index.js index 08cfb502c..64ec75724 100644 --- a/index.js +++ b/index.js @@ -1,38 +1,59 @@ const semver = require('semver'); const engines = require('./package.json').engines; +const indexJsRestart = 'indexjs.restart'; -const version = engines.node; -if (!semver.satisfies(process.version, version)) { - console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`); // eslint-disable-line +let controller; +let stopping = false; + +async function restart() { + await stop(indexJsRestart); + await start(); } -// Validate settings -const settings = require('./lib/util/settings'); -const errors = settings.validate(); -if (errors.length > 0) { - console.log(`\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`); - console.log(' READ THIS CAREFULLY\n'); - console.log(`Refusing to start because configuration is not valid, found the following errors:`); - for (const error of errors) { - console.log(`- ${error}`); +async function exit(code, reason) { + if (reason !== indexJsRestart) { + process.exit(code); } - console.log(`\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/information/configuration.html`); - console.log(`\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n`); - process.exit(1); } -const Controller = require('./lib/controller'); -const controller = new Controller(); -controller.start(); +async function start() { + const version = engines.node; + if (!semver.satisfies(process.version, version)) { + console.log(`\t\tZigbee2MQTT requires node version ${version}, you are running ${process.version}!\n`); // eslint-disable-line + } + + // Validate settings + const settings = require('./lib/util/settings'); + const errors = settings.validate(); + if (errors.length > 0) { + console.log(`\n\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`); + console.log(' READ THIS CAREFULLY\n'); + console.log(`Refusing to start because configuration is not valid, found the following errors:`); + for (const error of errors) { + console.log(`- ${error}`); + } + console.log(`\nIf you don't know how to solve this, read https://www.zigbee2mqtt.io/information/configuration.html`); // eslint-disable-line + console.log(`\n!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n\n`); + exit(1); + } + + const Controller = require('./lib/controller'); + controller = new Controller(restart, exit); + await controller.start(); +} + +async function stop(reason=null) { + await controller.stop(reason); +} + +async function handleQuit() { + if (!stopping && controller) { + stopping = true; + await stop(); + } +} process.on('SIGINT', handleQuit); process.on('SIGTERM', handleQuit); -let stopping = false; - -function handleQuit() { - if (!stopping) { - stopping = true; - controller.stop(); - } -} +start(); diff --git a/lib/controller.js b/lib/controller.js index 8dbd865d2..bf6b2a3ad 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -39,11 +39,13 @@ const AllExtensions = [ ]; class Controller { - constructor() { + constructor(restartCallback, exitCallback) { this.zigbee = new Zigbee(); this.mqtt = new MQTT(); this.eventBus = new EventBus(); this.state = new State(this.eventBus); + this.restartCallback = restartCallback; + this.exitCallback = exitCallback; this.publishEntityState = this.publishEntityState.bind(this); this.enableDisableExtension = this.enableDisableExtension.bind(this); @@ -52,7 +54,7 @@ class Controller { // Initialize extensions. const args = [this.zigbee, this.mqtt, this.state, this.publishEntityState, this.eventBus]; this.extensions = [ - new ExtensionBridge(...args, this.enableDisableExtension), + new ExtensionBridge(...args, this.enableDisableExtension, this.restartCallback), new ExtensionPublish(...args), new ExtensionReceive(...args), new ExtensionDeviceGroupMembership(...args), @@ -117,7 +119,7 @@ class Controller { logger.error('Failed to start zigbee'); logger.error('Exiting...'); logger.error(error.stack); - process.exit(1); + this.exitCallback(1); } // Log zigbee clients on startup @@ -185,7 +187,7 @@ class Controller { } } - async stop() { + async stop(reason=null) { // Call extensions await this.callExtensionMethod('stop', []); @@ -195,10 +197,11 @@ class Controller { try { await this.zigbee.stop(); - process.exit(0); + logger.info('Stopped Zigbee2MQTT'); + this.exitCallback(0, reason); } catch (error) { - logger.error('Failed to stop zigbee'); - process.exit(1); + logger.error('Failed to stop Zigbee2MQTT'); + this.exitCallback(1, reason); } } diff --git a/lib/extension/bridge.js b/lib/extension/bridge.js index 5c8be4410..52f8da44c 100644 --- a/lib/extension/bridge.js +++ b/lib/extension/bridge.js @@ -6,15 +6,18 @@ const settings = require('../util/settings'); const Transport = require('winston-transport'); const stringify = require('json-stable-stringify-without-jsonify'); const objectAssignDeep = require(`object-assign-deep`); +const {updatedDiff, addedDiff} = require('deep-object-diff'); const requestRegex = new RegExp(`${settings.get().mqtt.base_topic}/bridge/request/(.*)`); class Bridge extends Extension { - constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension) { + constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback) { super(zigbee, mqtt, state, publishEntityState, eventBus); this.enableDisableExtension = enableDisableExtension; + this.restartCallback = restartCallback; this.lastJoinedDeviceIeeeAddr = null; this.setupMQTTLogging(); + this.restartRequired = false; this.requestLookup = { 'device/options': this.deviceOptions.bind(this), @@ -26,15 +29,17 @@ class Bridge extends Extension { 'group/remove': this.groupRemove.bind(this), 'group/rename': this.groupRename.bind(this), 'permit_join': this.permitJoin.bind(this), - 'config/last_seen': this.configLastSeen.bind(this), - 'config/homeassistant': this.configHomeAssistant.bind(this), - 'config/elapsed': this.configElapsed.bind(this), - 'config/log_level': this.configLogLevel.bind(this), + 'restart': this.restart.bind(this), 'touchlink/factory_reset': this.touchlinkFactoryReset.bind(this), 'touchlink/identify': this.touchlinkIdentify.bind(this), 'touchlink/scan': this.touchlinkScan.bind(this), 'health_check': this.healthCheck.bind(this), 'options': this.bridgeOptions.bind(this), + // Below are deprecated + 'config/last_seen': this.configLastSeen.bind(this), + 'config/homeassistant': this.configHomeAssistant.bind(this), + 'config/elapsed': this.configElapsed.bind(this), + 'config/log_level': this.configLogLevel.bind(this), }; } @@ -73,7 +78,9 @@ class Bridge extends Extension { } permitJoinChanged(data) { - this.publishInfo(); + if (!this.zigbee.isStopping()) { + this.publishInfo(); + } } async onMQTTMessage(topic, message) { @@ -142,7 +149,46 @@ class Bridge extends Extension { } async bridgeOptions(message) { - return utils.getResponse(message, {}, 'Saving settings is not yet implemented'); + if (typeof message !== 'object' || typeof message.options !== 'object') { + throw new Error(`Invalid payload`); + } + + const diffUpdated = updatedDiff(settings.get(), message.options); + const diffAdded = addedDiff(settings.get(), message.options); + const newSettings = objectAssignDeep.noMutate(diffUpdated, diffAdded); + + // deep-object-diff converts arrays to objects, set original array back here + const convertBackArray = (before, after) => { + for (const [key, afterValue] of Object.entries(after)) { + const beforeValue = before[key]; + if (Array.isArray(beforeValue)) { + after[key] = beforeValue; + } else if (typeof beforeValue === 'object') { + convertBackArray(beforeValue, afterValue); + } + } + }; + convertBackArray(message.options, newSettings); + + const restartRequired = settings.apply(newSettings); + if (restartRequired) this.restartRequired = true; + + // Apply some settings on-the-fly. + if (newSettings.hasOwnProperty('permit_join')) { + await this.zigbee.permitJoin(newSettings.permit_join); + } + + if (newSettings.hasOwnProperty('homeassistant')) { + this.enableDisableExtension(newSettings.homeassistant, 'HomeAssistant'); + } + + if (newSettings.hasOwnProperty('advanced') && newSettings.advanced.hasOwnProperty('log_level')) { + logger.setLevel(newSettings.advanced.log_level); + } + + logger.info('Succesfully changed options'); + this.publishInfo(); + return utils.getResponse(message, {restart_required: this.restartRequired}, null); } async deviceRemove(message) { @@ -178,6 +224,13 @@ class Bridge extends Extension { return this.renameEntity('group', message); } + async restart(message) { + // Wait 500 ms before restarting so response can be send. + setTimeout(this.restartCallback, 500); + logger.info('Restarting Zigbee2MQTT'); + return utils.getResponse(message, {}, null); + } + async permitJoin(message) { if (typeof message === 'object' && !message.hasOwnProperty('value')) { throw new Error('Invalid payload'); @@ -210,6 +263,7 @@ class Bridge extends Extension { return utils.getResponse(message, response, null); } + // Deprecated configLastSeen(message) { const allowed = ['disable', 'ISO_8601', 'epoch', 'ISO_8601_local']; const value = this.getValue(message); @@ -222,6 +276,7 @@ class Bridge extends Extension { return utils.getResponse(message, {value}, null); } + // Deprecated configHomeAssistant(message) { const allowed = [true, false]; const value = this.getValue(message); @@ -235,6 +290,7 @@ class Bridge extends Extension { return utils.getResponse(message, {value}, null); } + // Deprecated configElapsed(message) { const allowed = [true, false]; const value = this.getValue(message); @@ -247,6 +303,7 @@ class Bridge extends Extension { return utils.getResponse(message, {value}, null); } + // Deprecated configLogLevel(message) { const allowed = ['error', 'warn', 'info', 'debug']; const value = this.getValue(message); @@ -498,6 +555,7 @@ class Bridge extends Extension { network: utils.toSnakeCase(await this.zigbee.getNetworkParameters()), log_level: logger.getLevel(), permit_join: await this.zigbee.getPermitJoin(), + restart_required: this.restartRequired, config, config_schema: settings.schema, }; diff --git a/lib/util/settings.js b/lib/util/settings.js index e65f1fc5c..36e7d22db 100644 --- a/lib/util/settings.js +++ b/lib/util/settings.js @@ -6,8 +6,10 @@ 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 schema = require('./settings.schema.json'); +const ajvSetting = new Ajv({allErrors: true}).compile(schema); +const ajvRestartRequired = new Ajv({allErrors: true}) + .addKeyword('requiresRestart', {validate: (v) => !v}).compile(schema); const defaults = { passlist: [], @@ -206,10 +208,8 @@ function validate() { return [error.message]; } - const validate = ajv.compile(schema); - validate(_settings); - if (validate.errors) { - return validate.errors.map((v) => `${v.dataPath.substring(1)} ${v.message}`); + if (!ajvSetting(_settings)) { + return ajvSetting.errors.map((v) => `${v.dataPath.substring(1)} ${v.message}`); } const errors = []; @@ -392,6 +392,24 @@ function set(path, value) { write(); } +function apply(newSettings) { + ajvSetting(newSettings); + const errors = ajvSetting.errors && ajvSetting.errors.filter((e) => e.keyword !== 'required'); + if (errors.length) { + const error = errors[0]; + throw new Error(`${error.dataPath.substring(1)} ${error.message}`); + } + + get(); // Ensure _settings is intialized. + _settings = objectAssignDeep.noMutate(_settings, newSettings); + write(); + + ajvRestartRequired(newSettings); + const restartRequired = ajvRestartRequired.errors && + !!ajvRestartRequired.errors.find((e) => e.keyword === 'requiresRestart'); + return restartRequired; +} + function getGroup(IDorName) { const settings = getWithDefaults(); const byID = settings.groups[IDorName]; @@ -637,6 +655,7 @@ module.exports = { validate, get: getWithDefaults, set, + apply, getDevice, getGroup, getGroups, diff --git a/lib/util/settings.schema.json b/lib/util/settings.schema.json index dc77dca63..d4020514f 100644 --- a/lib/util/settings.schema.json +++ b/lib/util/settings.schema.json @@ -5,7 +5,7 @@ "type": "object" }, "homeassistant": { - "title": "Homeassistant integration", + "title": "Home Assistant integration", "type": "boolean", "description": "Home Assistant integration (MQTT discovery)", "default": false @@ -14,11 +14,12 @@ "type": "boolean", "default": false, "title": "Permit join", - "description": "Allow new devices to join. WARNING: Disable this after all devices have been paired!" + "description": "Allow new devices to join (re-applied at restart)" }, "external_converters": { "type": "array", "title": "External converters", + "requiresRestart": true, "items": { "type": "string" }, @@ -31,60 +32,69 @@ "base_topic": { "type": "string", "title": "Base topic", + "requiresRestart": true, "description": "MQTT base topic for Zigbee2MQTT MQTT messages", "examples": ["zigbee2mqtt"] }, "server": { "type": "string", "title": "MQTT server", + "requiresRestart": true, "description": "MQTT server URL (use mqtts:// for SSL/TLS connection)", "examples": ["mqtt://localhost:1883"] }, "keepalive": { "type": "number", "title": "Keepalive", + "requiresRestart": true, "description": "MQTT keepalive in second", "default": 60 }, "ca": { "type": "string", "title": "Certificate authority", + "requiresRestart": true, "description": "Absolute path to SSL/TLS certificate of CA used to sign server and client certificates", "examples": ["/etc/ssl/mqtt-ca.crt"] }, "key": { "type": "string", "title": "SSL/TLS key", + "requiresRestart": true, "description": "Absolute paths to SSL/TLS key and certificate for client-authentication", "examples": ["/etc/ssl/mqtt-client.key"] - }, "cert": { "type": "string", "title": "SSL/TLS certificate", + "requiresRestart": true, "examples": ["/etc/ssl/mqtt-client.crt"] }, "user": { "type": "string", "title": "User", + "requiresRestart": true, "description": "MQTT server authentication user", "examples": ["johnnysilverhand"] }, "password": { "type": "string", "title": "Password", + "requiresRestart": true, "description": "MQTT server authentication password", "examples": ["ILOVEPELMENI"] }, "client_id": { "type": "string", "title": "Client ID", + "requiresRestart": true, "description": "MQTT client ID", "examples": ["MY_CLIENT_ID"] }, "reject_unauthorized": { "type": "boolean", "title": "Reject unauthorized", + "requiresRestart": true, "description": "Disable self-signed SSL certificate", "default": true }, @@ -97,12 +107,14 @@ "version": { "type": ["number", "null"], "title": "Version", + "requiresRestart": true, "description": "MQTT protocol version", "default": 4 }, "force_disable_retain": { "type": "boolean", "title": "Force disable retain", + "requiresRestart": true, "description": "Disable retain for all send messages. ONLY enable if you MQTT broker doesn't support retained message (e.g. AWS IoT core, Azure IoT Hub, Google Cloud IoT core, IBM Watson IoT Platform). Enabling will break the Home Assistant integration", "default": false } @@ -116,25 +128,29 @@ "port": { "type": ["string", "null"], "title": "Port", + "requiresRestart": true, "description": "Location of the adapter. To autodetect the port, set null", "examples": ["/dev/ttyACM0"] }, "disable_led": { "type": "boolean", "title": "Disable led", + "requiresRestart": true, "description": "Disable LED of the adapter if supported", "default": false }, "adapter": { - "type": "string", + "type": ["string", "null"], "enum": ["deconz", "zstack", "zigate"], "title": "Adapter", + "requiresRestart": true, "description": "Adapter type, not needed unless you are experiencing problems" } } }, "blocklist": { "title": "Blocklist", + "requiresRestart": true, "description": "Block devices from the network (by ieeeAddr)", "type": "array", "items": { @@ -143,6 +159,7 @@ }, "passlist": { "title": "Passlist", + "requiresRestart": true, "description": "Allow only certain devices to join the network (by ieeeAddr). Note that all devices not on the passlist will be removed from the network!", "type": "array", "items": { @@ -150,15 +167,19 @@ } }, "whitelist": { + "readOnly": true, "type": "array", - "title": "Whitelist (deprecated)", + "requiresRestart": true, + "title": "Whitelist (deprecated, use passlist)", "items": { "type": "string" } }, "ban": { + "readOnly": true, "type": "array", - "title": "Ban (deprecated)", + "requiresRestart": true, + "title": "Ban (deprecated, use blocklist)", "items": { "type": "string" } @@ -169,7 +190,9 @@ "properties": { "transmit_power": { "type": ["number", "null"], - "title": "Transmit power" + "title": "Transmit power", + "requiresRestart": true, + "description": "Transmit power of adapter, only available for Z-Stack (CC253*/CC2652/CC1352) adapters" }, "output": { "type": "string", @@ -186,6 +209,7 @@ "legacy_api": { "type": "boolean", "title": "Legacy API", + "requiresRestart": true, "description": "Disables the legacy api (false = disable)", "default": true }, @@ -200,7 +224,8 @@ } ], "title": "Pan ID", - "description": "ZigBee pan ID" + "requiresRestart": true, + "description": "ZigBee pan ID, changing requires repairing all devices!" }, "ext_pan_id": { "type": "array", @@ -208,7 +233,8 @@ "type": "number" }, "title": "Ext Pan ID", - "description": "Zigbee extended pan ID" + "requiresRestart": true, + "description": "Zigbee extended pan ID, changing requires repairing all devices!" }, "channel": { "type": "number", @@ -216,7 +242,8 @@ "maximum": 26, "default": 11, "title": "ZigBee channel", - "description": "Changing requires re-pairing of all devices. (Note: use a ZLL channel: 11, 15, 20, or 25 to avoid Problems)" + "requiresRestart": true, + "description": "Zigbee channel, changing requires repairing all devices! (Note: use a ZLL channel: 11, 15, 20, or 25 to avoid Problems)" }, "cache_state": { "type": "boolean", @@ -227,18 +254,19 @@ "cache_state_persistent": { "type": "boolean", "title": "Chache state persistent", - "description": "Persist cached state, only used when Cache state(cache_state: true)", + "description": "Persist cached state, only used when cache_state: true", "default": true }, "cache_state_send_on_startup": { "type": "boolean", "title": "Cache state send on startup", - "description": "Cache state send on startup, only used when Cache state(cache_state: true)", + "description": "Cache state send on startup, only used when cache_state: true", "default": true }, "log_rotation": { "type": "boolean", "title": "Log rotation", + "requiresRestart": true, "description": "Log rotation", "default": true }, @@ -251,6 +279,7 @@ }, "log_output": { "type": "array", + "requiresRestart": true, "items": { "type": "string", "enum": ["console", "file", "syslog"] @@ -261,12 +290,14 @@ "log_directory": { "type": "string", "title": "Log directory", + "requiresRestart": true, "description": "Location of log directory", "examples": ["data/log/%TIMESTAMP%"] }, "log_file": { "type": "string", "title": "Log file", + "requiresRestart": true, "description": "Log file name, can also contain timestamp", "examples": ["zigbee2mqtt_%TIMESTAMP%.log"], "default": "log.txt" @@ -274,18 +305,20 @@ "baudrate": { "type": "number", "title": "Baudrate", + "requiresRestart": true, "description": "Baudrate for serial port, default: 115200 for Z-Stack, 38400 for Deconz", "examples": [38400, 115200] }, "rtscts": { "type": "boolean", "title": "RTS / CTS", - "description": "RTS / CTS Hardware Flow Control for serial port", - "default": false + "requiresRestart": true, + "description": "RTS / CTS Hardware Flow Control for serial port" }, "soft_reset_timeout": { "type": "number", "minimum": 0, + "requiresRestart": true, "title": "Soft reset timeout (deprecated)", "description": "Soft reset ZNP after timeout", "readOnly": true @@ -304,7 +337,8 @@ } ], "title": "Network key", - "description": "Network encryption key, will improve security (Note: changing requires repairing of all devices)" + "requiresRestart": true, + "description": "Network encryption key, changing requires repairing all devices!" }, "last_seen": { "type": "string", @@ -323,14 +357,16 @@ "type": "number", "minimum": 0, "default": 0, + "requiresRestart": true, "title": "Availability Timeout", - "description": "Availability timeout in seconds When enabled, devices will be checked if they are still online. Only AC powered routers are checked for availability" + "description": "Availability timeout in seconds when enabled, devices will be checked if they are still online. Only AC powered routers are checked for availability" }, "availability_blocklist": { "type": "array", "items": { "type": "string" }, + "requiresRestart": true, "title": "Availability Blocklist", "description": "Prevent devices from being checked for availability" }, @@ -339,51 +375,63 @@ "items": { "type": "string" }, + "requiresRestart": true, "title": "Availability passlist", "description": "Only enable availability check for certain devices" }, "availability_blacklist": { "type": "array", - "title": "Availability blacklist (deprecated)", + "readOnly": true, + "requiresRestart": true, + "title": "Availability blacklist (deprecated, use availability_blocklist)", "items": { "type": "string" } }, "availability_whitelist": { "type": "array", + "readOnly": true, + "requiresRestart": true, "items": { "type": "string" }, - "title": "Availability whitelist (deprecated)" + "title": "Availability whitelist (deprecated, use passlist)" }, "report": { "type": "boolean", "title": "Reporting", - "description": "Enables report feature" + "requiresRestart": true, + "readOnly": true, + "description": "Enables report feature (deprecated)" }, "homeassistant_discovery_topic": { "type": "string", "title": "Homeassistant discovery topic", + "requiresRestart": true, "examples": ["homeassistant"] }, "homeassistant_status_topic": { "type": "string", "title": "Home Assistant status topic", + "requiresRestart": true, "examples": ["homeassistant/status"] }, "timestamp_format": { "type": "string", "title": "Timestamp format", + "requiresRestart": true, "description": "Log timestamp format", "examples": ["YYYY-MM-DD HH:mm:ss"] }, "adapter_concurrent": { "title": "Adapter concurrency", + "requiresRestart": true, "type": ["number", "null"], "description": "Adapter concurrency (e.g. 2 for CC2531 or 16 for CC26X2R1) (default: null, uses recommended value)" }, "adapter_delay": { "type": ["number", "null"], + "requiresRestart": true, "title": "Adapter delay" } } diff --git a/lib/zigbee.js b/lib/zigbee.js index 1cff4d73f..433db19d0 100644 --- a/lib/zigbee.js +++ b/lib/zigbee.js @@ -112,6 +112,10 @@ class Zigbee extends events.EventEmitter { return this.herdsman.getCoordinatorVersion(); } + isStopping() { + return this.herdsman.isStopping(); + } + async getNetworkParameters() { return this.herdsman.getNetworkParameters(); } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 7bca95b25..d4f9275bd 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -707,6 +707,11 @@ "fastq": "^1.6.0" } }, + "@serialport/parser-inter-byte-timeout": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-9.0.1.tgz", + "integrity": "sha512-lFflcUflcP5SF4vLIixAKs1xUI/wfOzCv1Xq78VbPOBlIjZ6ny9lQ6g7cMPR/sB/M1BHwGcdX7CEr90pe3kkog==" + }, "@sinonjs/commons": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.2.tgz", @@ -1719,6 +1724,11 @@ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, + "deep-object-diff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.0.tgz", + "integrity": "sha512-b+QLs5vHgS+IoSNcUE4n9HP2NwcHj7aqnJWsjPtuG75Rh5TOaGt0OjAYInh77d5T16V5cRDC+Pw/6ZZZiETBGw==" + }, "deepmerge": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", @@ -21213,11 +21223,6 @@ "resolved": "https://registry.npmjs.org/@serialport/parser-delimiter/-/parser-delimiter-9.0.1.tgz", "integrity": "sha512-+oaSl5zEu47OlrRiF5p5tn2qgGqYuhVcE+NI+Pv4E1xsNB/A0fFxxMv/8XUw466CRLEJ5IESIB9qbFvKE6ltaQ==" }, - "@serialport/parser-inter-byte-timeout": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-9.0.1.tgz", - "integrity": "sha512-lFflcUflcP5SF4vLIixAKs1xUI/wfOzCv1Xq78VbPOBlIjZ6ny9lQ6g7cMPR/sB/M1BHwGcdX7CEr90pe3kkog==" - }, "@serialport/parser-readline": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@serialport/parser-readline/-/parser-readline-9.0.1.tgz", @@ -22222,6 +22227,11 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -25374,6 +25384,13 @@ "requires": { "graceful-fs": "^4.1.6", "universalify": "^2.0.0" + }, + "dependencies": { + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==" + } } }, "jsprim": { @@ -26313,11 +26330,6 @@ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" }, - "require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" - }, "require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -26566,27 +26578,6 @@ "is-fullwidth-code-point": "^3.0.0" }, "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "requires": { - "color-convert": "^2.0.1" - } - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -26961,17 +26952,6 @@ "string-width": "^4.2.0" }, "dependencies": { - "ajv": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-7.0.3.tgz", - "integrity": "sha512-R50QRlXSxqXcQP5SvKUrw8VZeypvo12i2IX0EeR5PiZ7bEKeHWgzgo264LDadUsCU42lTJVhFikTqJwNeH34gQ==", - "requires": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - } - }, "ansi-regex": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", @@ -26982,11 +26962,6 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, "string-width": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", @@ -27522,6 +27497,11 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -27611,6 +27591,11 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", diff --git a/package.json b/package.json index 12b7bea16..7cb956238 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dependencies": { "ajv": "^6.12.4", "debounce": "^1.2.0", + "deep-object-diff": "^1.1.0", "fast-deep-equal": "^3.1.3", "finalhandler": "^1.1.2", "git-last-commit": "^1.0.0", diff --git a/test/availability.test.js b/test/availability.test.js index dbadfa4f4..bfe757add 100644 --- a/test/availability.test.js +++ b/test/availability.test.js @@ -29,7 +29,7 @@ describe('Availability', () => { data.writeEmptyState(); jest.useFakeTimers(); settings.set(['advanced', 'availability_timeout'], 10); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); mocksClear.forEach((m) => m.mockClear()); await controller.start(); await flushPromises(); @@ -203,7 +203,7 @@ describe('Availability', () => { settings.set(['advanced', 'availability_blocklist'], ['bulb_color']) await controller.stop(); await flushPromises(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); device.ping.mockClear(); @@ -217,7 +217,7 @@ describe('Availability', () => { settings.set(['advanced', 'availability_blacklist'], ['bulb_color']) await controller.stop(); await flushPromises(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); device.ping.mockClear(); @@ -231,7 +231,7 @@ describe('Availability', () => { settings.set(['advanced', 'availability_blocklist'], [device.ieeeAddr]); await controller.stop(); await flushPromises(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); device.ping.mockClear(); @@ -258,7 +258,7 @@ describe('Availability', () => { settings.set(['advanced', 'availability_passlist'], ['bulb_color']); await controller.stop(); await flushPromises(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); device.ping.mockClear(); @@ -272,7 +272,7 @@ describe('Availability', () => { settings.set(['advanced', 'availability_whitelist'], ['bulb_color']); await controller.stop(); await flushPromises(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); device.ping.mockClear(); @@ -286,7 +286,7 @@ describe('Availability', () => { settings.set(['advanced', 'availability_passlist'], [device.ieeeAddr]); await controller.stop(); await flushPromises(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); device.ping.mockClear(); @@ -301,7 +301,7 @@ describe('Availability', () => { settings.set(['advanced', 'availability_passlist'], ['0x000b57fffec6a5b3']) await controller.stop(); await flushPromises(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); jest.advanceTimersByTime(11 * 1000); @@ -387,7 +387,7 @@ describe('Availability', () => { await controller.stop(); await flushPromises(); MQTT.publish.mockClear(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); getExtension().state[device.ieeeAddr] = false; await controller.start(); await flushPromises(); diff --git a/test/bind.test.js b/test/bind.test.js index 0a2ed2a13..3a1619196 100644 --- a/test/bind.test.js +++ b/test/bind.test.js @@ -32,7 +32,7 @@ describe('Bind', () => { zigbeeHerdsman.devices.bulb_color.getEndpoint(1).bind.mockClear(); zigbeeHerdsman.devices.bulb_color_2.getEndpoint(1).read.mockClear(); debounce.mockClear(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); this.coordinatorEndoint = zigbeeHerdsman.devices.coordinator.getEndpoint(1); diff --git a/test/bridge.test.js b/test/bridge.test.js index 2aca21e23..10fa4dbc2 100644 --- a/test/bridge.test.js +++ b/test/bridge.test.js @@ -17,6 +17,7 @@ zigbeeHerdsman.returnDevices.push(ZNCZ02LM.ieeeAddr); describe('Bridge', () => { let controller; + let mockRestart; beforeEach(async () => { MQTT.mock.reconnecting = false; @@ -31,7 +32,8 @@ describe('Bridge', () => { const device = zigbeeHerdsman.devices.bulb; device.removeFromDatabase.mockClear(); device.removeFromNetwork.mockClear(); - controller = new Controller(); + mockRestart = jest.fn(); + controller = new Controller(mockRestart, jest.fn()); await controller.start(); await flushPromises(); }); @@ -41,7 +43,7 @@ describe('Bridge', () => { const directory = settings.get().advanced.log_directory; expect(MQTT.publish).toHaveBeenCalledWith( 'zigbee2mqtt/bridge/info', - stringify({ "commit": version.commitHash, "config": { "advanced": { "adapter_concurrent": null, "adapter_delay": null, "availability_blacklist": [], "availability_blocklist": [], "availability_passlist": [], "availability_timeout": 0, "availability_whitelist": [], "cache_state": true, "cache_state_persistent": true, "cache_state_send_on_startup": true, "channel": 11, "elapsed": false, "ext_pan_id": [221, 221, 221, 221, 221, 221, 221, 221], "homeassistant_discovery_topic": "homeassistant", "homeassistant_legacy_triggers": true, "homeassistant_status_topic": "hass/status", "last_seen": "disable", "legacy_api": false, "log_directory": directory, "log_file": "log.txt", "log_level": "info", "log_output": ["console", "file"], "log_rotation": true, "log_syslog": {}, "pan_id": 6754, "report": false, "soft_reset_timeout": 0, "timestamp_format": "YYYY-MM-DD HH:mm:ss" }, "ban": [], "blocklist": [], "device_options": {}, "devices": { "0x000b57fffec6a5b2": { "friendly_name": "bulb", "retain": true }, "0x000b57fffec6a5b3": { "friendly_name": "bulb_color", "retain": false }, "0x000b57fffec6a5b4": { "friendly_name": "bulb_color_2", "retain": false }, "0x000b57fffec6a5b7": { "friendly_name": "bulb_2", "retain": false }, "0x0017880104a44559": { "friendly_name": "J1_cover" }, "0x0017880104e43559": { "friendly_name": "U202DST600ZB" }, "0x0017880104e44559": { "friendly_name": "3157100_thermostat" }, "0x0017880104e45517": { "friendly_name": "remote", "retain": true }, "0x0017880104e45518": { "friendly_name": "0x0017880104e45518" }, "0x0017880104e45520": { "friendly_name": "button", "retain": false }, "0x0017880104e45521": { "friendly_name": "button_double_key", "retain": false }, "0x0017880104e45522": { "friendly_name": "weather_sensor", "qos": 1, "retain": false }, "0x0017880104e45523": { "friendly_name": "occupancy_sensor", "retain": false }, "0x0017880104e45524": { "friendly_name": "power_plug", "retain": false }, "0x0017880104e45526": { "friendly_name": "GL-S-007ZS" }, "0x0017880104e45529": { "friendly_name": "unsupported2", "retain": false }, "0x0017880104e45530": { "friendly_name": "button_double_key_interviewing", "retain": false }, "0x0017880104e45540": { "friendly_name": "ikea_onoff" }, "0x0017880104e45541": { "friendly_name": "wall_switch", "retain": false }, "0x0017880104e45542": { "friendly_name": "wall_switch_double", "retain": false }, "0x0017880104e45543": { "friendly_name": "led_controller_1", "retain": false }, "0x0017880104e45544": { "friendly_name": "led_controller_2", "retain": false }, "0x0017880104e45545": { "friendly_name": "dimmer_wall_switch", "retain": false }, "0x0017880104e45547": { "friendly_name": "curtain", "retain": false }, "0x0017880104e45548": { "friendly_name": "fan", "retain": false }, "0x0017880104e45549": { "friendly_name": "siren", "retain": false }, "0x0017880104e45550": { "friendly_name": "thermostat", "retain": false }, "0x0017880104e45551": { "friendly_name": "smart vent", "retain": false }, "0x0017880104e45552": { "friendly_name": "j1", "retain": false }, "0x0017880104e45553": { "friendly_name": "bulb_enddevice", "retain": false }, "0x0017880104e45559": { "friendly_name": "cc2530_router", "retain": false }, "0x0017880104e45560": { "friendly_name": "livolo", "retain": false }, "0x0017882104a44559": { "friendly_name": "TS0601_thermostat" }, "0x0017882194e45543": { "friendly_name": "QS-Zigbee-D02-TRIAC-2C-LN" }, "0x90fd9ffffe4b64aa": { "friendly_name": "SP600_OLD" }, "0x90fd9ffffe4b64ab": { "friendly_name": "SP600_NEW" }, "0x90fd9ffffe4b64ac": { "friendly_name": "MKS-CM-W5" }, "0x90fd9ffffe4b64ae": { "friendly_name": "tradfri_remote", "retain": false }, "0x90fd9ffffe4b64af": { "friendly_name": "roller_shutter" }, "0x90fd9ffffe4b64ax": { "friendly_name": "ZNLDP12LM" } }, "experimental": { "output": "json" }, "external_converters": [], "groups": { "1": { "friendly_name": "group_1", "retain": false }, "11": { "devices": ["bulb_2"], "friendly_name": "group_with_tradfri", "retain": false }, "14": { "devices": ["power_plug"], "friendly_name": "switch_group", "retain": false }, "12": { "devices": ["TS0601_thermostat"], "friendly_name": "thermostat_group", "retain": false }, "15071": { "devices": ["bulb_color_2", "bulb_2"], "friendly_name": "group_tradfri_remote", "retain": false }, "2": { "friendly_name": "group_2", "retain": false } }, "homeassistant": false, "map_options": { "graphviz": { "colors": { "fill": { "coordinator": "#e04e5d", "enddevice": "#fff8ce", "router": "#4ea3e0" }, "font": { "coordinator": "#ffffff", "enddevice": "#000000", "router": "#ffffff" }, "line": { "active": "#009900", "inactive": "#994444" } } } }, "mqtt": { "base_topic": "zigbee2mqtt", "include_device_information": false, "server": "mqtt://localhost", "force_disable_retain": false, }, "passlist": [], "permit_join": true, "serial": { "disable_led": false, "port": "/dev/dummy" }, "whitelist": [] }, "coordinator": { "meta": { "revision": 20190425, "version": 1 }, "type": "z-Stack" }, "log_level": "info", "network": { "channel": 15, "extended_pan_id": [0, 11, 22], "pan_id": 5674 }, "permit_join": false, "version": version.version, config_schema: settings.schema }), + stringify({ "restart_required": false, "commit": version.commitHash, "config": { "advanced": { "adapter_concurrent": null, "adapter_delay": null, "availability_blacklist": [], "availability_blocklist": [], "availability_passlist": [], "availability_timeout": 0, "availability_whitelist": [], "cache_state": true, "cache_state_persistent": true, "cache_state_send_on_startup": true, "channel": 11, "elapsed": false, "ext_pan_id": [221, 221, 221, 221, 221, 221, 221, 221], "homeassistant_discovery_topic": "homeassistant", "homeassistant_legacy_triggers": true, "homeassistant_status_topic": "hass/status", "last_seen": "disable", "legacy_api": false, "log_directory": directory, "log_file": "log.txt", "log_level": "info", "log_output": ["console", "file"], "log_rotation": true, "log_syslog": {}, "pan_id": 6754, "report": false, "soft_reset_timeout": 0, "timestamp_format": "YYYY-MM-DD HH:mm:ss" }, "ban": [], "blocklist": [], "device_options": {}, "devices": { "0x000b57fffec6a5b2": { "friendly_name": "bulb", "retain": true }, "0x000b57fffec6a5b3": { "friendly_name": "bulb_color", "retain": false }, "0x000b57fffec6a5b4": { "friendly_name": "bulb_color_2", "retain": false }, "0x000b57fffec6a5b7": { "friendly_name": "bulb_2", "retain": false }, "0x0017880104a44559": { "friendly_name": "J1_cover" }, "0x0017880104e43559": { "friendly_name": "U202DST600ZB" }, "0x0017880104e44559": { "friendly_name": "3157100_thermostat" }, "0x0017880104e45517": { "friendly_name": "remote", "retain": true }, "0x0017880104e45518": { "friendly_name": "0x0017880104e45518" }, "0x0017880104e45520": { "friendly_name": "button", "retain": false }, "0x0017880104e45521": { "friendly_name": "button_double_key", "retain": false }, "0x0017880104e45522": { "friendly_name": "weather_sensor", "qos": 1, "retain": false }, "0x0017880104e45523": { "friendly_name": "occupancy_sensor", "retain": false }, "0x0017880104e45524": { "friendly_name": "power_plug", "retain": false }, "0x0017880104e45526": { "friendly_name": "GL-S-007ZS" }, "0x0017880104e45529": { "friendly_name": "unsupported2", "retain": false }, "0x0017880104e45530": { "friendly_name": "button_double_key_interviewing", "retain": false }, "0x0017880104e45540": { "friendly_name": "ikea_onoff" }, "0x0017880104e45541": { "friendly_name": "wall_switch", "retain": false }, "0x0017880104e45542": { "friendly_name": "wall_switch_double", "retain": false }, "0x0017880104e45543": { "friendly_name": "led_controller_1", "retain": false }, "0x0017880104e45544": { "friendly_name": "led_controller_2", "retain": false }, "0x0017880104e45545": { "friendly_name": "dimmer_wall_switch", "retain": false }, "0x0017880104e45547": { "friendly_name": "curtain", "retain": false }, "0x0017880104e45548": { "friendly_name": "fan", "retain": false }, "0x0017880104e45549": { "friendly_name": "siren", "retain": false }, "0x0017880104e45550": { "friendly_name": "thermostat", "retain": false }, "0x0017880104e45551": { "friendly_name": "smart vent", "retain": false }, "0x0017880104e45552": { "friendly_name": "j1", "retain": false }, "0x0017880104e45553": { "friendly_name": "bulb_enddevice", "retain": false }, "0x0017880104e45559": { "friendly_name": "cc2530_router", "retain": false }, "0x0017880104e45560": { "friendly_name": "livolo", "retain": false }, "0x0017882104a44559": { "friendly_name": "TS0601_thermostat" }, "0x0017882194e45543": { "friendly_name": "QS-Zigbee-D02-TRIAC-2C-LN" }, "0x90fd9ffffe4b64aa": { "friendly_name": "SP600_OLD" }, "0x90fd9ffffe4b64ab": { "friendly_name": "SP600_NEW" }, "0x90fd9ffffe4b64ac": { "friendly_name": "MKS-CM-W5" }, "0x90fd9ffffe4b64ae": { "friendly_name": "tradfri_remote", "retain": false }, "0x90fd9ffffe4b64af": { "friendly_name": "roller_shutter" }, "0x90fd9ffffe4b64ax": { "friendly_name": "ZNLDP12LM" } }, "experimental": { "output": "json" }, "external_converters": [], "groups": { "1": { "friendly_name": "group_1", "retain": false }, "11": { "devices": ["bulb_2"], "friendly_name": "group_with_tradfri", "retain": false }, "14": { "devices": ["power_plug"], "friendly_name": "switch_group", "retain": false }, "12": { "devices": ["TS0601_thermostat"], "friendly_name": "thermostat_group", "retain": false }, "15071": { "devices": ["bulb_color_2", "bulb_2"], "friendly_name": "group_tradfri_remote", "retain": false }, "2": { "friendly_name": "group_2", "retain": false } }, "homeassistant": false, "map_options": { "graphviz": { "colors": { "fill": { "coordinator": "#e04e5d", "enddevice": "#fff8ce", "router": "#4ea3e0" }, "font": { "coordinator": "#ffffff", "enddevice": "#000000", "router": "#ffffff" }, "line": { "active": "#009900", "inactive": "#994444" } } } }, "mqtt": { "base_topic": "zigbee2mqtt", "include_device_information": false, "server": "mqtt://localhost", "force_disable_retain": false, }, "passlist": [], "permit_join": true, "serial": { "disable_led": false, "port": "/dev/dummy" }, "whitelist": [] }, "coordinator": { "meta": { "revision": 20190425, "version": 1 }, "type": "z-Stack" }, "log_level": "info", "network": { "channel": 15, "extended_pan_id": [0, 11, 22], "pan_id": 5674 }, "permit_join": false, "version": version.version, config_schema: settings.schema }), { retain: true, qos: 0 }, expect.any(Function) ); @@ -265,6 +267,14 @@ describe('Bridge', () => { expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); }); + it('Shouldnt republish bridge info when permit join changes and hersman is stopping', async () => { + MQTT.publish.mockClear(); + zigbeeHerdsman.isStopping.mockImplementationOnce(() => true); + await zigbeeHerdsman.events.permitJoinChanged({permitted: false, time: 10}); + await flushPromises(); + expect(MQTT.publish).not.toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); + }); + it('Should allow permit join via device', async () => { const device = zigbeeHerdsman.devices.bulb; zigbeeHerdsman.permitJoin.mockClear(); @@ -607,17 +617,6 @@ describe('Bridge', () => { ); }); - it('Should throw not implemented erron on bridge/request/options', async () => { - MQTT.publish.mockClear(); - MQTT.events.message('zigbee2mqtt/bridge/request/options', stringify({})); - await flushPromises(); - expect(MQTT.publish).toHaveBeenCalledWith( - 'zigbee2mqtt/bridge/response/options', - stringify({"data":{},"status":"error","error":"Saving settings is not yet implemented"}), - {retain: false, qos: 0}, expect.any(Function) - ); - }); - it('Should allow to add group by string', async () => { MQTT.publish.mockClear(); MQTT.events.message('zigbee2mqtt/bridge/request/group/add', 'group_193'); @@ -920,4 +919,117 @@ describe('Bridge', () => { {retain: false, qos: 0}, expect.any(Function) ); }); + + it('Should allow to restart', async () => { + zigbeeHerdsman.permitJoin.mockClear(); + jest.useFakeTimers(); + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/restart', ''); + await flushPromises(); + jest.runAllTimers(); + expect(mockRestart).toHaveBeenCalledTimes(1); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/restart', + stringify({"data":{},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + jest.useRealTimers(); + }); + + it('Change options', async () => { + zigbeeHerdsman.permitJoin.mockClear(); + settings.apply({permit_join: false}); + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/options', stringify({options: {permit_join: true}})); + await flushPromises(); + expect(settings.get().permit_join).toBe(true); + expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledTimes(1); + expect(zigbeeHerdsman.permitJoin).toHaveBeenCalledWith(true, undefined, undefined); + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/options', + stringify({"data":{"restart_required":false},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Change options and apply - homeassistant', async () => { + expect(controller.extensions.find((e) => e.constructor.name === 'HomeAssistant')).toBeUndefined(); + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/options', stringify({options: {homeassistant: true}})); + await flushPromises(); + expect(controller.extensions.find((e) => e.constructor.name === 'HomeAssistant')).not.toBeUndefined(); + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/options', + stringify({"data":{"restart_required":false},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Change options and apply - log_level', async () => { + logger.setLevel('info'); + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/options', stringify({options: {advanced: {log_level: 'debug'}}})); + await flushPromises(); + expect(logger.getLevel()).toBe('debug'); + expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/bridge/info', expect.any(String), { retain: true, qos: 0 }, expect.any(Function)); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/options', + stringify({"data":{"restart_required":false},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Change options restart required', async () => { + zigbeeHerdsman.permitJoin.mockClear(); + settings.apply({serial: {port: '123'}}); + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/options', stringify({options: {serial: {port: '/dev/newport'}}})); + await flushPromises(); + expect(settings.get().serial.port).toBe('/dev/newport'); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/options', + stringify({"data":{"restart_required":true},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Change options array', async () => { + zigbeeHerdsman.permitJoin.mockClear(); + expect(settings.get().advanced.ext_pan_id).toStrictEqual([221, 221, 221, 221, 221, 221, 221, 221]) + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/options', stringify({options: {advanced: {ext_pan_id: [220, 221, 221, 221, 221, 221, 221, 221]}}})); + await flushPromises(); + expect(settings.get().advanced.ext_pan_id).toStrictEqual([220, 221, 221, 221, 221, 221, 221, 221]); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/options', + stringify({"data":{"restart_required":true},"status":"ok"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Change options invalid payload', async () => { + zigbeeHerdsman.permitJoin.mockClear(); + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/options', 'I am invalid'); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/options', + stringify({"data":{},"error": "Invalid payload", "status":"error"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); + + it('Change options not valid against schema', async () => { + zigbeeHerdsman.permitJoin.mockClear(); + MQTT.publish.mockClear(); + MQTT.events.message('zigbee2mqtt/bridge/request/options', stringify({options: {permit_join: 'true'}})); + await flushPromises(); + expect(MQTT.publish).toHaveBeenCalledWith( + 'zigbee2mqtt/bridge/response/options', + stringify({"data":{},"error": "permit_join should be boolean", "status":"error"}), + {retain: false, qos: 0}, expect.any(Function) + ); + }); }); diff --git a/test/configure.test.js b/test/configure.test.js index 4975fec3e..9119e1ccf 100644 --- a/test/configure.test.js +++ b/test/configure.test.js @@ -46,7 +46,7 @@ describe('Configure', () => { data.writeDefaultConfiguration(); settings._reRead(); data.writeEmptyState(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); mocksClear.forEach((m) => m.mockClear()); await flushPromises(); diff --git a/test/controller.test.js b/test/controller.test.js index 691bb1c6d..e499dea15 100644 --- a/test/controller.test.js +++ b/test/controller.test.js @@ -3,14 +3,13 @@ const logger = require('./stub/logger'); const zigbeeHerdsman = require('./stub/zigbeeHerdsman'); const MQTT = require('./stub/mqtt'); const path = require('path'); -const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}); const settings = require('../lib/util/settings'); const Controller = require('../lib/controller'); const stringify = require('json-stable-stringify-without-jsonify'); const flushPromises = () => new Promise(setImmediate); const tmp = require('tmp'); const mocksClear = [ - zigbeeHerdsman.permitJoin, mockExit, MQTT.end, zigbeeHerdsman.stop, logger.debug, + zigbeeHerdsman.permitJoin, MQTT.end, zigbeeHerdsman.stop, logger.debug, MQTT.publish, MQTT.connect, zigbeeHerdsman.devices.bulb_color.removeFromNetwork, zigbeeHerdsman.devices.bulb.removeFromNetwork, logger.error, ]; @@ -19,10 +18,12 @@ const fs = require('fs'); describe('Controller', () => { let controller; + let mockExit; beforeEach(() => { zigbeeHerdsman.returnDevices.splice(0); - controller = new Controller(); + mockExit = jest.fn(); + controller = new Controller(jest.fn(), mockExit); mocksClear.forEach((m) => m.mockClear()); data.writeDefaultConfiguration(); settings._reRead(); @@ -228,7 +229,7 @@ describe('Controller', () => { expect(MQTT.end).toHaveBeenCalledTimes(1); expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1); expect(mockExit).toHaveBeenCalledTimes(1); - expect(mockExit).toHaveBeenCalledWith(0); + expect(mockExit).toHaveBeenCalledWith(0, null); }); it('Start controller and stop', async () => { @@ -238,7 +239,7 @@ describe('Controller', () => { expect(MQTT.end).toHaveBeenCalledTimes(1); expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1); expect(mockExit).toHaveBeenCalledTimes(1); - expect(mockExit).toHaveBeenCalledWith(1); + expect(mockExit).toHaveBeenCalledWith(1, null); }); it('Start controller adapter disconnects', async () => { @@ -249,7 +250,7 @@ describe('Controller', () => { expect(MQTT.end).toHaveBeenCalledTimes(1); expect(zigbeeHerdsman.stop).toHaveBeenCalledTimes(1); expect(mockExit).toHaveBeenCalledTimes(1); - expect(mockExit).toHaveBeenCalledWith(1); + expect(mockExit).toHaveBeenCalledWith(1, null); }); it('Handle mqtt message', async () => { @@ -606,7 +607,7 @@ describe('Controller', () => { const extensionPath = path.join(data.mockDir, 'extension'); fs.mkdirSync(extensionPath); fs.copyFileSync(path.join(__dirname, 'assets', 'exampleExtension.js'), path.join(extensionPath, 'exampleExtension.js')) - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); expect(MQTT.publish).toHaveBeenCalledWith('zigbee2mqtt/example/extension', 'test', { retain: false, qos: 0 }, expect.any(Function)); diff --git a/test/externalConverters.test.js b/test/externalConverters.test.js index b6bef0780..8ab6a582d 100644 --- a/test/externalConverters.test.js +++ b/test/externalConverters.test.js @@ -52,7 +52,7 @@ describe('Loads external converters', () => { it('Does not load external converters', async () => { settings.set(['external_converters'], []); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); expect(zigbeeHerdsmanConverters.addDeviceDefinition).toHaveBeenCalledTimes(0); @@ -62,7 +62,7 @@ describe('Loads external converters', () => { fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js')); const devicesCount = zigbeeHerdsman.devices.lenght; settings.set(['external_converters'], ['mock-external-converter.js']); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); expect(zigbeeHerdsmanConverters.addDeviceDefinition).toHaveBeenCalledTimes(1); @@ -82,7 +82,7 @@ describe('Loads external converters', () => { fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter-multiple.js'), path.join(data.mockDir, 'mock-external-converter-multiple.js')); const devicesCount = zigbeeHerdsman.devices.lenght; settings.set(['external_converters'], ['mock-external-converter-multiple.js']); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); expect(zigbeeHerdsmanConverters.addDeviceDefinition).toHaveBeenCalledTimes(2); @@ -110,7 +110,7 @@ describe('Loads external converters', () => { it('Loads external converters from package', async () => { settings.set(['external_converters'], ['mock-external-converter-module']); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); expect(zigbeeHerdsmanConverters.addDeviceDefinition).toHaveBeenCalledTimes(1); @@ -121,7 +121,7 @@ describe('Loads external converters', () => { it('Loads multiple external converters from package', async () => { settings.set(['external_converters'], ['mock-multiple-external-converter-module']); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); expect(zigbeeHerdsmanConverters.addDeviceDefinition).toHaveBeenCalledTimes(2); diff --git a/test/frontend.test.js b/test/frontend.test.js index e256b7376..2400c9da2 100644 --- a/test/frontend.test.js +++ b/test/frontend.test.js @@ -85,7 +85,7 @@ describe('Frontend', () => { }); it('Start/stop', async () => { - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); expect(mockNodeStatic.variables.path).toBe("my/dummy/path"); expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); @@ -103,7 +103,7 @@ describe('Frontend', () => { }); it('Websocket interaction', async () => { - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); // Connect @@ -158,7 +158,7 @@ describe('Frontend', () => { }); it('onReques/onUpgrade', async () => { - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); const mockSocket = {destroy: jest.fn()}; @@ -176,7 +176,7 @@ describe('Frontend', () => { }); it('Static server', async () => { - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, "127.0.0.1"); @@ -185,7 +185,7 @@ describe('Frontend', () => { it('Authentification', async () => { const authToken = 'sample-secure-token' settings.set(['frontend'], {auth_token: authToken}); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); const mockSocket = {destroy: jest.fn()}; diff --git a/test/group.test.js b/test/group.test.js index d5584c31e..cbeb804ad 100644 --- a/test/group.test.js +++ b/test/group.test.js @@ -20,7 +20,7 @@ describe('Groups', () => { beforeEach(() => { data.writeEmptyState(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); Object.values(zigbeeHerdsman.groups).forEach((g) => g.members = []); data.writeDefaultConfiguration(); settings._reRead(); diff --git a/test/homeassistant.test.js b/test/homeassistant.test.js index 55e8d79f4..3f073f406 100644 --- a/test/homeassistant.test.js +++ b/test/homeassistant.test.js @@ -790,7 +790,7 @@ describe('HomeAssistant extension', () => { }); it('Shouldnt discover when device leaves', async () => { - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); controller.extensions.find((e) => e.constructor.name === 'HomeAssistant').discovered = {}; @@ -804,7 +804,7 @@ describe('HomeAssistant extension', () => { it('Should send all status when home assistant comes online (default topic)', async () => { jest.useFakeTimers(); data.writeDefaultState(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); expect(MQTT.subscribe).toHaveBeenCalledWith('homeassistant/status'); await flushPromises(); @@ -830,7 +830,7 @@ describe('HomeAssistant extension', () => { it('Should send all status when home assistant comes online', async () => { jest.useFakeTimers(); data.writeDefaultState(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); expect(MQTT.subscribe).toHaveBeenCalledWith('hass/status'); @@ -856,7 +856,7 @@ describe('HomeAssistant extension', () => { it('Shouldnt send all status when home assistant comes offline', async () => { jest.useFakeTimers(); data.writeDefaultState(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); MQTT.publish.mockClear(); @@ -870,7 +870,7 @@ describe('HomeAssistant extension', () => { it('Shouldnt send all status when home assistant comes online with different topic', async () => { jest.useFakeTimers(); data.writeDefaultState(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); MQTT.publish.mockClear(); @@ -1405,7 +1405,7 @@ describe('HomeAssistant extension', () => { it('Load Home Assistant mapping from external converters', async () => { fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter-multiple.js'), path.join(data.mockDir, 'mock-external-converter-multiple.js')); settings.set(['external_converters'], ['mock-external-converter-multiple.js']); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); const ha = controller.extensions.find((e) => e.constructor.name === 'HomeAssistant'); await controller.start(); await flushPromises(); @@ -1424,7 +1424,7 @@ describe('HomeAssistant extension', () => { }); it('Should clear outdated configs', async () => { - controller = new Controller(false); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); diff --git a/test/legacy/bridgeLegacy.test.js b/test/legacy/bridgeLegacy.test.js index f94c5cba2..7c39d2018 100644 --- a/test/legacy/bridgeLegacy.test.js +++ b/test/legacy/bridgeLegacy.test.js @@ -15,7 +15,7 @@ describe('Bridge legacy', () => { beforeAll(async () => { this.version = await require('../../lib/util/utils').getZigbee2mqttVersion(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); }) @@ -251,7 +251,7 @@ describe('Bridge legacy', () => { }); it('Shouldnt rename when no device has been joined', async () => { - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); expect(settings.getDevice('0x000b57fffec6a5b2').friendlyName).toStrictEqual('bulb'); diff --git a/test/legacy/report.test.js b/test/legacy/report.test.js index 70725c02b..97f31285c 100644 --- a/test/legacy/report.test.js +++ b/test/legacy/report.test.js @@ -79,7 +79,7 @@ describe('Report', () => { delete device.meta.reporting; } - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); mocksClear.forEach((m) => m.mockClear()); await flushPromises(); @@ -97,7 +97,7 @@ describe('Report', () => { mockClear(device); delete device.meta.report; settings.set(['advanced', 'report'], false); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); expect(device.meta.reporting).toBe(undefined); @@ -110,7 +110,7 @@ describe('Report', () => { const endpoint = device.getEndpoint(1); settings.set(['advanced', 'report'], false); mockClear(device); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); expectOnOffBrightnessColorReportDisabled(endpoint, true); diff --git a/test/networkMap.test.js b/test/networkMap.test.js index a336aaaba..463e45d31 100644 --- a/test/networkMap.test.js +++ b/test/networkMap.test.js @@ -31,7 +31,7 @@ describe('Networkmap', () => { data.writeEmptyState(); fs.copyFileSync(path.join(__dirname, 'assets', 'mock-external-converter.js'), path.join(data.mockDir, 'mock-external-converter.js')); settings.set(['external_converters'], ['mock-external-converter.js']); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); mocksClear.forEach((m) => m.mockClear()); await flushPromises(); diff --git a/test/onEvent.test.js b/test/onEvent.test.js index 8f0e1105c..fdd277b77 100644 --- a/test/onEvent.test.js +++ b/test/onEvent.test.js @@ -25,7 +25,7 @@ describe('On event', () => { data.writeDefaultConfiguration(); settings._reRead(); data.writeEmptyState(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); mocksClear.forEach((m) => m.mockClear()); zigbeeHerdsmanConverters.onEvent.mockClear(); diff --git a/test/otaUpdate.test.js b/test/otaUpdate.test.js index b553c1a44..24565577d 100644 --- a/test/otaUpdate.test.js +++ b/test/otaUpdate.test.js @@ -21,7 +21,7 @@ describe('OTA update', () => { settings._reRead(); settings.set(['advanced', 'ikea_ota_use_test_url'], true); data.writeEmptyState(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); MQTT.publish.mockClear(); diff --git a/test/publish.test.js b/test/publish.test.js index 87b1bde64..e13ded5f6 100644 --- a/test/publish.test.js +++ b/test/publish.test.js @@ -28,7 +28,7 @@ describe('Publish', () => { beforeAll(async () => { data.writeEmptyState(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); await flushPromises(); }); diff --git a/test/receive.test.js b/test/receive.test.js index 9eacb279b..90af4f47f 100755 --- a/test/receive.test.js +++ b/test/receive.test.js @@ -17,7 +17,7 @@ describe('Receive', () => { data.writeDefaultConfiguration(); settings._reRead(); data.writeEmptyState(); - controller = new Controller(); + controller = new Controller(jest.fn(), jest.fn()); await controller.start(); mocksClear.forEach((m) => m.mockClear()); delete zigbeeHerdsman.devices.WXKG11LM.linkquality; diff --git a/test/stub/zigbeeHerdsman.js b/test/stub/zigbeeHerdsman.js index c178e02e2..d24d84b5a 100644 --- a/test/stub/zigbeeHerdsman.js +++ b/test/stub/zigbeeHerdsman.js @@ -193,6 +193,7 @@ const mock = { touchlinkScan: jest.fn(), touchlinkIdentify: jest.fn(), start: jest.fn(), + isStopping: jest.fn(), permitJoin: jest.fn(), getCoordinatorVersion: jest.fn().mockReturnValue({type: 'z-Stack', meta: {version: 1, revision: 20190425}}), getNetworkParameters: jest.fn().mockReturnValue({panID: 0x162a, extendedPanID: [0, 11, 22], channel: 15}),