From 670a87e9fbeb8a42e995c296f65c180a2d04eabc Mon Sep 17 00:00:00 2001 From: tsightler Date: Mon, 21 Sep 2020 00:23:31 -0400 Subject: [PATCH 01/34] Use devices.json for discovery * Usese devices.json from "tuya-cli wizard" for device discovery, no topic config required * Uses device discovery by default, but can manually edit devices.json to add IP address, protocol version, or change * Creates short topic name using just "friendly name" or "id". --- package.json | 5 +- tuya-device.js | 95 ++++++++---- tuya-mqtt.js | 386 +++++++++++++++++++------------------------------ 3 files changed, 217 insertions(+), 269 deletions(-) diff --git a/package.json b/package.json index f889d3b..ed89737 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tuya-mqtt", - "version": "2.1.0", + "version": "3.0.0-beta1", "description": "Control Tuya devices locally via MQTT", "homepage": "https://github.com/TheAgentK/tuya-mqtt#readme", "main": "tuya-mqtt.js", @@ -16,7 +16,8 @@ "color-convert": "^2.0.1", "debug": "^4.1.1", "mqtt": "^4.2.1", - "tuyapi": "^5.3.1" + "tuyapi": "^5.3.1", + "json5": "^2.1.3" }, "repository": { "type": "git", diff --git a/tuya-device.js b/tuya-device.js index c0ae76d..e788c35 100644 --- a/tuya-device.js +++ b/tuya-device.js @@ -18,14 +18,12 @@ var TuyaDevice = (function () { var devices = []; var events = {}; - function checkExisiting(id) { + function checkExisiting(options) { var existing = false; // Check for existing instance devices.forEach(device => { - if (device.hasOwnProperty("options")) { - if (id === device.options.id) { - existing = device; - } + if (device.topicLevel == options.topicLevel) { + existing = device; } }); return existing; @@ -42,10 +40,11 @@ var TuyaDevice = (function () { }); } - function TuyaDevice(options, callback) { + function TuyaDevice(options) { var device = this; - // Check for existing instance - if (existing = checkExisiting(options.id)) { + + // Check for existing instance by matching topicLevel value + if (existing = checkExisiting(options)) { return new Promise((resolve, reject) => { resolve({ status: "connected", @@ -58,32 +57,70 @@ var TuyaDevice = (function () { return new TuyaDevice(options); } - options.type = options.type || undefined; - - this.type = options.type; this.options = options; - Object.defineProperty(this, 'device', { - value: new TuyAPI(JSON.parse(JSON.stringify(this.options))) - }); + if (this.options.name) { + this.topicLevel = this.options.name.toLowerCase().replace(/ /g,"_"); + } else { + this.topicLevel = this.options.id; + } - this.device.on('data', data => { - if (typeof data == "string") { - debugError('Data from device not encrypted:', data.replace(/[^a-zA-Z0-9 ]/g, "")); - } else { - debug('Data from device:', data); - device.triggerAll('data', data); + if (!this.options.ip) { + const findOptions = { + id: this.options.id, + key: "yGAdlopoPVldABfn" } - }); + findDevice = new TuyAPI(JSON.parse(JSON.stringify(findOptions))) + findDevice.find().then(() => { + this.options.ip = findDevice.device.ip + this.options.version = findDevice.device.version + Object.defineProperty(this, 'device', { + value: new TuyAPI(JSON.parse(JSON.stringify(this.options))) + }); + + this.device.on('data', data => { + if (typeof data == "string") { + debugError('Data from device not encrypted:', data.replace(/[^a-zA-Z0-9 ]/g, "")); + } else { + debug('Data from device:', data); + device.triggerAll('data', data); + } + }); + + devices.push(this); + + // Find device on network + debug("Search device in network"); + this.find().then(() => { + debug("Device found in network"); + // Connect to device + this.device.connect(); + }); + }); + } else { + Object.defineProperty(this, 'device', { + value: new TuyAPI(JSON.parse(JSON.stringify(this.options))) + }); - devices.push(this); - // Find device on network - debug("Search device in network"); - this.find().then(() => { - debug("Device found in network"); - // Connect to device - this.device.connect(); - }); + this.device.on('data', data => { + if (typeof data == "string") { + debugError('Data from device not encrypted:', data.replace(/[^a-zA-Z0-9 ]/g, "")); + } else { + debug('Data from device:', data); + device.triggerAll('data', data); + } + }); + + devices.push(this); + + // Find device on network + debug("Search device in network"); + this.find().then(() => { + debug("Device found in network"); + // Connect to device + this.device.connect(); + }); + } /** * @return promis to wait for connection diff --git a/tuya-mqtt.js b/tuya-mqtt.js index ca802c2..fc49295 100644 --- a/tuya-mqtt.js +++ b/tuya-mqtt.js @@ -1,4 +1,6 @@ +const fs = require('fs') const mqtt = require('mqtt'); +const json5 = require('json5'); const TuyaDevice = require('./tuya-device'); const debug = require('debug')('TuyAPI:mqtt'); const debugColor = require('debug')('TuyAPI:mqtt:color'); @@ -13,10 +15,6 @@ function bmap(istate) { return istate ? 'ON' : "OFF"; } -function boolToString(istate) { - return istate ? 'true' : "false"; -} - /* * execute function on topic message */ @@ -30,103 +28,13 @@ function IsJsonString(text) { } /** - * check mqtt-topic string for old notation with included device type - * @param {String} topic - */ -function checkTopicNotation(_topic) { - var topic = _topic.split("/"); - var type = topic[1]; - var result = (type == "socket" || type == "lightbulb" || type == "ver3.1" || type == "ver3.3"); - return result; -} - -/** - * get action from mqtt-topic string - * @param {String} topic - * @returns {String} action type - */ -function getActionFromTopic(_topic) { - var topic = _topic.split("/"); - - if (checkTopicNotation(_topic)) { - return topic[5]; - } else { - return topic[4]; - } -} - -/** - * get device informations from mqtt-topic string - * @param {String} topic - * @returns {String} object.id - * @returns {String} object.key - * @returns {String} object.ip - */ -function getDeviceFromTopic(_topic) { - var topic = _topic.split("/"); - - if (checkTopicNotation(_topic)) { - // When there are 5 topic levels - // topic 2 is id, and topic 3 is key - var options = { - id: topic[2], - key: topic[3] - }; - - // 4th topic is IP address or "discover" keyword - if (topic[4] !== "discover") { - options.ip = topic[4] - // If IP is manually specified check if topic 1 - // is protocol version and set accordingly - if (topic[1] == "ver3.3") { - options.version = "3.3" - } else if (topic[1] == "ver3.1") { - options.version = "3.1" - } else { - // If topic is not version then it's device type - // Not used anymore but still supported for legacy setups - options.type = topic[1] - }; - }; - - return options; - } else { - // When there are 4 topic levels - // topic 1 is id, topic 2 is key - var options = { - id: topic[1], - key: topic[2] - }; - - // If topic 3 is not discover assume it is IP address - // Todo: Validate it is an IP address - if (topic[3] !== "discover") { - options.ip = topic[3] - }; - - return options; - } -} - -/** - * get command from mqtt - topic string - * converts simple commands to TuyAPI JSON commands - * @param {String} topic + * get command from mqtt message + * converts message to TuyAPI JSON commands + * @param {String} message * @returns {Object} */ -function getCommandFromTopic(_topic, _message) { - var topic = _topic.split("/"); - var command = null; - - if (checkTopicNotation(_topic)) { - command = topic[6]; - } else { - command = topic[5]; - } - - if (command == null) { - command = _message; - } +function getCommandFromMessage(_message) { + let command = _message if (command != "1" && command != "0" && IsJsonString(command)) { debug("command is JSON"); @@ -142,7 +50,6 @@ function getCommandFromTopic(_topic, _message) { command = command.toLowerCase(); } } - return command; } @@ -154,30 +61,12 @@ function getCommandFromTopic(_topic, _message) { function publishStatus(device, status) { if (mqtt_client.connected == true) { try { - var type = device.type; - var tuyaID = device.options.id; - var tuyaKey = device.options.key; - var tuyaIP = device.options.ip; - - if (typeof tuyaIP == "undefined") { - tuyaIP = "discover" - } - - if (typeof tuyaID != "undefined" && typeof tuyaKey != "undefined") { - var topic = CONFIG.topic; - if (typeof type != "undefined") { - topic += type + "/"; - } - topic += tuyaID + "/" + tuyaKey + "/" + tuyaIP + "/state"; - - mqtt_client.publish(topic, status, { - retain: CONFIG.retain, - qos: CONFIG.qos - }); - debugTuya("mqtt status updated to:" + topic + " -> " + status); - } else { - debugTuya("mqtt status not updated"); - } + let topic = CONFIG.topic + device.topicLevel + "/state"; + mqtt_client.publish(topic, status, { + retain: CONFIG.retain, + qos: CONFIG.qos + }); + debugTuya("mqtt status updated to:" + topic + " -> " + status); } catch (e) { debugError(e); } @@ -196,42 +85,25 @@ function publishColorState(device, state) { function publishDPS(device, dps) { if (mqtt_client.connected == true) { try { - var type = device.type; - var tuyaID = device.options.id; - var tuyaKey = device.options.key; - var tuyaIP = device.options.ip; - - if (typeof tuyaIP == "undefined") { - tuyaIP = "discover" - } - - if (typeof tuyaID != "undefined" && typeof tuyaKey != "undefined") { - var baseTopic = CONFIG.topic; - if (typeof type != "undefined") { - baseTopic += type + "/"; - } - baseTopic += tuyaID + "/" + tuyaKey + "/" + tuyaIP + "/dps"; - - var topic = baseTopic; - var data = JSON.stringify(dps); - debugTuya("mqtt dps updated to:" + topic + " -> ", data); + const baseTopic = CONFIG.topic + device.topicLevel + "/dps"; + + const topic = baseTopic; + const data = JSON.stringify(dps); + debugTuya("mqtt dps updated to:" + topic + " -> ", data); + mqtt_client.publish(topic, data, { + retain: CONFIG.retain, + qos: CONFIG.qos + }); + + Object.keys(dps).forEach(function (key) { + const topic = baseTopic + "/" + key; + const data = JSON.stringify(dps[key]); + debugTuya("mqtt dps updated to:" + topic + " -> dps[" + key + "]", data); mqtt_client.publish(topic, data, { retain: CONFIG.retain, qos: CONFIG.qos }); - - Object.keys(dps).forEach(function (key) { - var topic = baseTopic + "/" + key; - var data = JSON.stringify(dps[key]); - debugTuya("mqtt dps updated to:" + topic + " -> dps[" + key + "]", data); - mqtt_client.publish(topic, data, { - retain: CONFIG.retain, - qos: CONFIG.qos - }); - }); - } else { - debugTuya("mqtt dps not updated"); - } + }); } catch (e) { debugError(e); } @@ -269,78 +141,116 @@ function sleep(sec) { return new Promise(res => setTimeout(res, sec*1000)); } +function initTuyaDevices(tuyaDevices) { + for (const tuyaDevice of tuyaDevices) { + let options = { + id: tuyaDevice.id, + key: tuyaDevice.key + } + if (tuyaDevice.name) { options.name = tuyaDevice.name } + if (tuyaDevice.ip) { + options.ip = tuyaDevice.ip + if (tuyaDevice.version) { + options.version = tuyaDevice.version + } else { + version = "3.1" + } + } + new TuyaDevice(options); + } +} + // Main code function const main = async() => { + let tuyaDevices - try { - CONFIG = require("./config"); - } catch (e) { - console.error("Configuration file not found") - debugError(e) - process.exit(1) - } - - if (typeof CONFIG.qos == "undefined") { - CONFIG.qos = 2; - } - if (typeof CONFIG.retain == "undefined") { - CONFIG.retain = false; - } - - mqtt_client = mqtt.connect({ - host: CONFIG.host, - port: CONFIG.port, - username: CONFIG.mqtt_user, - password: CONFIG.mqtt_pass, - }); - - mqtt_client.on('connect', function (err) { - debug("Connection established to MQTT server"); - var topic = CONFIG.topic + '#'; - mqtt_client.subscribe(topic, { - retain: CONFIG.retain, - qos: CONFIG.qos - }); - }); + try { + CONFIG = require("./config"); + } catch (e) { + console.error("Configuration file not found!") + debugError(e) + process.exit(1) + } - mqtt_client.on("reconnect", function (error) { - if (mqtt_client.connected) { - debug("Connection to MQTT server lost. Attempting to reconnect..."); - } else { - debug("Unable to connect to MQTT server"); - } - }); + if (typeof CONFIG.qos == "undefined") { + CONFIG.qos = 2; + } + if (typeof CONFIG.retain == "undefined") { + CONFIG.retain = false; + } - mqtt_client.on("error", function (error) { - debug("Unable to connect to MQTT server", error); - }); + try { + tuyaDevices = fs.readFileSync('./devices.json', 'utf8'); + tuyaDevices = json5.parse(tuyaDevices) + } catch (e) { + console.error("Devices file not found!") + debugError(e) + process.exit(1) + } - mqtt_client.on('message', function (topic, message) { - try { - message = message.toString(); - var action = getActionFromTopic(topic); - var options = getDeviceFromTopic(topic); + if (!tuyaDevices.length) { + console.error("No devices found in devices file!") + process.exit(1) + } - debug("receive settings", JSON.stringify({ - topic: topic, - action: action, - message: message, - options: options - })); + mqtt_client = mqtt.connect({ + host: CONFIG.host, + port: CONFIG.port, + username: CONFIG.mqtt_user, + password: CONFIG.mqtt_pass, + }); + + mqtt_client.on('connect', function (err) { + debug("Connection established to MQTT server"); + let topic = CONFIG.topic + '#'; + mqtt_client.subscribe(topic, { + retain: CONFIG.retain, + qos: CONFIG.qos + }); + initTuyaDevices(tuyaDevices) + }); + + mqtt_client.on("reconnect", function (error) { + if (mqtt_client.connected) { + debug("Connection to MQTT server lost. Attempting to reconnect..."); + } else { + debug("Unable to connect to MQTT server"); + } + }); - var device = new TuyaDevice(options); + mqtt_client.on("error", function (error) { + debug("Unable to connect to MQTT server", error); + }); - device.then(function (params) { - var device = params.device; + mqtt_client.on('message', function (topic, message) { + try { + message = message.toString(); + splitTopic = topic.split("/"); + let action = splitTopic[2]; + let options = { + topicLevel: splitTopic[1] + } - switch (action) { - case "command": - var command = getCommandFromTopic(topic, message); - debug("Received command: ", command); - if (command == "toggle") { - device.switch(command).then((data) => { - debug("Set device status completed: ", data); - }); + debug("receive settings", JSON.stringify({ + topic: topic, + action: action, + message: message, + topicLevel: options.topicLevel + })); + + // Uses device topic level to find matching device + var device = new TuyaDevice(options); + + device.then(function (params) { + var device = params.device; + switch (action) { + case "command": + var command = getCommandFromMessage(message); + debug("Received command: ", command); + if (command == "toggle") { + device.switch(command).then((data) => { + debug("Set device status completed: ", data); + }); } if (command.schema === true) { // Trigger device schema update to update state @@ -348,27 +258,27 @@ const main = async() => { }); debug("Get schema status command complete"); } else { - device.set(command).then((data) => { - debug("Set device status completed: ", data); - }); - } - break; - case "color": - var color = message.toLowerCase(); - debugColor("Set color: ", color); - device.setColor(color).then((data) => { - debug("Set device color completed: ", data); - }); - break; - } + device.set(command).then((data) => { + debug("Set device status completed: ", data); + }); + } + break; + case "color": + var color = message.toLowerCase(); + debugColor("Set color: ", color); + device.setColor(color).then((data) => { + debug("Set device color completed: ", data); + }); + break; + } - }).catch((err) => { - debugError(err); - }); - } catch (e) { - debugError(e); - } - }); + }).catch((err) => { + debugError(err); + }); + } catch (e) { + debugError(e); + } + }); } // Call the main code From d8a24d4e6f3051513d58bf7bf96b98607018f44a Mon Sep 17 00:00:00 2001 From: tsightler Date: Mon, 21 Sep 2020 17:53:46 -0400 Subject: [PATCH 02/34] Use device.conf --- package-lock.json | 16 ++++++--- package.json | 2 +- tuya-device.js | 86 +++++++++++++---------------------------------- tuya-mqtt.js | 2 +- 4 files changed, 38 insertions(+), 68 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9a4d42f..529bf9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "tuya-mqtt", - "version": "2.1.0", + "version": "3.0.0-beta1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -378,6 +378,14 @@ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=" }, + "json5": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", + "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", + "requires": { + "minimist": "^1.2.5" + } + }, "leven": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", @@ -617,9 +625,9 @@ } }, "tuyapi": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/tuyapi/-/tuyapi-5.3.1.tgz", - "integrity": "sha512-l0bbWxe4L8J7/bAQn0bJtBVbVDAEglC1T3a/YKYM3UvDXaKgFQUDVKhfQfHFAt0bzXVq1TeqU0zG4WIrxgiTHg==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/tuyapi/-/tuyapi-5.3.2.tgz", + "integrity": "sha512-RKdTTnWVK+DDq3iRUTMh5DVd8coIwoulHntB+HvcDLuakDgSoNUc0Pzd69mw0CTTP7HTC6x6S9Ztg5pJIlYE8g==", "requires": { "debug": "4.1.1", "p-retry": "4.2.0", diff --git a/package.json b/package.json index ed89737..7e02573 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "color-convert": "^2.0.1", "debug": "^4.1.1", "mqtt": "^4.2.1", - "tuyapi": "^5.3.1", + "tuyapi": "^5.3.2", "json5": "^2.1.3" }, "repository": { diff --git a/tuya-device.js b/tuya-device.js index e788c35..be67d02 100644 --- a/tuya-device.js +++ b/tuya-device.js @@ -6,11 +6,11 @@ const debugColor = require('debug')('TuyAPI:device:color'); /** * - var steckdose = new TuyaDevice({ + var device = new TuyaDevice({ id: '03200240600194781244', key: 'b8bdebab418f5b55', ip: '192.168.178.45', - type: "ver33" + version: "3.3" }); */ @@ -65,65 +65,31 @@ var TuyaDevice = (function () { this.topicLevel = this.options.id; } - if (!this.options.ip) { - const findOptions = { - id: this.options.id, - key: "yGAdlopoPVldABfn" - } - findDevice = new TuyAPI(JSON.parse(JSON.stringify(findOptions))) - findDevice.find().then(() => { - this.options.ip = findDevice.device.ip - this.options.version = findDevice.device.version - Object.defineProperty(this, 'device', { - value: new TuyAPI(JSON.parse(JSON.stringify(this.options))) - }); - - this.device.on('data', data => { - if (typeof data == "string") { - debugError('Data from device not encrypted:', data.replace(/[^a-zA-Z0-9 ]/g, "")); - } else { - debug('Data from device:', data); - device.triggerAll('data', data); - } - }); - - devices.push(this); - - // Find device on network - debug("Search device in network"); - this.find().then(() => { - debug("Device found in network"); - // Connect to device - this.device.connect(); - }); - }); - } else { - Object.defineProperty(this, 'device', { - value: new TuyAPI(JSON.parse(JSON.stringify(this.options))) - }); + Object.defineProperty(this, 'device', { + value: new TuyAPI(JSON.parse(JSON.stringify(this.options))) + }); - this.device.on('data', data => { - if (typeof data == "string") { - debugError('Data from device not encrypted:', data.replace(/[^a-zA-Z0-9 ]/g, "")); - } else { - debug('Data from device:', data); - device.triggerAll('data', data); - } - }); + this.device.on('data', data => { + if (typeof data == "string") { + debugError('Data from device not encrypted:', data.replace(/[^a-zA-Z0-9 ]/g, "")); + } else { + debug('Data from device:', data); + device.triggerAll('data', data); + } + }); - devices.push(this); + devices.push(this); - // Find device on network - debug("Search device in network"); - this.find().then(() => { - debug("Device found in network"); - // Connect to device - this.device.connect(); - }); - } + // Find device on network + debug("Search device in network"); + this.find().then(() => { + debug("Device found in network"); + // Connect to device + this.device.connect(); + }); /** - * @return promis to wait for connection + * @return Promise to wait for connection */ return new Promise((resolve, reject) => { this.device.on('connected', () => { @@ -158,11 +124,7 @@ var TuyaDevice = (function () { } TuyaDevice.prototype.toString = function () { - if (typeof this.type != "undefined") { - return this.type + " (" + this.options.ip + ", " + this.options.id + ", " + this.options.key + ")"; - } else { - return " (" + this.options.ip + ", " + this.options.id + ", " + this.options.key + ")"; - } + return this.name + " (" + this.options.ip + ", " + this.options.id + ", " + this.options.key + ")"; } TuyaDevice.prototype.triggerAll = function (name, argument) { @@ -194,7 +156,7 @@ var TuyaDevice = (function () { return new Promise((resolve, reject) => { this.device.set(options).then((result) => { this.get().then(() => { - debug("set completed "); + debug("Set completed "); resolve(result); }); }); diff --git a/tuya-mqtt.js b/tuya-mqtt.js index fc49295..c98eb27 100644 --- a/tuya-mqtt.js +++ b/tuya-mqtt.js @@ -180,7 +180,7 @@ const main = async() => { } try { - tuyaDevices = fs.readFileSync('./devices.json', 'utf8'); + tuyaDevices = fs.readFileSync('./devices.conf', 'utf8'); tuyaDevices = json5.parse(tuyaDevices) } catch (e) { console.error("Devices file not found!") From 1181f66c3266d98cf3462b9beaf208769bb41ffd Mon Sep 17 00:00:00 2001 From: tsightler Date: Tue, 22 Sep 2020 01:01:43 -0400 Subject: [PATCH 03/34] Device/DPS commands * Add ability to set individual DPS values via /dps/<#>/command * Can still use Tuya JSON via /dps/command * Simple on/off sent to /command * Brightness sent to /brightness_command * Simple device type detection (currently only for sockets/switches/dimmers and non-RGB lights, other devices are unknown get DPS 1 on/off in state and all other values accessible via DPS --- tuya-device.js | 5 +- tuya-mqtt.js | 274 ++++++++++++++++++++++++++++++++----------------- 2 files changed, 184 insertions(+), 95 deletions(-) diff --git a/tuya-device.js b/tuya-device.js index be67d02..0c526e1 100644 --- a/tuya-device.js +++ b/tuya-device.js @@ -10,7 +10,8 @@ const debugColor = require('debug')('TuyAPI:device:color'); id: '03200240600194781244', key: 'b8bdebab418f5b55', ip: '192.168.178.45', - version: "3.3" + version: "3.3", + type: "" <- "switch", "light", "dimmer", etc. Attempts autodetect if not defined }); */ @@ -63,7 +64,7 @@ var TuyaDevice = (function () { this.topicLevel = this.options.name.toLowerCase().replace(/ /g,"_"); } else { this.topicLevel = this.options.id; - } + } Object.defineProperty(this, 'device', { value: new TuyAPI(JSON.parse(JSON.stringify(this.options))) diff --git a/tuya-mqtt.js b/tuya-mqtt.js index c98eb27..714667d 100644 --- a/tuya-mqtt.js +++ b/tuya-mqtt.js @@ -6,26 +6,24 @@ const debug = require('debug')('TuyAPI:mqtt'); const debugColor = require('debug')('TuyAPI:mqtt:color'); const debugTuya = require('debug')('TuyAPI:mqtt:device'); const debugError = require('debug')('TuyAPI:mqtt:error'); -var cleanup = require('./cleanup').Cleanup(onExit); var CONFIG = undefined; var mqtt_client = undefined; -function bmap(istate) { - return istate ? 'ON' : "OFF"; -} - /* - * execute function on topic message + * Check if data is JSON or not */ - -function IsJsonString(text) { - if (/^[\],:{}\s]*$/.test(text.replace(/\\["\\\/bfnrtu]/g, '@').replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']').replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) { - //the json is ok - return true; +function isJsonString (data){ + try { + const parsedData = JSON.parse(data); + if (parsedData && typeof parsedData === "object") { + return parsedData; + } } + catch (e) { } + return false; -} +}; /** * get command from mqtt message @@ -36,13 +34,13 @@ function IsJsonString(text) { function getCommandFromMessage(_message) { let command = _message - if (command != "1" && command != "0" && IsJsonString(command)) { + if (command != "1" && command != "0" && isJsonString(command)) { debug("command is JSON"); command = JSON.parse(command); } else { if (command.toLowerCase() != "toggle") { // convert simple commands (on, off, 1, 0) to TuyAPI-Commands - var convertString = command.toLowerCase() == "on" || command == "1" || command == 1 ? true : false; + const convertString = command.toLowerCase() == "on" || command == "1" || command == 1 ? true : false; command = { set: convertString } @@ -53,30 +51,78 @@ function getCommandFromMessage(_message) { return command; } -/** - * Publish current TuyaDevice state to MQTT-Topic - * @param {TuyaDevice} device - * @param {boolean} status - */ -function publishStatus(device, status) { - if (mqtt_client.connected == true) { - try { - let topic = CONFIG.topic + device.topicLevel + "/state"; - mqtt_client.publish(topic, status, { - retain: CONFIG.retain, - qos: CONFIG.qos - }); - debugTuya("mqtt status updated to:" + topic + " -> " + status); - } catch (e) { - debugError(e); +// Parse message +function parseDpsMessage(message) { + if (typeof message === "boolean" ) { + return message; + } else if (message === "true" || message === "false") { + return (message === "true") ? true : false + } else if (!isNaN(message)) { + return Number(message) + } else { + return message + } +} + +function publishMQTT(topic, data) { + mqtt_client.publish(topic, data, { + retain: CONFIG.retain, + qos: CONFIG.qos + }); +} + +function guessDeviceType(device, dps) { + keys = Object.keys(dps).length + if (keys === 2) { + if (typeof dps['1'] === "boolean" && dps['2'] >= 0 && dps['2'] <= 255) { + // A "dimmer" is a switch/light with brightness control only + device.options.type = "dimmer" + } + } else if (keys === 1) { + if (typeof dps['1'] === "boolean") { + // If it only has one value and it's a boolean, it's probably a switch/socket + device.options.type = "switch" } } + + if (!device.options.type) { + device.options.type = "unknown" + } } function publishColorState(device, state) { } +function publishDeviceTopics(device, dps) { + const baseTopic = CONFIG.topic + device.topicLevel + let state + let brightness_state + switch (device.options.type) { + case "switch": + case "unknown": + state = (dps['1']) ? 'ON' : 'OFF'; + topic = baseTopic+"/state" + debugTuya("MQTT state ("+device.options.type+"): "+topic+" -> ", state); + publishMQTT(topic, state); + break; + case "dimmer": + if ('1' in dps) { + state = (dps['1']) ? 'ON' : 'OFF'; + topic = baseTopic+"/state" + debugTuya("MQTT state ("+device.options.type+"): "+topic+" -> ", state); + publishMQTT(topic, state); + } + if ('2' in dps) { + brightness_state = JSON.stringify(dps['2']); + topic = baseTopic+"/brightness_state" + debugTuya("MQTT brightness ("+device.options.type+"): "+topic+" -> ", brightness_state); + publishMQTT(topic, brightness_state); + } + break; + } +} + /** * publish all dps-values to topic * @param {TuyaDevice} device @@ -85,25 +131,28 @@ function publishColorState(device, state) { function publishDPS(device, dps) { if (mqtt_client.connected == true) { try { - const baseTopic = CONFIG.topic + device.topicLevel + "/dps"; + if (!device.options.type) { + guessDeviceType(device, dps) + } - const topic = baseTopic; + const baseTopic = CONFIG.topic + device.topicLevel + "/dps"; + const topic = baseTopic + "/state" const data = JSON.stringify(dps); - debugTuya("mqtt dps updated to:" + topic + " -> ", data); - mqtt_client.publish(topic, data, { - retain: CONFIG.retain, - qos: CONFIG.qos - }); + // Publish raw DPS JSON data + debugTuya("MQTT DPS JSON (raw): " + topic + " -> ", data); + publishMQTT(topic, data); + + // Publish dps/<#>/state value for each DPS Object.keys(dps).forEach(function (key) { - const topic = baseTopic + "/" + key; + const topic = baseTopic + "/" + key + "/state"; const data = JSON.stringify(dps[key]); - debugTuya("mqtt dps updated to:" + topic + " -> dps[" + key + "]", data); - mqtt_client.publish(topic, data, { - retain: CONFIG.retain, - qos: CONFIG.qos - }); + debugTuya("MQTT DPS"+key+": "+topic+" -> ", data); + publishMQTT(topic, data); }); + + publishDeviceTopics(device, dps) + } catch (e) { debugError(e); } @@ -118,10 +167,6 @@ TuyaDevice.onAll('data', function (data) { try { if (typeof data.dps != "undefined") { debugTuya('Data from device ' + this.tuyID + ' :', data); - var status = data.dps['1']; - if (typeof status != "undefined") { - publishStatus(this, bmap(status)); - } publishDPS(this, data.dps); } } catch (e) { @@ -225,56 +270,99 @@ const main = async() => { mqtt_client.on('message', function (topic, message) { try { message = message.toString(); - splitTopic = topic.split("/"); - let action = splitTopic[2]; - let options = { + const splitTopic = topic.split("/"); + const topicLength = splitTopic.length + const action = splitTopic[topicLength - 1]; + const options = { topicLevel: splitTopic[1] } - debug("receive settings", JSON.stringify({ - topic: topic, - action: action, - message: message, - topicLevel: options.topicLevel - })); - - // Uses device topic level to find matching device - var device = new TuyaDevice(options); - - device.then(function (params) { - var device = params.device; - switch (action) { - case "command": - var command = getCommandFromMessage(message); - debug("Received command: ", command); - if (command == "toggle") { - device.switch(command).then((data) => { - debug("Set device status completed: ", data); - }); - } - if (command.schema === true) { - // Trigger device schema update to update state - device.schema(command).then((data) => { - }); - debug("Get schema status command complete"); - } else { - device.set(command).then((data) => { - debug("Set device status completed: ", data); + // If it looks like a valid command topic try to process it + if (action.includes("command")) { + debug("Receive settings", JSON.stringify({ + topic: topic, + action: action, + message: message, + topicLevel: options.topicLevel + })); + + // Uses device topic level to find matching device + var device = new TuyaDevice(options); + + device.then(function (params) { + var device = params.device; + switch (action) { + case "command": + if (topicLength === 3) { + const command = getCommandFromMessage(message); + debug("Received command: ", command); + if (command == "toggle") { + device.switch(command).then((data) => { + debug("Set device status completed: ", data); + }); + } + if (command == "schema") { + // Trigger device schema update to update state + device.schema(command).then((data) => { + }); + debug("Get schema status command complete"); + } else { + device.set(command).then((data) => { + debug("Set device status completed: ", data); + }); + } + } else if (topicLength === 4) { + if (isJsonString(message)) { + const command = getCommandFromMessage(message); + debug("Received command: ", command); + device.set(command).then((data) => { + debug("Set device status completed: ", data); + }); + } else { + debug("DPS command topic requires Tuya style JSON value") + } + } else if (topicLength === 5) { + if (isJsonString(message)) { + debug("Individual DPS command topics require string value") + } else { + const dpsMessage = parseDpsMessage(message) + debug("Received DPS "+splitTopic[topicLength-2]+" command: ", message); + const command = { + dps: splitTopic[topicLength-2], + set: dpsMessage + } + device.set(command).then((data) => { + debug("Set device status completed: ", data); + }); + } + } + break; + case "color": + const color = message.toLowerCase(); + debugColor("Set color: ", color); + device.setColor(color).then((data) => { + debug("Set device color completed: ", data); }); - } - break; - case "color": - var color = message.toLowerCase(); - debugColor("Set color: ", color); - device.setColor(color).then((data) => { - debug("Set device color completed: ", data); - }); - break; - } - - }).catch((err) => { - debugError(err); - }); + break; + case "brightness_command": + if (message >= 0 && message <= 255) { + const brightness = { + dps: 2, + set: parseInt(message) + } + debug("Set brighness: ", message) + device.set(brightness).then((data) => { + debug("Set device brightness completed: ",data); + }); + } else { + debug("Received invalid brightness value: " + message) + } + break; + } + }).catch((err) => { + debugError(err); + }); + } } catch (e) { debugError(e); } From 77b697f5bb3466204d67eeb8b72411917106a5d6 Mon Sep 17 00:00:00 2001 From: tsightler Date: Thu, 24 Sep 2020 15:30:47 -0400 Subject: [PATCH 04/34] Update tuya-mqtt.js --- tuya-mqtt.js | 261 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 156 insertions(+), 105 deletions(-) diff --git a/tuya-mqtt.js b/tuya-mqtt.js index 714667d..c148050 100644 --- a/tuya-mqtt.js +++ b/tuya-mqtt.js @@ -35,17 +35,22 @@ function getCommandFromMessage(_message) { let command = _message if (command != "1" && command != "0" && isJsonString(command)) { - debug("command is JSON"); + debug("Received command is JSON"); command = JSON.parse(command); } else { - if (command.toLowerCase() != "toggle") { - // convert simple commands (on, off, 1, 0) to TuyAPI-Commands - const convertString = command.toLowerCase() == "on" || command == "1" || command == 1 ? true : false; - command = { - set: convertString - } - } else { - command = command.toLowerCase(); + switch(command.toLowerCase()) { + case "on": + case "off": + case "0": + case "1": + // convert simple commands (on, off, 1, 0) to TuyAPI-Commands + const convertString = command.toLowerCase() == "on" || command == "1" || command == 1 ? true : false; + command = { + set: convertString + } + break; + default: + command = command.toLowerCase(); } } return command; @@ -77,16 +82,29 @@ function guessDeviceType(device, dps) { if (typeof dps['1'] === "boolean" && dps['2'] >= 0 && dps['2'] <= 255) { // A "dimmer" is a switch/light with brightness control only device.options.type = "dimmer" + device.options.template = + { + "state": { "dpsKey": 1, "dpsType": "bool" }, + "brightness_state": { "dpsKey": 2, "dpsType": "int", "minVal": 0, "maxVal": 255 } + } } } else if (keys === 1) { if (typeof dps['1'] === "boolean") { // If it only has one value and it's a boolean, it's probably a switch/socket device.options.type = "switch" + device.options.template = + { + "state": { "dpsKey": 1, "dpsType": "bool" } + } } } if (!device.options.type) { device.options.type = "unknown" + device.options.template = + { + "state": { "dpsKey": 1, "dpsType": "bool" } + } } } @@ -95,31 +113,28 @@ function publishColorState(device, state) { } function publishDeviceTopics(device, dps) { - const baseTopic = CONFIG.topic + device.topicLevel - let state - let brightness_state - switch (device.options.type) { - case "switch": - case "unknown": - state = (dps['1']) ? 'ON' : 'OFF'; - topic = baseTopic+"/state" - debugTuya("MQTT state ("+device.options.type+"): "+topic+" -> ", state); + if (!device.options.template) { + debugTuya ("No device template found!") + return + } + const baseTopic = CONFIG.topic + device.topicLevel + "/" + for (let stateTopic in device.options.template) { + const template = device.options.template[stateTopic] + const topic = baseTopic + stateTopic + let state + switch (template.dpsType) { + case "bool": + state = (dps[template.dpsKey]) ? 'ON' : 'OFF'; + break; + case "int": + state = (dps[template.dpsKey]) + state = (state > template.minVal && state < template.maxVal) ? state.toString() : "" + break; + } + if (state) { + debugTuya("MQTT "+device.options.type+" "+topic+" -> ", state); publishMQTT(topic, state); - break; - case "dimmer": - if ('1' in dps) { - state = (dps['1']) ? 'ON' : 'OFF'; - topic = baseTopic+"/state" - debugTuya("MQTT state ("+device.options.type+"): "+topic+" -> ", state); - publishMQTT(topic, state); - } - if ('2' in dps) { - brightness_state = JSON.stringify(dps['2']); - topic = baseTopic+"/brightness_state" - debugTuya("MQTT brightness ("+device.options.type+"): "+topic+" -> ", brightness_state); - publishMQTT(topic, brightness_state); - } - break; + } } } @@ -166,7 +181,7 @@ function publishDPS(device, dps) { TuyaDevice.onAll('data', function (data) { try { if (typeof data.dps != "undefined") { - debugTuya('Data from device ' + this.tuyID + ' :', data); + debugTuya('Data from device Id ' + data.devId + ' ->', data.dps); publishDPS(this, data.dps); } } catch (e) { @@ -187,7 +202,7 @@ function sleep(sec) { } function initTuyaDevices(tuyaDevices) { - for (const tuyaDevice of tuyaDevices) { + for (let tuyaDevice of tuyaDevices) { let options = { id: tuyaDevice.id, key: tuyaDevice.key @@ -205,6 +220,100 @@ function initTuyaDevices(tuyaDevices) { } } +// Process MQTT commands for all command topics at device level +function processDeviceCommand(message, device, commandTopic) { + let command = getCommandFromMessage(message); + // If it's the color command topic handle it manually + if (commandTopic === "color_command") { + const color = message.toLowerCase(); + debugColor("Set color: ", color); + device.setColor(color).then((data) => { + debug("Set device color completed: ", data); + }); + } else if (commandTopic === "command" && (command === "toggle" || command === "schema" )) { + // Handle special commands "toggle" and "schema" to primary device command topic + debug("Received command: ", command); + switch(command) { + case "toggle": + device.switch(command).then((data) => { + debug("Set device status completed: ", data); + }); + break; + case "schema": + // Trigger device schema to update state + device.schema(command).then((data) => { + debug("Get schema status command complete."); + }); + break; + } + } else { + // Recevied command on device topic level, check for matching device template + // and process command accordingly + const stateTopic = commandTopic.replace("command", "state") + const template = device.options.template[stateTopic] + if (template) { + debug("Received device "+commandTopic.replace("_"," "), message); + const tuyaCommand = new Object() + tuyaCommand.dps = template.dpsKey + switch (template.dpsType) { + case "bool": + if (command === "true") { + tuyaCommand.set = true + } else if (command === "false") { + tuyaCommand.set = false + } else if (typeof command.set === "boolean") { + tuyaCommand.set = command.set + } else { + tuyaCommand.set = "!!!!!" + } + break; + case "int": + tuyaCommand.set = (command > template.minVal && command < template.maxVal ) ? parseInt(command) : "!!!!!" + break; + } + if (tuyaCommand.set === "!!!!!") { + debug("Received invalid value for ", commandTopic, ", value:", command) + } else { + device.set(tuyaCommand).then((data) => { + debug("Set device "+commandTopic.replace("_"," ")+": ", data); + }); + } + } else { + debug("Received unknown command topic for device: ", commandTopic) + } + } +} + +// Process raw Tuya JSON commands via DPS command topic +function processDpsCommand(message, device) { + if (isJsonString(message)) { + const command = getCommandFromMessage(message); + debug("Received command: ", command); + device.set(command).then((data) => { + debug("Set device status completed: ", data); + }); + } else { + debug("DPS command topic requires Tuya style JSON value") + } +} + +// Process text base Tuya command via DPS key command topics +function processDpsKeyCommand(message, device, dpsKey) { + if (isJsonString(message)) { + debug("Individual DPS command topics do not accept JSON values") + } else { + const dpsMessage = parseDpsMessage(message) + debug("Received command for DPS"+dpsKey+": ", message); + const command = { + dps: dpsKey, + set: dpsMessage + } + device.set(command).then((data) => { + debug("Set device status completed: ", data); + }); + } +} + // Main code function const main = async() => { let tuyaDevices @@ -272,91 +381,33 @@ const main = async() => { message = message.toString(); const splitTopic = topic.split("/"); const topicLength = splitTopic.length - const action = splitTopic[topicLength - 1]; + const commandTopic = splitTopic[topicLength - 1]; const options = { topicLevel: splitTopic[1] } // If it looks like a valid command topic try to process it - if (action.includes("command")) { + if (commandTopic.includes("command")) { debug("Receive settings", JSON.stringify({ topic: topic, - action: action, - message: message, - topicLevel: options.topicLevel + message: message })); // Uses device topic level to find matching device var device = new TuyaDevice(options); device.then(function (params) { - var device = params.device; - switch (action) { - case "command": - if (topicLength === 3) { - const command = getCommandFromMessage(message); - debug("Received command: ", command); - if (command == "toggle") { - device.switch(command).then((data) => { - debug("Set device status completed: ", data); - }); - } - if (command == "schema") { - // Trigger device schema update to update state - device.schema(command).then((data) => { - }); - debug("Get schema status command complete"); - } else { - device.set(command).then((data) => { - debug("Set device status completed: ", data); - }); - } - } else if (topicLength === 4) { - if (isJsonString(message)) { - const command = getCommandFromMessage(message); - debug("Received command: ", command); - device.set(command).then((data) => { - debug("Set device status completed: ", data); - }); - } else { - debug("DPS command topic requires Tuya style JSON value") - } - } else if (topicLength === 5) { - if (isJsonString(message)) { - debug("Individual DPS command topics require string value") - } else { - const dpsMessage = parseDpsMessage(message) - debug("Received DPS "+splitTopic[topicLength-2]+" command: ", message); - const command = { - dps: splitTopic[topicLength-2], - set: dpsMessage - } - device.set(command).then((data) => { - debug("Set device status completed: ", data); - }); - } - } + let device = params.device; + switch (topicLength) { + case 3: + processDeviceCommand(message, device, commandTopic); break; - case "color": - const color = message.toLowerCase(); - debugColor("Set color: ", color); - device.setColor(color).then((data) => { - debug("Set device color completed: ", data); - }); + case 4: + processDpsCommand(message, device); break; - case "brightness_command": - if (message >= 0 && message <= 255) { - const brightness = { - dps: 2, - set: parseInt(message) - } - debug("Set brighness: ", message) - device.set(brightness).then((data) => { - debug("Set device brightness completed: ",data); - }); - } else { - debug("Received invalid brightness value: " + message) - } + case 5: + const dpsKey = splitTopic[topicLength-2] + processDpsKeyCommand(message, device, dpsKey); break; } }).catch((err) => { From 311144a9ed7e885a34b7c7e9c17c49f86e9070ff Mon Sep 17 00:00:00 2001 From: tsightler Date: Fri, 25 Sep 2020 00:27:10 -0400 Subject: [PATCH 05/34] Update tuya-mqtt.js --- tuya-mqtt.js | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/tuya-mqtt.js b/tuya-mqtt.js index c148050..70afdf5 100644 --- a/tuya-mqtt.js +++ b/tuya-mqtt.js @@ -122,18 +122,21 @@ function publishDeviceTopics(device, dps) { const template = device.options.template[stateTopic] const topic = baseTopic + stateTopic let state - switch (template.dpsType) { - case "bool": - state = (dps[template.dpsKey]) ? 'ON' : 'OFF'; - break; - case "int": - state = (dps[template.dpsKey]) - state = (state > template.minVal && state < template.maxVal) ? state.toString() : "" - break; - } - if (state) { - debugTuya("MQTT "+device.options.type+" "+topic+" -> ", state); - publishMQTT(topic, state); + // Only publish state updates for DPS values included in device data + if (dps.hasOwnProperty('dpsKey')) { + switch (template.dpsType) { + case "bool": + state = (dps[template.dpsKey]) ? 'ON' : 'OFF'; + break; + case "int": + state = (dps[template.dpsKey]) + state = (state > template.minVal && state < template.maxVal) ? state.toString() : "" + break; + } + if (state) { + debugTuya("MQTT "+device.options.type+" "+topic+" -> ", state); + publishMQTT(topic, state); + } } } } From 56b87aa27eaacbb392cbbdb3165d1f6021ef1093 Mon Sep 17 00:00:00 2001 From: tsightler Date: Fri, 25 Sep 2020 00:34:43 -0400 Subject: [PATCH 06/34] Update tuya-mqtt.js --- tuya-mqtt.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tuya-mqtt.js b/tuya-mqtt.js index 70afdf5..d85181c 100644 --- a/tuya-mqtt.js +++ b/tuya-mqtt.js @@ -123,7 +123,7 @@ function publishDeviceTopics(device, dps) { const topic = baseTopic + stateTopic let state // Only publish state updates for DPS values included in device data - if (dps.hasOwnProperty('dpsKey')) { + if (dps.hasOwnProperty(template.dpsType)) { switch (template.dpsType) { case "bool": state = (dps[template.dpsKey]) ? 'ON' : 'OFF'; From f598d246cff72332f530e1b6747af9acca8bee5c Mon Sep 17 00:00:00 2001 From: tsightler Date: Fri, 2 Oct 2020 23:16:53 -0400 Subject: [PATCH 07/34] 3.0.0-beta2 * Implement basic template model * Generic device can specify template in devices.conf * Simple switch, dimmer, and RGBTW specific support files --- .gitignore | 2 +- devices/generic-device.js | 30 +++ devices/rgbtw-light.js | 80 +++++++ devices/simple-dimmer.js | 56 +++++ devices/simple-switch.js | 45 ++++ devices/tuya-device.js | 420 ++++++++++++++++++++++++++++++++++ lib/utils.js | 24 ++ package.json | 4 +- tuya-color.js | 348 ---------------------------- tuya-device.js | 270 ---------------------- tuya-mqtt.js | 470 ++++++++------------------------------ 11 files changed, 755 insertions(+), 994 deletions(-) create mode 100644 devices/generic-device.js create mode 100644 devices/rgbtw-light.js create mode 100644 devices/simple-dimmer.js create mode 100644 devices/simple-switch.js create mode 100644 devices/tuya-device.js create mode 100644 lib/utils.js delete mode 100644 tuya-color.js delete mode 100644 tuya-device.js diff --git a/.gitignore b/.gitignore index a2070c6..6dbf473 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -devices/ +old/ test/ config.json diff --git a/devices/generic-device.js b/devices/generic-device.js new file mode 100644 index 0000000..9984b40 --- /dev/null +++ b/devices/generic-device.js @@ -0,0 +1,30 @@ +const TuyaDevice = require('./tuya-device') +const debug = require('debug')('tuya-mqtt:tuya') +const utils = require('../lib/utils') + +class GenericDevice extends TuyaDevice { + async init() { + this.deviceData.mdl = 'Generic Device' + + // Check if custom template in device config + if (this.config.hasOwnProperty('template')) { + // Map generic DPS topics to device specific topic names + this.deviceTopics = this.config.template + console.log(this.deviceTopics) + } else { + // Try to get schema to at least know what DPS keys to get initial update + const result = await this.device.get({"schema": true}) + if (!utils.isJsonString(result)) { + if (result === 'Schema for device not available') { + debug('Device id '+this.config.id+' failed schema discovery and no custom template defined') + debug('Cannot get initial DPS state data for device '+this.options.name+' but data updates will be publish') + } + } + } + + // Get initial states and start publishing topics + this.getStates() + } +} + +module.exports = GenericDevice \ No newline at end of file diff --git a/devices/rgbtw-light.js b/devices/rgbtw-light.js new file mode 100644 index 0000000..5f99179 --- /dev/null +++ b/devices/rgbtw-light.js @@ -0,0 +1,80 @@ +const TuyaDevice = require('./tuya-device') +const debug = require('debug')('tuya-mqtt:tuya') +const utils = require('../lib/utils') + +class RGBTWLight extends TuyaDevice { + async init() { + // Set device specific variables + this.config.dpsPower = this.config.dpsPower ? this.config.dpsPower : 1 + this.config.dpsMode = this.config.dpsMode ? this.config.dpsMode : 2 + this.config.dpsWhiteValue = this.config.dpsWhiteValue ? this.config.dpsWhiteValue : 3 + this.config.whiteValueScale = this.config.whiteValueScale ? this.config.whiteValueScale : 1000 + this.config.dpsColorTemp = this.config.dpsColorTemp ? this.config.dpsColorTemp : 4 + this.config.dpsColor = this.config.dpsColor ? this.config.dpsColor : 5 + this.config.colorType = this.config.colorType ? this.config.colorType : 'hsb' + + this.deviceData.mdl = 'RGBTW Light' + + // Map generic DPS topics to device specific topic names + this.deviceTopics = { + state: { + key: this.config.dpsPower, + type: 'bool' + }, + white_value_state: { + key: this.config.dpsWhiteValue, + type: 'int', + min: (this.config.whiteValueScale = 1000) ? 10 : 1, + max: this.config.whiteValueScale, + scale: this.config.whiteValueScale + }, + hs_state: { + key: this.config.dpsColor, + type: this.config.colorType, + components: 'h,s' + }, + brightness_state: { + key: this.config.dpsColor, + type: this.config.colorType, + components: 'b' + }, + mode_state: { + key: this.config.dpsMode, + type: 'str' + } + } + + // Send home assistant discovery data and give it a second before sending state updates + this.initDiscovery() + await utils.sleep(1) + + // Get initial states and start publishing topics + this.getStates() + } + + initDiscovery() { + const configTopic = 'homeassistant/light/'+this.config.id+'/config' + + const discoveryData = { + name: (this.config.name) ? this.config.name : this.config.id, + state_topic: this.baseTopic+'state', + command_topic: this.baseTopic+'command', + brightness_state_topic: this.baseTopic+'brightness_state', + brightness_command_topic: this.baseTopic+'brightness_command', + brightness_scale: 1000, + hs_state_topic: this.baseTopic+'hs_state', + hs_command_topic: this.baseTopic+'hs_command', + white_value_state_topic: this.baseTopic+'white_value_state', + white_value_command_topic: this.baseTopic+'white_value_command', + white_value_scale: 1000, + unique_id: this.config.id, + device: this.deviceData + } + + debug('Home Assistant config topic: '+configTopic) + debug(discoveryData) + this.publishMqtt(configTopic, JSON.stringify(discoveryData)) + } +} + +module.exports = RGBTWLight \ No newline at end of file diff --git a/devices/simple-dimmer.js b/devices/simple-dimmer.js new file mode 100644 index 0000000..d4d8448 --- /dev/null +++ b/devices/simple-dimmer.js @@ -0,0 +1,56 @@ +const TuyaDevice = require('./tuya-device') +const debug = require('debug')('tuya-mqtt:tuya') +const utils = require('../lib/utils') + +class SimpleDimmer extends TuyaDevice { + async init() { + // Set device specific variables + this.config.dpsPower = this.config.dpsPower ? this.config.dpsPower : 1 + this.config.dpsBrightness = this.config.dpsBrightness ? this.config.dpsBrightness : 2 + this.config.brightnessScale = this.config.brightnessScale ? this.config.brightnessScale : 255 + + this.deviceData.mdl = 'Dimmer Switch' + + // Map generic DPS topics to device specific topic names + this.deviceTopics = { + state: { + key: this.config.dpsPower, + type: 'bool' + }, + brightness_state: { + key: this.config.dpsBrightness, + type: 'int', + min: (this.config.brightnessScale = 1000) ? 10 : 1, + max: this.config.brightnessScale, + scale: this.config.brightnessScale + } + } + + // Send home assistant discovery data and give it a second before sending state updates + this.initDiscovery() + await utils.sleep(1) + + // Get initial states and start publishing topics + this.getStates() + } + + initDiscovery() { + const configTopic = 'homeassistant/light/'+this.config.id+'/config' + + const discoveryData = { + name: (this.config.name) ? this.config.name : this.config.id, + state_topic: this.baseTopic+'state', + command_topic: this.baseTopic+'command', + brightness_state_topic: this.baseTopic+'brightness_state', + brightness_command_topic: this.baseTopic+'brightness_command', + unique_id: this.config.id, + device: this.deviceData + } + + debug('Home Assistant config topic: '+configTopic) + debug(discoveryData) + this.publishMqtt(configTopic, JSON.stringify(discoveryData)) + } +} + +module.exports = SimpleDimmer \ No newline at end of file diff --git a/devices/simple-switch.js b/devices/simple-switch.js new file mode 100644 index 0000000..f6c92d4 --- /dev/null +++ b/devices/simple-switch.js @@ -0,0 +1,45 @@ +const TuyaDevice = require('./tuya-device') +const debug = require('debug')('tuya-mqtt:tuya') +const utils = require('../lib/utils') + +class SimpleSwitch extends TuyaDevice { + async init() { + // Set device specific variables + this.config.dpsPower = this.config.dpsPower ? this.config.dpsPower : 1 + + this.deviceData.mdl = 'Switch/Socket' + + // Map generic DPS topics to device specific topic names + this.deviceTopics = { + state: { + key: this.config.dpsPower, + type: 'bool' + } + } + + // Send home assistant discovery data and give it a second before sending state updates + this.initDiscovery() + await utils.sleep(1) + + // Get initial states and start publishing topics + this.getStates() + } + + initDiscovery() { + const configTopic = 'homeassistant/switch/'+this.config.id+'/config' + + const discoveryData = { + name: (this.config.name) ? this.config.name : this.config.id, + state_topic: this.baseTopic+'state', + command_topic: this.baseTopic+'command', + unique_id: this.config.id, + device: this.deviceData + } + + debug('Home Assistant config topic: '+configTopic) + debug(discoveryData) + this.publishMqtt(configTopic, JSON.stringify(discoveryData)) + } +} + +module.exports = SimpleSwitch \ No newline at end of file diff --git a/devices/tuya-device.js b/devices/tuya-device.js new file mode 100644 index 0000000..e23e0fd --- /dev/null +++ b/devices/tuya-device.js @@ -0,0 +1,420 @@ +const TuyAPI = require('tuyapi') +const utils = require('../lib/utils') +const debug = require('debug')('tuya-mqtt:tuya') +const debugMqtt = require('debug')('tuya-mqtt:mqtt') +const debugError = require('debug')('tuya-mqtt:error') + +class TuyaDevice { + constructor(deviceInfo) { + this.config = deviceInfo.configDevice + this.mqttClient = deviceInfo.mqttClient + this.topic = deviceInfo.topic + + // Build TuyAPI device options from device config info + this.options = { + id: this.config.id, + key: this.config.key + } + if (this.config.name) { this.options.name = this.config.name.toLowerCase().replace(/ /g,'_') } + if (this.config.ip) { + this.options.ip = this.config.ip + if (this.config.version) { + this.options.version = this.config.version + } else { + this.options.version = '3.1' + } + } + + // Set default device data for Home Assistant device registry + // Values may be overridden by individual devices + this.deviceData = { + ids: [ this.config.id ], + name: (this.config.name) ? this.config.name : this.config.id, + mf: 'Tuya' + } + + this.dps = {} // This will hold dps state data for device + this.prevDps = {} // This will hold previous dps value for device to avoid republish of non-changed states + + // Build the MQTT topic for this device (friendly name or device id) + if (this.options.name) { + this.baseTopic = this.topic + this.options.name + '/' + } else { + this.baseTopic = this.topic + this.options.id + '/' + } + + // Create the new Tuya Device + this.device = new TuyAPI(JSON.parse(JSON.stringify(this.options))) + + // Listen for device data and call update DPS function if valid + this.device.on('data', (data) => { + if (typeof data == 'string') { + debug('Data from device not encrypted:', data.replace(/[^a-zA-Z0-9 ]/g, '')) + } else { + if (!(data.dps['1'] === null && data.dps['2'] === null && data.dps['3'] === null && data.dps['101'] === null && data.dps['102'] === null && data.dps['103'] === null)) { + debug('Data from device '+this.options.id+' ->', data.dps) + this.updateDpsData(data) + } + } + }) + + // Find device on network + debug('Search for device id '+this.options.id) + this.device.find().then(() => { + debug('Found device id '+this.options.id) + // Attempt connection to device + this.device.connect() + }) + + // On connect perform device specific init + this.device.on('connected', () => { + debug('Connected to device ' + this.toString()) + this.init() + }) + + // On disconnect perform device specific disconnect + this.device.on('disconnected', () => { + this.connected = false + debug('Disconnected from device ' + this.toString()) + }) + + // On connect error call reconnect + this.device.on('error', (err) => { + if (err !== 'json obj data unvalid') { + debugError(err) + } + if (err.message === 'Error from socket') { + this.reconnect() + } + }) + } + + // Retry connection every 10 seconds if unable to connect + async reconnect() { + debug('Error connecting to device id '+this.options.id+'...retry in 10 seconds.') + await utils.sleep(10) + if (this.connected) { return } + debug('Search for device id '+this.options.id) + this.device.find().then(() => { + debug('Found device id '+this.options.id) + // Attempt connection to device + this.device.connect() + }) + } + + // Publish MQTT + publishMqtt(topic, message, isDebug) { + if (isDebug) { debugMqtt(topic, message) } + this.mqttClient.publish(topic, message, { qos: 1 }); + } + + // Publish device specific state topics + publishTopics() { + // Don't publish if device is not connected + if (!this.connected) return + + // Loop through and publish all device specific topics + for (let topic in this.deviceTopics) { + const state = this.getTopicState(topic) + this.publishMqtt(this.baseTopic + topic, state, true) + } + + // Publish Generic Dps Topics + this.publishDpsTopics() + } + + // Process MQTT commands for all command topics at device level + processDeviceCommand(message, commandTopic) { + // Determine state topic from command topic to find proper template + const stateTopic = commandTopic.replace('command', 'state') + const deviceTopic = this.deviceTopics.hasOwnProperty(stateTopic) ? this.deviceTopics[stateTopic] : '' + + if (deviceTopic) { + debug('Device '+this.options.id+' recieved command topic: '+commandTopic+', message: '+message) + const command = this.getCommandFromMessage(message) + let setResult = this.setState(command, deviceTopic) + if (!setResult) { + debug('Command topic '+this.baseTopic+commandTopic+' received invalid value: '+command) + } + } else { + debug('Invalid command topic '+this.baseTopic+commandTopic+' for device: '+this.config.name) + return + } + } + + // Get and update state of all dps properties for device + async getStates() { + // Suppress topic updates while syncing state + this.connected = false + for (let topic in this.deviceTopics) { + const key = this.deviceTopics[topic].key + const result = await this.device.get({"dps": key}) + } + this.connected = true + // Force topic update now that all states are fully in sync + this.publishTopics() + } + + // Update dps properties with device data updates + updateDpsData(data) { + try { + if (typeof data.dps != 'undefined') { + // Update device dps values + for (let key in data.dps) { + this.dps[key] = data.dps[key] + } + if (this.connected) { + this.publishTopics() + } + } + } catch (e) { + debugError(e); + } + } + + // Process MQTT commands for all command topics at device level + async processCommand(message, commandTopic) { + const command = this.getCommandFromMessage(message) + if (commandTopic === 'command' && command === 'get-states' ) { + // Handle "get-states" command to update device state + debug('Received command: ', command) + await this.getStates() + } else { + // Call device specific command topic handler + this.processDeviceCommand(message, commandTopic) + } + } + + // Publish all dps-values to topic + publishDpsTopics() { + try { + const dpsTopic = this.baseTopic + 'dps' + + // Publish DPS JSON data if not empty + if (Object.keys(this.dps).length) { + const data = JSON.stringify(this.dps) + const dpsStateTopic = dpsTopic + '/state' + debugMqtt('MQTT DPS JSON: ' + dpsStateTopic + ' -> ', data) + this.publishMqtt(dpsStateTopic, data, false) + } + + // Publish dps/<#>/state value for each device DPS + for (let key in this.dps) { + const dpsKeyTopic = dpsTopic + '/' + key + '/state' + const data = this.dps.hasOwnProperty(key) ? this.dps[key].toString() : 'None' + debugMqtt('MQTT DPS'+key+': '+dpsKeyTopic+' -> ', data) + this.publishMqtt(dpsKeyTopic, data, false) + } + } catch (e) { + debugError(e); + } + } + + getTopicState(topic) { + const deviceTopic = this.deviceTopics[topic] + const key = deviceTopic.key + let state = null + switch (deviceTopic.type) { + case 'bool': + state = this.dps[key] ? 'ON' : 'OFF' + break; + case 'int': + state = this.dps[key] ? this.dps[key].toString() : 'None' + break; + case 'hsb': + if (this.dps[key]) { + state = this.getColorState(this.dps[key], topic) + } + break; + case 'str': + state = this.dps[key] ? this.dps[key] : '' + } + return state + } + + // Set state based on command topic + setState(command, deviceTopic) { + const tuyaCommand = new Object() + tuyaCommand.dps = deviceTopic.key + switch (deviceTopic.type) { + case 'bool': + if (command === 'toggle') { + tuyaCommand.set = !this.dps[tuyaCommand.dps] + } else { + if (typeof command.set === 'boolean') { + tuyaCommand.set = command.set + } else { + tuyaCommand.set = '!!!INVALID!!!' + } + } + break; + case 'int': + if (isNaN(command)) { + tuyaCommand.set = '!!!INVALID!!!' + } else if (deviceTopic.hasOwnProperty('min') && deviceTopic.hasOwnProperty('max')) { + tuyaCommand.set = (command >= deviceTopic.min && command <= deviceTopic.max ) ? parseInt(command) : '!!!INVALID!!!' + } else { + tuyaCommand.set = parseInt(command) + } + break; + case 'hsb': + tuyaCommand.set = this.getColorCommand(command, deviceTopic) + this.setLightMode(deviceTopic) + break; + } + if (tuyaCommand.set === '!!!INVALID!!!') { + return false + } else { + if (this.config.dpsWhiteValue === deviceTopic.key) { + this.setLightMode(deviceTopic) + } + this.set(tuyaCommand) + return true + } + } + + // Converts message to TuyAPI JSON commands + getCommandFromMessage(_message) { + let command = _message + + if (command != '1' && command != '0' && utils.isJsonString(command)) { + debugMqtt('MQTT message is JSON'); + command = JSON.parse(command); + } else { + switch(command.toLowerCase()) { + case 'on': + case 'off': + case '0': + case '1': + case 'true': + case 'false': + // convert simple commands (on, off, 1, 0) to TuyAPI-Commands + const convertString = command.toLowerCase() === 'on' || command === '1' || command === 'true' || command === 1 ? true : false; + command = { + set: convertString + } + break; + default: + command = command.toLowerCase(); + } + } + return command; + } + + // Process Tuya JSON commands via DPS command topic + processDpsCommand(message) { + if (utils.isJsonString(message)) { + const tuyaCommand = this.getCommandFromMessage(message) + debugMqtt('Received command: '+tuyaCommand) + this.set(tuyaCommand) + } else { + debugError('DPS command topic requires Tuya style JSON value') + } + } + + // Process text base Tuya command via DPS key command topics + processDpsKeyCommand(message, dpsKey) { + if (utils.isJsonString(message)) { + debugError('Individual DPS command topics do not accept JSON values') + } else { + const dpsMessage = this.parseDpsMessage(message) + debugMqtt('Received command for DPS'+dpsKey+': ', message) + const tuyaCommand = { + dps: dpsKey, + set: dpsMessage + } + this.set(tuyaCommand) + } + } + + // Parse string message into boolean and number types + parseDpsMessage(message) { + if (typeof message === 'boolean' ) { + return message; + } else if (message === 'true' || message === 'false') { + return (message === 'true') ? true : false + } else if (!isNaN(message)) { + return Number(message) + } else { + return message + } + } + + // Simple function to help debug output + toString() { + return this.config.name+' (' +(this.options.ip ? this.options.ip+', ' : '')+this.options.id+', '+this.options.key+')' + } + + set(command) { + debug('Set device '+this.options.id+' -> '+command) + return new Promise((resolve, reject) => { + this.device.set(command).then((result) => { + debug(result) + resolve(result) + }) + }) + } + + // Takes the current Tuya color and splits it into component parts + // Returns decimal format comma delimeted string of components for selected topic + getColorState(value, topic) { + const [, h, s, b] = (value || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8']; + const decimalColor = { + h: parseInt(h, 16), + s: Math.round(parseInt(s, 16) / 10), + b: parseInt(b, 16) + } + const color = new Array() + const components = this.deviceTopics[topic].components.split(',') + for (let i in components) { + if (decimalColor.hasOwnProperty([components[i]])) { + color.push(decimalColor[components[i]]) + } + } + return (color.join(',')) + } + + // Takes provided decimal HSB components from MQTT topic, combine with existing + // settings for unchanged values since brightness is sometimes sent separately + // Convert to Tuya hex format and return value + getColorCommand(value, topic) { + const [, h, s, b] = (this.dps[topic.key] || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8']; + const decimalColor = { + h: parseInt(h, 16), + s: Math.round(parseInt(s, 16) / 10), + b: parseInt(b, 16) + } + const components = topic.components.split(',') + const values = value.split(',') + for (let i in components) { + decimalColor[components[i]] = Math.round(values[i]) + } + const hexColor = decimalColor.h.toString(16).padStart(4, '0') + (10 * decimalColor.s).toString(16).padStart(4, '0') + (decimalColor.b).toString(16).padStart(4, '0') + return hexColor + } + + // Set light mode based on received command + async setLightMode(topic) { + const currentMode = this.dps[this.config.dpsMode] + let targetMode + + if (this.config.dpsWhiteValue === topic.key) { + // If setting white level, switch to white mode + targetMode = 'white' + } else if (this.config.dpsColor === topic.key) { + // If setting an HSB value, switch to colour mode + targetMode = 'colour' + } + + // Set the correct light mode + if (targetMode && targetMode !== currentMode) { + const tuyaCommand = { + dps: this.config.dpsMode, + set: targetMode + } + await this.set(tuyaCommand) + } + } +} + +module.exports = TuyaDevice \ No newline at end of file diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..0943f9f --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,24 @@ +class Utils +{ + + // Check if data is JSON or not + isJsonString(data) { + try { + const parsedData = JSON.parse(data) + if (parsedData && typeof parsedData === "object") { + return parsedData + } + } + catch (e) { } + + return false + } + + // Simple sleep function for various required delays + sleep(sec) { + return new Promise(res => setTimeout(res, sec*1000)) + } + +} + +module.exports = new Utils() \ No newline at end of file diff --git a/package.json b/package.json index 7e02573..de9f1d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tuya-mqtt", - "version": "3.0.0-beta1", + "version": "3.0.0-beta2", "description": "Control Tuya devices locally via MQTT", "homepage": "https://github.com/TheAgentK/tuya-mqtt#readme", "main": "tuya-mqtt.js", @@ -16,7 +16,7 @@ "color-convert": "^2.0.1", "debug": "^4.1.1", "mqtt": "^4.2.1", - "tuyapi": "^5.3.2", + "tuyapi": "github:tsightler/tuyAPI", "json5": "^2.1.3" }, "repository": { diff --git a/tuya-color.js b/tuya-color.js deleted file mode 100644 index ea2374b..0000000 --- a/tuya-color.js +++ /dev/null @@ -1,348 +0,0 @@ -const convert = require('color-convert'); -const debug = require('debug')('TuyaColor'); - -/** - * Class to calculate settings for Tuya colors - */ -function TuyaColorLight() { - - this.colorMode = 'white'; // or 'colour' - this.brightness = 100; // percentage value use _convertValToPercentage functions below. - - this.color = { - H: 130, - S: 100, - L: 50 - }; - - this.hue = this.color.H; - this.saturation = this.color.S; - this.lightness = this.color.L; - - this.colorTemperature = 255; - this.colorTempMin = 153; - this.colorTempMax = 500; - - this.dps = {}; -} - -/** - * calculate color value from given brightness percentage - * @param (Integer) percentage 0-100 percentage value - * @returns (Integer) color value from 25 - 255 - * @private - */ -TuyaColorLight.prototype._convertBrightnessPercentageToVal = function(brt_percentage){ - // the brightness scale does not start at 0 but starts at 25 - 255 - // this linear equation is a better fit to the conversion to 255 scale - var tmp = Math.round(2.3206*brt_percentage+22.56); - debug('Converted brightness percentage ' + brt_percentage + ' to: ' + tmp); - return tmp; -} - -/** - * calculate percentage from brightness color value - * @param brt_val 25 - 255 brightness color value - * @returns {Integer} 0 - 100 integer percent - * @private - */ -TuyaColorLight.prototype._convertValtoBrightnessPercentage = function(brt_val){ - var tmp = Math.round( (brt_val-22.56)/2.3206); - debug('Converted brightness value ' + brt_val + ' to: ' + tmp); - return tmp; -} - -/** - * calculate color value from given saturation percentage OR color temperature percentage - * @param (Integer) temp_percentage 0-100 percentage value - * @returns {Integer} saturation or color temperature value from 0 - 255 - * @private - */ -TuyaColorLight.prototype._convertSATorColorTempPercentageToVal = function(temp_percentage){ - // the saturation OR temperature scale does start at 0 - 255 - // this is a perfect linear equation fit for the saturation OR temperature scale conversion - var tmp = Math.round(((2.5498*temp_percentage)-0.4601)); - debug('Converted saturation OR temperature percentage ' + temp_percentage + ' to: ' + tmp); - return tmp; -} - -/** - * calculate percentage from saturation value OR color temperature value - * @param temp_val 0 - 255 saturation or color temperature value - * @returns {Integer} 0 - 100 integer percent - * @private - */ -TuyaColorLight.prototype._convertValtoSATorColorTempPercentage = function(temp_val){ - var tmp = Math.round( (temp_val+0.4601/2.5498)); - debug('Converted saturation OR temperature value ' + temp_val + ' to: ' + tmp); - return tmp; -} - -/** - * calculate color value from given percentage - * @param {Integer} percentage 0-100 percentage value - * @returns {Integer} color value from 0-255 - */ -TuyaColorLight.prototype._convertPercentageToVal = function (percentage) { - var tmp = Math.round(255 * (percentage / 100)); - debug('Converted ' + percentage + ' to: ' + tmp); - return tmp; -}; - -/** - * calculate percentage from color value - * @param {Integer} val 0-255 color value - * @returns {Integer} HK-Value - */ -TuyaColorLight.prototype._convertValToPercentage = function (val) { - var tmp = Math.round((val / 255) * 100); - debug('Converted ' + val + ' to: ' + tmp); - return tmp; -}; - -/** - * converts color value to color temperature - * @param {Integer} val - * @returns {Integer} percentage from 0-100 - */ -TuyaColorLight.prototype._convertColorTemperature = function (val) { - var tmpRange = this.colorTempMax - this.colorTempMin; - var tmpCalc = Math.round((val / this.colorTempMax) * 100); - - debug('HK colorTemp Value: ' + val); - debug('HK colorTemp scale min : ' + this.colorTempMin); - debug('HK colorTemp scale max : ' + this.colorTempMax); - debug('HK colorTemp range (tmpRange): ' + tmpRange); - debug('HK colorTemp % tmpCalc: ' + tmpCalc); - - var tuyaColorTemp = this._convertPercentageToVal(tmpCalc); - - debug('HK tuyaColorTemp: ' + tuyaColorTemp); - - return tuyaColorTemp; -}; - -/** - * Convert color temperature to HK - * @param {Integer} val - * @returns {Integer} HK-Value - */ -TuyaColorLight.prototype._convertColorTemperatureToHK = function (val) { - - var tuyaColorTempPercent = this._convertValToPercentage(this.colorTemperature); - var tmpRange = this.colorTempMax - this.colorTempMin; - var tmpCalc = Math.round((tmpRange * (tuyaColorTempPercent / 100)) + this.colorTempMin); - var hkValue = Math.round(tmpCalc); - - debug('Tuya color Temperature : ' + val); - debug('Tuya color temp Percent of 255: ' + tuyaColorTempPercent + '%'); - - debug('HK colorTemp scale min : ' + this.colorTempMin); - debug('HK colorTemp scale max : ' + this.colorTempMax); - - debug('HK Color Temp Range: ' + tmpRange); - debug('HK range %: ' + tuyaColorTempPercent); - debug('HK Value: ' + hkValue); - - return hkValue; -}; - -/** - * check if given String is HEX - * @param {String} h - * @returns {boolean} - */ -TuyaColorLight.prototype._ValIsHex = function (h) { - debug("Check if value is hex", h); - return /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(h) -}; - -/** - * get width Hex digits from given value - * @param (Integer) value, decimal value to convert to hex string - * @param (Integer) width, the number of hex digits to return - * @returns {string} value as HEX containing (width) number of hex digits - * @private - */ -TuyaColorLight.prototype._getHex = function (value,width){ - var hex = (value+Math.pow(16, width)).toString(16).slice(-width).toLowerCase(); - debug('value: ' + value + ' hex: ' + hex); - return hex; -} -/** - * get AlphaHex from percentage brightness - * @param {Integer} brightness - * @return {string} brightness as HEX value - */ -TuyaColorLight.prototype._getAlphaHex = function (brightness) { - var i = brightness / 100; - var alpha = Math.round(i * 255); - var hex = (alpha + 0x10000).toString(16).substr(-2); - var perc = Math.round(i * 100); - - debug('alpha percent: ' + perc + '% hex: ' + hex + ' alpha: ' + alpha); - return hex; -}; - -/** - * Set saturation from value - * @param {Integer} value - */ -TuyaColorLight.prototype.setSaturation = function (value) { - this.color.S = value; - this.saturation = value; - this.colorMode = 'colour'; - - debug('SET SATURATION: ' + value); -}; - -/** - * Set Brightness - * @param {Integer} value - */ -TuyaColorLight.prototype.setBrightness = function (value) { - this.brightness = value; - //var newValue = this._convertPercentageToVal(value); - var newValue = this._convertBrightnessPercentageToVal(value); - debug("BRIGHTNESS from UI: " + value + ' Converted from 100 to 255 scale: ' + newValue); -} - -/** - * @param {} value - */ -TuyaColorLight.prototype.setHue = function (value) { - debug('SET HUE: ' + value); - debug('Saturation Value: ' + this.color.S); - this.color.H = value; - - //check color and set colormode if necessary - debug("colormode", value, this.color.S); - if (value === 0 && this.color.S === 0) { - this.colorMode = 'white'; - debug('SET Color Mode: \'white\''); - } else { - this.colorMode = 'colour'; - debug('SET Color Mode: \'colour\' -- dahhhhhh british spelling \'coulour\' really is annoying... why you gotta be special?'); - } - - - return { - color: this.color, - colorMode: this.colorMode, - hue: this.color.H, - saturation: this.saturation - }; -}; - -/** - * Set HSL color - * @param {Integer} hue - * @param {Integer} saturation - * @param {Integer} brightness - */ -TuyaColorLight.prototype.setHSL = function (hue, saturation, brightness) { - this.setSaturation(saturation); - this.setBrightness(brightness); - this.setHue(hue); -} - -/** - * Set color from given string - * @param {String} colorValue could be HEX or HSL color type - * @returns {Object} dps settings for given color - */ -TuyaColorLight.prototype.setColor = function (colorValue) { - debug("Recieved color", colorValue); - - if (this._ValIsHex(colorValue)) { - debug("Color is Hex"); - var color = convert.hex.hsl(colorValue); - } else { - debug("Color is HSL"); - var color = colorValue.split(","); - // convert strings to numbers - color.forEach(function (element, key) { - color[key] = parseInt(element, 10); - }); - } - debug("Converted color as HSL", { - 0: color[0] + " - " + typeof color[0], - 1: color[1] + " - " + typeof color[1], - 2: color[2] + " - " + typeof color[2] - }) - - this.setHSL(color[0], color[1], color[2]); - return this.getDps(); -} - -/** - * get dps settings for current color - * @returns {Object} dps settings - */ -TuyaColorLight.prototype.getDps = function () { - var color = this.color; - - var lightness = Math.round(this.brightness / 2); - var brightness = this.brightness; - //var apiBrightness = this._convertPercentageToVal(brightness); - var apiBrightness = this._convertBrightnessPercentageToVal(brightness); - - //var alphaBrightness = this._getAlphaHex(brightness); - var alphaBrightness = this._getHex(apiBrightness,2); - - var hexColor1 = convert.hsl.hex(color.H, color.S, lightness); - - //var hexColor2 = convert.hsl.hex(0, 0, lightness); - var hexColor2 = this._getHex(color.H,4); - hexColor2 = hexColor2 + this._getHex(this._convertSATorColorTempPercentageToVal(color.S),2); - - var colorTemperature = this.colorTemperature; - - var lightColor = (hexColor1 + hexColor2 + alphaBrightness).toLowerCase(); - - //var temperature = (this.colorMode === 'colour') ? 255 : this._convertColorTemperature(colorTemperature); - // color temperature percentage is at a fixed 51% - var temperature = this._convertSATorColorTempPercentageToVal(51); - - // if the bulb is in colour mode than the dps 3 and dps 4 are ignored by the bulb but if you set it now - // some tuya bulbs will ignore dps 5 because you set dps 3 or dps 4 - // FOR colour mode the bulb looks at dps 1, dps 2, and dps 5. - // DPS 5 is in the following format: - // HSL to HEX format are the leftmost hex digits (hex digits 14 - 9) - // hex digits 8 - 5 are the HSB/HSL Hue value in HEX format - // hex digits 4 - 3 are the HSB/HSL Saturation percentage as a value (converted to 0-255 scale) in HEX format - // hex digits 2 - 1 are the HSB Brightness percentage as a value (converted to 25-255 scale) in HEX format - - if (this.colorMode === 'colour') { - dpsTmp = { - '1': true, - '2': this.colorMode, - //'3': apiBrightness, - //'4': temperature, - '5': lightColor - // '6' : hexColor + hexColor + 'ff' - }; - debug("dps", dpsTmp); - return dpsTmp; - } - - // if the bulb is in white mode then the dps 5 value is ignored by the bulb but if you set dps 5 value now - // you may not get a response back from the bulb on the dps values - // FOR white mode the bulb looks at dps 1, dps 2, dps 3 and dps 4 - // DPS 3 is the HSB/HSL Brightness percentage converted to a value from 25 to 255 in decimal format - // DPS 4 is the HSB/HSL Saturation percentage converted to a value from 0 to 255 in decimal format - if (this.colorMode === 'white'){ - dpsTmp = { - '1': true, - '2': this.colorMode, - '3': apiBrightness, - '4': temperature, - //'5': lightColor - // '6' : hexColor + hexColor + 'ff' - }; - debug("dps", dpsTmp); - return dpsTmp; - } -} - -module.exports = TuyaColorLight; \ No newline at end of file diff --git a/tuya-device.js b/tuya-device.js deleted file mode 100644 index 0c526e1..0000000 --- a/tuya-device.js +++ /dev/null @@ -1,270 +0,0 @@ -const TuyAPI = require('tuyapi'); -const TuyColor = require('./tuya-color'); -const debug = require('debug')('TuyAPI:device'); -const debugError = require('debug')('TuyAPI:device:error'); -const debugColor = require('debug')('TuyAPI:device:color'); - -/** - * - var device = new TuyaDevice({ - id: '03200240600194781244', - key: 'b8bdebab418f5b55', - ip: '192.168.178.45', - version: "3.3", - type: "" <- "switch", "light", "dimmer", etc. Attempts autodetect if not defined - }); - */ - -var TuyaDevice = (function () { - var devices = []; - var events = {}; - - function checkExisiting(options) { - var existing = false; - // Check for existing instance - devices.forEach(device => { - if (device.topicLevel == options.topicLevel) { - existing = device; - } - }); - return existing; - } - - function deleteDevice(id) { - devices.forEach((device, key) => { - if (device.hasOwnProperty("options")) { - if (id === device.options.id) { - debug("delete Device", devices[key].toString()); - delete devices[key]; - } - } - }); - } - - function TuyaDevice(options) { - var device = this; - - // Check for existing instance by matching topicLevel value - if (existing = checkExisiting(options)) { - return new Promise((resolve, reject) => { - resolve({ - status: "connected", - device: existing - }); - }); - } - - if (!(this instanceof TuyaDevice)) { - return new TuyaDevice(options); - } - - this.options = options; - - if (this.options.name) { - this.topicLevel = this.options.name.toLowerCase().replace(/ /g,"_"); - } else { - this.topicLevel = this.options.id; - } - - Object.defineProperty(this, 'device', { - value: new TuyAPI(JSON.parse(JSON.stringify(this.options))) - }); - - this.device.on('data', data => { - if (typeof data == "string") { - debugError('Data from device not encrypted:', data.replace(/[^a-zA-Z0-9 ]/g, "")); - } else { - debug('Data from device:', data); - device.triggerAll('data', data); - } - }); - - devices.push(this); - - // Find device on network - debug("Search device in network"); - this.find().then(() => { - debug("Device found in network"); - // Connect to device - this.device.connect(); - }); - - /** - * @return Promise to wait for connection - */ - return new Promise((resolve, reject) => { - this.device.on('connected', () => { - device.triggerAll('connected'); - device.connected = true; - debug('Connected to device.', device.toString()); - resolve({ - status: "connected", - device: this - }); - }); - this.device.on('disconnected', () => { - device.triggerAll('disconnected'); - device.connected = false; - debug('Disconnected from device.', device.toString()); - deleteDevice(options.id); - return reject({ - status: "disconnect", - device: null - }); - }); - - this.device.on('error', (err) => { - debugError(err); - device.triggerAll('error', err); - return reject({ - error: err, - device: this - }); - }); - }); - } - - TuyaDevice.prototype.toString = function () { - return this.name + " (" + this.options.ip + ", " + this.options.id + ", " + this.options.key + ")"; - } - - TuyaDevice.prototype.triggerAll = function (name, argument) { - var device = this; - var e = events[name] || []; - e.forEach(event => { - event.call(device, argument); - }); - } - - TuyaDevice.prototype.on = function (name, callback) { - if (!this.connected) return; - var device = this; - this.device.on(name, function () { - callback.apply(device, arguments); - }); - } - - TuyaDevice.prototype.find = function () { - return this.device.find(); - } - - TuyaDevice.prototype.get = function () { - return this.device.get(); - } - - TuyaDevice.prototype.set = function (options) { - debug('set:', options); - return new Promise((resolve, reject) => { - this.device.set(options).then((result) => { - this.get().then(() => { - debug("Set completed "); - resolve(result); - }); - }); - }); - } - - TuyaDevice.prototype.switch = function (newStatus, callback) { - if (!this.connected) return; - newStatus = newStatus.toLowerCase(); - if (newStatus == "on") { - return this.switchOn(callback); - } - if (newStatus == "off") { - return this.switchOff(callback); - } - if (newStatus == "toggle") { - return this.toggle(callback); - } - } - - TuyaDevice.prototype.switchOn = function () { - if (!this.connected) return; - debug("switch -> ON"); - - return this.set({ - set: true - }); - } - - TuyaDevice.prototype.switchOff = function () { - if (!this.connected) return; - debug("switch -> OFF"); - - return this.set({ - set: false - }); - } - - TuyaDevice.prototype.toggle = function () { - if (!this.connected) return; - return new Promise((resolve, reject) => { - this.get().then((status) => { - debug("toogle state", status); - this.set({ - set: !status - }); - }); - }); - } - - TuyaDevice.prototype.schema = function(obj){ - return this.get(obj).then((status) => { - debug("get", obj); - }); - } - - TuyaDevice.prototype.setColor = function (hexColor) { - if (!this.connected) return; - debugColor("Set color to: ", hexColor); - var tuya = this.device; - var color = new TuyColor(tuya); - var dps = color.setColor(hexColor); - debugColor("dps values:", dps); - - return this.set({ - multiple: true, - data: dps - }); - } - - TuyaDevice.prototype.connect = function (callback) { - debug("Connect to TuyAPI Device"); - return this.device.connect(callback); - } - - TuyaDevice.prototype.disconnect = function (callback) { - debug("Disconnect from TuyAPI Device"); - return this.device.disconnect(callback); - } - - Object.defineProperty(TuyaDevice, 'devices', { - value: devices - }); - - TuyaDevice.connectAll = function () { - devices.forEach(device => { - device.connect(); - }); - } - - TuyaDevice.disconnectAll = function () { - devices.forEach(device => { - device.disconnect(); - }); - } - - TuyaDevice.onAll = function (name, callback) { - if (events[name] == undefined) { - events[name] = []; - } - events[name].push(callback); - devices.forEach(device => { - device.triggerAll(name); - }); - } - - return TuyaDevice; -}()); - -module.exports = TuyaDevice; diff --git a/tuya-mqtt.js b/tuya-mqtt.js index d85181c..bdd0e08 100644 --- a/tuya-mqtt.js +++ b/tuya-mqtt.js @@ -1,426 +1,150 @@ +#!/usr/bin/env node const fs = require('fs') -const mqtt = require('mqtt'); -const json5 = require('json5'); -const TuyaDevice = require('./tuya-device'); -const debug = require('debug')('TuyAPI:mqtt'); -const debugColor = require('debug')('TuyAPI:mqtt:color'); -const debugTuya = require('debug')('TuyAPI:mqtt:device'); -const debugError = require('debug')('TuyAPI:mqtt:error'); - -var CONFIG = undefined; -var mqtt_client = undefined; - -/* - * Check if data is JSON or not - */ -function isJsonString (data){ - try { - const parsedData = JSON.parse(data); - if (parsedData && typeof parsedData === "object") { - return parsedData; - } - } - catch (e) { } - - return false; -}; - -/** - * get command from mqtt message - * converts message to TuyAPI JSON commands - * @param {String} message - * @returns {Object} - */ -function getCommandFromMessage(_message) { - let command = _message - - if (command != "1" && command != "0" && isJsonString(command)) { - debug("Received command is JSON"); - command = JSON.parse(command); - } else { - switch(command.toLowerCase()) { - case "on": - case "off": - case "0": - case "1": - // convert simple commands (on, off, 1, 0) to TuyAPI-Commands - const convertString = command.toLowerCase() == "on" || command == "1" || command == 1 ? true : false; - command = { - set: convertString - } - break; - default: - command = command.toLowerCase(); - } - } - return command; -} - -// Parse message -function parseDpsMessage(message) { - if (typeof message === "boolean" ) { - return message; - } else if (message === "true" || message === "false") { - return (message === "true") ? true : false - } else if (!isNaN(message)) { - return Number(message) - } else { - return message - } -} - -function publishMQTT(topic, data) { - mqtt_client.publish(topic, data, { - retain: CONFIG.retain, - qos: CONFIG.qos - }); +const mqtt = require('mqtt') +const json5 = require('json5') +const debug = require('debug')('tuya-mqtt:mqtt') +const debugError = require('debug')('tuya-mqtt:error') +const SimpleSwitch = require('./devices/simple-switch') +const SimpleDimmer = require('./devices/simple-dimmer') +const RGBTWLight = require('./devices/rgbtw-light') +const GenericDevice = require('./devices/generic-device') + +var CONFIG = undefined +var tuyaDevices = new Array() + +function getDevice(configDevice, mqttClient) { + const deviceInfo = { + configDevice: configDevice, + mqttClient: mqttClient, + topic: CONFIG.topic + } + switch (configDevice.type) { + case 'SimpleSwitch': + return new SimpleSwitch(deviceInfo) + break; + case 'SimpleDimmer': + return new SimpleDimmer(deviceInfo) + break; + case 'RGBTWLight': + return new RGBTWLight(deviceInfo) + break; + case 'GenericDevice': + return new GenericDevice(deviceInfo) + break; + } + return null } -function guessDeviceType(device, dps) { - keys = Object.keys(dps).length - if (keys === 2) { - if (typeof dps['1'] === "boolean" && dps['2'] >= 0 && dps['2'] <= 255) { - // A "dimmer" is a switch/light with brightness control only - device.options.type = "dimmer" - device.options.template = - { - "state": { "dpsKey": 1, "dpsType": "bool" }, - "brightness_state": { "dpsKey": 2, "dpsType": "int", "minVal": 0, "maxVal": 255 } - } - } - } else if (keys === 1) { - if (typeof dps['1'] === "boolean") { - // If it only has one value and it's a boolean, it's probably a switch/socket - device.options.type = "switch" - device.options.template = - { - "state": { "dpsKey": 1, "dpsType": "bool" } - } - } - } - - if (!device.options.type) { - device.options.type = "unknown" - device.options.template = - { - "state": { "dpsKey": 1, "dpsType": "bool" } - } - } -} - -function publishColorState(device, state) { - -} - -function publishDeviceTopics(device, dps) { - if (!device.options.template) { - debugTuya ("No device template found!") - return - } - const baseTopic = CONFIG.topic + device.topicLevel + "/" - for (let stateTopic in device.options.template) { - const template = device.options.template[stateTopic] - const topic = baseTopic + stateTopic - let state - // Only publish state updates for DPS values included in device data - if (dps.hasOwnProperty(template.dpsType)) { - switch (template.dpsType) { - case "bool": - state = (dps[template.dpsKey]) ? 'ON' : 'OFF'; - break; - case "int": - state = (dps[template.dpsKey]) - state = (state > template.minVal && state < template.maxVal) ? state.toString() : "" - break; - } - if (state) { - debugTuya("MQTT "+device.options.type+" "+topic+" -> ", state); - publishMQTT(topic, state); - } - } - } -} - -/** - * publish all dps-values to topic - * @param {TuyaDevice} device - * @param {Object} dps - */ -function publishDPS(device, dps) { - if (mqtt_client.connected == true) { - try { - if (!device.options.type) { - guessDeviceType(device, dps) - } - - const baseTopic = CONFIG.topic + device.topicLevel + "/dps"; - const topic = baseTopic + "/state" - const data = JSON.stringify(dps); - - // Publish raw DPS JSON data - debugTuya("MQTT DPS JSON (raw): " + topic + " -> ", data); - publishMQTT(topic, data); - - // Publish dps/<#>/state value for each DPS - Object.keys(dps).forEach(function (key) { - const topic = baseTopic + "/" + key + "/state"; - const data = JSON.stringify(dps[key]); - debugTuya("MQTT DPS"+key+": "+topic+" -> ", data); - publishMQTT(topic, data); - }); - - publishDeviceTopics(device, dps) - - } catch (e) { - debugError(e); - } - } -} - -/** - * event fires if TuyaDevice sends data - * @see TuyAPI (https://github.com/codetheweb/tuyapi) - */ -TuyaDevice.onAll('data', function (data) { - try { - if (typeof data.dps != "undefined") { - debugTuya('Data from device Id ' + data.devId + ' ->', data.dps); - publishDPS(this, data.dps); - } - } catch (e) { - debugError(e); - } -}); - -/** - * Function call on script exit - */ -function onExit() { - TuyaDevice.disconnectAll(); -}; - -// Simple sleep to pause in async functions -function sleep(sec) { - return new Promise(res => setTimeout(res, sec*1000)); -} - -function initTuyaDevices(tuyaDevices) { - for (let tuyaDevice of tuyaDevices) { - let options = { - id: tuyaDevice.id, - key: tuyaDevice.key - } - if (tuyaDevice.name) { options.name = tuyaDevice.name } - if (tuyaDevice.ip) { - options.ip = tuyaDevice.ip - if (tuyaDevice.version) { - options.version = tuyaDevice.version - } else { - version = "3.1" - } - } - new TuyaDevice(options); - } -} - -// Process MQTT commands for all command topics at device level -function processDeviceCommand(message, device, commandTopic) { - let command = getCommandFromMessage(message); - // If it's the color command topic handle it manually - if (commandTopic === "color_command") { - const color = message.toLowerCase(); - debugColor("Set color: ", color); - device.setColor(color).then((data) => { - debug("Set device color completed: ", data); - }); - } else if (commandTopic === "command" && (command === "toggle" || command === "schema" )) { - // Handle special commands "toggle" and "schema" to primary device command topic - debug("Received command: ", command); - switch(command) { - case "toggle": - device.switch(command).then((data) => { - debug("Set device status completed: ", data); - }); - break; - case "schema": - // Trigger device schema to update state - device.schema(command).then((data) => { - debug("Get schema status command complete."); - }); - break; - } - } else { - // Recevied command on device topic level, check for matching device template - // and process command accordingly - const stateTopic = commandTopic.replace("command", "state") - const template = device.options.template[stateTopic] - if (template) { - debug("Received device "+commandTopic.replace("_"," "), message); - const tuyaCommand = new Object() - tuyaCommand.dps = template.dpsKey - switch (template.dpsType) { - case "bool": - if (command === "true") { - tuyaCommand.set = true - } else if (command === "false") { - tuyaCommand.set = false - } else if (typeof command.set === "boolean") { - tuyaCommand.set = command.set - } else { - tuyaCommand.set = "!!!!!" - } - break; - case "int": - tuyaCommand.set = (command > template.minVal && command < template.maxVal ) ? parseInt(command) : "!!!!!" - break; - } - if (tuyaCommand.set === "!!!!!") { - debug("Received invalid value for ", commandTopic, ", value:", command) - } else { - device.set(tuyaCommand).then((data) => { - debug("Set device "+commandTopic.replace("_"," ")+": ", data); - }); - } +function initDevices(configDevices, mqttClient) { + for (let configDevice of configDevices) { + if (!configDevice.type) { + debug('Device type not specified, skipping creation of this device') } else { - debug("Received unknown command topic for device: ", commandTopic) - } - } -} - -// Process raw Tuya JSON commands via DPS command topic -function processDpsCommand(message, device) { - if (isJsonString(message)) { - const command = getCommandFromMessage(message); - debug("Received command: ", command); - device.set(command).then((data) => { - debug("Set device status completed: ", data); - }); - } else { - debug("DPS command topic requires Tuya style JSON value") - } -} - -// Process text base Tuya command via DPS key command topics -function processDpsKeyCommand(message, device, dpsKey) { - if (isJsonString(message)) { - debug("Individual DPS command topics do not accept JSON values") - } else { - const dpsMessage = parseDpsMessage(message) - debug("Received command for DPS"+dpsKey+": ", message); - const command = { - dps: dpsKey, - set: dpsMessage + const newDevice = getDevice(configDevice, mqttClient) + if (newDevice) { + tuyaDevices.push(newDevice) + } } - device.set(command).then((data) => { - debug("Set device status completed: ", data); - }); } } // Main code function const main = async() => { - let tuyaDevices + let configDevices + let mqttClient try { - CONFIG = require("./config"); + CONFIG = require('./config') } catch (e) { - console.error("Configuration file not found!") + console.error('Configuration file not found!') debugError(e) process.exit(1) } - if (typeof CONFIG.qos == "undefined") { - CONFIG.qos = 2; + if (typeof CONFIG.qos == 'undefined') { + CONFIG.qos = 2 } - if (typeof CONFIG.retain == "undefined") { - CONFIG.retain = false; + if (typeof CONFIG.retain == 'undefined') { + CONFIG.retain = false } try { - tuyaDevices = fs.readFileSync('./devices.conf', 'utf8'); - tuyaDevices = json5.parse(tuyaDevices) + configDevices = fs.readFileSync('./devices.conf', 'utf8') + configDevices = json5.parse(configDevices) } catch (e) { - console.error("Devices file not found!") + console.error('Devices file not found!') debugError(e) process.exit(1) } - if (!tuyaDevices.length) { - console.error("No devices found in devices file!") + if (!configDevices.length) { + console.error('No devices found in devices file!') process.exit(1) } - mqtt_client = mqtt.connect({ + mqttClient = mqtt.connect({ host: CONFIG.host, port: CONFIG.port, username: CONFIG.mqtt_user, password: CONFIG.mqtt_pass, - }); + }) - mqtt_client.on('connect', function (err) { - debug("Connection established to MQTT server"); - let topic = CONFIG.topic + '#'; - mqtt_client.subscribe(topic, { + mqttClient.on('connect', function (err) { + debug('Connection established to MQTT server') + let topic = CONFIG.topic + '#' + mqttClient.subscribe(topic, { retain: CONFIG.retain, qos: CONFIG.qos - }); - initTuyaDevices(tuyaDevices) - }); + }) + initDevices(configDevices, mqttClient) + }) - mqtt_client.on("reconnect", function (error) { - if (mqtt_client.connected) { - debug("Connection to MQTT server lost. Attempting to reconnect..."); + mqttClient.on('reconnect', function (error) { + if (mqttClient.connected) { + debug('Connection to MQTT server lost. Attempting to reconnect...') } else { - debug("Unable to connect to MQTT server"); + debug('Unable to connect to MQTT server') } - }); + }) - mqtt_client.on("error", function (error) { - debug("Unable to connect to MQTT server", error); - }); + mqttClient.on('error', function (error) { + debug('Unable to connect to MQTT server', error) + }) - mqtt_client.on('message', function (topic, message) { + mqttClient.on('message', function (topic, message) { try { - message = message.toString(); - const splitTopic = topic.split("/"); + message = message.toString() + const splitTopic = topic.split('/') const topicLength = splitTopic.length - const commandTopic = splitTopic[topicLength - 1]; - const options = { - topicLevel: splitTopic[1] - } + const commandTopic = splitTopic[topicLength - 1] + const deviceTopicLevel = splitTopic[1] // If it looks like a valid command topic try to process it - if (commandTopic.includes("command")) { - debug("Receive settings", JSON.stringify({ + if (commandTopic.includes('command')) { + debug('Received MQTT message -> ', JSON.stringify({ topic: topic, message: message - })); - - // Uses device topic level to find matching device - var device = new TuyaDevice(options); - - device.then(function (params) { - let device = params.device; - switch (topicLength) { - case 3: - processDeviceCommand(message, device, commandTopic); - break; - case 4: - processDpsCommand(message, device); - break; - case 5: - const dpsKey = splitTopic[topicLength-2] - processDpsKeyCommand(message, device, dpsKey); - break; - } - }).catch((err) => { - debugError(err); - }); + })) + + // Use device topic level to find matching device + const device = tuyaDevices.find(d => d.options.name === deviceTopicLevel || d.options.id === deviceTopicLevel) + switch (topicLength) { + case 3: + device.processCommand(message, commandTopic) + break; + case 4: + device.processDpsCommand(message) + break; + case 5: + const dpsKey = splitTopic[topicLength-2] + device.processDpsKeyCommand(message, dpsKey) + break; + } } } catch (e) { - debugError(e); + debugError(e) } - }); + }) } // Call the main code From 99f5358794eb6e66567bce1616daf4e54598edc8 Mon Sep 17 00:00:00 2001 From: tsightler Date: Fri, 2 Oct 2020 23:24:42 -0400 Subject: [PATCH 08/34] Remove console output --- devices/generic-device.js | 1 - 1 file changed, 1 deletion(-) diff --git a/devices/generic-device.js b/devices/generic-device.js index 9984b40..b51722a 100644 --- a/devices/generic-device.js +++ b/devices/generic-device.js @@ -10,7 +10,6 @@ class GenericDevice extends TuyaDevice { if (this.config.hasOwnProperty('template')) { // Map generic DPS topics to device specific topic names this.deviceTopics = this.config.template - console.log(this.deviceTopics) } else { // Try to get schema to at least know what DPS keys to get initial update const result = await this.device.get({"schema": true}) From 68d8743882b06991e96d4055bf6456d0409971a4 Mon Sep 17 00:00:00 2001 From: tsightler Date: Sat, 3 Oct 2020 10:38:43 -0400 Subject: [PATCH 09/34] Fix for no template --- devices/generic-device.js | 1 + devices/tuya-device.js | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/devices/generic-device.js b/devices/generic-device.js index b51722a..45aa507 100644 --- a/devices/generic-device.js +++ b/devices/generic-device.js @@ -11,6 +11,7 @@ class GenericDevice extends TuyaDevice { // Map generic DPS topics to device specific topic names this.deviceTopics = this.config.template } else { + this.deviceTopics = {} // Try to get schema to at least know what DPS keys to get initial update const result = await this.device.get({"schema": true}) if (!utils.isJsonString(result)) { diff --git a/devices/tuya-device.js b/devices/tuya-device.js index e23e0fd..5888367 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -33,8 +33,10 @@ class TuyaDevice { mf: 'Tuya' } - this.dps = {} // This will hold dps state data for device - this.prevDps = {} // This will hold previous dps value for device to avoid republish of non-changed states + // Variables to hold device state data + this.dps = {} // Current dps state data for device + this.dpsPub = {} // Published dps state data for device + this.color = {h, s, b, t, w} // Current color values (Hue, Saturation, Brightness, White Temp, White Level) // Build the MQTT topic for this device (friendly name or device id) if (this.options.name) { @@ -135,7 +137,7 @@ class TuyaDevice { let setResult = this.setState(command, deviceTopic) if (!setResult) { debug('Command topic '+this.baseTopic+commandTopic+' received invalid value: '+command) - } + } } else { debug('Invalid command topic '+this.baseTopic+commandTopic+' for device: '+this.config.name) return From 9176b8f85c0617ad670d901ed192a1035670376b Mon Sep 17 00:00:00 2001 From: tsightler Date: Sat, 3 Oct 2020 11:45:41 -0400 Subject: [PATCH 10/34] Reorganize tuya-device --- devices/tuya-device.js | 290 ++++++++++++++++++++--------------------- 1 file changed, 144 insertions(+), 146 deletions(-) diff --git a/devices/tuya-device.js b/devices/tuya-device.js index 5888367..8fe3490 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -36,7 +36,7 @@ class TuyaDevice { // Variables to hold device state data this.dps = {} // Current dps state data for device this.dpsPub = {} // Published dps state data for device - this.color = {h, s, b, t, w} // Current color values (Hue, Saturation, Brightness, White Temp, White Level) + this.color = {'h': 0, 's': 0, 'b': 0, 't': 0, 'w': 0} // Current color values (Hue, Saturation, Brightness, White Temp, White Level) // Build the MQTT topic for this device (friendly name or device id) if (this.options.name) { @@ -53,10 +53,8 @@ class TuyaDevice { if (typeof data == 'string') { debug('Data from device not encrypted:', data.replace(/[^a-zA-Z0-9 ]/g, '')) } else { - if (!(data.dps['1'] === null && data.dps['2'] === null && data.dps['3'] === null && data.dps['101'] === null && data.dps['102'] === null && data.dps['103'] === null)) { - debug('Data from device '+this.options.id+' ->', data.dps) - this.updateDpsData(data) - } + debug('Data from device '+this.options.id+' ->', data.dps) + this.updateDpsData(data) } }) @@ -82,81 +80,13 @@ class TuyaDevice { // On connect error call reconnect this.device.on('error', (err) => { - if (err !== 'json obj data unvalid') { - debugError(err) - } + debugError(err) if (err.message === 'Error from socket') { this.reconnect() } }) } - // Retry connection every 10 seconds if unable to connect - async reconnect() { - debug('Error connecting to device id '+this.options.id+'...retry in 10 seconds.') - await utils.sleep(10) - if (this.connected) { return } - debug('Search for device id '+this.options.id) - this.device.find().then(() => { - debug('Found device id '+this.options.id) - // Attempt connection to device - this.device.connect() - }) - } - - // Publish MQTT - publishMqtt(topic, message, isDebug) { - if (isDebug) { debugMqtt(topic, message) } - this.mqttClient.publish(topic, message, { qos: 1 }); - } - - // Publish device specific state topics - publishTopics() { - // Don't publish if device is not connected - if (!this.connected) return - - // Loop through and publish all device specific topics - for (let topic in this.deviceTopics) { - const state = this.getTopicState(topic) - this.publishMqtt(this.baseTopic + topic, state, true) - } - - // Publish Generic Dps Topics - this.publishDpsTopics() - } - - // Process MQTT commands for all command topics at device level - processDeviceCommand(message, commandTopic) { - // Determine state topic from command topic to find proper template - const stateTopic = commandTopic.replace('command', 'state') - const deviceTopic = this.deviceTopics.hasOwnProperty(stateTopic) ? this.deviceTopics[stateTopic] : '' - - if (deviceTopic) { - debug('Device '+this.options.id+' recieved command topic: '+commandTopic+', message: '+message) - const command = this.getCommandFromMessage(message) - let setResult = this.setState(command, deviceTopic) - if (!setResult) { - debug('Command topic '+this.baseTopic+commandTopic+' received invalid value: '+command) - } - } else { - debug('Invalid command topic '+this.baseTopic+commandTopic+' for device: '+this.config.name) - return - } - } - - // Get and update state of all dps properties for device - async getStates() { - // Suppress topic updates while syncing state - this.connected = false - for (let topic in this.deviceTopics) { - const key = this.deviceTopics[topic].key - const result = await this.device.get({"dps": key}) - } - this.connected = true - // Force topic update now that all states are fully in sync - this.publishTopics() - } - // Update dps properties with device data updates updateDpsData(data) { try { @@ -174,18 +104,20 @@ class TuyaDevice { } } - // Process MQTT commands for all command topics at device level - async processCommand(message, commandTopic) { - const command = this.getCommandFromMessage(message) - if (commandTopic === 'command' && command === 'get-states' ) { - // Handle "get-states" command to update device state - debug('Received command: ', command) - await this.getStates() - } else { - // Call device specific command topic handler - this.processDeviceCommand(message, commandTopic) + // Publish device specific state topics + publishTopics() { + // Don't publish if device is not connected + if (!this.connected) return + + // Loop through and publish all device specific topics + for (let topic in this.deviceTopics) { + const state = this.getTopicState(topic) + this.publishMqtt(this.baseTopic + topic, state, true) } - } + + // Publish Generic Dps Topics + this.publishDpsTopics() + } // Publish all dps-values to topic publishDpsTopics() { @@ -211,7 +143,8 @@ class TuyaDevice { debugError(e); } } - + + // Get the friedly topic state based on DPS value type getTopicState(topic) { const deviceTopic = this.deviceTopics[topic] const key = deviceTopic.key @@ -233,45 +166,36 @@ class TuyaDevice { } return state } - - // Set state based on command topic - setState(command, deviceTopic) { - const tuyaCommand = new Object() - tuyaCommand.dps = deviceTopic.key - switch (deviceTopic.type) { - case 'bool': - if (command === 'toggle') { - tuyaCommand.set = !this.dps[tuyaCommand.dps] - } else { - if (typeof command.set === 'boolean') { - tuyaCommand.set = command.set - } else { - tuyaCommand.set = '!!!INVALID!!!' - } - } - break; - case 'int': - if (isNaN(command)) { - tuyaCommand.set = '!!!INVALID!!!' - } else if (deviceTopic.hasOwnProperty('min') && deviceTopic.hasOwnProperty('max')) { - tuyaCommand.set = (command >= deviceTopic.min && command <= deviceTopic.max ) ? parseInt(command) : '!!!INVALID!!!' - } else { - tuyaCommand.set = parseInt(command) - } - break; - case 'hsb': - tuyaCommand.set = this.getColorCommand(command, deviceTopic) - this.setLightMode(deviceTopic) - break; - } - if (tuyaCommand.set === '!!!INVALID!!!') { - return false + + // Process MQTT commands for all command topics at device level + async processCommand(message, commandTopic) { + const command = this.getCommandFromMessage(message) + if (commandTopic === 'command' && command === 'get-states' ) { + // Handle "get-states" command to update device state + debug('Received command: ', command) + await this.getStates() } else { - if (this.config.dpsWhiteValue === deviceTopic.key) { - this.setLightMode(deviceTopic) + // Call device specific command topic handler + this.processDeviceCommand(message, commandTopic) + } + } + + // Process MQTT commands for all command topics at device level + processDeviceCommand(message, commandTopic) { + // Determine state topic from command topic to find proper template + const stateTopic = commandTopic.replace('command', 'state') + const deviceTopic = this.deviceTopics.hasOwnProperty(stateTopic) ? this.deviceTopics[stateTopic] : '' + + if (deviceTopic) { + debug('Device '+this.options.id+' recieved command topic: '+commandTopic+', message: '+message) + const command = this.getCommandFromMessage(message) + let setResult = this.setState(command, deviceTopic) + if (!setResult) { + debug('Command topic '+this.baseTopic+commandTopic+' received invalid value: '+command) } - this.set(tuyaCommand) - return true + } else { + debug('Invalid command topic '+this.baseTopic+commandTopic+' for device: '+this.config.name) + return } } @@ -280,7 +204,7 @@ class TuyaDevice { let command = _message if (command != '1' && command != '0' && utils.isJsonString(command)) { - debugMqtt('MQTT message is JSON'); + debugMqtt('MQTT message is JSON') command = JSON.parse(command); } else { switch(command.toLowerCase()) { @@ -291,16 +215,16 @@ class TuyaDevice { case 'true': case 'false': // convert simple commands (on, off, 1, 0) to TuyAPI-Commands - const convertString = command.toLowerCase() === 'on' || command === '1' || command === 'true' || command === 1 ? true : false; + const convertString = command.toLowerCase() === 'on' || command === '1' || command === 'true' || command === 1 ? true : false command = { set: convertString } break; default: - command = command.toLowerCase(); + command = command.toLowerCase() } } - return command; + return command } // Process Tuya JSON commands via DPS command topic @@ -342,34 +266,73 @@ class TuyaDevice { } } - // Simple function to help debug output - toString() { - return this.config.name+' (' +(this.options.ip ? this.options.ip+', ' : '')+this.options.id+', '+this.options.key+')' + // Get and update state of all dps properties for device + async getStates() { + // Suppress topic updates while syncing state + this.connected = false + for (let topic in this.deviceTopics) { + const key = this.deviceTopics[topic].key + const result = await this.device.get({"dps": key}) + } + this.connected = true + // Force topic update now that all states are fully in sync + this.publishTopics() } - set(command) { - debug('Set device '+this.options.id+' -> '+command) - return new Promise((resolve, reject) => { - this.device.set(command).then((result) => { - debug(result) - resolve(result) - }) - }) + // Set state based on command topic + setState(command, deviceTopic) { + const tuyaCommand = new Object() + tuyaCommand.dps = deviceTopic.key + switch (deviceTopic.type) { + case 'bool': + if (command === 'toggle') { + tuyaCommand.set = !this.dps[tuyaCommand.dps] + } else { + if (typeof command.set === 'boolean') { + tuyaCommand.set = command.set + } else { + tuyaCommand.set = '!!!INVALID!!!' + } + } + break; + case 'int': + if (isNaN(command)) { + tuyaCommand.set = '!!!INVALID!!!' + } else if (deviceTopic.hasOwnProperty('min') && deviceTopic.hasOwnProperty('max')) { + tuyaCommand.set = (command >= deviceTopic.min && command <= deviceTopic.max ) ? parseInt(command) : '!!!INVALID!!!' + } else { + tuyaCommand.set = parseInt(command) + } + break; + case 'hsb': + tuyaCommand.set = this.getColorCommand(command, deviceTopic) + this.setLightMode(deviceTopic) + break; + } + if (tuyaCommand.set === '!!!INVALID!!!') { + return false + } else { + if (this.config.dpsWhiteValue === deviceTopic.key) { + this.setLightMode(deviceTopic) + } + this.set(tuyaCommand) + return true + } } - + // Takes the current Tuya color and splits it into component parts - // Returns decimal format comma delimeted string of components for selected topic + // Updates cached color state for device and returns decimal format + // comma delimeted string of components for selected topic getColorState(value, topic) { const [, h, s, b] = (value || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8']; - const decimalColor = { - h: parseInt(h, 16), - s: Math.round(parseInt(s, 16) / 10), - b: parseInt(b, 16) - } + this.color.h = parseInt(h, 16) + this.color.s = Math.round(parseInt(s, 16) / 10) + this.color.b = parseInt(b, 16) const color = new Array() const components = this.deviceTopics[topic].components.split(',') + for (let i in components) { - if (decimalColor.hasOwnProperty([components[i]])) { + if (components.hasOwnProperty([components[i]])) { color.push(decimalColor[components[i]]) } } @@ -417,6 +380,41 @@ class TuyaDevice { await this.set(tuyaCommand) } } + + // Simple function to help debug output + toString() { + return this.config.name+' (' +(this.options.ip ? this.options.ip+', ' : '')+this.options.id+', '+this.options.key+')' + } + + set(command) { + debug('Set device '+this.options.id+' -> '+command) + return new Promise((resolve, reject) => { + this.device.set(command).then((result) => { + debug(result) + resolve(result) + }) + }) + } + + // Retry connection every 10 seconds if unable to connect + async reconnect() { + debug('Error connecting to device id '+this.options.id+'...retry in 10 seconds.') + await utils.sleep(10) + if (this.connected) { return } + debug('Search for device id '+this.options.id) + this.device.find().then(() => { + debug('Found device id '+this.options.id) + // Attempt connection to device + this.device.connect() + }) + } + + + // Publish MQTT + publishMqtt(topic, message, isDebug) { + if (isDebug) { debugMqtt(topic, message) } + this.mqttClient.publish(topic, message, { qos: 1 }); + } } module.exports = TuyaDevice \ No newline at end of file From df01beb8fc7d695b19824e194d812c2c6a9729fc Mon Sep 17 00:00:00 2001 From: tsightler Date: Sat, 3 Oct 2020 13:32:48 -0400 Subject: [PATCH 11/34] RGBTW light fixes --- devices/generic-device.js | 1 - devices/rgbtw-light.js | 2 + devices/tuya-device.js | 85 ++++++++++++++++++++++----------------- 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/devices/generic-device.js b/devices/generic-device.js index 45aa507..b51722a 100644 --- a/devices/generic-device.js +++ b/devices/generic-device.js @@ -11,7 +11,6 @@ class GenericDevice extends TuyaDevice { // Map generic DPS topics to device specific topic names this.deviceTopics = this.config.template } else { - this.deviceTopics = {} // Try to get schema to at least know what DPS keys to get initial update const result = await this.device.get({"schema": true}) if (!utils.isJsonString(result)) { diff --git a/devices/rgbtw-light.js b/devices/rgbtw-light.js index 5f99179..3099a6a 100644 --- a/devices/rgbtw-light.js +++ b/devices/rgbtw-light.js @@ -15,6 +15,8 @@ class RGBTWLight extends TuyaDevice { this.deviceData.mdl = 'RGBTW Light' + this.isRgbtwLight = true + // Map generic DPS topics to device specific topic names this.deviceTopics = { state: { diff --git a/devices/tuya-device.js b/devices/tuya-device.js index 8fe3490..7698e23 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -33,10 +33,13 @@ class TuyaDevice { mf: 'Tuya' } - // Variables to hold device state data + // Property to hold device state data this.dps = {} // Current dps state data for device this.dpsPub = {} // Published dps state data for device - this.color = {'h': 0, 's': 0, 'b': 0, 't': 0, 'w': 0} // Current color values (Hue, Saturation, Brightness, White Temp, White Level) + this.color = {'h': 0, 's': 0, 'b': 0} // HSB color value cache + + // Property to hold friendly topics template + this.deviceTopics = {} // Build the MQTT topic for this device (friendly name or device id) if (this.options.name) { @@ -158,7 +161,7 @@ class TuyaDevice { break; case 'hsb': if (this.dps[key]) { - state = this.getColorState(this.dps[key], topic) + state = this.convertFromTuyaHsbColor(this.dps[key], topic) } break; case 'str': @@ -305,80 +308,87 @@ class TuyaDevice { } break; case 'hsb': - tuyaCommand.set = this.getColorCommand(command, deviceTopic) - this.setLightMode(deviceTopic) + tuyaCommand.set = this.convertToTuyaHsbColor(command, deviceTopic) break; } if (tuyaCommand.set === '!!!INVALID!!!') { return false } else { - if (this.config.dpsWhiteValue === deviceTopic.key) { - this.setLightMode(deviceTopic) + if (this.isRgbtwLight) { + this.setLight(deviceTopic, tuyaCommand) + } else { + this.set(tuyaCommand) } - this.set(tuyaCommand) return true } } - // Takes the current Tuya color and splits it into component parts + // Takes the current Tuya color and splits it into HSB component parts // Updates cached color state for device and returns decimal format - // comma delimeted string of components for selected topic - getColorState(value, topic) { + // comma delimeted string of components for selected friendly topic + convertFromTuyaHsbColor(value, topic) { + // Split Tuya HSB value into component parts const [, h, s, b] = (value || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8']; + + // Convert from Hex to Decimal and cache values this.color.h = parseInt(h, 16) this.color.s = Math.round(parseInt(s, 16) / 10) this.color.b = parseInt(b, 16) + + // Return comma separate array of component values for specific topic const color = new Array() const components = this.deviceTopics[topic].components.split(',') - for (let i in components) { - if (components.hasOwnProperty([components[i]])) { - color.push(decimalColor[components[i]]) - } + color.push(this.color[components[i]]) } return (color.join(',')) } - // Takes provided decimal HSB components from MQTT topic, combine with existing - // settings for unchanged values since brightness is sometimes sent separately - // Convert to Tuya hex format and return value - getColorCommand(value, topic) { - const [, h, s, b] = (this.dps[topic.key] || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8']; - const decimalColor = { - h: parseInt(h, 16), - s: Math.round(parseInt(s, 16) / 10), - b: parseInt(b, 16) - } + // Takes provided decimal HSB components from MQTT topic, combines with cached + // unchanged values since, for example, brightness is sometimes sent separately + // then convers to Tuya hex format and returns value + convertToTuyaHsbColor(value, topic) { + // Start with cached color values + const newColor = this.color + + // Update any HSB component with a changed value const components = topic.components.split(',') const values = value.split(',') for (let i in components) { - decimalColor[components[i]] = Math.round(values[i]) + newColor[components[i]] = Math.round(values[i]) } - const hexColor = decimalColor.h.toString(16).padStart(4, '0') + (10 * decimalColor.s).toString(16).padStart(4, '0') + (decimalColor.b).toString(16).padStart(4, '0') + + // Convert new HSB color to Tuya style HSB format + const hexColor = newColor.h.toString(16).padStart(4, '0') + (10 * newColor.s).toString(16).padStart(4, '0') + (newColor.b).toString(16).padStart(4, '0') return hexColor } - // Set light mode based on received command - async setLightMode(topic) { - const currentMode = this.dps[this.config.dpsMode] - let targetMode - + // Set light based on received command + async setLight(topic, command) { + let targetMode = undefined if (this.config.dpsWhiteValue === topic.key) { - // If setting white level, switch to white mode + // If setting white level, or saturation = 0, target is white mode targetMode = 'white' } else if (this.config.dpsColor === topic.key) { - // If setting an HSB value, switch to colour mode - targetMode = 'colour' + // Split Tuya HSB value into component parts + const [, h, s, b] = (command.set || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8']; + if (s > 0) { + // If setting an HSB value, switch to colour mode + targetMode = 'colour' + } else { + targetMode = 'white' + } } // Set the correct light mode - if (targetMode && targetMode !== currentMode) { + if (targetMode) { const tuyaCommand = { dps: this.config.dpsMode, set: targetMode } await this.set(tuyaCommand) } + this.set(command) } // Simple function to help debug output @@ -387,10 +397,9 @@ class TuyaDevice { } set(command) { - debug('Set device '+this.options.id+' -> '+command) + debug('Set device '+this.options.id+' -> '+JSON.stringify(command)) return new Promise((resolve, reject) => { this.device.set(command).then((result) => { - debug(result) resolve(result) }) }) From 3605037b697610e6ae29170319aaf958916295df Mon Sep 17 00:00:00 2001 From: tsightler Date: Sat, 3 Oct 2020 22:41:57 -0400 Subject: [PATCH 12/34] Granular state updates * State topics now only update if data for the specific DPS changed * Rework RGBTW support for more reliable white/color mode switching --- devices/tuya-device.js | 166 +++++++++++++++++++++-------------------- 1 file changed, 87 insertions(+), 79 deletions(-) diff --git a/devices/tuya-device.js b/devices/tuya-device.js index 7698e23..6b385cc 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -33,10 +33,11 @@ class TuyaDevice { mf: 'Tuya' } - // Property to hold device state data - this.dps = {} // Current dps state data for device - this.dpsPub = {} // Published dps state data for device - this.color = {'h': 0, 's': 0, 'b': 0} // HSB color value cache + // Objects to hold cached device state data + this.state = { + "dps": {}, + "color": {'h': 0, 's': 0, 'b': 0} + } // Property to hold friendly topics template this.deviceTopics = {} @@ -57,7 +58,7 @@ class TuyaDevice { debug('Data from device not encrypted:', data.replace(/[^a-zA-Z0-9 ]/g, '')) } else { debug('Data from device '+this.options.id+' ->', data.dps) - this.updateDpsData(data) + this.updateState(data) } }) @@ -91,19 +92,21 @@ class TuyaDevice { } // Update dps properties with device data updates - updateDpsData(data) { - try { - if (typeof data.dps != 'undefined') { - // Update device dps values - for (let key in data.dps) { - this.dps[key] = data.dps[key] + updateState(data) { + if (typeof data.dps != 'undefined') { + // Update cached device state data + for (let key in data.dps) { + this.state.dps[key] = { + 'val': data.dps[key], + 'updated': true } - if (this.connected) { - this.publishTopics() + if (this.config.dpsColor && this.config.dpsColor == key) { + this.updateColorState(data.dps[key]) } } - } catch (e) { - debugError(e); + if (this.connected) { + this.publishTopics() + } } } @@ -114,8 +117,12 @@ class TuyaDevice { // Loop through and publish all device specific topics for (let topic in this.deviceTopics) { - const state = this.getTopicState(topic) - this.publishMqtt(this.baseTopic + topic, state, true) + const deviceTopic = this.deviceTopics[topic] + const key = deviceTopic.key + if (this.state.dps[key].updated) { + const state = this.getTopicState(deviceTopic, this.state.dps[key].val) + this.publishMqtt(this.baseTopic + topic, state, true) + } } // Publish Generic Dps Topics @@ -125,47 +132,57 @@ class TuyaDevice { // Publish all dps-values to topic publishDpsTopics() { try { - const dpsTopic = this.baseTopic + 'dps' + if (!Object.keys(this.state.dps).length) { return } + const dpsTopic = this.baseTopic + 'dps' // Publish DPS JSON data if not empty - if (Object.keys(this.dps).length) { - const data = JSON.stringify(this.dps) - const dpsStateTopic = dpsTopic + '/state' - debugMqtt('MQTT DPS JSON: ' + dpsStateTopic + ' -> ', data) - this.publishMqtt(dpsStateTopic, data, false) + let data = {} + for (let key in this.state.dps) { + if (this.state.dps[key].updated) { + data[key] = this.state.dps[key].val + } } + data = JSON.stringify(data) + const dpsStateTopic = dpsTopic + '/state' + debugMqtt('MQTT DPS JSON: ' + dpsStateTopic + ' -> ', data) + this.publishMqtt(dpsStateTopic, data, false) // Publish dps/<#>/state value for each device DPS - for (let key in this.dps) { - const dpsKeyTopic = dpsTopic + '/' + key + '/state' - const data = this.dps.hasOwnProperty(key) ? this.dps[key].toString() : 'None' - debugMqtt('MQTT DPS'+key+': '+dpsKeyTopic+' -> ', data) - this.publishMqtt(dpsKeyTopic, data, false) + for (let key in this.state.dps) { + if (this.state.dps[key].updated) { + const dpsKeyTopic = dpsTopic + '/' + key + '/state' + const data = this.state.dps.hasOwnProperty(key) ? this.state.dps[key].val.toString() : 'None' + debugMqtt('MQTT DPS'+key+': '+dpsKeyTopic+' -> ', data) + this.publishMqtt(dpsKeyTopic, data, false) + this.state.dps[key].updated = false + } } } catch (e) { debugError(e); } } - // Get the friedly topic state based on DPS value type - getTopicState(topic) { - const deviceTopic = this.deviceTopics[topic] - const key = deviceTopic.key - let state = null + // Get the friendly topic state based on DPS value type + getTopicState(deviceTopic, value) { + let state switch (deviceTopic.type) { case 'bool': - state = this.dps[key] ? 'ON' : 'OFF' + state = value ? 'ON' : 'OFF' break; case 'int': - state = this.dps[key] ? this.dps[key].toString() : 'None' + state = value ? value.toString() : 'None' break; case 'hsb': - if (this.dps[key]) { - state = this.convertFromTuyaHsbColor(this.dps[key], topic) + // Return comma separate array of component values for specific topic + state = new Array() + const components = deviceTopic.components.split(',') + for (let i in components) { + state.push(this.state.color[components[i]]) } + state = (state.join(',')) break; case 'str': - state = this.dps[key] ? this.dps[key] : '' + state = value ? value : '' } return state } @@ -203,28 +220,27 @@ class TuyaDevice { } // Converts message to TuyAPI JSON commands - getCommandFromMessage(_message) { - let command = _message + getCommandFromMessage(message) { + let command - if (command != '1' && command != '0' && utils.isJsonString(command)) { + if (message != '1' && message != '0' && utils.isJsonString(message)) { debugMqtt('MQTT message is JSON') - command = JSON.parse(command); + command = JSON.parse(message); } else { - switch(command.toLowerCase()) { + switch(message.toLowerCase()) { case 'on': case 'off': case '0': case '1': case 'true': case 'false': - // convert simple commands (on, off, 1, 0) to TuyAPI-Commands - const convertString = command.toLowerCase() === 'on' || command === '1' || command === 'true' || command === 1 ? true : false + // convert simple messages (on, off, 1, 0) to TuyAPI commands command = { - set: convertString + set: (message.toLowerCase() === 'on' || message === '1' || message === 'true' || message === 1) ? true : false } break; default: - command = command.toLowerCase() + command = message.toLowerCase() } } return command @@ -289,7 +305,7 @@ class TuyaDevice { switch (deviceTopic.type) { case 'bool': if (command === 'toggle') { - tuyaCommand.set = !this.dps[tuyaCommand.dps] + tuyaCommand.set = !this.state.dps[tuyaCommand.dps].val } else { if (typeof command.set === 'boolean') { tuyaCommand.set = command.set @@ -323,33 +339,26 @@ class TuyaDevice { } } - // Takes the current Tuya color and splits it into HSB component parts - // Updates cached color state for device and returns decimal format - // comma delimeted string of components for selected friendly topic - convertFromTuyaHsbColor(value, topic) { - // Split Tuya HSB value into component parts - const [, h, s, b] = (value || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8']; - - // Convert from Hex to Decimal and cache values - this.color.h = parseInt(h, 16) - this.color.s = Math.round(parseInt(s, 16) / 10) - this.color.b = parseInt(b, 16) - - // Return comma separate array of component values for specific topic - const color = new Array() - const components = this.deviceTopics[topic].components.split(',') - for (let i in components) { - color.push(this.color[components[i]]) + // Takes Tuya color value in HSB or HSBHEX format and updates + // cached device HSB color state + updateColorState(value) { + let h, s, b + if (this.config.colorType === 'hsbhex') { + [, h, s, b] = (value || '0000000000ffff').match(/^.{6}([0-9a-f]{4})([0-9a-f]{2})([0-9a-f]{2})$/i) || [0, '0', 'ff', 'ff']; + } else { + [, h, s, b] = (value || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8'] } - return (color.join(',')) + // Convert from Hex to Decimal and cache values + this.state.color.h = parseInt(h, 16) + this.state.color.s = Math.round(parseInt(s, 16) / 10) + this.state.color.b = parseInt(b, 16) } - // Takes provided decimal HSB components from MQTT topic, combines with cached - // unchanged values since, for example, brightness is sometimes sent separately - // then convers to Tuya hex format and returns value + // Takes provided decimal HSB components from MQTT topic, combines with any + // cached (unchanged) component values and converts to Tuya HSB format convertToTuyaHsbColor(value, topic) { // Start with cached color values - const newColor = this.color + const newColor = this.state.color // Update any HSB component with a changed value const components = topic.components.split(',') @@ -363,30 +372,29 @@ class TuyaDevice { return hexColor } - // Set light based on received command + // Set white/colour mode based on target mode async setLight(topic, command) { let targetMode = undefined if (this.config.dpsWhiteValue === topic.key) { - // If setting white level, or saturation = 0, target is white mode + // If setting white level, light should be in white mode targetMode = 'white' } else if (this.config.dpsColor === topic.key) { - // Split Tuya HSB value into component parts - const [, h, s, b] = (command.set || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8']; - if (s > 0) { - // If setting an HSB value, switch to colour mode + if (this.state.color.s > 0) { + // If setting an HSB value with saturation > 0, light should be in color mode targetMode = 'colour' } else { + // If setting an HSB value but saturation is 0, put light in white mode targetMode = 'white' } } // Set the correct light mode - if (targetMode) { - const tuyaCommand = { + if (targetMode && targetMode !== this.state.dps[this.config.dpsMode].val) { + const modeCommand = { dps: this.config.dpsMode, set: targetMode } - await this.set(tuyaCommand) + await this.set(modeCommand) } this.set(command) } From 6c72afd8ab33ad945166496eeceed24056ae0287 Mon Sep 17 00:00:00 2001 From: tsightler Date: Sun, 4 Oct 2020 01:04:54 -0400 Subject: [PATCH 13/34] Color support for HSBHEX --- devices/tuya-device.js | 66 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/devices/tuya-device.js b/devices/tuya-device.js index 6b385cc..f333f1b 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -190,7 +190,7 @@ class TuyaDevice { // Process MQTT commands for all command topics at device level async processCommand(message, commandTopic) { const command = this.getCommandFromMessage(message) - if (commandTopic === 'command' && command === 'get-states' ) { + if (commandTopic === 'command' && command === 'get-states') { // Handle "get-states" command to update device state debug('Received command: ', command) await this.getStates() @@ -324,7 +324,10 @@ class TuyaDevice { } break; case 'hsb': - tuyaCommand.set = this.convertToTuyaHsbColor(command, deviceTopic) + tuyaCommand.set = this.convertToTuyaHsbColor(command, deviceTopic.components) + break; + case 'hsbhex': + tuyaCommand.set = this.convertToTuyaHsbHexColor(command, deviceTopic.components) break; } if (tuyaCommand.set === '!!!INVALID!!!') { @@ -340,28 +343,31 @@ class TuyaDevice { } // Takes Tuya color value in HSB or HSBHEX format and updates - // cached device HSB color state + // cached HSB color state for device updateColorState(value) { let h, s, b if (this.config.colorType === 'hsbhex') { [, h, s, b] = (value || '0000000000ffff').match(/^.{6}([0-9a-f]{4})([0-9a-f]{2})([0-9a-f]{2})$/i) || [0, '0', 'ff', 'ff']; + this.state.color.h = parseInt(h, 16) + this.state.color.s = Math.round(parseInt(s, 16) / 2.55) // Convert saturation to 100 scale + this.state.color.b = Math.round(parseInt(b, 16) / .255) // Convert brightness to 1000 scale } else { [, h, s, b] = (value || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8'] + // Convert from Hex to Decimal and cache values + this.state.color.h = parseInt(h, 16) + this.state.color.s = Math.round(parseInt(s, 16) / 10) // Convert saturation to 100 Scale + this.state.color.b = parseInt(b, 16) // Convert brightness to 1000 scale } - // Convert from Hex to Decimal and cache values - this.state.color.h = parseInt(h, 16) - this.state.color.s = Math.round(parseInt(s, 16) / 10) - this.state.color.b = parseInt(b, 16) } // Takes provided decimal HSB components from MQTT topic, combines with any // cached (unchanged) component values and converts to Tuya HSB format - convertToTuyaHsbColor(value, topic) { + convertToTuyaHsbColor(value, components) { // Start with cached color values const newColor = this.state.color // Update any HSB component with a changed value - const components = topic.components.split(',') + components = components.split(',') const values = value.split(',') for (let i in components) { newColor[components[i]] = Math.round(values[i]) @@ -372,6 +378,48 @@ class TuyaDevice { return hexColor } + convertToTuyaHsbHexColor(value, components) { + // Start with cached color values + const newColor = this.state.color + + // Update any HSB component with a changed value + components = components.split(',') + const values = value.split(',') + for (let i in components) { + newColor[components[i]] = Math.round(values[i]) + } + let {h, s, b} = newColor + const hsb = h.toString(16).padStart(4, '0') + Math.round(2.55 * s).toString(16).padStart(2, '0') + Math.round(b * .255).toString(16).padStart(2, '0'); + h /= 60; + s /= 100; + b *= .255; + const + i = Math.floor(h), + f = h - i, + p = b * (1 - s), + q = b * (1 - s * f), + t = b * (1 - s * (1 - f)), + rgb = (() => { + switch (i % 6) { + case 0: + return [b, t, p]; + case 1: + return [q, b, p]; + case 2: + return [p, b, t]; + case 3: + return [p, q, b]; + case 4: + return [t, p, b]; + case 5: + return [b, p, q]; + } + })().map(c => Math.round(c).toString(16).padStart(2, '0')), + hex = rgb.join(''); + + return hex + hsb; + } + // Set white/colour mode based on target mode async setLight(topic, command) { let targetMode = undefined From 3705efaaed160e4950b2d2b3026741a3496ad8d2 Mon Sep 17 00:00:00 2001 From: tsightler Date: Sun, 4 Oct 2020 23:34:37 -0400 Subject: [PATCH 14/34] More RGB fixes --- devices/tuya-device.js | 73 ++++++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/devices/tuya-device.js b/devices/tuya-device.js index f333f1b..6024f94 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -119,9 +119,11 @@ class TuyaDevice { for (let topic in this.deviceTopics) { const deviceTopic = this.deviceTopics[topic] const key = deviceTopic.key - if (this.state.dps[key].updated) { + if (this.state.dps[key] && this.state.dps[key].updated) { const state = this.getTopicState(deviceTopic, this.state.dps[key].val) - this.publishMqtt(this.baseTopic + topic, state, true) + if (state) { + this.publishMqtt(this.baseTopic + topic, state, true) + } } } @@ -170,9 +172,11 @@ class TuyaDevice { state = value ? 'ON' : 'OFF' break; case 'int': - state = value ? value.toString() : 'None' + case 'float': + state = value ? value.toString() : '' break; case 'hsb': + case 'hsbhex': // Return comma separate array of component values for specific topic state = new Array() const components = deviceTopic.components.split(',') @@ -209,7 +213,7 @@ class TuyaDevice { if (deviceTopic) { debug('Device '+this.options.id+' recieved command topic: '+commandTopic+', message: '+message) const command = this.getCommandFromMessage(message) - let setResult = this.setState(command, deviceTopic) + let setResult = this.setTuyaState(command, deviceTopic) if (!setResult) { debug('Command topic '+this.baseTopic+commandTopic+' received invalid value: '+command) } @@ -299,7 +303,7 @@ class TuyaDevice { } // Set state based on command topic - setState(command, deviceTopic) { + setTuyaState(command, deviceTopic) { const tuyaCommand = new Object() tuyaCommand.dps = deviceTopic.key switch (deviceTopic.type) { @@ -315,19 +319,26 @@ class TuyaDevice { } break; case 'int': + case 'float': if (isNaN(command)) { tuyaCommand.set = '!!!INVALID!!!' } else if (deviceTopic.hasOwnProperty('min') && deviceTopic.hasOwnProperty('max')) { - tuyaCommand.set = (command >= deviceTopic.min && command <= deviceTopic.max ) ? parseInt(command) : '!!!INVALID!!!' + if (command >= deviceTopic.min && command <= deviceTopic.max ) { + tuyaCommand.set = deviceTopic.type = 'int' ? parseInt(command) : parseFloat(command) + } else { + tuyaCommand.set = '!!!INVALID!!!' + } } else { - tuyaCommand.set = parseInt(command) + tuyaCommand.set = deviceTopic.type = 'int' ? parseInt(command) : parseFloat(command) } break; case 'hsb': - tuyaCommand.set = this.convertToTuyaHsbColor(command, deviceTopic.components) + this.updateSetColorState(command, deviceTopic.components) + tuyaCommand.set = this.getTuyaHsbColor() break; case 'hsbhex': - tuyaCommand.set = this.convertToTuyaHsbHexColor(command, deviceTopic.components) + this.updateSetColorState(command, deviceTopic.components) + tuyaCommand.set = this.getTuyaHsbHexColor() break; } if (tuyaCommand.set === '!!!INVALID!!!') { @@ -342,8 +353,8 @@ class TuyaDevice { } } - // Takes Tuya color value in HSB or HSBHEX format and updates - // cached HSB color state for device + // Takes Tuya color value in HSB or HSBHEX format and + // updates cached HSB color state for device updateColorState(value) { let h, s, b if (this.config.colorType === 'hsbhex') { @@ -358,37 +369,37 @@ class TuyaDevice { this.state.color.s = Math.round(parseInt(s, 16) / 10) // Convert saturation to 100 Scale this.state.color.b = parseInt(b, 16) // Convert brightness to 1000 scale } - } - // Takes provided decimal HSB components from MQTT topic, combines with any - // cached (unchanged) component values and converts to Tuya HSB format - convertToTuyaHsbColor(value, components) { - // Start with cached color values - const newColor = this.state.color + // Initialize the set color values for first time. Used to conflicts + // when mulitple HSB components are updated in quick succession + if (!this.state.setColor) { + this.state.setColor = this.state.color + } + } + // Updates the set color values based on received value from command topics + // This is used to cache set color values when mulitple HSB components use + // different topics and updates come in quick succession + updateSetColorState(value, components) { // Update any HSB component with a changed value components = components.split(',') const values = value.split(',') for (let i in components) { - newColor[components[i]] = Math.round(values[i]) + this.state.setColor[components[i]] = Math.round(values[i]) } + } + // Returns Tuya HSB format value from current setColor HSB value + getTuyaHsbColor() { // Convert new HSB color to Tuya style HSB format - const hexColor = newColor.h.toString(16).padStart(4, '0') + (10 * newColor.s).toString(16).padStart(4, '0') + (newColor.b).toString(16).padStart(4, '0') + let {h, s, b} = this.state.setColor + const hexColor = h.toString(16).padStart(4, '0') + (10 * s).toString(16).padStart(4, '0') + (b).toString(16).padStart(4, '0') return hexColor } - convertToTuyaHsbHexColor(value, components) { - // Start with cached color values - const newColor = this.state.color - - // Update any HSB component with a changed value - components = components.split(',') - const values = value.split(',') - for (let i in components) { - newColor[components[i]] = Math.round(values[i]) - } - let {h, s, b} = newColor + // Returns Tuya HSBHEX format value from current setColor HSB value + getTuyaHsbHexColor() { + let {h, s, b} = this.state.setColor const hsb = h.toString(16).padStart(4, '0') + Math.round(2.55 * s).toString(16).padStart(2, '0') + Math.round(b * .255).toString(16).padStart(2, '0'); h /= 60; s /= 100; @@ -427,7 +438,7 @@ class TuyaDevice { // If setting white level, light should be in white mode targetMode = 'white' } else if (this.config.dpsColor === topic.key) { - if (this.state.color.s > 0) { + if (this.state.setColor.s > 0) { // If setting an HSB value with saturation > 0, light should be in color mode targetMode = 'colour' } else { From 4ff6fc221e7d8f173be79cb43eaf0ba3e7ec5bc2 Mon Sep 17 00:00:00 2001 From: tsightler Date: Mon, 5 Oct 2020 02:05:34 -0400 Subject: [PATCH 15/34] More RGBTW tweaks --- devices/rgbtw-light.js | 7 ++++++- devices/tuya-device.js | 45 +++++++++++++++++++++++++----------------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/devices/rgbtw-light.js b/devices/rgbtw-light.js index 3099a6a..3ca6e2e 100644 --- a/devices/rgbtw-light.js +++ b/devices/rgbtw-light.js @@ -26,7 +26,7 @@ class RGBTWLight extends TuyaDevice { white_value_state: { key: this.config.dpsWhiteValue, type: 'int', - min: (this.config.whiteValueScale = 1000) ? 10 : 1, + min: (this.config.whiteValueScale == 1000) ? 10 : 1, max: this.config.whiteValueScale, scale: this.config.whiteValueScale }, @@ -40,6 +40,11 @@ class RGBTWLight extends TuyaDevice { type: this.config.colorType, components: 'b' }, + hsb_state: { + key: this.config.dpsColor, + type: this.config.colorType, + components: 'h,s,b' + }, mode_state: { key: this.config.dpsMode, type: 'str' diff --git a/devices/tuya-device.js b/devices/tuya-device.js index 6024f94..34eda0f 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -211,7 +211,7 @@ class TuyaDevice { const deviceTopic = this.deviceTopics.hasOwnProperty(stateTopic) ? this.deviceTopics[stateTopic] : '' if (deviceTopic) { - debug('Device '+this.options.id+' recieved command topic: '+commandTopic+', message: '+message) + debug('Device '+this.options.id+' received command topic: '+commandTopic+', message: '+message) const command = this.getCommandFromMessage(message) let setResult = this.setTuyaState(command, deviceTopic) if (!setResult) { @@ -324,12 +324,12 @@ class TuyaDevice { tuyaCommand.set = '!!!INVALID!!!' } else if (deviceTopic.hasOwnProperty('min') && deviceTopic.hasOwnProperty('max')) { if (command >= deviceTopic.min && command <= deviceTopic.max ) { - tuyaCommand.set = deviceTopic.type = 'int' ? parseInt(command) : parseFloat(command) + tuyaCommand.set = deviceTopic.type === 'int' ? parseInt(command) : parseFloat(command) } else { tuyaCommand.set = '!!!INVALID!!!' } } else { - tuyaCommand.set = deviceTopic.type = 'int' ? parseInt(command) : parseFloat(command) + tuyaCommand.set = deviceTopic.type === 'int' ? parseInt(command) : parseFloat(command) } break; case 'hsb': @@ -373,7 +373,11 @@ class TuyaDevice { // Initialize the set color values for first time. Used to conflicts // when mulitple HSB components are updated in quick succession if (!this.state.setColor) { - this.state.setColor = this.state.color + this.state.setColor = { + 'h': this.state.color.h, + 's': this.state.color.s, + 'b': this.state.color.b + } } } @@ -433,28 +437,33 @@ class TuyaDevice { // Set white/colour mode based on target mode async setLight(topic, command) { + const currentMode = this.state.dps[this.config.dpsMode].val let targetMode = undefined - if (this.config.dpsWhiteValue === topic.key) { + if (topic.key === this.config.dpsWhiteValue) { // If setting white level, light should be in white mode targetMode = 'white' - } else if (this.config.dpsColor === topic.key) { - if (this.state.setColor.s > 0) { - // If setting an HSB value with saturation > 0, light should be in color mode - targetMode = 'colour' - } else { - // If setting an HSB value but saturation is 0, put light in white mode + } else if (topic.key === this.config.dpsColor) { + if (this.state.setColor.s === 0 && this.state.setColor.s !== this.state.color.s) { + // If setting saturation to 0 and not already zero, target mode is 'white' targetMode = 'white' + } else if ((this.state.setColor.s > 0 && this.state.setColor.s !== this.state.color.s) || + this.state.setColor.h !== this.state.color.h || + this.state.setColor.b !== this.state.color.b) { + // If setting saturation > 0, or changing any other color value, target mode is 'colour' + targetMode = 'colour' } } - - // Set the correct light mode - if (targetMode && targetMode !== this.state.dps[this.config.dpsMode].val) { - const modeCommand = { - dps: this.config.dpsMode, - set: targetMode + // If mode change required, add it to the set command + if (targetMode && currentMode !== targetMode) { + command = { + multiple: true, + data: { + [command.dps]: command.set, + [this.config.dpsMode]: targetMode + } } - await this.set(modeCommand) } + console.log(command) this.set(command) } From d75e2189daef25014f72d78561e254cb6c39f5a9 Mon Sep 17 00:00:00 2001 From: tsightler Date: Mon, 5 Oct 2020 08:35:56 -0400 Subject: [PATCH 16/34] Update tuya-device.js --- devices/tuya-device.js | 1 - 1 file changed, 1 deletion(-) diff --git a/devices/tuya-device.js b/devices/tuya-device.js index 34eda0f..42c6d3c 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -463,7 +463,6 @@ class TuyaDevice { } } } - console.log(command) this.set(command) } From 38d3092af3bb625633edd6ff1d7ec7157245dd3c Mon Sep 17 00:00:00 2001 From: tsightler Date: Mon, 5 Oct 2020 09:56:04 -0400 Subject: [PATCH 17/34] Update rgbtw-light.js --- devices/rgbtw-light.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devices/rgbtw-light.js b/devices/rgbtw-light.js index 3ca6e2e..c50428a 100644 --- a/devices/rgbtw-light.js +++ b/devices/rgbtw-light.js @@ -11,7 +11,7 @@ class RGBTWLight extends TuyaDevice { this.config.whiteValueScale = this.config.whiteValueScale ? this.config.whiteValueScale : 1000 this.config.dpsColorTemp = this.config.dpsColorTemp ? this.config.dpsColorTemp : 4 this.config.dpsColor = this.config.dpsColor ? this.config.dpsColor : 5 - this.config.colorType = this.config.colorType ? this.config.colorType : 'hsb' + this.config.colorType = this.config.colorType ? this.config.colorType : 'hsbhex' this.deviceData.mdl = 'RGBTW Light' From d5217ce237f826cb0ba042536873e47be13786e6 Mon Sep 17 00:00:00 2001 From: tsightler Date: Wed, 7 Oct 2020 08:26:13 -0400 Subject: [PATCH 18/34] 3.0.0-beta3 * Improve RGBTW white/color logic * Rebase MQTT topic brightess to 100 scale (vs 255/1000) * Added based auto-discovery for RGBTW light * Added math functions --- devices/rgbtw-light.js | 64 +- devices/tuya-device.js | 120 ++- package-lock.json | 1736 +++++++++++++++++++++++++++++++++++++++- package.json | 8 +- 4 files changed, 1865 insertions(+), 63 deletions(-) diff --git a/devices/rgbtw-light.js b/devices/rgbtw-light.js index c50428a..a863454 100644 --- a/devices/rgbtw-light.js +++ b/devices/rgbtw-light.js @@ -4,14 +4,17 @@ const utils = require('../lib/utils') class RGBTWLight extends TuyaDevice { async init() { + await this.guessLightInfo() + // Set device specific variables - this.config.dpsPower = this.config.dpsPower ? this.config.dpsPower : 1 - this.config.dpsMode = this.config.dpsMode ? this.config.dpsMode : 2 - this.config.dpsWhiteValue = this.config.dpsWhiteValue ? this.config.dpsWhiteValue : 3 - this.config.whiteValueScale = this.config.whiteValueScale ? this.config.whiteValueScale : 1000 - this.config.dpsColorTemp = this.config.dpsColorTemp ? this.config.dpsColorTemp : 4 - this.config.dpsColor = this.config.dpsColor ? this.config.dpsColor : 5 - this.config.colorType = this.config.colorType ? this.config.colorType : 'hsbhex' + this.config.dpsPower = this.config.dpsPower ? this.config.dpsPower : this.guess.dpsPower + this.config.dpsMode = this.config.dpsMode ? this.config.dpsMode : this.guess.dpsMode + this.config.dpsWhiteValue = this.config.dpsWhiteValue ? this.config.dpsWhiteValue : this.guess.dpsWhiteValue + this.config.whiteValueScale = this.config.whiteValueScale ? this.config.whiteValueScale : this.guess.whiteValueScale + this.config.dpsColorTemp = this.config.dpsColorTemp ? this.config.dpsColorTemp : this.guess.dpsColorTemp + this.config.dpsColor = this.config.dpsColor ? this.config.dpsColor : this.guess.dpsColor + this.config.colorType = this.config.colorType ? this.config.colorType : this.guess.colorType + this.config.colorType = 'hsb' this.deviceData.mdl = 'RGBTW Light' @@ -26,9 +29,11 @@ class RGBTWLight extends TuyaDevice { white_value_state: { key: this.config.dpsWhiteValue, type: 'int', - min: (this.config.whiteValueScale == 1000) ? 10 : 1, - max: this.config.whiteValueScale, - scale: this.config.whiteValueScale + min: 1, + max: 100, + scale: this.config.whiteValueScale, + stateMath: (this.config.whiteValueScale == 1000) ? '/10' : '/2.55', + commandMath: (this.config.whiteValueScale == 1000) ? '*10' : '*2.55' }, hs_state: { key: this.config.dpsColor, @@ -68,12 +73,12 @@ class RGBTWLight extends TuyaDevice { command_topic: this.baseTopic+'command', brightness_state_topic: this.baseTopic+'brightness_state', brightness_command_topic: this.baseTopic+'brightness_command', - brightness_scale: 1000, + brightness_scale: 100, hs_state_topic: this.baseTopic+'hs_state', hs_command_topic: this.baseTopic+'hs_command', white_value_state_topic: this.baseTopic+'white_value_state', white_value_command_topic: this.baseTopic+'white_value_command', - white_value_scale: 1000, + white_value_scale: 100, unique_id: this.config.id, device: this.deviceData } @@ -82,6 +87,41 @@ class RGBTWLight extends TuyaDevice { debug(discoveryData) this.publishMqtt(configTopic, JSON.stringify(discoveryData)) } + + async guessLightInfo() { + this.guess = new Object() + let mode = await this.device.get({"dps": 2}) + if (mode && (mode === 'white' || mode === 'colour')) { + this.guess.dpsPower = 1 + this.guess.dpsMode = 2 + this.guess.dpsWhiteValue = 3 + this.guess.whiteValueScale = 255 + const colorTemp = await this.device.get({"dps": 4}) + if (colorTemp) { + this.guess.dpsColorTemp = 4 + } else { + this.guess.dpsColorTemp = 0 + } + this.guess.dpsColor = 5 + const color = await this.device.get({"dps": this.guess.dpsColor}) + this.guess.colorType = (color && color.length === 14) ? 'hsbhex' : 'hsb' + } else { + mode = await this.device.get({"dps": 20}) + this.guess.dpsPower = 20 + this.guess.dpsMode = 21 + this.guess.dpsWhiteValue = 22 + this.guess.whiteValueScale = 1000 + const colorTemp = await this.device.get({"dps": 23}) + if (colorTemp) { + this.guess.dpsColorTemp = 23 + } else { + this.guess.dpsColorTemp = 0 + } + this.guess.dpsColor = 24 + const color = await this.device.get({"dps": this.guess.dpsColor}) + this.guess.colorType = (color && color.length === 12) ? 'hsb' : 'hsbhex' + } + } } module.exports = RGBTWLight \ No newline at end of file diff --git a/devices/tuya-device.js b/devices/tuya-device.js index 42c6d3c..bc7f94d 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -54,11 +54,13 @@ class TuyaDevice { // Listen for device data and call update DPS function if valid this.device.on('data', (data) => { - if (typeof data == 'string') { - debug('Data from device not encrypted:', data.replace(/[^a-zA-Z0-9 ]/g, '')) - } else { - debug('Data from device '+this.options.id+' ->', data.dps) + if (typeof data === 'object') { + debug('Received JSON data from device '+this.options.id+' ->', data.dps) this.updateState(data) + } else { + if (data !== 'json obj data unvalid') { + debug('Received string data from device '+this.options.id+' ->', data.replace(/[^a-zA-Z0-9 ]/g, '')) + } } }) @@ -173,7 +175,7 @@ class TuyaDevice { break; case 'int': case 'float': - state = value ? value.toString() : '' + state = this.parseStateNumber(value, deviceTopic) break; case 'hsb': case 'hsbhex': @@ -190,6 +192,34 @@ class TuyaDevice { } return state } + + // Parse the received state value based on deviceTopic config + parseStateNumber(value, deviceTopic) { + // Check if it's a number and it's not outside of defined range + if (isNaN(value)) { + return '' + } + + // Perform any required math transforms before returing command value + switch (deviceTopic.type) { + case 'int': + if (deviceTopic.stateMath) { + value = parseInt(Math.round(eval(value+deviceTopic.stateMath))) + } else { + value = parseInt(value) + } + break; + case 'float': + if (deviceTopic.stateMath) { + value = parseFloat(eval(value+deviceTopic.stateMath)) + } else { + value = parseFloat(value) + } + break; + } + + return value.toString() + } // Process MQTT commands for all command topics at device level async processCommand(message, commandTopic) { @@ -213,8 +243,8 @@ class TuyaDevice { if (deviceTopic) { debug('Device '+this.options.id+' received command topic: '+commandTopic+', message: '+message) const command = this.getCommandFromMessage(message) - let setResult = this.setTuyaState(command, deviceTopic) - if (!setResult) { + let commandResult = this.sendTuyaCommand(command, deviceTopic) + if (!commandResult) { debug('Command topic '+this.baseTopic+commandTopic+' received invalid value: '+command) } } else { @@ -303,7 +333,7 @@ class TuyaDevice { } // Set state based on command topic - setTuyaState(command, deviceTopic) { + sendTuyaCommand(command, deviceTopic) { const tuyaCommand = new Object() tuyaCommand.dps = deviceTopic.key switch (deviceTopic.type) { @@ -320,17 +350,7 @@ class TuyaDevice { break; case 'int': case 'float': - if (isNaN(command)) { - tuyaCommand.set = '!!!INVALID!!!' - } else if (deviceTopic.hasOwnProperty('min') && deviceTopic.hasOwnProperty('max')) { - if (command >= deviceTopic.min && command <= deviceTopic.max ) { - tuyaCommand.set = deviceTopic.type === 'int' ? parseInt(command) : parseFloat(command) - } else { - tuyaCommand.set = '!!!INVALID!!!' - } - } else { - tuyaCommand.set = deviceTopic.type === 'int' ? parseInt(command) : parseFloat(command) - } + tuyaCommand.set = this.parseCommandNumber(command, deviceTopic) break; case 'hsb': this.updateSetColorState(command, deviceTopic.components) @@ -353,6 +373,40 @@ class TuyaDevice { } } + // Validate/transform set interger values + parseCommandNumber(command, deviceTopic) { + let value = undefined + const invalid = '!!!INVALID!!!' + + // Check if it's a number and it's not outside of defined range + if (isNaN(command)) { + return invalid + } else if ((deviceTopic.hasOwnProperty('min') && command < deviceTopic.min) || + (deviceTopic.hasOwnProperty('max') && command > deviceTopic.max)) { + return invalid + } + + // Perform any required math transforms before returing command value + switch (deviceTopic.type) { + case 'int': + if (deviceTopic.commandMath) { + value = parseInt(Math.round(eval(command+deviceTopic.commandMath))) + } else { + value = parseInt(command) + } + break; + case 'float': + if (deviceTopic.commandMath) { + value = parseFloat(eval(command+deviceTopic.commandMath)) + } else { + value = parseFloat(command) + } + break; + } + + return value + } + // Takes Tuya color value in HSB or HSBHEX format and // updates cached HSB color state for device updateColorState(value) { @@ -360,14 +414,14 @@ class TuyaDevice { if (this.config.colorType === 'hsbhex') { [, h, s, b] = (value || '0000000000ffff').match(/^.{6}([0-9a-f]{4})([0-9a-f]{2})([0-9a-f]{2})$/i) || [0, '0', 'ff', 'ff']; this.state.color.h = parseInt(h, 16) - this.state.color.s = Math.round(parseInt(s, 16) / 2.55) // Convert saturation to 100 scale - this.state.color.b = Math.round(parseInt(b, 16) / .255) // Convert brightness to 1000 scale + this.state.color.s = Math.round(parseInt(s, 16) / 2.55) // Convert saturation to 100 scale + this.state.color.b = Math.round(parseInt(b, 16) / 2.55) // Convert brightness to 100 scale } else { [, h, s, b] = (value || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8'] // Convert from Hex to Decimal and cache values this.state.color.h = parseInt(h, 16) this.state.color.s = Math.round(parseInt(s, 16) / 10) // Convert saturation to 100 Scale - this.state.color.b = parseInt(b, 16) // Convert brightness to 1000 scale + this.state.color.b = Math.round(parseInt(b, 16) / 10) // Convert brightness to 1000 scale } // Initialize the set color values for first time. Used to conflicts @@ -397,17 +451,17 @@ class TuyaDevice { getTuyaHsbColor() { // Convert new HSB color to Tuya style HSB format let {h, s, b} = this.state.setColor - const hexColor = h.toString(16).padStart(4, '0') + (10 * s).toString(16).padStart(4, '0') + (b).toString(16).padStart(4, '0') + const hexColor = h.toString(16).padStart(4, '0') + (10 * s).toString(16).padStart(4, '0') + (10 * b).toString(16).padStart(4, '0') return hexColor } // Returns Tuya HSBHEX format value from current setColor HSB value getTuyaHsbHexColor() { let {h, s, b} = this.state.setColor - const hsb = h.toString(16).padStart(4, '0') + Math.round(2.55 * s).toString(16).padStart(2, '0') + Math.round(b * .255).toString(16).padStart(2, '0'); + const hsb = h.toString(16).padStart(4, '0') + Math.round(2.55 * s).toString(16).padStart(2, '0') + Math.round(2.55 * b).toString(16).padStart(2, '0'); h /= 60; s /= 100; - b *= .255; + b *= 2.55; const i = Math.floor(h), f = h - i, @@ -435,21 +489,19 @@ class TuyaDevice { return hex + hsb; } - // Set white/colour mode based on target mode + // Set white/colour mode based on async setLight(topic, command) { const currentMode = this.state.dps[this.config.dpsMode].val let targetMode = undefined - if (topic.key === this.config.dpsWhiteValue) { - // If setting white level, light should be in white mode + if (topic.key === this.config.dpsWhiteValue || topic.key === this.config.dpsColorTemp) { + // If setting white level or color temperature, light should be in white mode targetMode = 'white' } else if (topic.key === this.config.dpsColor) { - if (this.state.setColor.s === 0 && this.state.setColor.s !== this.state.color.s) { - // If setting saturation to 0 and not already zero, target mode is 'white' + if (this.state.setColor.s < 10) { + // If saturation is < 10 then white mode targetMode = 'white' - } else if ((this.state.setColor.s > 0 && this.state.setColor.s !== this.state.color.s) || - this.state.setColor.h !== this.state.color.h || - this.state.setColor.b !== this.state.color.b) { - // If setting saturation > 0, or changing any other color value, target mode is 'colour' + } else { + // If saturation > 0 and changing hue, set color mode targetMode = 'colour' } } diff --git a/package-lock.json b/package-lock.json index 529bf9b..656fecb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,265 @@ { "name": "tuya-mqtt", - "version": "3.0.0-beta1", + "version": "3.0.0-beta3", "lockfileVersion": 1, "requires": true, "dependencies": { + "@sindresorhus/is": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-2.1.1.tgz", + "integrity": "sha512-/aPsuoj/1Dw/kzhkgz+ES6TxG0zfTMGLwuK2ZG00k/iJzYHTLCE8mVU8EPqEOp/lmxPoq1C1C9RYToRKb2KEfg==" + }, + "@szmarczak/http-timer": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.5.tgz", + "integrity": "sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ==", + "requires": { + "defer-to-connect": "^2.0.0" + } + }, + "@tuyapi/cli": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@tuyapi/cli/-/cli-1.13.4.tgz", + "integrity": "sha512-EiqeqqiAY2kHokd5uELbu2IYOarxPd3rNP3xgCPUypcH3UoN5gnHdrRB9iwgiDfbEL89v2rn48aPEjQX9NyQ0Q==", + "requires": { + "@tuyapi/link": "^0.3.3", + "@tuyapi/openapi": "^1.2.0", + "@tuyapi/stub": "^0.1.4", + "cli-table3": "^0.6.0", + "colors": "^1.4.0", + "commander": "^5.1.0", + "configstore": "^5.0.1", + "debug": "^4.1.1", + "finalhandler": "^1.1.2", + "http-mitm-proxy": "^0.8.2", + "inquirer": "^7.2.0", + "keypress": "^0.2.1", + "ora": "^4.0.4", + "promise.any": "^2.0.1", + "qrcode-terminal": "^0.12.0", + "serve-static": "^1.14.1", + "tuyapi": "^5.3.1", + "update-notifier": "^4.1.0" + }, + "dependencies": { + "tuyapi": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/tuyapi/-/tuyapi-5.3.2.tgz", + "integrity": "sha512-RKdTTnWVK+DDq3iRUTMh5DVd8coIwoulHntB+HvcDLuakDgSoNUc0Pzd69mw0CTTP7HTC6x6S9Ztg5pJIlYE8g==", + "requires": { + "debug": "4.1.1", + "p-retry": "4.2.0", + "p-timeout": "3.2.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + } + } + } + } + }, + "@tuyapi/link": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@tuyapi/link/-/link-0.3.3.tgz", + "integrity": "sha512-YWsMLJxqGe+kma188Lm9F/u8mZYQ9X0T//stzfPagbx5CcPAGVIvs0nmaYFr189Tu5dDc8xs/cFc2zAlj63kow==", + "requires": { + "@tuyapi/openapi": "^0.2.0", + "debug": "^4.1.1", + "delay": "^4.3.0" + }, + "dependencies": { + "@tuyapi/openapi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@tuyapi/openapi/-/openapi-0.2.0.tgz", + "integrity": "sha512-SqwZ0hAquAVA8V6D7tjNgn8ji8oyMaguh766K3tiXxSSULLa5Q3TkrWhd1t2mFMINL+QKAvNpU2hzGVFmyyjig==", + "requires": { + "got": "^10.2.2" + } + } + } + }, + "@tuyapi/openapi": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@tuyapi/openapi/-/openapi-1.2.0.tgz", + "integrity": "sha512-ABS6F4H1UAneyso+3ttS4TiwKChRnT4r9aiYwMvvIKqym13cgJm54xylFn10gwFoFu17kfNm1D5fh0wxzgrumA==", + "requires": { + "got": "^10.2.2" + } + }, + "@tuyapi/stub": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@tuyapi/stub/-/stub-0.1.4.tgz", + "integrity": "sha512-uF2u1Z78Qif3hK92RYxMG4AbjedNRZYmM1jIvegf4KUfDVV/BhHFooz2y3Q28jPl5Q+H39QY/WBH2bMiXMAg/g==", + "requires": { + "debug": "^4.1.1", + "tuyapi": "^5.0.0" + }, + "dependencies": { + "tuyapi": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/tuyapi/-/tuyapi-5.3.2.tgz", + "integrity": "sha512-RKdTTnWVK+DDq3iRUTMh5DVd8coIwoulHntB+HvcDLuakDgSoNUc0Pzd69mw0CTTP7HTC6x6S9Ztg5pJIlYE8g==", + "requires": { + "debug": "4.1.1", + "p-retry": "4.2.0", + "p-timeout": "3.2.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + } + } + } + } + }, + "@types/cacheable-request": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.1.tgz", + "integrity": "sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ==", + "requires": { + "@types/http-cache-semantics": "*", + "@types/keyv": "*", + "@types/node": "*", + "@types/responselike": "*" + } + }, + "@types/http-cache-semantics": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz", + "integrity": "sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A==" + }, + "@types/keyv": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.1.tgz", + "integrity": "sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw==", + "requires": { + "@types/node": "*" + } + }, + "@types/node": { + "version": "14.11.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.11.5.tgz", + "integrity": "sha512-jVFzDV6NTbrLMxm4xDSIW/gKnk8rQLF9wAzLWIOg+5nU6ACrIMndeBdXci0FGtqJbP9tQvm6V39eshc96TO2wQ==" + }, + "@types/responselike": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", + "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", + "requires": { + "@types/node": "*" + } + }, "@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" }, + "ansi-align": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz", + "integrity": "sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw==", + "requires": { + "string-width": "^3.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "ansi-escapes": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.1.tgz", + "integrity": "sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA==", + "requires": { + "type-fest": "^0.11.0" + }, + "dependencies": { + "type-fest": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.11.0.tgz", + "integrity": "sha512-OdjXJxnCN1AvyLSzeKIgXTXxV+99ZuXl3Hpo9XpJAv9MBcHrrJOQ5kV7ypXOuQie+AmWG25hLbiKdwYTifzcfQ==" + } + } + }, + "ansi-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", + "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==" + }, + "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" + } + }, + "array.prototype.map": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array.prototype.map/-/array.prototype.map-1.0.2.tgz", + "integrity": "sha512-Az3OYxgsa1g7xDYp86l0nnN4bcmuEITGe1rbdEBVkrqkzMgDcbdQ2R7r41pNzti+4NMces3H8gMmuioZUilLgw==", + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1", + "es-array-method-boxes-properly": "^1.0.0", + "is-string": "^1.0.4" + } + }, + "async": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", + "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", + "requires": { + "lodash": "^4.17.14" + } + }, + "async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==" + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -41,6 +292,37 @@ } } }, + "boxen": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-4.2.0.tgz", + "integrity": "sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ==", + "requires": { + "ansi-align": "^3.0.0", + "camelcase": "^5.3.1", + "chalk": "^3.0.0", + "cli-boxes": "^2.2.0", + "string-width": "^4.1.0", + "term-size": "^2.1.0", + "type-fest": "^0.8.1", + "widest-line": "^3.1.0" + }, + "dependencies": { + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==" + } + } + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -64,6 +346,29 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" }, + "cacheable-lookup": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-2.0.1.tgz", + "integrity": "sha512-EMMbsiOTcdngM/K6gV/OxF2x0t07+vMOWxZNSCRQMjO2MY2nhZQ6OYhOOpyQrbhqsgtvKGI7hcq6xjnA92USjg==", + "requires": { + "@types/keyv": "^3.1.1", + "keyv": "^4.0.0" + } + }, + "cacheable-request": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.1.tgz", + "integrity": "sha512-lt0mJ6YAnsrBErpTMWeu5kl/tg9xMAWjavYTN6VQXM1A/teBITuNcccXsCxF0tDQQJf9DfAaX5O4e0zp0KlfZw==", + "requires": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^4.1.0", + "responselike": "^2.0.0" + } + }, "callback-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/callback-stream/-/callback-stream-1.1.0.tgz", @@ -73,6 +378,83 @@ "readable-stream": "> 1.0.0 < 3.0.0" } }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==" + }, + "cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==" + }, + "cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "requires": { + "restore-cursor": "^3.1.0" + } + }, + "cli-spinners": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.4.0.tgz", + "integrity": "sha512-sJAofoarcm76ZGpuooaO0eDy8saEy+YoZBLjC4h8srt4jeBnkYeOgqxgsJQTpyt2LjI5PTfLJHSL+41Yu4fEJA==" + }, + "cli-table3": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.0.tgz", + "integrity": "sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ==", + "requires": { + "colors": "^1.1.2", + "object-assign": "^4.1.0", + "string-width": "^4.2.0" + } + }, + "cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==" + }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=" + }, + "clone-response": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", + "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", + "requires": { + "mimic-response": "^1.0.0" + }, + "dependencies": { + "mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==" + } + } + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -86,6 +468,16 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==" + }, + "commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==" + }, "commist": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/commist/-/commist-1.1.0.tgz", @@ -111,11 +503,29 @@ "typedarray": "^0.0.6" } }, + "configstore": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", + "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", + "requires": { + "dot-prop": "^5.2.0", + "graceful-fs": "^4.1.2", + "make-dir": "^3.0.0", + "unique-string": "^2.0.0", + "write-file-atomic": "^3.0.0", + "xdg-basedir": "^4.0.0" + } + }, "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, + "crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==" + }, "d": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", @@ -126,13 +536,75 @@ } }, "debug": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz", + "integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==", + "requires": { + "ms": "2.1.2" + } + }, + "decompress-response": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-5.0.0.tgz", + "integrity": "sha512-TLZWWybuxWgoW7Lykv+gq9xvzOsUjQ9tF09Tj6NSTYGMTCHNXzrPnD6Hi+TgZq19PyTAGH4Ll/NIM/eTGglnMw==", + "requires": { + "mimic-response": "^2.0.0" + } + }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" + }, + "defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", "requires": { - "ms": "^2.1.1" + "clone": "^1.0.2" } }, + "defer-to-connect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.0.tgz", + "integrity": "sha512-bYL2d05vOSf1JEZNx5vSAtPuBMkX8K9EUutg7zlKvTqKXHt7RhWJFbmd7qakVuf13i+IkGmp6FwSsONOf6VYIg==" + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "requires": { + "object-keys": "^1.0.12" + } + }, + "delay": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/delay/-/delay-4.4.0.tgz", + "integrity": "sha512-txgOrJu3OdtOfTiEOT2e76dJVfG/1dz2NZ4F0Pyt4UGZJryssMRp5vdM5wQoLwSOBNdrJv3F9PAhp/heqd7vrA==" + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "requires": { + "is-obj": "^2.0.0" + } + }, + "duplexer3": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=" + }, "duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -144,6 +616,21 @@ "stream-shift": "^1.0.0" } }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "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==" + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, "end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -152,6 +639,65 @@ "once": "^1.4.0" } }, + "es-abstract": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.7.tgz", + "integrity": "sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g==", + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.2", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.1", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + }, + "es-aggregate-error": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/es-aggregate-error/-/es-aggregate-error-1.0.4.tgz", + "integrity": "sha512-syuWJHsRD5TJ3nwqjf8eFEeGLJM6OxUjVFz0dMg2b/GB/Ub5VAFiQPEVB6ewdU2VHgkOJBo00uYwPmo7fyfzEg==", + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.6", + "function-bind": "^1.1.1", + "functions-have-names": "^1.2.1", + "globalthis": "^1.0.1" + } + }, + "es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==" + }, + "es-get-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.0.tgz", + "integrity": "sha512-UfrmHuWQlNMTs35e1ypnvikg6jCz3SK8v8ImvmDsh36fCVUR1MqoFDiyn0/k52C8NqO3YsO8Oe0azeesNuqSsQ==", + "requires": { + "es-abstract": "^1.17.4", + "has-symbols": "^1.0.1", + "is-arguments": "^1.0.4", + "is-map": "^2.0.1", + "is-set": "^2.0.1", + "is-string": "^1.0.5", + "isarray": "^2.0.5" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, "es5-ext": { "version": "0.10.53", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz", @@ -217,6 +763,26 @@ "ext": "^1.1.2" } }, + "escape-goat": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", + "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==" + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, "event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", @@ -226,6 +792,11 @@ "es5-ext": "~0.10.14" } }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "ext": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz", @@ -246,11 +817,81 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "requires": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + } + }, + "figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "functions-have-names": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.1.tgz", + "integrity": "sha512-j48B/ZI7VKs3sgeI2cZp7WXWmZXu7Iq5pl5/vptV5N2mq+DGFuS/ulaDjtaoLpYzuD6u8UgrUKHfgo7fDTSiBA==" + }, + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "requires": { + "pump": "^3.0.0" + } + }, "glob": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", @@ -290,6 +931,72 @@ "unique-stream": "^2.0.2" } }, + "global-dirs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-2.0.1.tgz", + "integrity": "sha512-5HqUqdhkEovj2Of/ms3IeS/EekcO54ytHRLV4PEY2rhRwrHXLQjeVEES0Lhka0xwNDtGYn58wyC4s5+MHsOO6A==", + "requires": { + "ini": "^1.3.5" + } + }, + "globalthis": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.1.tgz", + "integrity": "sha512-mJPRTc/P39NH/iNG4mXa9aIhNymaQikTrnspeCa2ZuJ+mH2QN/rXwtX3XwKrHqWgUQFbNZKtHM105aHzJalElw==", + "requires": { + "define-properties": "^1.1.3" + } + }, + "got": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/got/-/got-10.7.0.tgz", + "integrity": "sha512-aWTDeNw9g+XqEZNcTjMMZSy7B7yE9toWOFYip7ofFTLleJhvZwUxxTxkTpKvF+p1SAA4VHmuEy7PiHTHyq8tJg==", + "requires": { + "@sindresorhus/is": "^2.0.0", + "@szmarczak/http-timer": "^4.0.0", + "@types/cacheable-request": "^6.0.1", + "cacheable-lookup": "^2.0.0", + "cacheable-request": "^7.0.1", + "decompress-response": "^5.0.0", + "duplexer3": "^0.1.4", + "get-stream": "^5.0.0", + "lowercase-keys": "^2.0.0", + "mimic-response": "^2.1.0", + "p-cancelable": "^2.0.0", + "p-event": "^4.0.0", + "responselike": "^2.0.0", + "to-readable-stream": "^2.0.0", + "type-fest": "^0.10.0" + } + }, + "graceful-fs": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==" + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "has-symbols": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==" + }, + "has-yarn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz", + "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==" + }, "help-me": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-1.1.0.tgz", @@ -301,11 +1008,60 @@ "xtend": "^4.0.0" } }, + "http-cache-semantics": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", + "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==" + }, + "http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "http-mitm-proxy": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/http-mitm-proxy/-/http-mitm-proxy-0.8.2.tgz", + "integrity": "sha512-QqaqHWssz4acqu2aIPJqJWt/gDa4SzQ9kj/rs16ONA2nBWNh/mfOW0Ez1Wxa5IivHHZSTciQ7wG0Dxzogurngw==", + "requires": { + "async": "^2.6.2", + "debug": "^4.1.0", + "mkdirp": "^0.5.1", + "node-forge": "^0.8.4", + "optimist": "^0.6.1", + "semaphore": "^1.1.0", + "ws": "^3.2.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, "ieee754": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" }, + "import-lazy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", + "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=" + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=" + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -320,6 +1076,31 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" + }, + "inquirer": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", + "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", + "requires": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.19", + "mute-stream": "0.0.8", + "run-async": "^2.4.0", + "rxjs": "^6.6.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6" + } + }, "is-absolute": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", @@ -329,11 +1110,39 @@ "is-windows": "^1.0.1" } }, + "is-arguments": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz", + "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==" + }, + "is-callable": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz", + "integrity": "sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==" + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "requires": { + "ci-info": "^2.0.0" + } + }, + "is-date-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==" + }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=" }, + "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", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, "is-glob": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", @@ -342,11 +1151,58 @@ "is-extglob": "^2.1.0" } }, + "is-installed-globally": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz", + "integrity": "sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g==", + "requires": { + "global-dirs": "^2.0.1", + "is-path-inside": "^3.0.1" + } + }, + "is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==" + }, + "is-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.1.tgz", + "integrity": "sha512-T/S49scO8plUiAOA2DBTBG3JHpn1yiw0kRp6dgiZ0v2/6twi5eiB0rHtHFH9ZIrvlWc6+4O+m4zg5+Z833aXgw==" + }, "is-negated-glob": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", "integrity": "sha1-aRC8pdqMleeEtXUbl2z1oQ/uNtI=" }, + "is-negative-zero": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.0.tgz", + "integrity": "sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE=" + }, + "is-npm": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz", + "integrity": "sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig==" + }, + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" + }, + "is-path-inside": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz", + "integrity": "sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==" + }, + "is-regex": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz", + "integrity": "sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==", + "requires": { + "has-symbols": "^1.0.1" + } + }, "is-relative": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", @@ -355,6 +1211,29 @@ "is-unc-path": "^1.0.0" } }, + "is-set": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.1.tgz", + "integrity": "sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA==" + }, + "is-string": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz", + "integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==" + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "requires": { + "has-symbols": "^1.0.1" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, "is-unc-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", @@ -368,10 +1247,34 @@ "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==" }, + "is-yarn-global": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz", + "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==" + }, "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + }, + "iterate-iterator": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/iterate-iterator/-/iterate-iterator-1.0.1.tgz", + "integrity": "sha512-3Q6tudGN05kbkDQDI4CqjaBf4qf85w6W6GnuZDtUVYwKgtC1q8yxYX7CZed7N+tLzQqS6roujWvszf13T+n9aw==" + }, + "iterate-value": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/iterate-value/-/iterate-value-1.0.2.tgz", + "integrity": "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ==", + "requires": { + "es-get-iterator": "^1.0.2", + "iterate-iterator": "^1.0.1" + } + }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -386,11 +1289,119 @@ "minimist": "^1.2.5" } }, + "keypress": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/keypress/-/keypress-0.2.1.tgz", + "integrity": "sha1-HoBFQlABjbrUw/6USX1uZ7YmnHc=" + }, + "keyv": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.3.tgz", + "integrity": "sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==", + "requires": { + "json-buffer": "3.0.1" + } + }, + "latest-version": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz", + "integrity": "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==", + "requires": { + "package-json": "^6.3.0" + } + }, "leven": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", "integrity": "sha1-wuep93IJTe6dNCAq6KzORoeHVYA=" }, + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" + }, + "log-symbols": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", + "requires": { + "chalk": "^2.4.2" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "requires": { + "semver": "^6.0.0" + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" + }, + "mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==" + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -404,6 +1415,14 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "requires": { + "minimist": "^1.2.5" + } + }, "mqtt": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-4.2.1.tgz", @@ -425,6 +1444,13 @@ "split2": "^3.1.0", "ws": "^7.3.1", "xtend": "^4.0.1" + }, + "dependencies": { + "ws": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz", + "integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==" + } } }, "mqtt-packet": { @@ -442,11 +1468,81 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" + }, "next-tick": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=" }, + "node-forge": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.8.5.tgz", + "integrity": "sha512-vFMQIWt+J/7FLNyKouZ9TazT74PRV3wgv9UT4cRjC8BffxFbKXkgIWR42URCPSnHm/QDz6BOlb2Q0U4+VQT67Q==" + }, + "normalize-url": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz", + "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-inspect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz", + "integrity": "sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==" + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + }, + "object.assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.1.tgz", + "integrity": "sha512-VT/cxmx5yaoHSOTSyrCygIDFco+RsibY2NM0a4RdEeY/4KgqezwFtK1yr3U67xYhqJSlASm2pKhLVzPj2lr4bA==", + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.0", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + }, + "dependencies": { + "es-abstract": { + "version": "1.18.0-next.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.1.tgz", + "integrity": "sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==", + "requires": { + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1", + "is-callable": "^1.2.2", + "is-negative-zero": "^2.0.0", + "is-regex": "^1.1.1", + "object-inspect": "^1.8.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.1", + "string.prototype.trimend": "^1.0.1", + "string.prototype.trimstart": "^1.0.1" + } + } + } + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -455,6 +1551,56 @@ "wrappy": "1" } }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "minimist": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", + "integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=" + } + } + }, + "ora": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-4.1.1.tgz", + "integrity": "sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A==", + "requires": { + "chalk": "^3.0.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.2.0", + "is-interactive": "^1.0.0", + "log-symbols": "^3.0.0", + "mute-stream": "0.0.8", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "dependencies": { + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, "ordered-read-streams": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-1.0.1.tgz", @@ -463,11 +1609,38 @@ "readable-stream": "^2.0.1" } }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" + }, + "p-cancelable": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.0.0.tgz", + "integrity": "sha512-wvPXDmbMmu2ksjkB4Z3nZWTSkJEb9lqVdMaCKpZUGJG9TMiNp9XcbG3fn9fPKjem04fJMJnXoyFPk2FmgiaiNg==" + }, + "p-event": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz", + "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==", + "requires": { + "p-timeout": "^3.1.0" + } + }, "p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" }, + "p-queue": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.1.tgz", + "integrity": "sha512-miQiSxLYPYBxGkrldecZC18OTLjdUqnlRebGzPRiVxB8mco7usCmm7hFuxiTvp93K18JnLtE4KMMycjAu/cQQg==", + "requires": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.1.0" + } + }, "p-retry": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.2.0.tgz", @@ -485,6 +1658,146 @@ "p-finally": "^1.0.0" } }, + "package-json": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz", + "integrity": "sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==", + "requires": { + "got": "^9.6.0", + "registry-auth-token": "^4.0.0", + "registry-url": "^5.0.0", + "semver": "^6.2.0" + }, + "dependencies": { + "@sindresorhus/is": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", + "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==" + }, + "@szmarczak/http-timer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", + "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", + "requires": { + "defer-to-connect": "^1.0.1" + } + }, + "cacheable-request": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", + "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", + "requires": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^3.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^4.1.0", + "responselike": "^1.0.2" + }, + "dependencies": { + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "requires": { + "pump": "^3.0.0" + } + }, + "lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" + } + } + }, + "decompress-response": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", + "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", + "requires": { + "mimic-response": "^1.0.0" + } + }, + "defer-to-connect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", + "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==" + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "requires": { + "pump": "^3.0.0" + } + }, + "got": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", + "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", + "requires": { + "@sindresorhus/is": "^0.14.0", + "@szmarczak/http-timer": "^1.1.2", + "cacheable-request": "^6.0.0", + "decompress-response": "^3.3.0", + "duplexer3": "^0.1.4", + "get-stream": "^4.1.0", + "lowercase-keys": "^1.0.1", + "mimic-response": "^1.0.1", + "p-cancelable": "^1.0.0", + "to-readable-stream": "^1.0.0", + "url-parse-lax": "^3.0.0" + } + }, + "json-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", + "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=" + }, + "keyv": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", + "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", + "requires": { + "json-buffer": "3.0.0" + } + }, + "lowercase-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==" + }, + "mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==" + }, + "p-cancelable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", + "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==" + }, + "responselike": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", + "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", + "requires": { + "lowercase-keys": "^1.0.0" + } + }, + "to-readable-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", + "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==" + } + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, "path-dirname": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", @@ -495,11 +1808,29 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, + "prepend-http": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", + "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=" + }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "promise.any": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise.any/-/promise.any-2.0.1.tgz", + "integrity": "sha512-3amHUuhVhkhFVw8mAM33pyt1zBoYK9O9SorjWbE+E3zSTb4AUpJmK5+rt5g6OCtZpgBlT1cTxF/bp/SNeMQSUQ==", + "requires": { + "array.prototype.map": "^1.0.1", + "define-properties": "^1.1.3", + "es-abstract": "^1.17.0-next.1", + "es-aggregate-error": "^1.0.2", + "function-bind": "^1.1.1", + "iterate-value": "^1.0.1" + } + }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -530,6 +1861,35 @@ } } }, + "pupa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.0.1.tgz", + "integrity": "sha512-hEJH0s8PXLY/cdXh66tNEQGndDrIKNqNC5xmrysZy3i5C3oEoLna7YAOad+7u125+zH1HNXUmGEkrhb3c2VriA==", + "requires": { + "escape-goat": "^2.0.0" + } + }, + "qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==" + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + } + }, "readable-stream": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", @@ -542,6 +1902,29 @@ "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + } + } + }, + "registry-auth-token": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.0.tgz", + "integrity": "sha512-P+lWzPrsgfN+UEpDS3U8AQKg/UjZX6mQSJueZj3EK+vNESoqBSpBUD3gmu4sF9lOsjXWjF11dQKUqemf3veq1w==", + "requires": { + "rc": "^1.2.8" + } + }, + "registry-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-5.1.0.tgz", + "integrity": "sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==", + "requires": { + "rc": "^1.2.8" } }, "reinterval": { @@ -554,16 +1937,132 @@ "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=" }, + "responselike": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz", + "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==", + "requires": { + "lowercase-keys": "^2.0.0" + } + }, + "restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, "retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=" }, + "run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==" + }, + "rxjs": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz", + "integrity": "sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ==", + "requires": { + "tslib": "^1.9.0" + } + }, "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "semaphore": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/semaphore/-/semaphore-1.1.0.tgz", + "integrity": "sha512-O4OZEaNtkMd/K0i6js9SL+gqy0ZCBMgUvlSqHKi4IBdjhe7wB8pwztUk1BbZ1fmrvpwFrPbHzqd2w5pTcJH6LA==" + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + }, + "semver-diff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", + "integrity": "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==", + "requires": { + "semver": "^6.3.0" + } + }, + "send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "serve-static": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.1" + } + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" + }, "split2": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", @@ -584,11 +2083,44 @@ } } }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, "stream-shift": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" }, + "string-width": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", + "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "string.prototype.trimend": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz", + "integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==", + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "string.prototype.trimstart": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz", + "integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==", + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -597,6 +2129,37 @@ "safe-buffer": "~5.1.0" } }, + "strip-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", + "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==", + "requires": { + "ansi-regex": "^5.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + }, + "term-size": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.0.tgz", + "integrity": "sha512-a6sumDlzyHVJWb8+YofY4TW112G6p2FCPEAFk+59gIYHv3XHRhm9ltVQ9kli4hNWeQBwSpe8cRN25x0ROunMOw==" + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + }, "through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", @@ -615,6 +2178,14 @@ "xtend": "~4.0.0" } }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "requires": { + "os-tmpdir": "~1.0.2" + } + }, "to-absolute-glob": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", @@ -624,14 +2195,39 @@ "is-negated-glob": "^1.0.0" } }, + "to-readable-stream": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-2.1.0.tgz", + "integrity": "sha512-o3Qa6DGg1CEXshSdvWNX2sN4QHqg03SPq7U6jPXRahlQdl5dK8oXjkU/2/sGrnOZKeGV1zLSO8qPwyKklPPE7w==" + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, + "tslib": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.0.tgz", + "integrity": "sha512-+Zw5lu0D9tvBMjGP8LpvMb0u2WW2QV3y+D8mO6J+cNzCYIN4sVy43Bf9vl92nqFahutN0I8zHa7cc4vihIshnw==" + }, "tuyapi": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/tuyapi/-/tuyapi-5.3.2.tgz", - "integrity": "sha512-RKdTTnWVK+DDq3iRUTMh5DVd8coIwoulHntB+HvcDLuakDgSoNUc0Pzd69mw0CTTP7HTC6x6S9Ztg5pJIlYE8g==", + "version": "github:tsightler/tuyapi#5e99e36a41be43768451ff844375abfb6061ad5e", + "from": "github:tsightler/tuyapi#bugfix-null-get", "requires": { "debug": "4.1.1", + "p-queue": "6.6.1", "p-retry": "4.2.0", "p-timeout": "3.2.0" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + } } }, "type": { @@ -639,11 +2235,29 @@ "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" }, + "type-fest": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.10.0.tgz", + "integrity": "sha512-EUV9jo4sffrwlg8s0zDhP0T2WD3pru5Xi0+HTE3zTUmBaZNhfkite9PdSJwdXLwPVW0jnAHT56pZHIOYckPEiw==" + }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "requires": { + "is-typedarray": "^1.0.0" + } + }, + "ultron": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz", + "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==" + }, "unc-path-regex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", @@ -658,20 +2272,114 @@ "through2-filter": "^3.0.0" } }, + "unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "requires": { + "crypto-random-string": "^2.0.0" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "update-notifier": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-4.1.3.tgz", + "integrity": "sha512-Yld6Z0RyCYGB6ckIjffGOSOmHXj1gMeE7aROz4MG+XMkmixBX4jUngrGXNYz7wPKBmtoD4MnBa2Anu7RSKht/A==", + "requires": { + "boxen": "^4.2.0", + "chalk": "^3.0.0", + "configstore": "^5.0.1", + "has-yarn": "^2.1.0", + "import-lazy": "^2.1.0", + "is-ci": "^2.0.0", + "is-installed-globally": "^0.3.1", + "is-npm": "^4.0.0", + "is-yarn-global": "^0.3.0", + "latest-version": "^5.0.0", + "pupa": "^2.0.1", + "semver-diff": "^3.1.1", + "xdg-basedir": "^4.0.0" + }, + "dependencies": { + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "url-parse-lax": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", + "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", + "requires": { + "prepend-http": "^2.0.0" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "requires": { + "defaults": "^1.0.3" + } + }, + "widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "requires": { + "string-width": "^4.0.0" + } + }, + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=" + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, "ws": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz", - "integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==" + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-3.3.3.tgz", + "integrity": "sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==", + "requires": { + "async-limiter": "~1.0.0", + "safe-buffer": "~5.1.0", + "ultron": "~1.1.0" + } + }, + "xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==" }, "xtend": { "version": "4.0.2", diff --git a/package.json b/package.json index de9f1d3..984be8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tuya-mqtt", - "version": "3.0.0-beta2", + "version": "3.0.0-beta3", "description": "Control Tuya devices locally via MQTT", "homepage": "https://github.com/TheAgentK/tuya-mqtt#readme", "main": "tuya-mqtt.js", @@ -13,11 +13,13 @@ }, "license": "ISC", "dependencies": { + "@tuyapi/cli": "^1.13.4", "color-convert": "^2.0.1", "debug": "^4.1.1", + "json5": "^2.1.3", "mqtt": "^4.2.1", - "tuyapi": "github:tsightler/tuyAPI", - "json5": "^2.1.3" + "supports-color": "^7.2.0", + "tuyapi": "github:tsightler/tuyapi#bugfix-null-get" }, "repository": { "type": "git", From 60b50c760e70bb7693c2e5c0d9dbef63af536ce6 Mon Sep 17 00:00:00 2001 From: tsightler Date: Mon, 12 Oct 2020 16:14:22 -0400 Subject: [PATCH 19/34] 3.0.0-beta4 * Default to generic device * Improved debugging granularity/increased categories * Add heartbeat monitoring for availability * Catch more failure cases with retry (still some missing I'd guess) * Switch to MathJS evaluate for simple math transforms * RGBTW: Switch base scale for all friendly topics to 100 (automatic conversion on backend) * RGBTW: Add color temperature support * RGBTW: Improve autodetection * RGBTW: Improved white/color mode handling (still work to do here) --- devices/generic-device.js | 2 +- devices/rgbtw-light.js | 82 +++++++++++++------- devices/simple-dimmer.js | 7 +- devices/simple-switch.js | 7 +- devices/tuya-device.js | 159 ++++++++++++++++++++++++++------------ lib/utils.js | 4 + package-lock.json | 68 ++++++++++++++-- package.json | 5 +- tuya-mqtt.js | 22 ++---- 9 files changed, 246 insertions(+), 110 deletions(-) diff --git a/devices/generic-device.js b/devices/generic-device.js index b51722a..a4f974e 100644 --- a/devices/generic-device.js +++ b/devices/generic-device.js @@ -1,5 +1,5 @@ const TuyaDevice = require('./tuya-device') -const debug = require('debug')('tuya-mqtt:tuya') +const debug = require('debug')('tuya-mqtt:device') const utils = require('../lib/utils') class GenericDevice extends TuyaDevice { diff --git a/devices/rgbtw-light.js b/devices/rgbtw-light.js index a863454..4cae050 100644 --- a/devices/rgbtw-light.js +++ b/devices/rgbtw-light.js @@ -1,5 +1,6 @@ const TuyaDevice = require('./tuya-device') -const debug = require('debug')('tuya-mqtt:tuya') +const debug = require('debug')('tuya-mqtt:device-detect') +const debugDiscovery = require('debug')('tuya-mqtt:discovery') const utils = require('../lib/utils') class RGBTWLight extends TuyaDevice { @@ -12,9 +13,11 @@ class RGBTWLight extends TuyaDevice { this.config.dpsWhiteValue = this.config.dpsWhiteValue ? this.config.dpsWhiteValue : this.guess.dpsWhiteValue this.config.whiteValueScale = this.config.whiteValueScale ? this.config.whiteValueScale : this.guess.whiteValueScale this.config.dpsColorTemp = this.config.dpsColorTemp ? this.config.dpsColorTemp : this.guess.dpsColorTemp + this.config.minColorTemp = this.config.minColorTemp ? this.config.minColorTemp : 165 + this.config.maxColorTemp = this.config.maxColorTemp ? this.config.maxColorTemp : 375 + this.config.colorTempScale = this.config.colorTempScale ? this.config.colorTempScale : this.guess.colorTempScale this.config.dpsColor = this.config.dpsColor ? this.config.dpsColor : this.guess.dpsColor this.config.colorType = this.config.colorType ? this.config.colorType : this.guess.colorType - this.config.colorType = 'hsb' this.deviceData.mdl = 'RGBTW Light' @@ -32,8 +35,8 @@ class RGBTWLight extends TuyaDevice { min: 1, max: 100, scale: this.config.whiteValueScale, - stateMath: (this.config.whiteValueScale == 1000) ? '/10' : '/2.55', - commandMath: (this.config.whiteValueScale == 1000) ? '*10' : '*2.55' + stateMath: '/('+this.config.whiteValueScale+'/100)', + commandMath: '*('+this.config.whiteValueScale+'/100)' }, hs_state: { key: this.config.dpsColor, @@ -56,6 +59,23 @@ class RGBTWLight extends TuyaDevice { } } + // If device supports Color Temperature add color temp device topic + if (this.config.dpsColorTemp) { + // Values used for tranform + const rangeFactor = (this.config.maxColorTemp-this.config.minColorTemp)/100 + const scaleFactor = this.config.colorTempScale/100 + const tuyaMaxColorTemp = this.config.maxColorTemp/rangeFactor*scaleFactor + + this.deviceTopics.color_temp_state = { + key: this.config.dpsColorTemp, + type: 'int', + min: this.config.minColorTemp, + max: this.config.maxColorTemp, + stateMath: '/'+scaleFactor+'*-'+rangeFactor+'+'+this.config.maxColorTemp, + commandMath: '/'+rangeFactor+'*-'+scaleFactor+'+'+tuyaMaxColorTemp + } + } + // Send home assistant discovery data and give it a second before sending state updates this.initDiscovery() await utils.sleep(1) @@ -83,44 +103,46 @@ class RGBTWLight extends TuyaDevice { device: this.deviceData } - debug('Home Assistant config topic: '+configTopic) - debug(discoveryData) + if (this.config.dpsColorTemp) { + discoveryData.color_temp_state_topic = this.baseTopic+'color_temp_state' + discoveryData.color_temp_command_topic = this.baseTopic+'color_temp_command' + discoveryData.min_mireds = this.config.minColorTemp + discoveryData.max_mireds = this.config.maxColorTemp + } + + debugDiscovery('Home Assistant config topic: '+configTopic) + debugDiscovery(discoveryData) this.publishMqtt(configTopic, JSON.stringify(discoveryData)) } async guessLightInfo() { this.guess = new Object() + debug('Attempting to detect light capabilites and DPS values...') + debug('Querying DPS 2 for white/color mode setting...') let mode = await this.device.get({"dps": 2}) - if (mode && (mode === 'white' || mode === 'colour')) { - this.guess.dpsPower = 1 - this.guess.dpsMode = 2 - this.guess.dpsWhiteValue = 3 - this.guess.whiteValueScale = 255 - const colorTemp = await this.device.get({"dps": 4}) - if (colorTemp) { - this.guess.dpsColorTemp = 4 - } else { - this.guess.dpsColorTemp = 0 - } - this.guess.dpsColor = 5 - const color = await this.device.get({"dps": this.guess.dpsColor}) - this.guess.colorType = (color && color.length === 14) ? 'hsbhex' : 'hsb' + if (mode && (mode === 'white' || mode === 'colour' || mode.toString().includes('scene'))) { + debug('Detected probably Tuya color bulb at DPS 1-5, checking more details...') + this.guess = {'dpsPower': 1, 'dpsMode': 2, 'dpsWhiteValue': 3, 'whiteValueScale': 255, 'dpsColorTemp': 4, 'colorTempScale': 255, 'dpsColor': 5} } else { - mode = await this.device.get({"dps": 20}) - this.guess.dpsPower = 20 - this.guess.dpsMode = 21 - this.guess.dpsWhiteValue = 22 - this.guess.whiteValueScale = 1000 - const colorTemp = await this.device.get({"dps": 23}) - if (colorTemp) { - this.guess.dpsColorTemp = 23 + debug('Detected likely Tuya color bulb at DPS 20-24, checking more details...') + this.guess = {'dpsPower': 20, 'dpsMode': 21, 'dpsWhiteValue': 22, 'whiteValueScale': 1000, 'dpsColorTemp': 23, 'colorTempScale': 1000, 'dpsColor': 24} + } + if (this.guess.dpsPower) { + debug('Attempting to detect if bulb supports color temperature...') + const colorTemp = await this.device.get({"dps": this.guess.dpsColorTemp}) + if (colorTemp !== '' && colorTemp >= 0 && colorTemp <= this.guess.colorTempScale) { + debug('Detected likely color temerature support') } else { + debug('No color temperature support detected') this.guess.dpsColorTemp = 0 } - this.guess.dpsColor = 24 + debug('Attempting to detect Tuya color format used by device...') const color = await this.device.get({"dps": this.guess.dpsColor}) this.guess.colorType = (color && color.length === 12) ? 'hsb' : 'hsbhex' - } + debug ('Detected Tuya color format '+this.guess.colorType.toUpperCase()) + } else { + debug('No Tuya color bulb detected, if this device is definitely a Tuya bulb please manually specify settings.') + } } } diff --git a/devices/simple-dimmer.js b/devices/simple-dimmer.js index d4d8448..23e2c3e 100644 --- a/devices/simple-dimmer.js +++ b/devices/simple-dimmer.js @@ -1,5 +1,6 @@ const TuyaDevice = require('./tuya-device') -const debug = require('debug')('tuya-mqtt:tuya') +const debug = require('debug')('tuya-mqtt:device') +const debugDiscovery = require('debug')('tuya-mqtt:discovery') const utils = require('../lib/utils') class SimpleDimmer extends TuyaDevice { @@ -47,8 +48,8 @@ class SimpleDimmer extends TuyaDevice { device: this.deviceData } - debug('Home Assistant config topic: '+configTopic) - debug(discoveryData) + debugDiscovery('Home Assistant config topic: '+configTopic) + debugDiscovery(discoveryData) this.publishMqtt(configTopic, JSON.stringify(discoveryData)) } } diff --git a/devices/simple-switch.js b/devices/simple-switch.js index f6c92d4..e0f57c7 100644 --- a/devices/simple-switch.js +++ b/devices/simple-switch.js @@ -1,5 +1,6 @@ const TuyaDevice = require('./tuya-device') -const debug = require('debug')('tuya-mqtt:tuya') +const debug = require('debug')('tuya-mqtt:device') +const debugDiscovery = require('debug')('tuya-mqtt:discovery') const utils = require('../lib/utils') class SimpleSwitch extends TuyaDevice { @@ -36,8 +37,8 @@ class SimpleSwitch extends TuyaDevice { device: this.deviceData } - debug('Home Assistant config topic: '+configTopic) - debug(discoveryData) + debugDiscovery('Home Assistant config topic: '+configTopic) + debugDiscovery(discoveryData) this.publishMqtt(configTopic, JSON.stringify(discoveryData)) } } diff --git a/devices/tuya-device.js b/devices/tuya-device.js index bc7f94d..2db749f 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -1,7 +1,10 @@ const TuyAPI = require('tuyapi') +const { evaluate } = require('mathjs') const utils = require('../lib/utils') -const debug = require('debug')('tuya-mqtt:tuya') -const debugMqtt = require('debug')('tuya-mqtt:mqtt') +const { msSleep } = require('../lib/utils') +const debug = require('debug')('tuya-mqtt:tuyapi') +const debugState = require('debug')('tuya-mqtt:state') +const debugCommand = require('debug')('tuya-mqtt:command') const debugError = require('debug')('tuya-mqtt:error') class TuyaDevice { @@ -39,15 +42,15 @@ class TuyaDevice { "color": {'h': 0, 's': 0, 'b': 0} } - // Property to hold friendly topics template - this.deviceTopics = {} + this.deviceTopics = {} // Property to hold friendly topics template + this.heartbeatsMissed = 0 // Used to monitor heartbeat status to detect offline device // Build the MQTT topic for this device (friendly name or device id) if (this.options.name) { this.baseTopic = this.topic + this.options.name + '/' } else { this.baseTopic = this.topic + this.options.id + '/' - } + } // Create the new Tuya Device this.device = new TuyAPI(JSON.parse(JSON.stringify(this.options))) @@ -55,7 +58,7 @@ class TuyaDevice { // Listen for device data and call update DPS function if valid this.device.on('data', (data) => { if (typeof data === 'object') { - debug('Received JSON data from device '+this.options.id+' ->', data.dps) + debug('Received JSON data from device '+this.options.id+' ->', JSON.stringify(data.dps)) this.updateState(data) } else { if (data !== 'json obj data unvalid') { @@ -64,36 +67,46 @@ class TuyaDevice { } }) - // Find device on network - debug('Search for device id '+this.options.id) - this.device.find().then(() => { - debug('Found device id '+this.options.id) - // Attempt connection to device - this.device.connect() - }) + // Attempt to find/connect to device and start heartbeat monitor + this.connectDevice() + this.monitorHeartbeat() // On connect perform device specific init - this.device.on('connected', () => { - debug('Connected to device ' + this.toString()) - this.init() + this.device.on('connected', async () => { + // Sometimes TuyAPI reports connection even on socket error + // Wait one second to check if device is really connected before initializing + await utils.sleep(1) + if (this.device.isConnected()) { + debug('Connected to device ' + this.toString()) + this.heartbeatsMissed = 0 + this.publishMqtt(this.baseTopic+'status', 'online') + this.init() + } }) // On disconnect perform device specific disconnect this.device.on('disconnected', () => { this.connected = false + this.publishMqtt(this.baseTopic+'status', 'offline') debug('Disconnected from device ' + this.toString()) }) // On connect error call reconnect - this.device.on('error', (err) => { + this.device.on('error', async (err) => { debugError(err) - if (err.message === 'Error from socket') { + await utils.sleep(1) + if (!this.device.isConnected()) { this.reconnect() } }) + + // On heartbeat reset heartbeat timer + this.device.on('heartbeat', () => { + this.heartbeatsMissed = 0 + }) } - // Update dps properties with device data updates + // Update cached DPS states on data updates updateState(data) { if (typeof data.dps != 'undefined') { // Update cached device state data @@ -148,7 +161,7 @@ class TuyaDevice { } data = JSON.stringify(data) const dpsStateTopic = dpsTopic + '/state' - debugMqtt('MQTT DPS JSON: ' + dpsStateTopic + ' -> ', data) + debugState('MQTT DPS JSON: ' + dpsStateTopic + ' -> ', data) this.publishMqtt(dpsStateTopic, data, false) // Publish dps/<#>/state value for each device DPS @@ -156,7 +169,7 @@ class TuyaDevice { if (this.state.dps[key].updated) { const dpsKeyTopic = dpsTopic + '/' + key + '/state' const data = this.state.dps.hasOwnProperty(key) ? this.state.dps[key].val.toString() : 'None' - debugMqtt('MQTT DPS'+key+': '+dpsKeyTopic+' -> ', data) + debugState('MQTT DPS'+key+': '+dpsKeyTopic+' -> ', data) this.publishMqtt(dpsKeyTopic, data, false) this.state.dps[key].updated = false } @@ -193,7 +206,7 @@ class TuyaDevice { return state } - // Parse the received state value based on deviceTopic config + // Parse the received state numeric value based on deviceTopic rules parseStateNumber(value, deviceTopic) { // Check if it's a number and it's not outside of defined range if (isNaN(value)) { @@ -204,14 +217,14 @@ class TuyaDevice { switch (deviceTopic.type) { case 'int': if (deviceTopic.stateMath) { - value = parseInt(Math.round(eval(value+deviceTopic.stateMath))) + value = parseInt(Math.round(evaluate(value+deviceTopic.stateMath))) } else { value = parseInt(value) } break; case 'float': if (deviceTopic.stateMath) { - value = parseFloat(eval(value+deviceTopic.stateMath)) + value = parseFloat(evaluate(value+deviceTopic.stateMath)) } else { value = parseFloat(value) } @@ -222,12 +235,12 @@ class TuyaDevice { } // Process MQTT commands for all command topics at device level - async processCommand(message, commandTopic) { + processCommand(message, commandTopic) { const command = this.getCommandFromMessage(message) if (commandTopic === 'command' && command === 'get-states') { // Handle "get-states" command to update device state - debug('Received command: ', command) - await this.getStates() + debugCommand('Received command: ', command) + this.getStates() } else { // Call device specific command topic handler this.processDeviceCommand(message, commandTopic) @@ -241,14 +254,14 @@ class TuyaDevice { const deviceTopic = this.deviceTopics.hasOwnProperty(stateTopic) ? this.deviceTopics[stateTopic] : '' if (deviceTopic) { - debug('Device '+this.options.id+' received command topic: '+commandTopic+', message: '+message) + debugCommand('Device '+this.options.id+' received command topic: '+commandTopic+', message: '+message) const command = this.getCommandFromMessage(message) let commandResult = this.sendTuyaCommand(command, deviceTopic) if (!commandResult) { - debug('Command topic '+this.baseTopic+commandTopic+' received invalid value: '+command) + debugCommand('Command topic '+this.baseTopic+commandTopic+' received invalid value: '+command) } } else { - debug('Invalid command topic '+this.baseTopic+commandTopic+' for device: '+this.config.name) + debugCommand('Invalid command topic '+this.baseTopic+commandTopic+' for device: '+this.config.name) return } } @@ -258,7 +271,7 @@ class TuyaDevice { let command if (message != '1' && message != '0' && utils.isJsonString(message)) { - debugMqtt('MQTT message is JSON') + debugCommand('MQTT message is JSON') command = JSON.parse(message); } else { switch(message.toLowerCase()) { @@ -284,20 +297,20 @@ class TuyaDevice { processDpsCommand(message) { if (utils.isJsonString(message)) { const tuyaCommand = this.getCommandFromMessage(message) - debugMqtt('Received command: '+tuyaCommand) + debugCommand('Received command: '+tuyaCommand) this.set(tuyaCommand) } else { - debugError('DPS command topic requires Tuya style JSON value') + debugCommand('DPS command topic requires Tuya style JSON value') } } // Process text base Tuya command via DPS key command topics processDpsKeyCommand(message, dpsKey) { if (utils.isJsonString(message)) { - debugError('Individual DPS command topics do not accept JSON values') + debugCommand('Individual DPS command topics do not accept JSON values') } else { const dpsMessage = this.parseDpsMessage(message) - debugMqtt('Received command for DPS'+dpsKey+': ', message) + debugCommand('Received command for DPS'+dpsKey+': ', message) const tuyaCommand = { dps: dpsKey, set: dpsMessage @@ -325,7 +338,13 @@ class TuyaDevice { this.connected = false for (let topic in this.deviceTopics) { const key = this.deviceTopics[topic].key - const result = await this.device.get({"dps": key}) + try { + const result = await this.device.get({"dps": key}) + this.state.dps[key].val = result + this.state.dps[key].updated = true + } catch { + debugError('Could not get value for device DPS key '+key) + } } this.connected = true // Force topic update now that all states are fully in sync @@ -390,14 +409,14 @@ class TuyaDevice { switch (deviceTopic.type) { case 'int': if (deviceTopic.commandMath) { - value = parseInt(Math.round(eval(command+deviceTopic.commandMath))) + value = parseInt(Math.round(evaluate(command+deviceTopic.commandMath))) } else { value = parseInt(command) } break; case 'float': if (deviceTopic.commandMath) { - value = parseFloat(eval(command+deviceTopic.commandMath)) + value = parseFloat(evaluate(command+deviceTopic.commandMath)) } else { value = parseFloat(command) } @@ -421,7 +440,7 @@ class TuyaDevice { // Convert from Hex to Decimal and cache values this.state.color.h = parseInt(h, 16) this.state.color.s = Math.round(parseInt(s, 16) / 10) // Convert saturation to 100 Scale - this.state.color.b = Math.round(parseInt(b, 16) / 10) // Convert brightness to 1000 scale + this.state.color.b = Math.round(parseInt(b, 16) / 10) // Convert brightness to 100 scale } // Initialize the set color values for first time. Used to conflicts @@ -491,12 +510,16 @@ class TuyaDevice { // Set white/colour mode based on async setLight(topic, command) { - const currentMode = this.state.dps[this.config.dpsMode].val + let targetMode = undefined + const currentMode = this.config.dpsMode.val + if (topic.key === this.config.dpsWhiteValue || topic.key === this.config.dpsColorTemp) { // If setting white level or color temperature, light should be in white mode targetMode = 'white' } else if (topic.key === this.config.dpsColor) { + // Short sleep for cases where mulitple updates occur quickly + await msSleep(100) if (this.state.setColor.s < 10) { // If saturation is < 10 then white mode targetMode = 'white' @@ -505,17 +528,18 @@ class TuyaDevice { targetMode = 'colour' } } - // If mode change required, add it to the set command - if (targetMode && currentMode !== targetMode) { + + // Set the proper value + this.set(command) + + // Put the bulb in the correct mode + if (targetMode) { command = { - multiple: true, - data: { - [command.dps]: command.set, - [this.config.dpsMode]: targetMode - } + dps: this.config.dpsMode, + set: targetMode } + this.set(command) } - this.set(command) } // Simple function to help debug output @@ -532,9 +556,27 @@ class TuyaDevice { }) } + connectDevice() { + // Find device on network + debug('Search for device id '+this.options.id) + this.device.find().then(() => { + debug('Found device id '+this.options.id) + // Attempt connection to device + this.device.connect().catch((error) => { + debugError(error.message) + this.reconnect() + }) + }).catch(async (error) => { + debugError(error.message) + debugError('Will attempt to find device again in 60 seconds') + await utils.sleep(60) + this.connectDevice() + }) + } + // Retry connection every 10 seconds if unable to connect async reconnect() { - debug('Error connecting to device id '+this.options.id+'...retry in 10 seconds.') + debugError('Error connecting to device id '+this.options.id+'...retry in 10 seconds.') await utils.sleep(10) if (this.connected) { return } debug('Search for device id '+this.options.id) @@ -545,10 +587,27 @@ class TuyaDevice { }) } + // Simple function to monitor heartbeats to determine if + monitorHeartbeat() { + setInterval(async () => { + if (this.connected) { + if (this.heartbeatsMissed > 3) { + debugError('Device id '+this.options.id+' not responding to heartbeats...disconnecting') + this.device.disconnect() + await utils.sleep(1) + this.connectDevice() + } else if (this.heartbeatsMissed > 0) { + const errMessage = this.heartbeatsMissed > 1 ? " consecutive heartbeats" : " heartbeat" + debugError('Device id '+this.options.id+' has missed '+this.heartbeatsMissed+errMessage) + } + this.heartbeatsMissed++ + } + }, 10000) + } // Publish MQTT publishMqtt(topic, message, isDebug) { - if (isDebug) { debugMqtt(topic, message) } + if (isDebug) { debugState(topic, message) } this.mqttClient.publish(topic, message, { qos: 1 }); } } diff --git a/lib/utils.js b/lib/utils.js index 0943f9f..e80e21b 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -19,6 +19,10 @@ class Utils return new Promise(res => setTimeout(res, sec*1000)) } + msSleep(ms) { + return new Promise(res => setTimeout(res, ms)) + } + } module.exports = new Utils() \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 656fecb..783b057 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "tuya-mqtt", - "version": "3.0.0-beta3", + "version": "3.0.0-beta4", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -148,9 +148,9 @@ } }, "@types/node": { - "version": "14.11.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.11.5.tgz", - "integrity": "sha512-jVFzDV6NTbrLMxm4xDSIW/gKnk8rQLF9wAzLWIOg+5nU6ACrIMndeBdXci0FGtqJbP9tQvm6V39eshc96TO2wQ==" + "version": "14.11.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.11.8.tgz", + "integrity": "sha512-KPcKqKm5UKDkaYPTuXSx8wEP7vE9GnuaXIZKijwRYcePpZFDVuy2a57LarFKiORbHOuTOOwYzxVxcUzsh2P2Pw==" }, "@types/responselike": { "version": "1.0.0", @@ -487,6 +487,11 @@ "minimist": "^1.1.0" } }, + "complex.js": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.0.11.tgz", + "integrity": "sha512-6IArJLApNtdg1P1dFtn3dnyzoZBEF0MwMnrfF1exSBRpZYoy4yieMkpZhQDC0uwctw48vii0CFVyHfpgZ/DfGw==" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -543,6 +548,11 @@ "ms": "2.1.2" } }, + "decimal.js": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.2.1.tgz", + "integrity": "sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw==" + }, "decompress-response": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-5.0.0.tgz", @@ -773,6 +783,11 @@ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" }, + "escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==" + }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -864,6 +879,11 @@ } } }, + "fraction.js": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.0.12.tgz", + "integrity": "sha512-8Z1K0VTG4hzYY7kA/1sj4/r1/RWLBD3xwReT/RCrUCbzPszjNQCCsy3ktkU/eaEqX3MYa4pY37a52eiBlPMlhA==" + }, "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -1271,6 +1291,11 @@ "iterate-iterator": "^1.0.1" } }, + "javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha1-+eIwPUUH9tdDVac2ZNFED7Wg71k=" + }, "json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -1387,6 +1412,21 @@ "semver": "^6.0.0" } }, + "mathjs": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-7.5.1.tgz", + "integrity": "sha512-H2q/Dq0qxBLMw+G84SSXmGqo/znihuxviGgAQwAcyeFLwK2HksvSGNx4f3dllZF51bWOnu2op60VZxH2Sb51Pw==", + "requires": { + "complex.js": "^2.0.11", + "decimal.js": "^10.2.1", + "escape-latex": "^1.2.0", + "fraction.js": "^4.0.12", + "javascript-natural-sort": "^0.7.1", + "seed-random": "^2.2.0", + "tiny-emitter": "^2.1.0", + "typed-function": "^2.0.0" + } + }, "mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -1982,6 +2022,11 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "seed-random": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/seed-random/-/seed-random-2.2.0.tgz", + "integrity": "sha1-KpsZ4lCoFwmSMaW5mk2vgLf77VQ=" + }, "semaphore": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/semaphore/-/semaphore-1.1.0.tgz", @@ -2178,6 +2223,11 @@ "xtend": "~4.0.0" } }, + "tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -2211,8 +2261,9 @@ "integrity": "sha512-+Zw5lu0D9tvBMjGP8LpvMb0u2WW2QV3y+D8mO6J+cNzCYIN4sVy43Bf9vl92nqFahutN0I8zHa7cc4vihIshnw==" }, "tuyapi": { - "version": "github:tsightler/tuyapi#5e99e36a41be43768451ff844375abfb6061ad5e", - "from": "github:tsightler/tuyapi#bugfix-null-get", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tuyapi/-/tuyapi-6.0.1.tgz", + "integrity": "sha512-2Qg0/avg3gtsDLRIktssFtxUA/WT6fvB+GCmQwb1uRQq6KoTsS9he2vDGzmExwaek8Fz3vgAACH7WNIRemCgDw==", "requires": { "debug": "4.1.1", "p-queue": "6.6.1", @@ -2240,6 +2291,11 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.10.0.tgz", "integrity": "sha512-EUV9jo4sffrwlg8s0zDhP0T2WD3pru5Xi0+HTE3zTUmBaZNhfkite9PdSJwdXLwPVW0jnAHT56pZHIOYckPEiw==" }, + "typed-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-2.0.0.tgz", + "integrity": "sha512-Hhy1Iwo/e4AtLZNK10ewVVcP2UEs408DS35ubP825w/YgSBK1KVLwALvvIG4yX75QJrxjCpcWkzkVRB0BwwYlA==" + }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", diff --git a/package.json b/package.json index 984be8a..a80dd3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tuya-mqtt", - "version": "3.0.0-beta3", + "version": "3.0.0-beta4", "description": "Control Tuya devices locally via MQTT", "homepage": "https://github.com/TheAgentK/tuya-mqtt#readme", "main": "tuya-mqtt.js", @@ -19,7 +19,8 @@ "json5": "^2.1.3", "mqtt": "^4.2.1", "supports-color": "^7.2.0", - "tuyapi": "github:tsightler/tuyapi#bugfix-null-get" + "tuyapi": "^6.0.1", + "mathjs": "7.5.1" }, "repository": { "type": "git", diff --git a/tuya-mqtt.js b/tuya-mqtt.js index bdd0e08..ecf30bf 100644 --- a/tuya-mqtt.js +++ b/tuya-mqtt.js @@ -2,7 +2,8 @@ const fs = require('fs') const mqtt = require('mqtt') const json5 = require('json5') -const debug = require('debug')('tuya-mqtt:mqtt') +const debug = require('debug')('tuya-mqtt:info') +const debugCommand = require('debug')('tuya-mqtt:command') const debugError = require('debug')('tuya-mqtt:error') const SimpleSwitch = require('./devices/simple-switch') const SimpleDimmer = require('./devices/simple-dimmer') @@ -28,23 +29,14 @@ function getDevice(configDevice, mqttClient) { case 'RGBTWLight': return new RGBTWLight(deviceInfo) break; - case 'GenericDevice': - return new GenericDevice(deviceInfo) - break; } - return null + return new GenericDevice(deviceInfo) } function initDevices(configDevices, mqttClient) { for (let configDevice of configDevices) { - if (!configDevice.type) { - debug('Device type not specified, skipping creation of this device') - } else { - const newDevice = getDevice(configDevice, mqttClient) - if (newDevice) { - tuyaDevices.push(newDevice) - } - } + const newDevice = getDevice(configDevice, mqttClient) + tuyaDevices.push(newDevice) } } @@ -62,7 +54,7 @@ const main = async() => { } if (typeof CONFIG.qos == 'undefined') { - CONFIG.qos = 2 + CONFIG.qos = 1 } if (typeof CONFIG.retain == 'undefined') { CONFIG.retain = false @@ -121,7 +113,7 @@ const main = async() => { // If it looks like a valid command topic try to process it if (commandTopic.includes('command')) { - debug('Received MQTT message -> ', JSON.stringify({ + debugCommand('Received MQTT message -> ', JSON.stringify({ topic: topic, message: message })) From 8f129617b0aca64d9403eaef7cd14c6d1cf96a92 Mon Sep 17 00:00:00 2001 From: tsightler Date: Tue, 13 Oct 2020 00:39:56 -0400 Subject: [PATCH 20/34] Color Tweaks/Cleanups Minor tweaks and misc cleanups preparing for 3.0.0 release --- devices/rgbtw-light.js | 39 ++++++---- devices/tuya-device.js | 165 ++++++++++++++++++++--------------------- 2 files changed, 104 insertions(+), 100 deletions(-) diff --git a/devices/rgbtw-light.js b/devices/rgbtw-light.js index 4cae050..1d13620 100644 --- a/devices/rgbtw-light.js +++ b/devices/rgbtw-light.js @@ -7,14 +7,19 @@ class RGBTWLight extends TuyaDevice { async init() { await this.guessLightInfo() + if (!this.guess.dpsPower && !this.config.dpsPower) { + debug('Automatic discovery of Tuya bulb settings failed and no manual configuration') + return + } + // Set device specific variables this.config.dpsPower = this.config.dpsPower ? this.config.dpsPower : this.guess.dpsPower this.config.dpsMode = this.config.dpsMode ? this.config.dpsMode : this.guess.dpsMode this.config.dpsWhiteValue = this.config.dpsWhiteValue ? this.config.dpsWhiteValue : this.guess.dpsWhiteValue this.config.whiteValueScale = this.config.whiteValueScale ? this.config.whiteValueScale : this.guess.whiteValueScale this.config.dpsColorTemp = this.config.dpsColorTemp ? this.config.dpsColorTemp : this.guess.dpsColorTemp - this.config.minColorTemp = this.config.minColorTemp ? this.config.minColorTemp : 165 - this.config.maxColorTemp = this.config.maxColorTemp ? this.config.maxColorTemp : 375 + this.config.minColorTemp = this.config.minColorTemp ? this.config.minColorTemp : 160 + this.config.maxColorTemp = this.config.maxColorTemp ? this.config.maxColorTemp : 385 this.config.colorTempScale = this.config.colorTempScale ? this.config.colorTempScale : this.guess.colorTempScale this.config.dpsColor = this.config.dpsColor ? this.config.dpsColor : this.guess.dpsColor this.config.colorType = this.config.colorType ? this.config.colorType : this.guess.colorType @@ -29,7 +34,7 @@ class RGBTWLight extends TuyaDevice { key: this.config.dpsPower, type: 'bool' }, - white_value_state: { + white_brightness_state: { key: this.config.dpsWhiteValue, type: 'int', min: 1, @@ -43,7 +48,7 @@ class RGBTWLight extends TuyaDevice { type: this.config.colorType, components: 'h,s' }, - brightness_state: { + color_brightness_state: { key: this.config.dpsColor, type: this.config.colorType, components: 'b' @@ -91,13 +96,13 @@ class RGBTWLight extends TuyaDevice { name: (this.config.name) ? this.config.name : this.config.id, state_topic: this.baseTopic+'state', command_topic: this.baseTopic+'command', - brightness_state_topic: this.baseTopic+'brightness_state', - brightness_command_topic: this.baseTopic+'brightness_command', + brightness_state_topic: this.baseTopic+'color_brightness_state', + brightness_command_topic: this.baseTopic+'color_brightness_command', brightness_scale: 100, hs_state_topic: this.baseTopic+'hs_state', hs_command_topic: this.baseTopic+'hs_command', - white_value_state_topic: this.baseTopic+'white_value_state', - white_value_command_topic: this.baseTopic+'white_value_command', + white_value_state_topic: this.baseTopic+'white_brightness_state', + white_value_command_topic: this.baseTopic+'white_brightness_command', white_value_scale: 100, unique_id: this.config.id, device: this.deviceData @@ -119,14 +124,18 @@ class RGBTWLight extends TuyaDevice { this.guess = new Object() debug('Attempting to detect light capabilites and DPS values...') debug('Querying DPS 2 for white/color mode setting...') - let mode = await this.device.get({"dps": 2}) - if (mode && (mode === 'white' || mode === 'colour' || mode.toString().includes('scene'))) { + + // Check if DPS 2 contains typical values for RGBTW light + const mode2 = await this.device.get({"dps": 2}) + const mode21 = await this.device.get({"dps": 21}) + if (mode2 && (mode2 === 'white' || mode2 === 'colour' || mode2.toString().includes('scene'))) { debug('Detected probably Tuya color bulb at DPS 1-5, checking more details...') this.guess = {'dpsPower': 1, 'dpsMode': 2, 'dpsWhiteValue': 3, 'whiteValueScale': 255, 'dpsColorTemp': 4, 'colorTempScale': 255, 'dpsColor': 5} - } else { - debug('Detected likely Tuya color bulb at DPS 20-24, checking more details...') - this.guess = {'dpsPower': 20, 'dpsMode': 21, 'dpsWhiteValue': 22, 'whiteValueScale': 1000, 'dpsColorTemp': 23, 'colorTempScale': 1000, 'dpsColor': 24} + } else if (mode21 && (mode21 === 'white' || mode21 === 'colour' || mode21.toString().includes('scene'))) { + debug('Detected likely Tuya color bulb at DPS 20-24, checking more details...') + this.guess = {'dpsPower': 20, 'dpsMode': 21, 'dpsWhiteValue': 22, 'whiteValueScale': 1000, 'dpsColorTemp': 23, 'colorTempScale': 1000, 'dpsColor': 24} } + if (this.guess.dpsPower) { debug('Attempting to detect if bulb supports color temperature...') const colorTemp = await this.device.get({"dps": this.guess.dpsColorTemp}) @@ -140,9 +149,7 @@ class RGBTWLight extends TuyaDevice { const color = await this.device.get({"dps": this.guess.dpsColor}) this.guess.colorType = (color && color.length === 12) ? 'hsb' : 'hsbhex' debug ('Detected Tuya color format '+this.guess.colorType.toUpperCase()) - } else { - debug('No Tuya color bulb detected, if this device is definitely a Tuya bulb please manually specify settings.') - } + } } } diff --git a/devices/tuya-device.js b/devices/tuya-device.js index 2db749f..c4b3ce2 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -106,6 +106,25 @@ class TuyaDevice { }) } + // Get and update state of all dps properties for device + async getStates() { + // Suppress topic updates while syncing state + this.connected = false + for (let topic in this.deviceTopics) { + const key = this.deviceTopics[topic].key + try { + const result = await this.device.get({"dps": key}) + this.state.dps[key].val = result + this.state.dps[key].updated = true + } catch { + debugError('Could not get value for device DPS key '+key) + } + } + this.connected = true + // Force topic update now that all states are fully in sync + this.publishTopics() + } + // Update cached DPS states on data updates updateState(data) { if (typeof data.dps != 'undefined') { @@ -188,7 +207,7 @@ class TuyaDevice { break; case 'int': case 'float': - state = this.parseStateNumber(value, deviceTopic) + state = this.parseNumberState(value, deviceTopic) break; case 'hsb': case 'hsbhex': @@ -207,7 +226,7 @@ class TuyaDevice { } // Parse the received state numeric value based on deviceTopic rules - parseStateNumber(value, deviceTopic) { + parseNumberState(value, deviceTopic) { // Check if it's a number and it's not outside of defined range if (isNaN(value)) { return '' @@ -216,18 +235,10 @@ class TuyaDevice { // Perform any required math transforms before returing command value switch (deviceTopic.type) { case 'int': - if (deviceTopic.stateMath) { - value = parseInt(Math.round(evaluate(value+deviceTopic.stateMath))) - } else { - value = parseInt(value) - } + value = (deviceTopic.stateMath) ? parseInt(Math.round(evaluate(value+deviceTopic.stateMath))) : value = parseInt(value) break; case 'float': - if (deviceTopic.stateMath) { - value = parseFloat(evaluate(value+deviceTopic.stateMath)) - } else { - value = parseFloat(value) - } + value = (deviceTopic.stateMath) ? parseFloat(evaluate(value+deviceTopic.stateMath)) : value = parseFloat(value) break; } @@ -236,69 +247,49 @@ class TuyaDevice { // Process MQTT commands for all command topics at device level processCommand(message, commandTopic) { - const command = this.getCommandFromMessage(message) + let command + if (utils.isJsonString(message)) { + debugCommand('Received MQTT command message is a JSON string') + command = JSON.parse(message); + } else { + debugCommand('Received MQTT command message is a text string') + command = message.toLowerCase() + } + if (commandTopic === 'command' && command === 'get-states') { // Handle "get-states" command to update device state debugCommand('Received command: ', command) this.getStates() } else { // Call device specific command topic handler - this.processDeviceCommand(message, commandTopic) + this.processDeviceCommand(command, commandTopic) } } // Process MQTT commands for all command topics at device level - processDeviceCommand(message, commandTopic) { + processDeviceCommand(command, commandTopic) { // Determine state topic from command topic to find proper template const stateTopic = commandTopic.replace('command', 'state') const deviceTopic = this.deviceTopics.hasOwnProperty(stateTopic) ? this.deviceTopics[stateTopic] : '' if (deviceTopic) { - debugCommand('Device '+this.options.id+' received command topic: '+commandTopic+', message: '+message) - const command = this.getCommandFromMessage(message) + debugCommand('Device '+this.options.id+' received command topic: '+commandTopic+', message: '+command) let commandResult = this.sendTuyaCommand(command, deviceTopic) if (!commandResult) { debugCommand('Command topic '+this.baseTopic+commandTopic+' received invalid value: '+command) } } else { - debugCommand('Invalid command topic '+this.baseTopic+commandTopic+' for device: '+this.config.name) + debugCommand('Invalid command topic '+this.baseTopic+commandTopic+' for device id: '+this.config.id) return } } - - // Converts message to TuyAPI JSON commands - getCommandFromMessage(message) { - let command - - if (message != '1' && message != '0' && utils.isJsonString(message)) { - debugCommand('MQTT message is JSON') - command = JSON.parse(message); - } else { - switch(message.toLowerCase()) { - case 'on': - case 'off': - case '0': - case '1': - case 'true': - case 'false': - // convert simple messages (on, off, 1, 0) to TuyAPI commands - command = { - set: (message.toLowerCase() === 'on' || message === '1' || message === 'true' || message === 1) ? true : false - } - break; - default: - command = message.toLowerCase() - } - } - return command - } - + // Process Tuya JSON commands via DPS command topic processDpsCommand(message) { if (utils.isJsonString(message)) { - const tuyaCommand = this.getCommandFromMessage(message) - debugCommand('Received command: '+tuyaCommand) - this.set(tuyaCommand) + const command = JSON.parse(message) + debugCommand('Parsed Tuya JSON command: '+JSON.stringify(command)) + this.set(command) } else { debugCommand('DPS command topic requires Tuya style JSON value') } @@ -311,11 +302,11 @@ class TuyaDevice { } else { const dpsMessage = this.parseDpsMessage(message) debugCommand('Received command for DPS'+dpsKey+': ', message) - const tuyaCommand = { + const command = { dps: dpsKey, set: dpsMessage } - this.set(tuyaCommand) + this.set(command) } } @@ -332,27 +323,9 @@ class TuyaDevice { } } - // Get and update state of all dps properties for device - async getStates() { - // Suppress topic updates while syncing state - this.connected = false - for (let topic in this.deviceTopics) { - const key = this.deviceTopics[topic].key - try { - const result = await this.device.get({"dps": key}) - this.state.dps[key].val = result - this.state.dps[key].updated = true - } catch { - debugError('Could not get value for device DPS key '+key) - } - } - this.connected = true - // Force topic update now that all states are fully in sync - this.publishTopics() - } - // Set state based on command topic - sendTuyaCommand(command, deviceTopic) { + sendTuyaCommand(message, deviceTopic) { + let command = message.toLowerCase() const tuyaCommand = new Object() tuyaCommand.dps = deviceTopic.key switch (deviceTopic.type) { @@ -360,6 +333,7 @@ class TuyaDevice { if (command === 'toggle') { tuyaCommand.set = !this.state.dps[tuyaCommand.dps].val } else { + command = this.parseBoolCommand(command) if (typeof command.set === 'boolean') { tuyaCommand.set = command.set } else { @@ -369,16 +343,19 @@ class TuyaDevice { break; case 'int': case 'float': - tuyaCommand.set = this.parseCommandNumber(command, deviceTopic) + tuyaCommand.set = this.parseNumberCommand(command, deviceTopic) break; case 'hsb': this.updateSetColorState(command, deviceTopic.components) - tuyaCommand.set = this.getTuyaHsbColor() + tuyaCommand.set = this.parseTuyaHsbColor() break; case 'hsbhex': this.updateSetColorState(command, deviceTopic.components) - tuyaCommand.set = this.getTuyaHsbHexColor() + tuyaCommand.set = this.parseTuyaHsbHexColor() break; + default: + // If type is not one of the above just use the raw string as is + tuyaCommand.set = message } if (tuyaCommand.set === '!!!INVALID!!!') { return false @@ -391,18 +368,40 @@ class TuyaDevice { return true } } - + + // Convert simple bool commands to true/flase + parseBoolCommand(command) { + switch(command) { + case 'on': + case 'off': + case '0': + case '1': + case 'true': + case 'false': + return { + set: (command === 'on' || command === '1' || command === 'true' || command === 1) ? true : false + } + default: + return command + } + } + // Validate/transform set interger values - parseCommandNumber(command, deviceTopic) { + parseNumberCommand(command, deviceTopic) { let value = undefined const invalid = '!!!INVALID!!!' // Check if it's a number and it's not outside of defined range if (isNaN(command)) { return invalid - } else if ((deviceTopic.hasOwnProperty('min') && command < deviceTopic.min) || - (deviceTopic.hasOwnProperty('max') && command > deviceTopic.max)) { - return invalid + } else if (deviceTopic.hasOwnProperty('min') && command < deviceTopic.min) { + debugError('Received command value "'+command+'" that is less than the configured minimum value') + debugError('Overriding command with minimum value '+deviceTopic.min) + command = deviceTopic.min + } else if (deviceTopic.hasOwnProperty('max') && command > deviceTopic.max) { + debugError('Received command value "'+command+'" that is greater than the configured maximum value') + debugError('Overriding command with maximum value: '+deviceTopic.max) + command = deviceTopic.max } // Perform any required math transforms before returing command value @@ -445,7 +444,7 @@ class TuyaDevice { // Initialize the set color values for first time. Used to conflicts // when mulitple HSB components are updated in quick succession - if (!this.state.setColor) { + if (!this.state.hasOwnProperty('setColor')) { this.state.setColor = { 'h': this.state.color.h, 's': this.state.color.s, @@ -467,7 +466,7 @@ class TuyaDevice { } // Returns Tuya HSB format value from current setColor HSB value - getTuyaHsbColor() { + parseTuyaHsbColor() { // Convert new HSB color to Tuya style HSB format let {h, s, b} = this.state.setColor const hexColor = h.toString(16).padStart(4, '0') + (10 * s).toString(16).padStart(4, '0') + (10 * b).toString(16).padStart(4, '0') @@ -475,7 +474,7 @@ class TuyaDevice { } // Returns Tuya HSBHEX format value from current setColor HSB value - getTuyaHsbHexColor() { + parseTuyaHsbHexColor() { let {h, s, b} = this.state.setColor const hsb = h.toString(16).padStart(4, '0') + Math.round(2.55 * s).toString(16).padStart(2, '0') + Math.round(2.55 * b).toString(16).padStart(2, '0'); h /= 60; @@ -518,8 +517,6 @@ class TuyaDevice { // If setting white level or color temperature, light should be in white mode targetMode = 'white' } else if (topic.key === this.config.dpsColor) { - // Short sleep for cases where mulitple updates occur quickly - await msSleep(100) if (this.state.setColor.s < 10) { // If saturation is < 10 then white mode targetMode = 'white' From 271697439cc91dd0dab36e2de246c704e712c17f Mon Sep 17 00:00:00 2001 From: tsightler Date: Wed, 14 Oct 2020 11:40:43 -0400 Subject: [PATCH 21/34] Always set saturation to 0 in white mode --- devices/tuya-device.js | 63 +++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/devices/tuya-device.js b/devices/tuya-device.js index c4b3ce2..e3ef901 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -106,9 +106,9 @@ class TuyaDevice { }) } - // Get and update state of all dps properties for device + // Get and update cached values of all configured/known dps value for device async getStates() { - // Suppress topic updates while syncing state + // Suppress topic updates while syncing device state with cached state this.connected = false for (let topic in this.deviceTopics) { const key = this.deviceTopics[topic].key @@ -121,21 +121,30 @@ class TuyaDevice { } } this.connected = true - // Force topic update now that all states are fully in sync + // Force topic update now that all states are fully syncronized this.publishTopics() } - // Update cached DPS states on data updates + // Update cached DPS values on data updates updateState(data) { if (typeof data.dps != 'undefined') { // Update cached device state data for (let key in data.dps) { - this.state.dps[key] = { - 'val': data.dps[key], - 'updated': true + // Only update if the received value is different from previous value + if (this.state.dps[key] !== data.dps[key]) { + this.state.dps[key] = { + 'val': data.dps[key], + 'updated': true + } } - if (this.config.dpsColor && this.config.dpsColor == key) { - this.updateColorState(data.dps[key]) + if (this.isRgbtwLight) { + if (this.config.hasOwnProperty('dpsColor') && this.config.dpsColor == key) { + this.updateColorState(data.dps[key]) + } else if (this.config.hasOwnProperty('dpsMode') && this.config.dpsMode == key) { + // If color/white mode is changing, force sending color state + // Allows overriding saturation value to 0% for white mode for the HSB device topics + this.state.dps[this.config.dpsColor].updated = true + } } } if (this.connected) { @@ -153,6 +162,7 @@ class TuyaDevice { for (let topic in this.deviceTopics) { const deviceTopic = this.deviceTopics[topic] const key = deviceTopic.key + // Only publish values if different from previous value if (this.state.dps[key] && this.state.dps[key].updated) { const state = this.getTopicState(deviceTopic, this.state.dps[key].val) if (state) { @@ -174,6 +184,7 @@ class TuyaDevice { // Publish DPS JSON data if not empty let data = {} for (let key in this.state.dps) { + // Only publish values if different from previous value if (this.state.dps[key].updated) { data[key] = this.state.dps[key].val } @@ -185,6 +196,7 @@ class TuyaDevice { // Publish dps/<#>/state value for each device DPS for (let key in this.state.dps) { + // Only publish values if different from previous value if (this.state.dps[key].updated) { const dpsKeyTopic = dpsTopic + '/' + key + '/state' const data = this.state.dps.hasOwnProperty(key) ? this.state.dps[key].val.toString() : 'None' @@ -198,7 +210,7 @@ class TuyaDevice { } } - // Get the friendly topic state based on DPS value type + // Get the friendly topic state based on configured DPS value type getTopicState(deviceTopic, value) { let state switch (deviceTopic.type) { @@ -215,12 +227,14 @@ class TuyaDevice { state = new Array() const components = deviceTopic.components.split(',') for (let i in components) { - state.push(this.state.color[components[i]]) + // If light is in white mode always report saturation 0%, otherwise report actual value + state.push((components[i] === 's' && this.state.dps[this.config.dpsMode].val === 'white') ? 0 : this.state.color[components[i]]) } state = (state.join(',')) break; case 'str': state = value ? value : '' + break; } return state } @@ -245,7 +259,7 @@ class TuyaDevice { return value.toString() } - // Process MQTT commands for all command topics at device level + // Initial processing of MQTT commands for all command topics processCommand(message, commandTopic) { let command if (utils.isJsonString(message)) { @@ -256,6 +270,7 @@ class TuyaDevice { command = message.toLowerCase() } + // If get-states command, then updates all states and re-publish topics if (commandTopic === 'command' && command === 'get-states') { // Handle "get-states" command to update device state debugCommand('Received command: ', command) @@ -266,7 +281,7 @@ class TuyaDevice { } } - // Process MQTT commands for all command topics at device level + // Process MQTT commands for all device command topics processDeviceCommand(command, commandTopic) { // Determine state topic from command topic to find proper template const stateTopic = commandTopic.replace('command', 'state') @@ -295,7 +310,7 @@ class TuyaDevice { } } - // Process text base Tuya command via DPS key command topics + // Process text based Tuya commands via DPS key command topics processDpsKeyCommand(message, dpsKey) { if (utils.isJsonString(message)) { debugCommand('Individual DPS command topics do not accept JSON values') @@ -369,7 +384,7 @@ class TuyaDevice { } } - // Convert simple bool commands to true/flase + // Convert simple bool commands to true/false parseBoolCommand(command) { switch(command) { case 'on': @@ -425,8 +440,8 @@ class TuyaDevice { return value } - // Takes Tuya color value in HSB or HSBHEX format and - // updates cached HSB color state for device + // Takes Tuya color value in HSB or HSBHEX format and updates cached HSB color state for device + // Credit homebridge-tuya project for HSB/HSBHEX conversion code updateColorState(value) { let h, s, b if (this.config.colorType === 'hsbhex') { @@ -466,6 +481,7 @@ class TuyaDevice { } // Returns Tuya HSB format value from current setColor HSB value + // Credit homebridge-tuya project for HSB conversion code parseTuyaHsbColor() { // Convert new HSB color to Tuya style HSB format let {h, s, b} = this.state.setColor @@ -474,6 +490,7 @@ class TuyaDevice { } // Returns Tuya HSBHEX format value from current setColor HSB value + // Credit homebridge-tuya project for HSBHEX conversion code parseTuyaHsbHexColor() { let {h, s, b} = this.state.setColor const hsb = h.toString(16).padStart(4, '0') + Math.round(2.55 * s).toString(16).padStart(2, '0') + Math.round(2.55 * b).toString(16).padStart(2, '0'); @@ -526,10 +543,10 @@ class TuyaDevice { } } - // Set the proper value + // Send the issued command this.set(command) - // Put the bulb in the correct mode + // Make sure the bulb stays in the correct mode if (targetMode) { command = { dps: this.config.dpsMode, @@ -553,6 +570,7 @@ class TuyaDevice { }) } + // Search for and connect to device connectDevice() { // Find device on network debug('Search for device id '+this.options.id) @@ -576,12 +594,7 @@ class TuyaDevice { debugError('Error connecting to device id '+this.options.id+'...retry in 10 seconds.') await utils.sleep(10) if (this.connected) { return } - debug('Search for device id '+this.options.id) - this.device.find().then(() => { - debug('Found device id '+this.options.id) - // Attempt connection to device - this.device.connect() - }) + this.connectDevice() } // Simple function to monitor heartbeats to determine if From 748a5cae39c2be5a42342f0cdd1a70d0c0f42a64 Mon Sep 17 00:00:00 2001 From: tsightler Date: Thu, 15 Oct 2020 11:05:41 -0400 Subject: [PATCH 22/34] Minor enhancements * Properly disconnect from devices on exit * Monitor for Home Assistant status and resend discovery --- devices/rgbtw-light.js | 14 ++++++++++---- devices/simple-dimmer.js | 3 +++ devices/simple-switch.js | 3 +++ devices/tuya-device.js | 1 - tuya-mqtt.js | 42 ++++++++++++++++++++++++++++++++++------ 5 files changed, 52 insertions(+), 11 deletions(-) diff --git a/devices/rgbtw-light.js b/devices/rgbtw-light.js index 1d13620..1a66781 100644 --- a/devices/rgbtw-light.js +++ b/devices/rgbtw-light.js @@ -5,12 +5,16 @@ const utils = require('../lib/utils') class RGBTWLight extends TuyaDevice { async init() { - await this.guessLightInfo() + // If no manual config try to detect device settings + if (!this.config.dpsPower) { + await this.guessLightInfo() + } + // If detection failed and no manual config return without initializing if (!this.guess.dpsPower && !this.config.dpsPower) { debug('Automatic discovery of Tuya bulb settings failed and no manual configuration') return - } + } // Set device specific variables this.config.dpsPower = this.config.dpsPower ? this.config.dpsPower : this.guess.dpsPower @@ -25,7 +29,6 @@ class RGBTWLight extends TuyaDevice { this.config.colorType = this.config.colorType ? this.config.colorType : this.guess.colorType this.deviceData.mdl = 'RGBTW Light' - this.isRgbtwLight = true // Map generic DPS topics to device specific topic names @@ -66,7 +69,7 @@ class RGBTWLight extends TuyaDevice { // If device supports Color Temperature add color temp device topic if (this.config.dpsColorTemp) { - // Values used for tranform + // Values used for tranforming from 1-255 scale to mireds range const rangeFactor = (this.config.maxColorTemp-this.config.minColorTemp)/100 const scaleFactor = this.config.colorTempScale/100 const tuyaMaxColorTemp = this.config.maxColorTemp/rangeFactor*scaleFactor @@ -104,6 +107,9 @@ class RGBTWLight extends TuyaDevice { white_value_state_topic: this.baseTopic+'white_brightness_state', white_value_command_topic: this.baseTopic+'white_brightness_command', white_value_scale: 100, + availability_topic: this.baseTopic+'status', + payload_available: 'online', + payload_not_available: 'offline', unique_id: this.config.id, device: this.deviceData } diff --git a/devices/simple-dimmer.js b/devices/simple-dimmer.js index 23e2c3e..766117e 100644 --- a/devices/simple-dimmer.js +++ b/devices/simple-dimmer.js @@ -44,6 +44,9 @@ class SimpleDimmer extends TuyaDevice { command_topic: this.baseTopic+'command', brightness_state_topic: this.baseTopic+'brightness_state', brightness_command_topic: this.baseTopic+'brightness_command', + availability_topic: this.baseTopic+'status', + payload_available: 'online', + payload_not_available: 'offline', unique_id: this.config.id, device: this.deviceData } diff --git a/devices/simple-switch.js b/devices/simple-switch.js index e0f57c7..ef8337e 100644 --- a/devices/simple-switch.js +++ b/devices/simple-switch.js @@ -33,6 +33,9 @@ class SimpleSwitch extends TuyaDevice { name: (this.config.name) ? this.config.name : this.config.id, state_topic: this.baseTopic+'state', command_topic: this.baseTopic+'command', + availability_topic: this.baseTopic+'status', + payload_available: 'online', + payload_not_available: 'offline', unique_id: this.config.id, device: this.deviceData } diff --git a/devices/tuya-device.js b/devices/tuya-device.js index e3ef901..ae27a89 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -1,7 +1,6 @@ const TuyAPI = require('tuyapi') const { evaluate } = require('mathjs') const utils = require('../lib/utils') -const { msSleep } = require('../lib/utils') const debug = require('debug')('tuya-mqtt:tuyapi') const debugState = require('debug')('tuya-mqtt:state') const debugCommand = require('debug')('tuya-mqtt:command') diff --git a/tuya-mqtt.js b/tuya-mqtt.js index ecf30bf..811c5b1 100644 --- a/tuya-mqtt.js +++ b/tuya-mqtt.js @@ -9,10 +9,27 @@ const SimpleSwitch = require('./devices/simple-switch') const SimpleDimmer = require('./devices/simple-dimmer') const RGBTWLight = require('./devices/rgbtw-light') const GenericDevice = require('./devices/generic-device') +const utils = require('./lib/utils') var CONFIG = undefined var tuyaDevices = new Array() +// Setup Exit Handlers +process.on('exit', processExit.bind(0)) +process.on('SIGINT', processExit.bind(0)) +process.on('SIGTERM', processExit.bind(0)) +process.on('uncaughtException', processExit.bind(1)) + +// Set unreachable status on exit +async function processExit(exitCode) { + for (let tuyaDevice of tuyaDevices) { + tuyaDevice.device.disconnect() + } + if (exitCode || exitCode === 0) debug('Exit code: '+exitCode) + await utils.sleep(1) + process.exit() +} + function getDevice(configDevice, mqttClient) { const deviceInfo = { configDevice: configDevice, @@ -40,6 +57,15 @@ function initDevices(configDevices, mqttClient) { } } +async function republishDevices() { + // Republish devices and state after 30 seconds if restart of HA is detected + debug('Resending device config/state in 30 seconds') + await utils.sleep(30) + for (let device of tuyaDevices) { + device.init() + } +} + // Main code function const main = async() => { let configDevices @@ -84,10 +110,9 @@ const main = async() => { mqttClient.on('connect', function (err) { debug('Connection established to MQTT server') let topic = CONFIG.topic + '#' - mqttClient.subscribe(topic, { - retain: CONFIG.retain, - qos: CONFIG.qos - }) + mqttClient.subscribe(topic) + mqttClient.subscribe('homeassistant/status') + mqttClient.subscribe('hass/status') initDevices(configDevices, mqttClient) }) @@ -111,8 +136,13 @@ const main = async() => { const commandTopic = splitTopic[topicLength - 1] const deviceTopicLevel = splitTopic[1] - // If it looks like a valid command topic try to process it - if (commandTopic.includes('command')) { + if (topic === 'homeassistant/status' || topic === 'hass/status' ) { + debug('Home Assistant state topic '+topic+' received message: '+message) + if (message === 'online') { + republishDevices() + } + } else if (commandTopic.includes('command')) { + // If it looks like a valid command topic try to process it debugCommand('Received MQTT message -> ', JSON.stringify({ topic: topic, message: message From ab2d4da5d62f8e70890f8d52fa6b444b5253b5c0 Mon Sep 17 00:00:00 2001 From: tsightler Date: Fri, 16 Oct 2020 01:23:54 -0400 Subject: [PATCH 23/34] Simplify color logic * Color/white switch logic simplified and more reliable * Clean up variable names --- devices/tuya-device.js | 120 +++++++++++++++++++++-------------------- 1 file changed, 63 insertions(+), 57 deletions(-) diff --git a/devices/tuya-device.js b/devices/tuya-device.js index ae27a89..28ea0ff 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -35,14 +35,15 @@ class TuyaDevice { mf: 'Tuya' } - // Objects to hold cached device state data - this.state = { - "dps": {}, - "color": {'h': 0, 's': 0, 'b': 0} - } + // Initialize properties to hold cached device state data + this.dps = {} + this.color = {'h': 0, 's': 0, 'b': 0} + + // Device friendly topics + this.deviceTopics = {} - this.deviceTopics = {} // Property to hold friendly topics template - this.heartbeatsMissed = 0 // Used to monitor heartbeat status to detect offline device + // Missed heartbeat monitor + this.heartbeatsMissed = 0 // Build the MQTT topic for this device (friendly name or device id) if (this.options.name) { @@ -113,8 +114,8 @@ class TuyaDevice { const key = this.deviceTopics[topic].key try { const result = await this.device.get({"dps": key}) - this.state.dps[key].val = result - this.state.dps[key].updated = true + this.dps[key].val = result + this.dps[key].updated = true } catch { debugError('Could not get value for device DPS key '+key) } @@ -130,8 +131,8 @@ class TuyaDevice { // Update cached device state data for (let key in data.dps) { // Only update if the received value is different from previous value - if (this.state.dps[key] !== data.dps[key]) { - this.state.dps[key] = { + if (this.dps[key] !== data.dps[key]) { + this.dps[key] = { 'val': data.dps[key], 'updated': true } @@ -142,7 +143,7 @@ class TuyaDevice { } else if (this.config.hasOwnProperty('dpsMode') && this.config.dpsMode == key) { // If color/white mode is changing, force sending color state // Allows overriding saturation value to 0% for white mode for the HSB device topics - this.state.dps[this.config.dpsColor].updated = true + this.dps[this.config.dpsColor].updated = true } } } @@ -162,8 +163,8 @@ class TuyaDevice { const deviceTopic = this.deviceTopics[topic] const key = deviceTopic.key // Only publish values if different from previous value - if (this.state.dps[key] && this.state.dps[key].updated) { - const state = this.getTopicState(deviceTopic, this.state.dps[key].val) + if (this.dps[key] && this.dps[key].updated) { + const state = this.getTopicState(deviceTopic, this.dps[key].val) if (state) { this.publishMqtt(this.baseTopic + topic, state, true) } @@ -177,15 +178,15 @@ class TuyaDevice { // Publish all dps-values to topic publishDpsTopics() { try { - if (!Object.keys(this.state.dps).length) { return } + if (!Object.keys(this.dps).length) { return } const dpsTopic = this.baseTopic + 'dps' // Publish DPS JSON data if not empty let data = {} - for (let key in this.state.dps) { + for (let key in this.dps) { // Only publish values if different from previous value - if (this.state.dps[key].updated) { - data[key] = this.state.dps[key].val + if (this.dps[key].updated) { + data[key] = this.dps[key].val } } data = JSON.stringify(data) @@ -194,14 +195,14 @@ class TuyaDevice { this.publishMqtt(dpsStateTopic, data, false) // Publish dps/<#>/state value for each device DPS - for (let key in this.state.dps) { + for (let key in this.dps) { // Only publish values if different from previous value - if (this.state.dps[key].updated) { + if (this.dps[key].updated) { const dpsKeyTopic = dpsTopic + '/' + key + '/state' - const data = this.state.dps.hasOwnProperty(key) ? this.state.dps[key].val.toString() : 'None' + const data = this.dps.hasOwnProperty(key) ? this.dps[key].val.toString() : 'None' debugState('MQTT DPS'+key+': '+dpsKeyTopic+' -> ', data) this.publishMqtt(dpsKeyTopic, data, false) - this.state.dps[key].updated = false + this.dps[key].updated = false } } } catch (e) { @@ -227,7 +228,7 @@ class TuyaDevice { const components = deviceTopic.components.split(',') for (let i in components) { // If light is in white mode always report saturation 0%, otherwise report actual value - state.push((components[i] === 's' && this.state.dps[this.config.dpsMode].val === 'white') ? 0 : this.state.color[components[i]]) + state.push((components[i] === 's' && this.dps[this.config.dpsMode].val === 'white') ? 0 : this.color[components[i]]) } state = (state.join(',')) break; @@ -345,7 +346,7 @@ class TuyaDevice { switch (deviceTopic.type) { case 'bool': if (command === 'toggle') { - tuyaCommand.set = !this.state.dps[tuyaCommand.dps].val + tuyaCommand.set = !this.dps[tuyaCommand.dps].val } else { command = this.parseBoolCommand(command) if (typeof command.set === 'boolean') { @@ -360,11 +361,11 @@ class TuyaDevice { tuyaCommand.set = this.parseNumberCommand(command, deviceTopic) break; case 'hsb': - this.updateSetColorState(command, deviceTopic.components) + this.updateCommandColor(command, deviceTopic.components) tuyaCommand.set = this.parseTuyaHsbColor() break; case 'hsbhex': - this.updateSetColorState(command, deviceTopic.components) + this.updateCommandColor(command, deviceTopic.components) tuyaCommand.set = this.parseTuyaHsbHexColor() break; default: @@ -445,53 +446,51 @@ class TuyaDevice { let h, s, b if (this.config.colorType === 'hsbhex') { [, h, s, b] = (value || '0000000000ffff').match(/^.{6}([0-9a-f]{4})([0-9a-f]{2})([0-9a-f]{2})$/i) || [0, '0', 'ff', 'ff']; - this.state.color.h = parseInt(h, 16) - this.state.color.s = Math.round(parseInt(s, 16) / 2.55) // Convert saturation to 100 scale - this.state.color.b = Math.round(parseInt(b, 16) / 2.55) // Convert brightness to 100 scale + this.color.h = parseInt(h, 16) + this.color.s = Math.round(parseInt(s, 16) / 2.55) // Convert saturation to 100 scale + this.color.b = Math.round(parseInt(b, 16) / 2.55) // Convert brightness to 100 scale } else { [, h, s, b] = (value || '000003e803e8').match(/^([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})$/i) || [0, '0', '3e8', '3e8'] // Convert from Hex to Decimal and cache values - this.state.color.h = parseInt(h, 16) - this.state.color.s = Math.round(parseInt(s, 16) / 10) // Convert saturation to 100 Scale - this.state.color.b = Math.round(parseInt(b, 16) / 10) // Convert brightness to 100 scale + this.color.h = parseInt(h, 16) + this.color.s = Math.round(parseInt(s, 16) / 10) // Convert saturation to 100 Scale + this.color.b = Math.round(parseInt(b, 16) / 10) // Convert brightness to 100 scale } - // Initialize the set color values for first time. Used to conflicts - // when mulitple HSB components are updated in quick succession - if (!this.state.hasOwnProperty('setColor')) { - this.state.setColor = { - 'h': this.state.color.h, - 's': this.state.color.s, - 'b': this.state.color.b + // Initialize the command color values with existing color state + if (!this.hasOwnProperty('cmdColor')) { + this.cmdColor = { + 'h': this.color.h, + 's': this.color.s, + 'b': this.color.b } } } - // Updates the set color values based on received value from command topics - // This is used to cache set color values when mulitple HSB components use - // different topics and updates come in quick succession - updateSetColorState(value, components) { + // Caches color updates when HSB components have separate device topics + // cmdColor property always contains the desired HSB color state based on received + // command topic messages vs actual device color state, which may be pending + updateCommandColor(value, components) { // Update any HSB component with a changed value components = components.split(',') const values = value.split(',') for (let i in components) { - this.state.setColor[components[i]] = Math.round(values[i]) + this.cmdColor[components[i]] = Math.round(values[i]) } } - // Returns Tuya HSB format value from current setColor HSB value + // Returns Tuya HSB format value from current cmdColor HSB values // Credit homebridge-tuya project for HSB conversion code parseTuyaHsbColor() { - // Convert new HSB color to Tuya style HSB format - let {h, s, b} = this.state.setColor + let {h, s, b} = this.cmdColor const hexColor = h.toString(16).padStart(4, '0') + (10 * s).toString(16).padStart(4, '0') + (10 * b).toString(16).padStart(4, '0') return hexColor } - // Returns Tuya HSBHEX format value from current setColor HSB value + // Returns Tuya HSBHEX format value from current cmdColor HSB values // Credit homebridge-tuya project for HSBHEX conversion code parseTuyaHsbHexColor() { - let {h, s, b} = this.state.setColor + let {h, s, b} = this.cmdColor const hsb = h.toString(16).padStart(4, '0') + Math.round(2.55 * s).toString(16).padStart(2, '0') + Math.round(2.55 * b).toString(16).padStart(2, '0'); h /= 60; s /= 100; @@ -525,20 +524,27 @@ class TuyaDevice { // Set white/colour mode based on async setLight(topic, command) { - let targetMode = undefined - const currentMode = this.config.dpsMode.val - + if (topic.key === this.config.dpsWhiteValue || topic.key === this.config.dpsColorTemp) { // If setting white level or color temperature, light should be in white mode targetMode = 'white' } else if (topic.key === this.config.dpsColor) { - if (this.state.setColor.s < 10) { - // If saturation is < 10 then white mode - targetMode = 'white' + // Split device topic HSB components into array + const components = topic.components.split(',') + + // If device topic inlucdes saturation check for changes + if (components.includes('s')) { + if (this.cmdColor.s < 10) { + // Saturation changed to < 10% = white mode + targetMode = 'white' + } else { + // Saturation changed to >= 10% = color mode + targetMode = 'colour' + } } else { - // If saturation > 0 and changing hue, set color mode - targetMode = 'colour' + // For other cases stay in existing mode + targetMode = this.dps[this.config.dpsMode].val } } From 1abcdb02dcef0f554b0f9ddca9ce969cb4d686c4 Mon Sep 17 00:00:00 2001 From: tsightler Date: Fri, 16 Oct 2020 21:32:27 -0400 Subject: [PATCH 24/34] 3.0.0 Documentation Update --- README.md | 110 +++++++++++++++++------------------------ devices/tuya-device.js | 2 +- docs/DEVICES.md | 0 docs/TOPICS.md | 100 +++++++++++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 67 deletions(-) create mode 100644 docs/DEVICES.md create mode 100644 docs/TOPICS.md diff --git a/README.md b/README.md index de70d25..440260f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ -# tuyAPI-MQTT Client -MQTT interface for Tuya home automation devices sold under various names. -This is a wrapper script for the Project codetheweb/tuyapi. https://github.com/codetheweb/tuyapi +# tuya-mqtt +This project provides an MQTT gateway for locally controlling home automation devices made by Tuya Inc and sold under many different brands. To use this script you will need to obtain the device ID and local keys for each of your devices after they are configured via the Tuya/Smart Life or other Tuya compatible app (there are many). With this information it is possible to communicate locally with Tuya devices using Tuya protocol version 3.1 and 3.3, without using the Tuya Cloud service, however, getting the keys requires signing up for a Tuya IOT developer account or using one of several other alternative methods (such as dumping the memory of a Tuya based app running on Andriod). -This project provides an MQTT gateway for locally controlling home automation devices made by Tuya Inc. To use this script you will need to obtain the device ID and local keys for each of your devices after they are configured via the Tuya/Smart Life or other Tuya compatible app (there are many). With this information it is possible to communicate locally with Tuya devices using protocol 3.1 and 3.3, without using the Tuya Cloud service, however, getting the keys requires signing up for a Tuya IOT developer account or using one of several other alternative methods (such as dumping the memory of a Tuya based app running on Andriod). Acquiring keys is not part of this project, please see the instructions at the TuyAPI project (on which this script is based) available at the TuyAPI project site: +Acquiring keys is not part of this project, please see the instructions at the TuyAPI project (on which this script is based) available at the TuyAPI project site: https://github.com/codetheweb/tuyapi/blob/master/docs/SETUP.md. +**!!!!!!!!!! Important information regarding the 3.0 release !!!!!!!!!!** +The 3.0.0 release (Oct 17th, 2020) is a major refactor of the tuya-mqtt project and, as such, is a breaking release. Almost everything about the project is different, including configuration, topic names, etc. Upgrading users should carefully read the instructions below and assume they are starting over from scratch. + ## Instructions: Download this project to your system into any directory (example below uses /opt/tuya-mqtt) and install tuyapi from the same folder that the tuya-mqtt.js is in ``` @@ -22,16 +24,37 @@ cd tuya-mqtt npm install ``` +## Configuration +tuya-mqtt uses two different configuration files, config.json is a simple file which contains settings for connection to the MQTT broken, and devices.conf is a JSON5 file which defines the Tuya device that the script should connect to and expose via MQTT. This file uses the same basic format as the "tuya-cli wizard" outputs when used to acquire the device keys, so it can be used as the basis for you configuration. -## Basic Usage -### Create your configuration file: +### Seting up config.json: ``` cp config.json.sample config.json -// edit the configuration file +// Edit config.json with your MQTT broker settings and save nano config.json ``` +## Setup devices.conf: +If you use the "tuya-cli wizard" method to acquire your device keys you can leverage the output of this tool as the start of your devices.conf file. Otherwise, you want to create a file using a formate like this: +``` +[ + { + name: 'Tuya Device 1', + id: '86435357d8b123456789', + key: '8b2a69c9876543210' + }, + { + name: 'Tuya Device 2', + id: 'eb532eea7d12345678abc', + key: '899810012345678' + } +] + +Note that, because the format is JSON5, which is a superset of JSON, you can use standard, strict JSON syntax, or the more forgiving JSON5 format, or even mix and match in the same file. + +While the above syntax is enough to create a working tuya-mqtt install with generic devices, the full power and simplicity of tuya-mqtt 3.0 is only unlocked by configuring device types to get . Please see the full [DEVICES](docs/DEVICES.md) documenation for details. + ### Start command ``` node tuya-mqtt.js @@ -39,76 +62,31 @@ node tuya-mqtt.js // For debugging purpose, to use DEBUG : https://www.npmjs.com/package/debug //on Linux machines at the bash command prompt, to turn ON DEBUG: -DEBUG=* tuya-mqtt.js - -//on Linux machines at the bash command prompt, to turn OFF DEBUG: -DEBUG=-* tuya-mqtt.js +DEBUG=tuya-mqtt:* tuya-mqtt.js // on Windows machines at the cmd.exe command prompt, to turn ON DEBUG: -Set DEBUG=* & node c:/openhab2/userdata/etc/scripts/tuya-mqtt.js - -// on Windows machines at the cmd.exe command prompt, to turn OFF DEBUG: -Set DEBUG=-* & node c:/openhab2/userdata/etc/scripts/tuya-mqtt.js +Set DEBUG=tuya-mqtt:* & node c:/openhab2/userdata/etc/scripts/tuya-mqtt.js ``` -### MQTT Topic's (send data) -**It's possible to replace the device IP address \ with the word "discover" to have the API attempt to automatically discover the device IP address. This allows support for 3.3 protocol devices transparently, without additional configuraiton, but does require the system running this script to be on the same IP subnet as the Tuya device since the discovery protocol relies on UDP broadcast packets from the devices.** -``` - tuya///discover/state - tuya///discover/command -``` -**If discovery will not work for your case you can still use the IP address, but, to use protocol 3.3 you must specify it in the topic explicitly** -``` - tuya/ver3.3//////command -``` -### Example command topic to set the device state: -``` - tuya////command -``` -### Example MQTT message payload for basic commands (default controls DPS[1] value, assumes true/false state control): -``` - "ON" - "OFF" - "on" - "off" - "1" - "0" - "toggle" - "TOGGLE" -``` -### Example MQTT message payload for advanced commands (set any DPS value): +### Tuya DPS values Overview +Tuya devices are monitored and controlled using a simple API where a devices functions are mapped to DPS (data point state) values stored in various numbered keys. For example, a simple on/off switch may have a single key, DPS1, with a setting of true/false representing the on/off state of the device. The device state can be read via this DPS1 key, and, for values that can be changed, sending true/false to DPS 1 will turn the device on/off. A simple dimmer might have the same DPS1 key as true/false for on/off, and a DPS2 key as a value from 0-255 to represent the state of the dimmer value. More complex devices use more DPS keys with various values representing the states and control functions of the device. + +### MQTT Topic Overview +The top level topics are created using the device name or ID as the primary identifier. If the device name is available, it will be converted to lowercase and spaced replace with underscores('_') characters so, for example, if using the sample devices.conf file from above, the top level topic would be: ``` - "{ \"dps\": 1, \"set\": true }" - "{ \"dps\": 7, \"set\": true }" - "{ \"multiple\": true, \"data\": { \"1\": true, \"7\": true } }" - "{ \"schema\": true }" - "{ \"multiple\": true, \"data\": { \"1\": true, \"2\": \"scene_4\" } }" - "{ \"multiple\": true, \"data\": { \"1\": true, \"2\": \"scene\", \"6\": \"c479000025ffc3\" } }" +tuya/tuya_device_1/ ``` -### Example command topic for color change of lightbulb +If the device name was not available, it would instead use the device ID: ``` - tuya////color - - Example MQTT message payload: - 64,0,100 - 0,0,89 +tuya/86435357d8b123456789/ ``` +All other topics are then build below this level. -### Example state topics (get device data) -### Get current device state (always DPS[1] value): - tuya////state +tuya-mqtt directly exposes the Tuya DPS keys and values via MQTT topics and you can control any Tuya device using these topics, however, because it is not always easy to translate the Tuya values into something easy to consume by standard Home Automation systems, tuya-mqtt includes a simple templating engine to map DPS values to "friendly topics", i.e. topics that are easier to consume. -### Get all available device DPS values -Returns JSON.stringify(dps) values, use with care, does not always contain all dps values -``` - tuya////dps -``` +By default, all devices are treated as generic Tuya devices and only the raw DPS values are exposed, however, some devices have predefined templates which can be configured in the device.conf file. Also, you can manually define a template mapping using the "GenericDevice" configuraiton. Please read more details in the [DEVICES](docs/DEVICES.md) documentation. -### Get any single DPS data value -``` - tuya////dps/ -``` +For more details on DPS and friendly topics, please see the [TOPICS](TOPICS.md) documentation. ## Issues Not all Tuya protocols are supported. For example, some devices use protocol 3.2 which currently remains unsupported by the TuyAPI project due to lack of enough information to reverse engineer the protcol. If you are unable to control your devices with tuya-mqtt please verify that you can query and control them with tuya-cli first. If tuya-cli works, then this script should also work, if it doesn't then this script will not work either. diff --git a/devices/tuya-device.js b/devices/tuya-device.js index 28ea0ff..0b8962a 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -522,7 +522,7 @@ class TuyaDevice { return hex + hsb; } - // Set white/colour mode based on + // Set white/colour mode based on received commands async setLight(topic, command) { let targetMode = undefined diff --git a/docs/DEVICES.md b/docs/DEVICES.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/TOPICS.md b/docs/TOPICS.md new file mode 100644 index 0000000..a6c6ed2 --- /dev/null +++ b/docs/TOPICS.md @@ -0,0 +1,100 @@ +# tuya-mqtt Topics +tuya-mqtt support two styles of topics for devices. For all devices the DPS topics are always published and commands are accepted, however, friendly topics allow mapping DPS values into friendier topics names with more control over allowed values, and allow simple functions like math transforms, etc. While it's always possible to use the DPS topics directly, friendly topics are the generally recommended approach but require you to create a template for your device if it doesn't match one of the pre-defined templates. + +If you create a template for your device, please feel free to share it with the community as adding additional pre-defined devices is desired for future versions of tuya-mqtt. + +If you would like to use the raw DPS topics, please jump to the DPS topic section of this document. + +## Friendly Topics +Friendly topics are only available when using a pre-defined device template or, when using the generic device, when you have defined a custom template. Friendly topics use a simple templating engine to map raw Tuya DPS key values to easy to consume topic and transform the data where needed. The other advantage to using friendly topics is that not all devices respond to schema requets so it's not always possible for tuya-mqtt to know which DPS topics to acquire state information from during startup. With a template the required DPS topics are configured and tuya-mqtt will always query these individual values during initial connection to the device. + +When using pre-device device templates, please see the appropriate section in the [DEVICES](docs/DEVICES.md) documentation. When using a generic device, you can define a template in the devices.conf file. Imagine a simple dimmer with key 1 representing on/off and key 2 represeting brightness. When using just the basic devices.conf file, controlling a device requires using the DPS topics directly, for example: +``` +tuya/dimmer_device/DPS/1 <-- true/false for state and control +tuya/dimmer_device/DPS/2 <-- value 1-255, for state and control, accepts invalid values, etc. + +``` +While this will work, in this case the automation system would need to understand true/false vs on/off, and would need to be manually configured for 1-255 scale instead of, for example, a 1-100% scale (some systems deal with this quite easily, others, not so much). However, using a template you can quickly create an easy to use set of friendly topics with easier to consume values: +``` +[ + { + name: 'Tuya Device 1', + id: '86435357d8b123456789', + key: '8b2a69c9876543210', + template: { + state: { + key: 1, + type: 'bool' + }, + brightness_state: { + key: 2, + type: 'int', + min: 1, + max: 255, + stateMath: '/2.55', + commandMath: '*2.55' + } + } + } +] +``` +Now, controlling the device can be done with the following topics: +``` +tuya/dimmer_device/state <-- Reports ON/OFF +tuya/dimmer_device/command <-- Accepts 0/1, on/off, or true/false +tuya/dimmer_device/brightness_state <-- Reports 1-100 scale for brightness % +tuya/dimmer_device/brightness_state <-- Accepts 1-100 scale for brightness % +``` +More complex mappings are possible. All pre-defined device templates use the same, built-in, templating engine, so further examples can been seen by browsing the source code of the device files. Below are the available options for each value type: + +### Boolean values +| type | 'bool' | +| key | DPS key for the value | + +### Integer values +| type | 'int' | +| key | DPS key for the value | +| min | Minumum value allowed for the command topic | +| max | Maximum value allowed for the command topic | +| stateMath | Simple math applied to the DPS key value before being published to state topic | +| commandMath | Simple math applied to command value before being set to DPS key | + +### Floating point values +| type | 'float' | +| key | DPS key for the value | +| min | Minumum value allowed for the command topic | +| max | Maximum value allowed for the command topic | +| stateMath | Simple math applied to the DPS key value before being published to state topic | +| commandMath | Simple math applied to command value before being set to DPS key | + +### String values +| type | 'string' | +| key | DPS key for the value | + +### Tuya HSB values (newer style Tuya, 12 character color value) +| type | 'hsb' | +| key | DPS key for the value | +| components | Comma separated list of HSB components that should be included in this topic | + +### Tuya HSBHEX values (older style Tuya 14 character color value) +| type | 'hsbhex' | +| key | DPS key for the value | +| components | Comma separated list of HSB components that should be included in this topic | + +## DPS Topics +Controlling devices directly via DPS topics requires enough knowledge of the device to know which topics accept what values. There are actually two differnt methods interfacing with DPS values, the JSON DPS topic, and the individual DPS key topics. + +### JSON DPS Topics +The JSON DPS topic allows controlling Tuya devices by sending raw, Tuya style JSON messages to the command topic, and by monitoring for Tuya style JSON replies on the state topic. You can get more details on this format by reading the [TuyAPI documentaiton](https://codetheweb.github.io/tuyapi/index.html), but, for example, to turn off a dimmer switch you could issue a MQTT message containing the JSON value {dps: 1, set: false} to the DPS/command topic for the device. If you wanted to turn the dimmer on, and set brightness to 50%, you could issue separate messages {dps: 1, set: true} and then {dps: 2, set: 128}, or, the Tuya JSON protocol also allows setting multiple values in a single set command using the format {'multiple': true, 'data': {'1': true, '2': 128}}. JSON state and commands should use the DPS/state and DPS/command topics respectively. Below is an example of the topics: +``` +tuya/dimmer_device/DPS/state +tuya/dimmer_device/DPS/command +``` +In addition to the JSON DPS topic, it's also possible to use the DPS key topics. DPS key topics allow you to monitor and send simple bool/number/string values directly to DPS keys without having to use the Tuya JSON format, the conversion to Tuya JSON is handled by tuya-mqtt. Using the example from above, turning on the dimmer and setting brightness to 50% you would simply issue the message "true" to DPS/1/command and the message "128" to DPS/2/command. +``` +tuya/dimmer_device/DPS/1/state <-- true/false for on/off state +tuya/dimmer_device/DPS/2/command <-- 1-255 for brightness state +tuya/dimmer_device/DPS/1/state <-- accept true/false for turning device on/off +tuya/dimmer_device/DPS/2/command <-- accepts 1-255 for controlling brightness level +``` +**!!! Important Note !!!** When sending commands directly to DPS values there are no controls on what values are sent as tuya-mqtt has no way to know what are valid vs invalid values. Sending values that are out-of-range or of different types can cause unpredicatable behavior of your device, from causing timeouts, to reboots, to hanging the device. While I've never seen a device not recover after a restart, please keep this in mind when sending commands to your device. \ No newline at end of file From b4942dffa17686b2c8f1f1a61d2006f02f62a4f2 Mon Sep 17 00:00:00 2001 From: tsightler Date: Fri, 16 Oct 2020 21:42:10 -0400 Subject: [PATCH 25/34] 3.0.0 documentation updates --- README.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 440260f..85418ee 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,18 @@ # tuya-mqtt -This project provides an MQTT gateway for locally controlling home automation devices made by Tuya Inc and sold under many different brands. To use this script you will need to obtain the device ID and local keys for each of your devices after they are configured via the Tuya/Smart Life or other Tuya compatible app (there are many). With this information it is possible to communicate locally with Tuya devices using Tuya protocol version 3.1 and 3.3, without using the Tuya Cloud service, however, getting the keys requires signing up for a Tuya IOT developer account or using one of several other alternative methods (such as dumping the memory of a Tuya based app running on Andriod). +This project provides an MQTT gateway for locally controlling IOT devices manufactured by Tuya Inc and sold under many different brands. -Acquiring keys is not part of this project, please see the instructions at the TuyAPI project (on which this script is based) available at the TuyAPI project site: +Using this script requires obtaining the device ID and local keys for each of your devices after they are configured via the Tuya/Smart Life or other Tuya compatible app (there are many). With this information it is possible to communicate locally with Tuya devices using Tuya protocol version 3.1 and 3.3 without using the Tuya Cloud service, however, getting the keys requires signing up for a Tuya IOT developer account or using one of several other alternative methods (such as dumping the memory of a Tuya based app running on Andriod). + +Acquiring device keys outside of the scope of this project. Please see the instructions at the TuyAPI project (on which this script is based) available at the TuyAPI project site: https://github.com/codetheweb/tuyapi/blob/master/docs/SETUP.md. -**!!!!!!!!!! Important information regarding the 3.0 release !!!!!!!!!!** -The 3.0.0 release (Oct 17th, 2020) is a major refactor of the tuya-mqtt project and, as such, is a breaking release. Almost everything about the project is different, including configuration, topic names, etc. Upgrading users should carefully read the instructions below and assume they are starting over from scratch. +Issues opened regarding acquiring keys will be closed without comment. Please verify that your device can be queried and controlled via tuya-cli before opening an issue. If your device can't be controlled by tuya-cli then it cannot be used with this project. + +**!!!!!!!!!! Important information regarding the 3.0 release !!!!!!!!!!**\ +The 3.0.0 release (Oct 17th, 2020) is a major refactor of the tuya-mqtt project and, as such, is a breaking release for all users of previous versions. Almost everything about the project is different, including configuration method, topic names, etc. Upgrading users should carefully read the instructions below and assume they are starting over from scratch. -## Instructions: +## Installation instructions: Download this project to your system into any directory (example below uses /opt/tuya-mqtt) and install tuyapi from the same folder that the tuya-mqtt.js is in ``` // switch to opt directory @@ -35,7 +39,7 @@ cp config.json.sample config.json nano config.json ``` -## Setup devices.conf: +## Seting up devices.conf: If you use the "tuya-cli wizard" method to acquire your device keys you can leverage the output of this tool as the start of your devices.conf file. Otherwise, you want to create a file using a formate like this: ``` [ @@ -68,8 +72,10 @@ DEBUG=tuya-mqtt:* tuya-mqtt.js Set DEBUG=tuya-mqtt:* & node c:/openhab2/userdata/etc/scripts/tuya-mqtt.js ``` -### Tuya DPS values Overview -Tuya devices are monitored and controlled using a simple API where a devices functions are mapped to DPS (data point state) values stored in various numbered keys. For example, a simple on/off switch may have a single key, DPS1, with a setting of true/false representing the on/off state of the device. The device state can be read via this DPS1 key, and, for values that can be changed, sending true/false to DPS 1 will turn the device on/off. A simple dimmer might have the same DPS1 key as true/false for on/off, and a DPS2 key as a value from 0-255 to represent the state of the dimmer value. More complex devices use more DPS keys with various values representing the states and control functions of the device. +### Usage Overview +Tuya devices are monitored and controlled using a simple API where a devices various functions are mapped to DPS (data point state) values stored in various key indexes. For example, a simple on/off switch may have a single key, DPS 1, with a setting of true/false representing the on/off state of the device. The device state can be read via this DPS 1 key, and, for values that can be changed, sending true/false to DPS 1 will turn the device on/off. A simple dimmer might have the same DPS 1 key using true/false for on/off, and an additional DPS 2 key as a value from 0-255 representing the state of the dimmer. More complex devices use more DPS keys with various values representing the states and control functions of the device. + +tuya-mqtt exposes these DPS keys and their values via MQTT to allow for monitoring and control of such devices via anything that can connect to an MQTT broker. ### MQTT Topic Overview The top level topics are created using the device name or ID as the primary identifier. If the device name is available, it will be converted to lowercase and spaced replace with underscores('_') characters so, for example, if using the sample devices.conf file from above, the top level topic would be: @@ -80,7 +86,7 @@ If the device name was not available, it would instead use the device ID: ``` tuya/86435357d8b123456789/ ``` -All other topics are then build below this level. +All state/command topics are then built below this level. tuya-mqtt directly exposes the Tuya DPS keys and values via MQTT topics and you can control any Tuya device using these topics, however, because it is not always easy to translate the Tuya values into something easy to consume by standard Home Automation systems, tuya-mqtt includes a simple templating engine to map DPS values to "friendly topics", i.e. topics that are easier to consume. From 4a0a167863576977c16dc92d2bb238834a2803f5 Mon Sep 17 00:00:00 2001 From: tsightler Date: Fri, 16 Oct 2020 21:43:59 -0400 Subject: [PATCH 26/34] 3.0.0 Documentation updates --- README.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 85418ee..c4cc24e 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ If you use the "tuya-cli wizard" method to acquire your device keys you can leve key: '899810012345678' } ] - +``` Note that, because the format is JSON5, which is a superset of JSON, you can use standard, strict JSON syntax, or the more forgiving JSON5 format, or even mix and match in the same file. While the above syntax is enough to create a working tuya-mqtt install with generic devices, the full power and simplicity of tuya-mqtt 3.0 is only unlocked by configuring device types to get . Please see the full [DEVICES](docs/DEVICES.md) documenation for details. @@ -63,13 +63,8 @@ While the above syntax is enough to create a working tuya-mqtt install with gene ``` node tuya-mqtt.js -// For debugging purpose, to use DEBUG : https://www.npmjs.com/package/debug - -//on Linux machines at the bash command prompt, to turn ON DEBUG: +// To enable debugging output (required when opening an issue) DEBUG=tuya-mqtt:* tuya-mqtt.js - -// on Windows machines at the cmd.exe command prompt, to turn ON DEBUG: -Set DEBUG=tuya-mqtt:* & node c:/openhab2/userdata/etc/scripts/tuya-mqtt.js ``` ### Usage Overview From 1033e129de4aa3adeac8ef245199b464c7ace9c9 Mon Sep 17 00:00:00 2001 From: tsightler Date: Fri, 16 Oct 2020 21:52:18 -0400 Subject: [PATCH 27/34] 3.0.0 Documentation update --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c4cc24e..b4c1585 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Issues opened regarding acquiring keys will be closed without comment. Please v **!!!!!!!!!! Important information regarding the 3.0 release !!!!!!!!!!**\ The 3.0.0 release (Oct 17th, 2020) is a major refactor of the tuya-mqtt project and, as such, is a breaking release for all users of previous versions. Almost everything about the project is different, including configuration method, topic names, etc. Upgrading users should carefully read the instructions below and assume they are starting over from scratch. -## Installation instructions: +## Installation Download this project to your system into any directory (example below uses /opt/tuya-mqtt) and install tuyapi from the same folder that the tuya-mqtt.js is in ``` // switch to opt directory @@ -39,7 +39,7 @@ cp config.json.sample config.json nano config.json ``` -## Seting up devices.conf: +## Setting up devices.conf: If you use the "tuya-cli wizard" method to acquire your device keys you can leverage the output of this tool as the start of your devices.conf file. Otherwise, you want to create a file using a formate like this: ``` [ @@ -87,7 +87,7 @@ tuya-mqtt directly exposes the Tuya DPS keys and values via MQTT topics and you By default, all devices are treated as generic Tuya devices and only the raw DPS values are exposed, however, some devices have predefined templates which can be configured in the device.conf file. Also, you can manually define a template mapping using the "GenericDevice" configuraiton. Please read more details in the [DEVICES](docs/DEVICES.md) documentation. -For more details on DPS and friendly topics, please see the [TOPICS](TOPICS.md) documentation. +For more details on DPS and friendly topics, please see the [TOPICS](docs/TOPICS.md) documentation. ## Issues Not all Tuya protocols are supported. For example, some devices use protocol 3.2 which currently remains unsupported by the TuyAPI project due to lack of enough information to reverse engineer the protcol. If you are unable to control your devices with tuya-mqtt please verify that you can query and control them with tuya-cli first. If tuya-cli works, then this script should also work, if it doesn't then this script will not work either. From 1147b9e8c1caaef78f3a56197153a57c1cadd990 Mon Sep 17 00:00:00 2001 From: tsightler Date: Fri, 16 Oct 2020 22:03:45 -0400 Subject: [PATCH 28/34] 3.0.0 Documentation updates --- README.md | 4 ++-- docs/TOPICS.md | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b4c1585..90c2e0c 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ This project provides an MQTT gateway for locally controlling IOT devices manufa Using this script requires obtaining the device ID and local keys for each of your devices after they are configured via the Tuya/Smart Life or other Tuya compatible app (there are many). With this information it is possible to communicate locally with Tuya devices using Tuya protocol version 3.1 and 3.3 without using the Tuya Cloud service, however, getting the keys requires signing up for a Tuya IOT developer account or using one of several other alternative methods (such as dumping the memory of a Tuya based app running on Andriod). -Acquiring device keys outside of the scope of this project. Please see the instructions at the TuyAPI project (on which this script is based) available at the TuyAPI project site: +Acquiring device keys is outside the scope of this project. Please see the instructions at the TuyAPI project (on which this script is based) available at the TuyAPI project site: https://github.com/codetheweb/tuyapi/blob/master/docs/SETUP.md. -Issues opened regarding acquiring keys will be closed without comment. Please verify that your device can be queried and controlled via tuya-cli before opening an issue. If your device can't be controlled by tuya-cli then it cannot be used with this project. +Issues opened regarding acquiring keys will likely be closed without comment. Please verify that your device can be queried and controlled via tuya-cli before opening an issue. If your device can't be controlled by tuya-cli then it cannot be used with this project. **!!!!!!!!!! Important information regarding the 3.0 release !!!!!!!!!!**\ The 3.0.0 release (Oct 17th, 2020) is a major refactor of the tuya-mqtt project and, as such, is a breaking release for all users of previous versions. Almost everything about the project is different, including configuration method, topic names, etc. Upgrading users should carefully read the instructions below and assume they are starting over from scratch. diff --git a/docs/TOPICS.md b/docs/TOPICS.md index a6c6ed2..c222e15 100644 --- a/docs/TOPICS.md +++ b/docs/TOPICS.md @@ -48,10 +48,14 @@ tuya/dimmer_device/brightness_state <-- Accepts 1-100 scale for brightness % More complex mappings are possible. All pre-defined device templates use the same, built-in, templating engine, so further examples can been seen by browsing the source code of the device files. Below are the available options for each value type: ### Boolean values +| option | value | +| --- | --- | | type | 'bool' | | key | DPS key for the value | ### Integer values +| option | value | +| --- | --- | | type | 'int' | | key | DPS key for the value | | min | Minumum value allowed for the command topic | @@ -60,6 +64,8 @@ More complex mappings are possible. All pre-defined device templates use the sa | commandMath | Simple math applied to command value before being set to DPS key | ### Floating point values +| option | value | +| --- | --- | | type | 'float' | | key | DPS key for the value | | min | Minumum value allowed for the command topic | @@ -68,15 +74,21 @@ More complex mappings are possible. All pre-defined device templates use the sa | commandMath | Simple math applied to command value before being set to DPS key | ### String values +| option | value | +| --- | --- | | type | 'string' | | key | DPS key for the value | ### Tuya HSB values (newer style Tuya, 12 character color value) +| option | value | +| --- | --- | | type | 'hsb' | | key | DPS key for the value | | components | Comma separated list of HSB components that should be included in this topic | ### Tuya HSBHEX values (older style Tuya 14 character color value) +| option | value | +| --- | --- | | type | 'hsbhex' | | key | DPS key for the value | | components | Comma separated list of HSB components that should be included in this topic | @@ -84,12 +96,13 @@ More complex mappings are possible. All pre-defined device templates use the sa ## DPS Topics Controlling devices directly via DPS topics requires enough knowledge of the device to know which topics accept what values. There are actually two differnt methods interfacing with DPS values, the JSON DPS topic, and the individual DPS key topics. -### JSON DPS Topics +### DPS JSON topic The JSON DPS topic allows controlling Tuya devices by sending raw, Tuya style JSON messages to the command topic, and by monitoring for Tuya style JSON replies on the state topic. You can get more details on this format by reading the [TuyAPI documentaiton](https://codetheweb.github.io/tuyapi/index.html), but, for example, to turn off a dimmer switch you could issue a MQTT message containing the JSON value {dps: 1, set: false} to the DPS/command topic for the device. If you wanted to turn the dimmer on, and set brightness to 50%, you could issue separate messages {dps: 1, set: true} and then {dps: 2, set: 128}, or, the Tuya JSON protocol also allows setting multiple values in a single set command using the format {'multiple': true, 'data': {'1': true, '2': 128}}. JSON state and commands should use the DPS/state and DPS/command topics respectively. Below is an example of the topics: ``` tuya/dimmer_device/DPS/state tuya/dimmer_device/DPS/command ``` +### DPS key topics In addition to the JSON DPS topic, it's also possible to use the DPS key topics. DPS key topics allow you to monitor and send simple bool/number/string values directly to DPS keys without having to use the Tuya JSON format, the conversion to Tuya JSON is handled by tuya-mqtt. Using the example from above, turning on the dimmer and setting brightness to 50% you would simply issue the message "true" to DPS/1/command and the message "128" to DPS/2/command. ``` tuya/dimmer_device/DPS/1/state <-- true/false for on/off state From 45a26f84de0ebc22e1f9ef600c8e11fc9fc2d40e Mon Sep 17 00:00:00 2001 From: tsightler Date: Sat, 17 Oct 2020 13:32:50 -0400 Subject: [PATCH 29/34] 3.0.0 Documentation updates --- README.md | 35 ++++++- devices/rgbtw-light.js | 4 +- docs/DEVICES.md | 209 +++++++++++++++++++++++++++++++++++++++++ docs/TOPICS.md | 113 ---------------------- 4 files changed, 242 insertions(+), 119 deletions(-) delete mode 100644 docs/TOPICS.md diff --git a/README.md b/README.md index 90c2e0c..7d89f7d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ npm install ``` ## Configuration -tuya-mqtt uses two different configuration files, config.json is a simple file which contains settings for connection to the MQTT broken, and devices.conf is a JSON5 file which defines the Tuya device that the script should connect to and expose via MQTT. This file uses the same basic format as the "tuya-cli wizard" outputs when used to acquire the device keys, so it can be used as the basis for you configuration. +Tuya-mqtt has two different configuration files. The first is config.json, a simple file which contains settings for connection to the MQTT broker. The second is devices.conf, a JSON5 formatted file which defines the Tuya devices that the script should connect to and expose via MQTT. This file uses the same basic format as the "tuya-cli wizard" outputs when used to acquire the device keys, so it can be used as the basis for your tuya-mqtt device configuration. ### Seting up config.json: ``` @@ -83,11 +83,38 @@ tuya/86435357d8b123456789/ ``` All state/command topics are then built below this level. -tuya-mqtt directly exposes the Tuya DPS keys and values via MQTT topics and you can control any Tuya device using these topics, however, because it is not always easy to translate the Tuya values into something easy to consume by standard Home Automation systems, tuya-mqtt includes a simple templating engine to map DPS values to "friendly topics", i.e. topics that are easier to consume. +Tuya-mqtt supports two distinct topic types for interfacing with and controlling devices. For all devices, the DPS topics are always published and commands are accepted, however, friendly topics allow mapping DPS values into device specific topic names allowing more control over allowed values, and allowing simple functions like math transforms, min/max limits, etc. While it's always possible to use the DPS topics directly, friendly topics are the generally recommended approach but require you to create a template for your device if a pre-defined template for your device does not currently exist. -By default, all devices are treated as generic Tuya devices and only the raw DPS values are exposed, however, some devices have predefined templates which can be configured in the device.conf file. Also, you can manually define a template mapping using the "GenericDevice" configuraiton. Please read more details in the [DEVICES](docs/DEVICES.md) documentation. +If you create a template for your device, please feel free to share it with the community as adding additional pre-defined devices is desired for future versions of tuya-mqtt. There is a templates section of the project that you can submit a PR for your templates. -For more details on DPS and friendly topics, please see the [TOPICS](docs/TOPICS.md) documentation. +If you would like to use the raw DPS topics, please jump to the [DPS topics](#dps-topics) section of this document. + +## Friendly Topics +As noted above, friendly topics are only available when using a pre-defined device template or, for the generic device, when you have defined a custom template for your device. Friendly topics use the tuyq-mqtt templating engine to map raw Tuya DPS key values to easy to consume topics and transform the data where needed. + +Another advantage of friendly topics is that not all devices respond to schema requets (i.e. a request to report all DPS topics the device uses). Because of this, it's not always possible for tuya-mqtt to know which DPS topics to acquire state information from during initial startup. With a defined template the required DPS keys for each friendly topic are configured and tuya-mqtt will always query these DPS key values during initial connection to the device and report their state appropriately. + +Also, when using friendly topics, it is always possible to get the current state of all topics by sending a message to the device "command" topic with the mssage "get-states". For more details on using friendly topics, please read the [DEVICES](docs/DEVICES.md) documentation. + +## DPS Topics +Controlling devices directly via DPS topics requires enough knowledge of the device to know which topics accept what values. There are two differnt methods interfacing with DPS values, the JSON DPS topic, and the individual DPS key topics. + +### DPS JSON topic +The JSON DPS topic allows controlling Tuya devices by sending raw, Tuya style JSON messages to the command topic, and by monitoring for Tuya style JSON replies on the state topic. You can get more details on this format by reading the [TuyAPI documentaiton](https://codetheweb.github.io/tuyapi/index.html), but, for example, to turn off a dimmer switch you could issue a MQTT message containing the JSON value {dps: 1, set: false} to the DPS/command topic for the device. If you wanted to turn the dimmer on, and set brightness to 50%, you could issue separate messages {dps: 1, set: true} and then {dps: 2, set: 128}, or, the Tuya JSON protocol also allows setting multiple values in a single set command using the format {'multiple': true, 'data': {'1': true, '2': 128}}. JSON state and commands should use the DPS/state and DPS/command topics respectively. Below is an example of the topics: +``` +tuya/dimmer_device/DPS/state +tuya/dimmer_device/DPS/command +``` +### DPS Key topics +In addition to the JSON DPS topic, it's also possible to use the DPS key topics. DPS key topics allow you to monitor and send simple bool/number/string values directly to DPS keys without having to use the Tuya JSON format, the conversion to Tuya JSON is handled by tuya-mqtt. Using the example from above, turning on the dimmer and setting brightness to 50% you would simply issue the message "true" to DPS/1/command and the message "128" to DPS/2/command. +``` +tuya/dimmer_device/DPS/1/state <-- true/false for on/off state +tuya/dimmer_device/DPS/2/command <-- 1-255 for brightness state +tuya/dimmer_device/DPS/1/state <-- accept true/false for turning device on/off +tuya/dimmer_device/DPS/2/command <-- accepts 1-255 for controlling brightness level +``` +**!!! Important Note !!!**\ +When sending commands directly to DPS values there are no controls on what values are sent as tuya-mqtt has no way to know what are valid vs invalid values. Sending values that are out-of-range or of different types can cause unpredicatable behavior of your device, from causing timeouts, to reboots, to hanging the device. While I've never seen a device not recover after a restart, please keep this in mind when sending commands to your device. ## Issues Not all Tuya protocols are supported. For example, some devices use protocol 3.2 which currently remains unsupported by the TuyAPI project due to lack of enough information to reverse engineer the protcol. If you are unable to control your devices with tuya-mqtt please verify that you can query and control them with tuya-cli first. If tuya-cli works, then this script should also work, if it doesn't then this script will not work either. diff --git a/devices/rgbtw-light.js b/devices/rgbtw-light.js index 1a66781..e8a4f82 100644 --- a/devices/rgbtw-light.js +++ b/devices/rgbtw-light.js @@ -22,8 +22,8 @@ class RGBTWLight extends TuyaDevice { this.config.dpsWhiteValue = this.config.dpsWhiteValue ? this.config.dpsWhiteValue : this.guess.dpsWhiteValue this.config.whiteValueScale = this.config.whiteValueScale ? this.config.whiteValueScale : this.guess.whiteValueScale this.config.dpsColorTemp = this.config.dpsColorTemp ? this.config.dpsColorTemp : this.guess.dpsColorTemp - this.config.minColorTemp = this.config.minColorTemp ? this.config.minColorTemp : 160 - this.config.maxColorTemp = this.config.maxColorTemp ? this.config.maxColorTemp : 385 + this.config.minColorTemp = this.config.minColorTemp ? this.config.minColorTemp : 154 // ~6500K + this.config.maxColorTemp = this.config.maxColorTemp ? this.config.maxColorTemp : 400 // ~2500K this.config.colorTempScale = this.config.colorTempScale ? this.config.colorTempScale : this.guess.colorTempScale this.config.dpsColor = this.config.dpsColor ? this.config.dpsColor : this.guess.dpsColor this.config.colorType = this.config.colorType ? this.config.colorType : this.guess.colorType diff --git a/docs/DEVICES.md b/docs/DEVICES.md index e69de29..c99efe2 100644 --- a/docs/DEVICES.md +++ b/docs/DEVICES.md @@ -0,0 +1,209 @@ +# Device in tuya-mqtt +The most powerful feature in tuya-mqtt is the ability to configure devices to use friendly topics. For some devices there exist pre-defined device templates which makes using those devices quite easy, simply add the type information to the devices.conf file and tuya-mqtt automatically creates friendly topics for that device. Friendly topics make it easy to communicate with the device in a standard way and thus integrating into various Home Automation platforms. The topic style generally follows that used by the Home Assistant MQTT integration components and the pre-defined devices even send Home Assistant style MQTT discovery messages during startup to make integration with Home Assistant, or other platforms which understand Home Assistant MQTT discovery, even easier. + +If your device does not have a pre-defined device template, you can still create a template using the [generic device template](#generic-device-templates) feature. + +## Pre-defined Device Templates +Pre-defined device templates (except for the Generic Device) will always expose friendly topics for the given device in a consistent manner. Currently the following pre-defined device templates are available: + +| Device Type | Descrition | +| --- | --- | +| SimpleSwitch | Supports simple on/off devices | +| SimpleDimmer | Supports simple devices with on/on and brightness | +| RGBTWLight | Supports color/white lights with optional color temerature support | +| GenericDevice | Allows defining a custom template for any device | + +To use a device template, simply add the "type" option to the devices.conf similar to the following example: +``` +[ + { + name: 'Tuya Device 1', + id: '86435357d8b123456789', + key: '8b2a69c9876543210', + type: 'RGBTWLight' + } +] +``` +Once the device type is defined tuya-mqtt will attempt to create friendly topics for that device type on connection to the device. Each device type defines specific defaults for DPS values which are typical for common Tuya devices and some, like RGBTWLight, have logic to attempt to detect different variation by querying the device. The goal is that, in most cases, simply adding the type is all that is needed, however, in many cases it is also possible to override the manual settings for the device. The device friendly topics and options for each device are documented below. + +### SimpleSwitch +Simple devices that support only on/off. +| Topic | Description | Values | +| --- | --- | --- | +| state | Power state | on/off | +| command | Set power state | on/off, 0/1, true/false | + +Configuration override options: +| Option | Description | Default | +| --- | --- | | +| dpsPower | DPS key for power state | 1 | + +### SimpleDimmer +Simple device with on/off and brightness functions (dimmer switches or lights) +| Topic | Description | Values | +| --- | --- | --- | +| state | Power state | on/off | +| command | Set power state | on/off, 0/1, true/false | +| brightness_state | Brightness in % | 1-100 | +| brightness_command | set brightness in % | 1-100 | + +Configuration override options: +| Option | Description | Default | +| --- | --- | | +| dpsPower | DPS key for power state | 1 | +| dpsBrightness | DPS key for brightness state | 2 | +| brightnessScale | Scale for brightness DPS value | 255 | + +### RGBTWLight +The RGBTWLight device support Tuya color lights (bulbs and LEDs). Tuya lights operate in either white or color mode. The RGBTWLight device automatically switches between modes on certain conditions as documented below: +| Condition | Mode Switch | +| --- | --- | +| Changes white brightness | Yes: white | +| Changes to color temperature (for device with color temp support) | Yes: white | +| Saturation < 10 % | Yes: white | +| Saturation >= 10 % | Yes: color | +| All other changes | No: remain in current mode | + +This means changing the hue of the light will only switch to color mode if saturation is also >= 10%. Some lights automatically switch to color mode when any HSB value is updated, but, if saturation remains < 10%, the code will force the light back to white mode. This sometimes causes a very fast flicker when chaning Hue or color brightness while the saturation is < 10%. I expect this not to be a common issue and implemented this in an attempt to make all bulbs have a consistent behavior. + +When the bulb is in white mode, saturation values in the friendly topics are always reported as 0%. This is true even if the mode is toggled manually from color to white mode using the mode_command or the Tuya/SmartLife app. When the light is toggled back to color mode, saturation will be reported at the correct level. This is done primarly as a means to indicate color state to automation platforms that don't have a concept of white/color mode, otherwise a light in white mode my still be represented with a color icon in the platform UI. + +Not all devices support color temperature and the script attempts to detect this capability and enables the color temperature topics only when found. Color temperature topics report in Mireds (commonly used by automation tools) and the default range supports roughly 2500K-6500K. This works reasonably well for most available Tuya devices, even if they are not exactly in this range, but, if you know a devices specific color range, the limits can be manually specified to more accurately reflect the exact color temperature. + +| Topic | Description | Values | +| --- | --- | --- | +| state | Power state | on/off | +| command | Set power state | on/off, 0/1, true/false | +| white_brightness_state | White mode brightness in % | 1-100 | +| white_brightness_command | Set white mode brightness in % | 1-100 | +| color_brightness_state | Color mode brightness in % | 1-100 | +| color_brightness_command | Set white mode brightness in % | 1-100 | +| hs_state | Hue, saturation % | H,S (Hue 0-360, Saturation 1-100) | +| hs_command | Set hue, saturation % | H,S (Hue 0-360, Saturation 1-100) | +| hsb_state | Hue, saturation %, brightness % | H,S,B (Hue 0-360, Saturation 1-100, Brightness 1-100) | +| hsb_command | Set hue, saturation %, brightness % | H,S,B (Hue 0-360, Saturation 1-100, Brightness 1-100) | +| mode_state | White/Color mode | 'white', 'colour' (some devices also support scenes here) | +| mode_command | Set white/color mode | 'white', 'colour' (some devices also support scenes here) | +| color_temp_state | Color temperature in mireds (only available if device support color temp) | 154-400 (defult range, can be overridden) | +| color_temp_command | Set color temperature in mireds (only available if device support color temp) | 154-400 (defult range, can be overridden) | + +The RGBTWLight function attempts to detect all required options automatically, this works for most common devices which use a standard Tuya DPS configurations (typically 1-5 or 20-24), however, it is possible to override the defaults for all used values. Below are the manual options for RGBTWLight: +| Option | Description | Default (common detected values) | +| --- | --- | | +| dpsPower | DPS key for power state | Auto Detect (1,20) | +| dpsMode | DPS key for white/color mode state | Auto Detect (2,21) | +| dpsWhiteValue | DPS key for white mode brightness | Auto Detect (3,22) | +| whiteValueScale | White mode brightness DPS scale | Auto Detect (255, 1000) | +| dpsColorTemp | DPS key for color temperature | Auto Detect (4,23) | +| minColorTemp | Min color temperature in Mireds | 154 (~6500K) | +| maxColorTemp | Max color temperature in Mireds | 400 (~2500K) | +| colorTempScale | Color temperature DPS key scale | Auto Detect (255, 1000) | +| dpsColor | DPS key for HSB color values | Auto Detect (5,24) | +| colorType | Tuya color format for color DPS key | Auto Detect (hsb, hsbhex) | + +To use these options simply add them to device.conf file, example: +``` +[ + { + name: 'Tuya Device 1', + id: '86435357d8b123456789', + key: '8b2a69c9876543210', + type: 'RGBTWLight', + dpsPower: 31, + dpsMode: 32, + dpsWhiteValue: 33, + whiteValueScale: 255, + dpsColorTemp: 34, + minColorTemp: 165, + maxColorTemp: 385, + colorTempScale: 255, + dpsColor: 34, + colorType: 'hsbhex' + } +] +``` + +## Generic Device Templates +If a pre-defined device tempate does not exist for the device, or does not expose all capabilities of the device, there are still mulitple options available to control the devices. One method is to use the DPS topics directly to control the device using either native Tuya JSON commands or via the DPS key values by using the DPS key topics (see [DPS Topics](TOPICS.md#dps-topics)). The second method is to create a template for your device to map DPS key values to friendly topics. The GenericDevice type allows you to manually create a template for any device using the same templating engine as the pre-defined device templates. Once you've created a tempalte for your device, it can be re-used with other, similar devices and you can submit your template to the tuya-mqtt project for other to use, or even for inclusion at a pre-defined device template in the future. + +Creating a device template is relatively straightforward, but first you must know what DPS keys your devices uses. The GenericDevice attempts to query all device DPS states on startup, but some devices to not respond to this command, however, the generic device will ALWAYS report any DPS topics from which it receives upated. The easiest way to determine how your device uses it's DPS topics is to connect to the MQTT broker via a tool like MQTT Explorer or mosquitto_sub, and watch the topics as you manipulate the device with the Tuya/Smartlife app. + +Once you have a reasonable idea of how the device uses it's DPS key values, you can create a template. A simple template for a dimmer looks something like this: +``` +[ + { + name: 'Tuya Device 1', + id: '86435357d8b123456789', + key: '8b2a69c9876543210', + template: { + state: { + key: 1, + type: 'bool' + }, + brightness_state: { + key: 2, + type: 'int', + topicMin: 1, + topicMax: 100, + stateMath: '/2.55', + commandMath: '*2.55' + } + } + } +] +``` +The template above defines two topics "state" and "brightness_state", and the template engine automatically creates the corresponding command topics, in this case specifically "command" and "brightness_command". + +The "state" topic maps to DPS key 1, and uses a bool (true/false) value in the DPS key. Now you will be able to see "on/off" state in the state topic instead of having to read the true/false value from the DPS/1 topic + +The the "brightness_state" topic maps to DPS key 2, and this value defines the brightness using an integer in the 1-255 scale. We define the value as an integer (type: 'int') and the stateMath and commandMath values allow transforming the raw DPS value into a more friendly value that will be presented in the topic. In this case the raw DPS value will be divided by 2.55 before being published to the state, and and received commands will be mulitpled by that same value, converting the 1-255 to a simple 1-100 scale. Note that the topicMin and topicMax values set the minimum and maximum values that the state topic will report and that the command topic will accept. These values are "post-math" for state topics, and "pre-math" for command topics. + +The following tables define the available template value types and their options: + +### Boolean values +| option | value | +| --- | --- | +| type | 'bool' | +| key | DPS key of the value | + +### Integer values +| option | value | +| --- | --- | +| type | 'int' | +| key | DPS key of the value | +| topicMin | Minumum value allowed for the command topic | +| topicMax | Maximum value allowed for the command topic | +| stateMath | Simple math applied to the DPS key value before being published to state topic | +| commandMath | Simple math applied to command value before being set to DPS key | + +### Floating point values +| option | value | +| --- | --- | +| type | 'float' | +| key | DPS key of the value | +| topicMin | Minumum value allowed for the command topic | +| topicMax | Maximum value allowed for the command topic | +| stateMath | Simple math applied to the DPS key value before being published to state topic | +| commandMath | Simple math applied to command value before being set to DPS key | + +### String values +| option | value | +| --- | --- | +| type | 'string' | +| key | DPS key of the value | + +### Tuya HSB values (newer style Tuya, 12 character color value) +| option | value | +| --- | --- | +| type | 'hsb' | +| key | DPS key of the value | +| components | Comma separated list of HSB components that should be included in this topic | + +### Tuya HSBHEX values (older style Tuya 14 character color value) +| option | value | +| --- | --- | +| type | 'hsbhex' | +| key | DPS key of the value | +| components | Comma separated list of HSB components that should be included in this topic | + +Using these value types you can define templates for a wide range of devices. Additional types and options are likely to be included in future versions of tuya-mqtt. \ No newline at end of file diff --git a/docs/TOPICS.md b/docs/TOPICS.md deleted file mode 100644 index c222e15..0000000 --- a/docs/TOPICS.md +++ /dev/null @@ -1,113 +0,0 @@ -# tuya-mqtt Topics -tuya-mqtt support two styles of topics for devices. For all devices the DPS topics are always published and commands are accepted, however, friendly topics allow mapping DPS values into friendier topics names with more control over allowed values, and allow simple functions like math transforms, etc. While it's always possible to use the DPS topics directly, friendly topics are the generally recommended approach but require you to create a template for your device if it doesn't match one of the pre-defined templates. - -If you create a template for your device, please feel free to share it with the community as adding additional pre-defined devices is desired for future versions of tuya-mqtt. - -If you would like to use the raw DPS topics, please jump to the DPS topic section of this document. - -## Friendly Topics -Friendly topics are only available when using a pre-defined device template or, when using the generic device, when you have defined a custom template. Friendly topics use a simple templating engine to map raw Tuya DPS key values to easy to consume topic and transform the data where needed. The other advantage to using friendly topics is that not all devices respond to schema requets so it's not always possible for tuya-mqtt to know which DPS topics to acquire state information from during startup. With a template the required DPS topics are configured and tuya-mqtt will always query these individual values during initial connection to the device. - -When using pre-device device templates, please see the appropriate section in the [DEVICES](docs/DEVICES.md) documentation. When using a generic device, you can define a template in the devices.conf file. Imagine a simple dimmer with key 1 representing on/off and key 2 represeting brightness. When using just the basic devices.conf file, controlling a device requires using the DPS topics directly, for example: -``` -tuya/dimmer_device/DPS/1 <-- true/false for state and control -tuya/dimmer_device/DPS/2 <-- value 1-255, for state and control, accepts invalid values, etc. - -``` -While this will work, in this case the automation system would need to understand true/false vs on/off, and would need to be manually configured for 1-255 scale instead of, for example, a 1-100% scale (some systems deal with this quite easily, others, not so much). However, using a template you can quickly create an easy to use set of friendly topics with easier to consume values: -``` -[ - { - name: 'Tuya Device 1', - id: '86435357d8b123456789', - key: '8b2a69c9876543210', - template: { - state: { - key: 1, - type: 'bool' - }, - brightness_state: { - key: 2, - type: 'int', - min: 1, - max: 255, - stateMath: '/2.55', - commandMath: '*2.55' - } - } - } -] -``` -Now, controlling the device can be done with the following topics: -``` -tuya/dimmer_device/state <-- Reports ON/OFF -tuya/dimmer_device/command <-- Accepts 0/1, on/off, or true/false -tuya/dimmer_device/brightness_state <-- Reports 1-100 scale for brightness % -tuya/dimmer_device/brightness_state <-- Accepts 1-100 scale for brightness % -``` -More complex mappings are possible. All pre-defined device templates use the same, built-in, templating engine, so further examples can been seen by browsing the source code of the device files. Below are the available options for each value type: - -### Boolean values -| option | value | -| --- | --- | -| type | 'bool' | -| key | DPS key for the value | - -### Integer values -| option | value | -| --- | --- | -| type | 'int' | -| key | DPS key for the value | -| min | Minumum value allowed for the command topic | -| max | Maximum value allowed for the command topic | -| stateMath | Simple math applied to the DPS key value before being published to state topic | -| commandMath | Simple math applied to command value before being set to DPS key | - -### Floating point values -| option | value | -| --- | --- | -| type | 'float' | -| key | DPS key for the value | -| min | Minumum value allowed for the command topic | -| max | Maximum value allowed for the command topic | -| stateMath | Simple math applied to the DPS key value before being published to state topic | -| commandMath | Simple math applied to command value before being set to DPS key | - -### String values -| option | value | -| --- | --- | -| type | 'string' | -| key | DPS key for the value | - -### Tuya HSB values (newer style Tuya, 12 character color value) -| option | value | -| --- | --- | -| type | 'hsb' | -| key | DPS key for the value | -| components | Comma separated list of HSB components that should be included in this topic | - -### Tuya HSBHEX values (older style Tuya 14 character color value) -| option | value | -| --- | --- | -| type | 'hsbhex' | -| key | DPS key for the value | -| components | Comma separated list of HSB components that should be included in this topic | - -## DPS Topics -Controlling devices directly via DPS topics requires enough knowledge of the device to know which topics accept what values. There are actually two differnt methods interfacing with DPS values, the JSON DPS topic, and the individual DPS key topics. - -### DPS JSON topic -The JSON DPS topic allows controlling Tuya devices by sending raw, Tuya style JSON messages to the command topic, and by monitoring for Tuya style JSON replies on the state topic. You can get more details on this format by reading the [TuyAPI documentaiton](https://codetheweb.github.io/tuyapi/index.html), but, for example, to turn off a dimmer switch you could issue a MQTT message containing the JSON value {dps: 1, set: false} to the DPS/command topic for the device. If you wanted to turn the dimmer on, and set brightness to 50%, you could issue separate messages {dps: 1, set: true} and then {dps: 2, set: 128}, or, the Tuya JSON protocol also allows setting multiple values in a single set command using the format {'multiple': true, 'data': {'1': true, '2': 128}}. JSON state and commands should use the DPS/state and DPS/command topics respectively. Below is an example of the topics: -``` -tuya/dimmer_device/DPS/state -tuya/dimmer_device/DPS/command -``` -### DPS key topics -In addition to the JSON DPS topic, it's also possible to use the DPS key topics. DPS key topics allow you to monitor and send simple bool/number/string values directly to DPS keys without having to use the Tuya JSON format, the conversion to Tuya JSON is handled by tuya-mqtt. Using the example from above, turning on the dimmer and setting brightness to 50% you would simply issue the message "true" to DPS/1/command and the message "128" to DPS/2/command. -``` -tuya/dimmer_device/DPS/1/state <-- true/false for on/off state -tuya/dimmer_device/DPS/2/command <-- 1-255 for brightness state -tuya/dimmer_device/DPS/1/state <-- accept true/false for turning device on/off -tuya/dimmer_device/DPS/2/command <-- accepts 1-255 for controlling brightness level -``` -**!!! Important Note !!!** When sending commands directly to DPS values there are no controls on what values are sent as tuya-mqtt has no way to know what are valid vs invalid values. Sending values that are out-of-range or of different types can cause unpredicatable behavior of your device, from causing timeouts, to reboots, to hanging the device. While I've never seen a device not recover after a restart, please keep this in mind when sending commands to your device. \ No newline at end of file From 535b6edecfe11281cb19ef3b50a1e3d98cabc49c Mon Sep 17 00:00:00 2001 From: tsightler Date: Sat, 17 Oct 2020 13:53:22 -0400 Subject: [PATCH 30/34] 3.0.0 Documentation updates --- README.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7d89f7d..c9d4f8c 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ cp config.json.sample config.json nano config.json ``` -## Setting up devices.conf: +### Setting up devices.conf: If you use the "tuya-cli wizard" method to acquire your device keys you can leverage the output of this tool as the start of your devices.conf file. Otherwise, you want to create a file using a formate like this: ``` [ @@ -59,7 +59,7 @@ Note that, because the format is JSON5, which is a superset of JSON, you can use While the above syntax is enough to create a working tuya-mqtt install with generic devices, the full power and simplicity of tuya-mqtt 3.0 is only unlocked by configuring device types to get . Please see the full [DEVICES](docs/DEVICES.md) documenation for details. -### Start command +### Starting tuya-mqtt ``` node tuya-mqtt.js @@ -68,24 +68,30 @@ DEBUG=tuya-mqtt:* tuya-mqtt.js ``` ### Usage Overview -Tuya devices are monitored and controlled using a simple API where a devices various functions are mapped to DPS (data point state) values stored in various key indexes. For example, a simple on/off switch may have a single key, DPS 1, with a setting of true/false representing the on/off state of the device. The device state can be read via this DPS 1 key, and, for values that can be changed, sending true/false to DPS 1 will turn the device on/off. A simple dimmer might have the same DPS 1 key using true/false for on/off, and an additional DPS 2 key as a value from 0-255 representing the state of the dimmer. More complex devices use more DPS keys with various values representing the states and control functions of the device. +Tuya devices maps their unique functions to various values stored in data points (DPS) which are references by an index number, generally referred to as the DPS key. For example, a simple on/off switch may have a single DPS value, store in DPS index 1, with a setting of true/false representing the on/off state of the device. The device state can be read via DPS1, and, for values that can be changed, sending true/false to DPS1 will turn the device on/off. A simple dimmer might have the same DPS1 value, but an additional DPS2 value from 1-255 representing the state of the dimmer. More complex devices use more DPS keys with various values representing the states and control functions of the device. -tuya-mqtt exposes these DPS keys and their values via MQTT to allow for monitoring and control of such devices via anything that can connect to an MQTT broker. +The tuya-mqtt script provides access to these DPS keys and their values via MQTT, allowing any tool that can use MQTT to monitoring and control these devices locally. In addition to providing access to the raw DPS data, there is also a simple template engine that allows those DPS values to be mapped to device specific topics, called "friendly topics" allowing for consistent mapping even between devices that use different DPS keys for the same functions. These friendly topics also support various transforms and other functions that make it easier for other devices to communicate with Tuya devices without know specific details of how those devices store that data. ### MQTT Topic Overview -The top level topics are created using the device name or ID as the primary identifier. If the device name is available, it will be converted to lowercase and spaced replace with underscores('_') characters so, for example, if using the sample devices.conf file from above, the top level topic would be: +The top level topics are created using the device name or ID as the primary identifier. If the device name is available, it will be converted to lowercase and any spaces replace with underscores('_') characters so, for example, if the device as the name "Kitche Table", the top level topic would be: ``` -tuya/tuya_device_1/ +tuya/kitche_table/ ``` -If the device name was not available, it would instead use the device ID: +If the device name was not available in the devices.conf file, tuya-mqtt falls back to using the device ID for the top level topic: ``` tuya/86435357d8b123456789/ ``` -All state/command topics are then built below this level. - -Tuya-mqtt supports two distinct topic types for interfacing with and controlling devices. For all devices, the DPS topics are always published and commands are accepted, however, friendly topics allow mapping DPS values into device specific topic names allowing more control over allowed values, and allowing simple functions like math transforms, min/max limits, etc. While it's always possible to use the DPS topics directly, friendly topics are the generally recommended approach but require you to create a template for your device if a pre-defined template for your device does not currently exist. +All additional state/command topics are then built below this level. You can view the status of the device using the status topic, which reports "online" or "offline" based on whether tuya-mqtt currently has an active connection to the device or not. +``` +tuya/kitche_table/state <-- oneline/offline +``` +You can also trigger the device to send an immediate update of all known device DPS topics by sending the message "get-states" to the command topic (this topic exist even if there is no correspoinding state topic): +``` +tuya/kitche_table/command <-- get-states +``` +As noted above, tuya-mqtt supports two distinct topic types for interfacing with and controlling devices. For all devices, the DPS topics are always published and commands are accepted, however, friendly topics are the generally recommended approach but require you to create a template for your device if a pre-defined template does not currently exist. -If you create a template for your device, please feel free to share it with the community as adding additional pre-defined devices is desired for future versions of tuya-mqtt. There is a templates section of the project that you can submit a PR for your templates. +If you do create a template for your device, please feel free to share it with the community as adding additional pre-defined devices is desired for future versions of tuya-mqtt. There is a templates section of the project that you can submit a PR for your templates. If you would like to use the raw DPS topics, please jump to the [DPS topics](#dps-topics) section of this document. @@ -94,7 +100,7 @@ As noted above, friendly topics are only available when using a pre-defined devi Another advantage of friendly topics is that not all devices respond to schema requets (i.e. a request to report all DPS topics the device uses). Because of this, it's not always possible for tuya-mqtt to know which DPS topics to acquire state information from during initial startup. With a defined template the required DPS keys for each friendly topic are configured and tuya-mqtt will always query these DPS key values during initial connection to the device and report their state appropriately. -Also, when using friendly topics, it is always possible to get the current state of all topics by sending a message to the device "command" topic with the mssage "get-states". For more details on using friendly topics, please read the [DEVICES](docs/DEVICES.md) documentation. +For more details on using friendly topics, please read the [DEVICES](docs/DEVICES.md) documentation. ## DPS Topics Controlling devices directly via DPS topics requires enough knowledge of the device to know which topics accept what values. There are two differnt methods interfacing with DPS values, the JSON DPS topic, and the individual DPS key topics. From 66541558dd2e130d4b9c2a2e87c2e09e5532d85b Mon Sep 17 00:00:00 2001 From: tsightler Date: Sat, 17 Oct 2020 14:13:35 -0400 Subject: [PATCH 31/34] 3.0.0 Documentation updates --- README.md | 22 +++++++++++----------- docs/DEVICES.md | 32 +++++++++++++++++--------------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index c9d4f8c..888361a 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,9 @@ This project provides an MQTT gateway for locally controlling IOT devices manufa Using this script requires obtaining the device ID and local keys for each of your devices after they are configured via the Tuya/Smart Life or other Tuya compatible app (there are many). With this information it is possible to communicate locally with Tuya devices using Tuya protocol version 3.1 and 3.3 without using the Tuya Cloud service, however, getting the keys requires signing up for a Tuya IOT developer account or using one of several other alternative methods (such as dumping the memory of a Tuya based app running on Andriod). -Acquiring device keys is outside the scope of this project. Please see the instructions at the TuyAPI project (on which this script is based) available at the TuyAPI project site: +To acquire keys for your device please see the instructions at the TuyAPI project (on which this script is based) available at the [TuyAPI GitHub site](https://github.com/codetheweb/tuyapi/blob/master/docs/SETUP.md). -https://github.com/codetheweb/tuyapi/blob/master/docs/SETUP.md. - -Issues opened regarding acquiring keys will likely be closed without comment. Please verify that your device can be queried and controlled via tuya-cli before opening an issue. If your device can't be controlled by tuya-cli then it cannot be used with this project. +**Acquiring device keys is outside the scope of this project!** Issues opened regarding acquiring keys will likely be closed without comment. Please verify that your device can be queried and controlled via tuya-cli before opening any issue. If your device can't be controlled by tuya-cli then it cannot be used with this project. **!!!!!!!!!! Important information regarding the 3.0 release !!!!!!!!!!**\ The 3.0.0 release (Oct 17th, 2020) is a major refactor of the tuya-mqtt project and, as such, is a breaking release for all users of previous versions. Almost everything about the project is different, including configuration method, topic names, etc. Upgrading users should carefully read the instructions below and assume they are starting over from scratch. @@ -34,8 +32,9 @@ Tuya-mqtt has two different configuration files. The first is config.json, a si ### Seting up config.json: ``` cp config.json.sample config.json - -// Edit config.json with your MQTT broker settings and save +``` +**Edit config.json with your MQTT broker settings and save** +``` nano config.json ``` @@ -62,8 +61,9 @@ While the above syntax is enough to create a working tuya-mqtt install with gene ### Starting tuya-mqtt ``` node tuya-mqtt.js - -// To enable debugging output (required when opening an issue) +``` +**Enable debugging output (required when opening an issue)** +``` DEBUG=tuya-mqtt:* tuya-mqtt.js ``` @@ -83,7 +83,7 @@ tuya/86435357d8b123456789/ ``` All additional state/command topics are then built below this level. You can view the status of the device using the status topic, which reports "online" or "offline" based on whether tuya-mqtt currently has an active connection to the device or not. ``` -tuya/kitche_table/state <-- oneline/offline +tuya/kitche_table/state --> online/offline ``` You can also trigger the device to send an immediate update of all known device DPS topics by sending the message "get-states" to the command topic (this topic exist even if there is no correspoinding state topic): ``` @@ -114,9 +114,9 @@ tuya/dimmer_device/DPS/command ### DPS Key topics In addition to the JSON DPS topic, it's also possible to use the DPS key topics. DPS key topics allow you to monitor and send simple bool/number/string values directly to DPS keys without having to use the Tuya JSON format, the conversion to Tuya JSON is handled by tuya-mqtt. Using the example from above, turning on the dimmer and setting brightness to 50% you would simply issue the message "true" to DPS/1/command and the message "128" to DPS/2/command. ``` -tuya/dimmer_device/DPS/1/state <-- true/false for on/off state +tuya/dimmer_device/DPS/1/state --> true/false for on/off state tuya/dimmer_device/DPS/2/command <-- 1-255 for brightness state -tuya/dimmer_device/DPS/1/state <-- accept true/false for turning device on/off +tuya/dimmer_device/DPS/1/state --> accept true/false for turning device on/off tuya/dimmer_device/DPS/2/command <-- accepts 1-255 for controlling brightness level ``` **!!! Important Note !!!**\ diff --git a/docs/DEVICES.md b/docs/DEVICES.md index c99efe2..0938fe5 100644 --- a/docs/DEVICES.md +++ b/docs/DEVICES.md @@ -33,9 +33,9 @@ Simple devices that support only on/off. | state | Power state | on/off | | command | Set power state | on/off, 0/1, true/false | -Configuration override options: +Manual configuration options: | Option | Description | Default | -| --- | --- | | +| --- | --- | --- | | dpsPower | DPS key for power state | 1 | ### SimpleDimmer @@ -47,29 +47,31 @@ Simple device with on/off and brightness functions (dimmer switches or lights) | brightness_state | Brightness in % | 1-100 | | brightness_command | set brightness in % | 1-100 | -Configuration override options: +Manual configuration options: | Option | Description | Default | -| --- | --- | | +| --- | --- | --- | | dpsPower | DPS key for power state | 1 | | dpsBrightness | DPS key for brightness state | 2 | | brightnessScale | Scale for brightness DPS value | 255 | ### RGBTWLight The RGBTWLight device support Tuya color lights (bulbs and LEDs). Tuya lights operate in either white or color mode. The RGBTWLight device automatically switches between modes on certain conditions as documented below: -| Condition | Mode Switch | +| Condition | Mode | | --- | --- | -| Changes white brightness | Yes: white | -| Changes to color temperature (for device with color temp support) | Yes: white | -| Saturation < 10 % | Yes: white | -| Saturation >= 10 % | Yes: color | -| All other changes | No: remain in current mode | +| Changes white brightness | white | +| Changes to color temperature (for device with color temp support) | white | +| Saturation < 10 % | white | +| Saturation >= 10 % | color | +| All other changes | current mode | -This means changing the hue of the light will only switch to color mode if saturation is also >= 10%. Some lights automatically switch to color mode when any HSB value is updated, but, if saturation remains < 10%, the code will force the light back to white mode. This sometimes causes a very fast flicker when chaning Hue or color brightness while the saturation is < 10%. I expect this not to be a common issue and implemented this in an attempt to make all bulbs have a consistent behavior. +This means changing the hue of the light will only switch to color mode if saturation is also >= 10%. Some lights automatically attempt to switch to color mode when any HSB value is updated however, if the saturation setting remains < 10%, tuya-mqtt will force the light back to white mode in this case. This can cause a very quick flicker when chaning hue or color brightness while the saturation remains below the 10% threshold. I expect this not to be a common issue and implemented this in an attempt to make all tuya lights behave in a consistent way. -When the bulb is in white mode, saturation values in the friendly topics are always reported as 0%. This is true even if the mode is toggled manually from color to white mode using the mode_command or the Tuya/SmartLife app. When the light is toggled back to color mode, saturation will be reported at the correct level. This is done primarly as a means to indicate color state to automation platforms that don't have a concept of white/color mode, otherwise a light in white mode my still be represented with a color icon in the platform UI. +When the bulb is in white mode, saturation values in the friendly topics are always reported as 0%. This is true even if the mode is toggled manually from color to white mode using the mode_command topic or the Tuya/SmartLife app. When the light is toggled back to color mode, saturation will be reported at the correct level. This is done primarly as a means to indicate color state to automation platforms that don't have a concept of white/color mode, otherwise a light in white mode may still be represented with a color icon in the platforms UI. Not all devices support color temperature and the script attempts to detect this capability and enables the color temperature topics only when found. Color temperature topics report in Mireds (commonly used by automation tools) and the default range supports roughly 2500K-6500K. This works reasonably well for most available Tuya devices, even if they are not exactly in this range, but, if you know a devices specific color range, the limits can be manually specified to more accurately reflect the exact color temperature. +Tuya bulbs store their HSB color value in a single DPS key using a custom format. Some bulbs use a 14 character format, referred to as HSBHEX, which represents the saturation and brightness values from 0-255 as 2 character hex, while the others use a 12 character format, referred to as HSB, which still uses hex values, but stores saturation and brightness values from 0-1000 as 4 character hex. The code attempts to autodetect the format used by the bulb and perform the proper conversion in all cases, but this can be overridden for cases where the dection method fails. + | Topic | Description | Values | | --- | --- | --- | | state | Power state | on/off | @@ -87,9 +89,9 @@ Not all devices support color temperature and the script attempts to detect this | color_temp_state | Color temperature in mireds (only available if device support color temp) | 154-400 (defult range, can be overridden) | | color_temp_command | Set color temperature in mireds (only available if device support color temp) | 154-400 (defult range, can be overridden) | -The RGBTWLight function attempts to detect all required options automatically, this works for most common devices which use a standard Tuya DPS configurations (typically 1-5 or 20-24), however, it is possible to override the defaults for all used values. Below are the manual options for RGBTWLight: +Manual configuration options: | Option | Description | Default (common detected values) | -| --- | --- | | +| --- | --- | --- | | dpsPower | DPS key for power state | Auto Detect (1,20) | | dpsMode | DPS key for white/color mode state | Auto Detect (2,21) | | dpsWhiteValue | DPS key for white mode brightness | Auto Detect (3,22) | @@ -101,7 +103,7 @@ The RGBTWLight function attempts to detect all required options automatically, t | dpsColor | DPS key for HSB color values | Auto Detect (5,24) | | colorType | Tuya color format for color DPS key | Auto Detect (hsb, hsbhex) | -To use these options simply add them to device.conf file, example: +To use the manual configuration options simply add them to device.conf file after defining the device type like the following example: ``` [ { From 5219c94cd7144a64707ae5f91abf95b629c652f4 Mon Sep 17 00:00:00 2001 From: tsightler Date: Sat, 17 Oct 2020 16:38:57 -0400 Subject: [PATCH 32/34] 3.0.0 Documentation update --- README.md | 2 +- docs/DEVICES.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 888361a..3e7a7b9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # tuya-mqtt -This project provides an MQTT gateway for locally controlling IOT devices manufactured by Tuya Inc and sold under many different brands. +This project provides a method for locally controlling IOT devices manufactured by Tuya Inc., and sold under many different brands, via MQTT. Using this script requires obtaining the device ID and local keys for each of your devices after they are configured via the Tuya/Smart Life or other Tuya compatible app (there are many). With this information it is possible to communicate locally with Tuya devices using Tuya protocol version 3.1 and 3.3 without using the Tuya Cloud service, however, getting the keys requires signing up for a Tuya IOT developer account or using one of several other alternative methods (such as dumping the memory of a Tuya based app running on Andriod). diff --git a/docs/DEVICES.md b/docs/DEVICES.md index 0938fe5..c220783 100644 --- a/docs/DEVICES.md +++ b/docs/DEVICES.md @@ -1,4 +1,4 @@ -# Device in tuya-mqtt +# tuya-mqtt - Devices The most powerful feature in tuya-mqtt is the ability to configure devices to use friendly topics. For some devices there exist pre-defined device templates which makes using those devices quite easy, simply add the type information to the devices.conf file and tuya-mqtt automatically creates friendly topics for that device. Friendly topics make it easy to communicate with the device in a standard way and thus integrating into various Home Automation platforms. The topic style generally follows that used by the Home Assistant MQTT integration components and the pre-defined devices even send Home Assistant style MQTT discovery messages during startup to make integration with Home Assistant, or other platforms which understand Home Assistant MQTT discovery, even easier. If your device does not have a pre-defined device template, you can still create a template using the [generic device template](#generic-device-templates) feature. From 51bbd612f2ed69c9976aa60ecb53a27a88a79a2b Mon Sep 17 00:00:00 2001 From: tsightler Date: Sat, 17 Oct 2020 17:21:38 -0400 Subject: [PATCH 33/34] 3.0.0 Documentation update --- README.md | 26 +++++++++++++------------- docs/DEVICES.md | 6 ++++-- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3e7a7b9..2a3883a 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Tuya-mqtt has two different configuration files. The first is config.json, a si ``` cp config.json.sample config.json ``` -**Edit config.json with your MQTT broker settings and save** +Edit config.json with your MQTT broker settings and save: ``` nano config.json ``` @@ -62,51 +62,51 @@ While the above syntax is enough to create a working tuya-mqtt install with gene ``` node tuya-mqtt.js ``` -**Enable debugging output (required when opening an issue)** +To enable debugging output (required when opening an issue): ``` DEBUG=tuya-mqtt:* tuya-mqtt.js ``` ### Usage Overview -Tuya devices maps their unique functions to various values stored in data points (DPS) which are references by an index number, generally referred to as the DPS key. For example, a simple on/off switch may have a single DPS value, store in DPS index 1, with a setting of true/false representing the on/off state of the device. The device state can be read via DPS1, and, for values that can be changed, sending true/false to DPS1 will turn the device on/off. A simple dimmer might have the same DPS1 value, but an additional DPS2 value from 1-255 representing the state of the dimmer. More complex devices use more DPS keys with various values representing the states and control functions of the device. +Tuya devices work by mapping device functions to various values stored in data points (refered to as DPS values) which are referenced via an index number, referred to as the DPS key. For example, a simple on/off switch may have a single DPS value, stored in DPS kep 1 (DPS1). This value is likely to have a setting of true/false representing the on/off state of the device. The device state can be read via DPS1, and, for values that can be changed (some DPS values are read-only), sending true/false to DPS1 will turn the device on/off. A simple dimmer might have the same DPS1 value, but an additional DPS2 value from 1-255 representing the state of the dimmer. More complex devices use more DPS keys with various values representing the states and control functions of the device. -The tuya-mqtt script provides access to these DPS keys and their values via MQTT, allowing any tool that can use MQTT to monitoring and control these devices locally. In addition to providing access to the raw DPS data, there is also a simple template engine that allows those DPS values to be mapped to device specific topics, called "friendly topics" allowing for consistent mapping even between devices that use different DPS keys for the same functions. These friendly topics also support various transforms and other functions that make it easier for other devices to communicate with Tuya devices without know specific details of how those devices store that data. +The tuya-mqtt script provides access to these DPS keys and their values via MQTT, allowing any tool that can use MQTT to monitor and control these devices via a local network connection. In addition to providing access to the raw DPS data, there is also a template engine that allows those DPS values to be mapped to device specific topics, called "friendly topics", allowing for consistent mapping even between devices that use different DPS keys for the same functions. These friendly topics also support various transforms and other functions that make it easier for other devices to communicate with Tuya devices without a detailed understanding of the data formats Tuya devices use. ### MQTT Topic Overview The top level topics are created using the device name or ID as the primary identifier. If the device name is available, it will be converted to lowercase and any spaces replace with underscores('_') characters so, for example, if the device as the name "Kitche Table", the top level topic would be: ``` -tuya/kitche_table/ +tuya/kitchen_table/ ``` If the device name was not available in the devices.conf file, tuya-mqtt falls back to using the device ID for the top level topic: ``` tuya/86435357d8b123456789/ ``` -All additional state/command topics are then built below this level. You can view the status of the device using the status topic, which reports "online" or "offline" based on whether tuya-mqtt currently has an active connection to the device or not. +All additional state/command topics are then built below this level. You can view the connectivity status of the device using the status topic, which reports online/offline based on whether tuya-mqtt has an active connection to the device or not. The script monitors both the device socket connection for errors and also device heartbeats, to report proper status. ``` tuya/kitche_table/state --> online/offline ``` -You can also trigger the device to send an immediate update of all known device DPS topics by sending the message "get-states" to the command topic (this topic exist even if there is no correspoinding state topic): +You can also trigger the device to send an immediate update of all known device DPS topics by sending the message "get-states" to the command topic (this topic exist for all devices): ``` tuya/kitche_table/command <-- get-states ``` -As noted above, tuya-mqtt supports two distinct topic types for interfacing with and controlling devices. For all devices, the DPS topics are always published and commands are accepted, however, friendly topics are the generally recommended approach but require you to create a template for your device if a pre-defined template does not currently exist. +As noted above, tuya-mqtt supports two distinct topic types for interfacing with and controlling devices. For all devices, the DPS topics are always published and commands are accepted, however, friendly topics are the generally recommended approach but require you to use a pre-defined device template or create a customer template for your device when using the generic device. If you do create a template for your device, please feel free to share it with the community as adding additional pre-defined devices is desired for future versions of tuya-mqtt. There is a templates section of the project that you can submit a PR for your templates. If you would like to use the raw DPS topics, please jump to the [DPS topics](#dps-topics) section of this document. ## Friendly Topics -As noted above, friendly topics are only available when using a pre-defined device template or, for the generic device, when you have defined a custom template for your device. Friendly topics use the tuyq-mqtt templating engine to map raw Tuya DPS key values to easy to consume topics and transform the data where needed. +Friendly topics are only available when using a pre-defined device template or, for the generic device, when you have defined a custom template for your device. Friendly topics use the tuyq-mqtt templating engine to map raw Tuya DPS key values to easy to consume topics and transform the data where needed. Another advantage of friendly topics is that not all devices respond to schema requets (i.e. a request to report all DPS topics the device uses). Because of this, it's not always possible for tuya-mqtt to know which DPS topics to acquire state information from during initial startup. With a defined template the required DPS keys for each friendly topic are configured and tuya-mqtt will always query these DPS key values during initial connection to the device and report their state appropriately. -For more details on using friendly topics, please read the [DEVICES](docs/DEVICES.md) documentation. +For more details on using friendly topics, please read the [DEVICES](docs/DEVICES.md) documentation which discusses how to configure supported devices or define a custom template. ## DPS Topics -Controlling devices directly via DPS topics requires enough knowledge of the device to know which topics accept what values. There are two differnt methods interfacing with DPS values, the JSON DPS topic, and the individual DPS key topics. +Controlling devices directly via DPS topics requires enough knowledge of the device to know which topics accept what values. Described below are two differnt methods for interfacing with DPS values, the JSON DPS topic, and the individual DPS key topics. ### DPS JSON topic -The JSON DPS topic allows controlling Tuya devices by sending raw, Tuya style JSON messages to the command topic, and by monitoring for Tuya style JSON replies on the state topic. You can get more details on this format by reading the [TuyAPI documentaiton](https://codetheweb.github.io/tuyapi/index.html), but, for example, to turn off a dimmer switch you could issue a MQTT message containing the JSON value {dps: 1, set: false} to the DPS/command topic for the device. If you wanted to turn the dimmer on, and set brightness to 50%, you could issue separate messages {dps: 1, set: true} and then {dps: 2, set: 128}, or, the Tuya JSON protocol also allows setting multiple values in a single set command using the format {'multiple': true, 'data': {'1': true, '2': 128}}. JSON state and commands should use the DPS/state and DPS/command topics respectively. Below is an example of the topics: +The JSON DPS topic allows controlling Tuya devices by sending Tuya native style JSON messages to the command topic, and by monitoring for Tuya style JSON replies on the state topic. You can get more details on this format by reading the [TuyAPI documentaiton](https://codetheweb.github.io/tuyapi/index.html), but, for example, to turn off a dimmer switch you could issue a MQTT message containing the JSON value ```{dps: 1, set: false}``` to the DPS/command topic for the device. If you wanted to turn the dimmer on, and set brightness to 50%, you could issue separate messages ```{dps: 1, set: true}``` and then ```{dps: 2, set: 128}```, or, the Tuya JSON protocol also allows setting multiple values in a single set command using the format ```{'multiple': true, 'data': {'1': true, '2': 128}}```. JSON state and commands should use the DPS/state and DPS/command topics respectively. Below is an example of the topics: ``` tuya/dimmer_device/DPS/state tuya/dimmer_device/DPS/command @@ -120,7 +120,7 @@ tuya/dimmer_device/DPS/1/state --> accept true/false for turning device on/of tuya/dimmer_device/DPS/2/command <-- accepts 1-255 for controlling brightness level ``` **!!! Important Note !!!**\ -When sending commands directly to DPS values there are no controls on what values are sent as tuya-mqtt has no way to know what are valid vs invalid values. Sending values that are out-of-range or of different types can cause unpredicatable behavior of your device, from causing timeouts, to reboots, to hanging the device. While I've never seen a device not recover after a restart, please keep this in mind when sending commands to your device. +When sending commands directly to DPS values there are no limitation on what values are sent as tuya-mqtt has no way to know what are valid vs invalid for any given DPS key. Sending values that are out-of-range or of different types than the DPS key expects can cause unpredicatable behavior of your device, from causing timeouts, to reboots, to hanging the device. While I've never seen a device fail to recover after a restart, please keep this in mind when sending commands to your device. ## Issues Not all Tuya protocols are supported. For example, some devices use protocol 3.2 which currently remains unsupported by the TuyAPI project due to lack of enough information to reverse engineer the protcol. If you are unable to control your devices with tuya-mqtt please verify that you can query and control them with tuya-cli first. If tuya-cli works, then this script should also work, if it doesn't then this script will not work either. diff --git a/docs/DEVICES.md b/docs/DEVICES.md index c220783..e03df22 100644 --- a/docs/DEVICES.md +++ b/docs/DEVICES.md @@ -1,7 +1,9 @@ # tuya-mqtt - Devices -The most powerful feature in tuya-mqtt is the ability to configure devices to use friendly topics. For some devices there exist pre-defined device templates which makes using those devices quite easy, simply add the type information to the devices.conf file and tuya-mqtt automatically creates friendly topics for that device. Friendly topics make it easy to communicate with the device in a standard way and thus integrating into various Home Automation platforms. The topic style generally follows that used by the Home Assistant MQTT integration components and the pre-defined devices even send Home Assistant style MQTT discovery messages during startup to make integration with Home Assistant, or other platforms which understand Home Assistant MQTT discovery, even easier. +The most powerful feature in tuya-mqtt is the ability to configure devices to use friendly topics. For some devices there exist pre-defined device templates which makes using those devices quite easy, simply add the type information to the devices.conf file and tuya-mqtt automatically creates friendly topics for that device. -If your device does not have a pre-defined device template, you can still create a template using the [generic device template](#generic-device-templates) feature. +Friendly topics make it easy to communicate with the device in a standard way and thus integrating into various Home Automation platforms. The topic style generally follows that used by the Home Assistant MQTT integration components and the pre-defined devices automatically send Home Assistant style MQTT discovery messages during startup to make integration with Home Assistant, or other platforms which understand Home Assistant MQTT discovery, even easier. + +If the device does not have a pre-defined device template, it's possible to create a template using the [generic device template](#generic-device-templates) feature. ## Pre-defined Device Templates Pre-defined device templates (except for the Generic Device) will always expose friendly topics for the given device in a consistent manner. Currently the following pre-defined device templates are available: From d61c1f7ceecb8c05b8bc55726c662b837813f7ae Mon Sep 17 00:00:00 2001 From: tsightler Date: Sun, 18 Oct 2020 20:53:58 -0400 Subject: [PATCH 34/34] Release 3.0.0 3.0.0 Release Major changes from 2.1.0: * Completely new configuration engine * Completely new topic structure * New template engine for creating friendly topic structure from raw DPS values * Pre-defined templates for some common devices * Directly control devices via Tuya JSON topic or via DPS key topics --- devices/rgbtw-light.js | 27 ++++++++++++++++++++------- devices/simple-dimmer.js | 22 +++++++++++++++++++--- devices/tuya-device.js | 12 ++++++------ docs/DEVICES.md | 20 ++++++++++---------- package-lock.json | 2 +- package.json | 2 +- tuya-mqtt.js | 17 +++++++++++------ 7 files changed, 68 insertions(+), 34 deletions(-) diff --git a/devices/rgbtw-light.js b/devices/rgbtw-light.js index e8a4f82..53db969 100644 --- a/devices/rgbtw-light.js +++ b/devices/rgbtw-light.js @@ -31,6 +31,20 @@ class RGBTWLight extends TuyaDevice { this.deviceData.mdl = 'RGBTW Light' this.isRgbtwLight = true + // Set white value transform math + let whiteValueStateMath + let whiteValueCommandMath + if (this.config.whiteValueScale === 255) { + // Devices with brightness scale of 255 seem to not allow values + // less then 25 (10%) without producing timeout errors. + whiteValueStateMath = '/2.3-10.86' + whiteValueCommandMath = '*2.3+25' + } else { + // For other scale (usually 1000), 10-1000 seems OK. + whiteValueStateMath = '/('+this.config.whiteValueScale+'/100)' + whiteValueCommandMath = '*('+this.config.whiteValueScale+'/100)' + } + // Map generic DPS topics to device specific topic names this.deviceTopics = { state: { @@ -40,11 +54,10 @@ class RGBTWLight extends TuyaDevice { white_brightness_state: { key: this.config.dpsWhiteValue, type: 'int', - min: 1, - max: 100, - scale: this.config.whiteValueScale, - stateMath: '/('+this.config.whiteValueScale+'/100)', - commandMath: '*('+this.config.whiteValueScale+'/100)' + topicMin: 0, + topicMax: 100, + stateMath: whiteValueStateMath, + commandMath: whiteValueCommandMath }, hs_state: { key: this.config.dpsColor, @@ -77,8 +90,8 @@ class RGBTWLight extends TuyaDevice { this.deviceTopics.color_temp_state = { key: this.config.dpsColorTemp, type: 'int', - min: this.config.minColorTemp, - max: this.config.maxColorTemp, + topicMin: this.config.minColorTemp, + topicMax: this.config.maxColorTemp, stateMath: '/'+scaleFactor+'*-'+rangeFactor+'+'+this.config.maxColorTemp, commandMath: '/'+rangeFactor+'*-'+scaleFactor+'+'+tuyaMaxColorTemp } diff --git a/devices/simple-dimmer.js b/devices/simple-dimmer.js index 766117e..5cbcdd9 100644 --- a/devices/simple-dimmer.js +++ b/devices/simple-dimmer.js @@ -12,6 +12,20 @@ class SimpleDimmer extends TuyaDevice { this.deviceData.mdl = 'Dimmer Switch' + // Set white value transform math + let brightnessStateMath + let brightnessCommandMath + if (this.config.brightnessScale === 255) { + // Devices with brightness scale of 255 seem to not allow values + // less then 25 (10%) without producing timeout errors. + brightnessStateMath = '/2.3-10.86' + brightnessCommandMath = '*2.3+25' + } else { + // For other scale (usually 1000), 10-1000 seems OK. + brightnessStateMath = '/('+this.config.brightnessScale+'/100)' + brightnessCommandMath = '*('+this.config.brightnessScale+'/100)' + } + // Map generic DPS topics to device specific topic names this.deviceTopics = { state: { @@ -21,9 +35,10 @@ class SimpleDimmer extends TuyaDevice { brightness_state: { key: this.config.dpsBrightness, type: 'int', - min: (this.config.brightnessScale = 1000) ? 10 : 1, - max: this.config.brightnessScale, - scale: this.config.brightnessScale + topicMin: 0, + topicMax: 100, + stateMath: brightnessStateMath, + commandMath: brightnessCommandMath } } @@ -44,6 +59,7 @@ class SimpleDimmer extends TuyaDevice { command_topic: this.baseTopic+'command', brightness_state_topic: this.baseTopic+'brightness_state', brightness_command_topic: this.baseTopic+'brightness_command', + brightness_scale: 100, availability_topic: this.baseTopic+'status', payload_available: 'online', payload_not_available: 'offline', diff --git a/devices/tuya-device.js b/devices/tuya-device.js index 0b8962a..c3a9d93 100644 --- a/devices/tuya-device.js +++ b/devices/tuya-device.js @@ -409,14 +409,14 @@ class TuyaDevice { // Check if it's a number and it's not outside of defined range if (isNaN(command)) { return invalid - } else if (deviceTopic.hasOwnProperty('min') && command < deviceTopic.min) { + } else if (deviceTopic.hasOwnProperty('topicMin') && command < deviceTopic.topicMin) { debugError('Received command value "'+command+'" that is less than the configured minimum value') - debugError('Overriding command with minimum value '+deviceTopic.min) - command = deviceTopic.min - } else if (deviceTopic.hasOwnProperty('max') && command > deviceTopic.max) { + debugError('Overriding command with minimum value '+deviceTopic.topicMin) + command = deviceTopic.topicMin + } else if (deviceTopic.hasOwnProperty('topicMax') && command > deviceTopic.topicMax) { debugError('Received command value "'+command+'" that is greater than the configured maximum value') - debugError('Overriding command with maximum value: '+deviceTopic.max) - command = deviceTopic.max + debugError('Overriding command with maximum value: '+deviceTopic.topicMax) + command = deviceTopic.topicMax } // Perform any required math transforms before returing command value diff --git a/docs/DEVICES.md b/docs/DEVICES.md index e03df22..0acdb53 100644 --- a/docs/DEVICES.md +++ b/docs/DEVICES.md @@ -46,8 +46,8 @@ Simple device with on/off and brightness functions (dimmer switches or lights) | --- | --- | --- | | state | Power state | on/off | | command | Set power state | on/off, 0/1, true/false | -| brightness_state | Brightness in % | 1-100 | -| brightness_command | set brightness in % | 1-100 | +| brightness_state | Brightness in % | 0-100 | +| brightness_command | set brightness in % | 0-100 | Manual configuration options: | Option | Description | Default | @@ -78,14 +78,14 @@ Tuya bulbs store their HSB color value in a single DPS key using a custom format | --- | --- | --- | | state | Power state | on/off | | command | Set power state | on/off, 0/1, true/false | -| white_brightness_state | White mode brightness in % | 1-100 | -| white_brightness_command | Set white mode brightness in % | 1-100 | -| color_brightness_state | Color mode brightness in % | 1-100 | -| color_brightness_command | Set white mode brightness in % | 1-100 | -| hs_state | Hue, saturation % | H,S (Hue 0-360, Saturation 1-100) | -| hs_command | Set hue, saturation % | H,S (Hue 0-360, Saturation 1-100) | -| hsb_state | Hue, saturation %, brightness % | H,S,B (Hue 0-360, Saturation 1-100, Brightness 1-100) | -| hsb_command | Set hue, saturation %, brightness % | H,S,B (Hue 0-360, Saturation 1-100, Brightness 1-100) | +| white_brightness_state | White mode brightness in % | 0-100 | +| white_brightness_command | Set white mode brightness in % | 0-100 | +| color_brightness_state | Color mode brightness in % | 0-100 | +| color_brightness_command | Set white mode brightness in % | 0-100 | +| hs_state | Hue, saturation % | H,S (Hue 0-360, Saturation 0-100) | +| hs_command | Set hue, saturation % | H,S (Hue 0-360, Saturation 0-100) | +| hsb_state | Hue, saturation %, brightness % | H,S,B (Hue 0-360, Saturation 0-100, Brightness 0-100) | +| hsb_command | Set hue, saturation %, brightness % | H,S,B (Hue 0-360, Saturation 0-100, Brightness 0-100) | | mode_state | White/Color mode | 'white', 'colour' (some devices also support scenes here) | | mode_command | Set white/color mode | 'white', 'colour' (some devices also support scenes here) | | color_temp_state | Color temperature in mireds (only available if device support color temp) | 154-400 (defult range, can be overridden) | diff --git a/package-lock.json b/package-lock.json index 783b057..075cabb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "tuya-mqtt", - "version": "3.0.0-beta4", + "version": "3.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index a80dd3d..44d48e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tuya-mqtt", - "version": "3.0.0-beta4", + "version": "3.0.0", "description": "Control Tuya devices locally via MQTT", "homepage": "https://github.com/TheAgentK/tuya-mqtt#readme", "main": "tuya-mqtt.js", diff --git a/tuya-mqtt.js b/tuya-mqtt.js index 811c5b1..048d8fa 100644 --- a/tuya-mqtt.js +++ b/tuya-mqtt.js @@ -20,7 +20,7 @@ process.on('SIGINT', processExit.bind(0)) process.on('SIGTERM', processExit.bind(0)) process.on('uncaughtException', processExit.bind(1)) -// Set unreachable status on exit +// Disconnect from and publish offline status for all devices on exit async function processExit(exitCode) { for (let tuyaDevice of tuyaDevices) { tuyaDevice.device.disconnect() @@ -30,6 +30,7 @@ async function processExit(exitCode) { process.exit() } +// Get new deivce based on configured type function getDevice(configDevice, mqttClient) { const deviceInfo = { configDevice: configDevice, @@ -57,12 +58,16 @@ function initDevices(configDevices, mqttClient) { } } +// Republish devices 2x with 30 seconds sleep if restart of HA is detected async function republishDevices() { - // Republish devices and state after 30 seconds if restart of HA is detected - debug('Resending device config/state in 30 seconds') - await utils.sleep(30) - for (let device of tuyaDevices) { - device.init() + for (let i = 0; i < 2; i++) { + debug('Resending device config/state in 30 seconds') + await utils.sleep(30) + for (let device of tuyaDevices) { + device.publishMqtt(device.baseTopic+'status', 'online') + device.init() + } + await utils.sleep(2) } }