From 60b9c85041fda1388660c927963bd39c178d6b34 Mon Sep 17 00:00:00 2001 From: Max Isom Date: Thu, 11 Jan 2018 14:27:22 -0600 Subject: [PATCH] Cleaned up autodiscovery, combined schema() and getStatus() => get() --- README.md | 1 + index.js | 202 +++++++++++++++++++++++++++------------------- package-lock.json | 27 ++++--- package.json | 3 +- 4 files changed, 136 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index b564cdc..41d9066 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ See the [docs](docs/API.md). 4. Support arbitrary control schemes for devices as self-reported. 5. Use Promises for all functions? 6. Autodiscovery of devices? +7. Make the JSON parser more reliable. ## Contributors diff --git a/index.js b/index.js index 78ddf22..0e55c64 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,7 @@ const dgram = require('dgram'); const forge = require('node-forge'); const retryConnect = require('net-retry-connect'); +const stringOccurrence = require('string-occurrence'); // Import requests for devices const requests = require('./requests.json'); @@ -22,68 +23,116 @@ const requests = require('./requests.json'); */ function TuyaDevice(options) { this.devices = []; - const needIP = []; // If argument is [{id: '', key: ''}] if (options.constructor === Array) { - options.forEach(function (device) { - if (device.ip === undefined) { - needIP.push(device.id); - } else { - this.devices.push(device); - } - }); - - this.discoverDevices(needIP).then(devices => { - this.devices.push(devices); - }); + this.devices = options; } + // If argument is {id: '', key: ''} else if (options.constructor === Object) { - if (options.ip === undefined) { - this.discoverDevices(options.id).then(device => { - this.devices.push(device); - }); - } else { - this.devices.push({ - type: options.type || 'outlet', - ip: options.ip, - port: options.port || 6668, - key: options.key, - cipher: forge.cipher.createCipher('AES-ECB', options.key), - version: options.version || 3.1 - }); - } + this.devices = [options]; + } + + // standardize devices array + for (var i = 0; i < this.devices.length; i++) { + if (this.devices[i].type === undefined) { this.devices[i].type = 'outlet'; } + if (this.devices[i].port === undefined) { this.devices[i].port = 6668; } + if (this.devices[i].version === undefined) { this.devices[i].version = 3.1; } + + // create cipher from key + this.devices[i].cipher = forge.cipher.createCipher('AES-ECB', this.devices[i].key); } } /** -* Gets the device's current status. +* Resolves IDs stored in class to IPs. +* @returns {Promise} - true if IPs were found and devices are ready to be used +*/ +TuyaDevice.prototype.resolveIds = function() { + // Create new listener if it hasn't already been created + if (this.listener == undefined) { + this.listener = dgram.createSocket('udp4'); + this.listener.bind(6666); + } + + // find devices that need an IP + var needIP = []; + for (var i = 0; i < this.devices.length; i++) { + if (this.devices[i].ip == undefined) { + needIP.push(this.devices[i].id); + } + } + + // todo: add timeout for when IP cannot be found, then reject(with error) + // add IPs to devices in array and return true + return new Promise((resolve, reject) => { + this.listener.on('message', (message, info) => { + let thisId = this._extractJSON(message).gwId; + + if (needIP.length > 0) { + if (needIP.includes(thisId)) { + var deviceIndex = this.devices.findIndex(device => { + if (device.id === thisId) { return true; } + }); + + this.devices[deviceIndex].ip = this._extractJSON(message).ip; + + needIP.splice(needIP.indexOf(thisId), 1); + } + } + else { // all devices have been resolved + this.listener.removeAllListeners(); + resolve(true); + } + }) + }); +}; + +/** +* Gets the device's current status. Defaults to returning only the first 'dps', but by setting {schema: true} you can get everything. +* @param {string} ID - optional, ID of device. Defaults to first device. * @param {function(error, result)} callback */ -TuyaDevice.prototype.getStatus = function (callback) { +TuyaDevice.prototype.get = function (options) { + var currentDevice; + + // If no ID is provided + if (options === undefined || options.id === undefined) { + currentDevice = this.devices[0]; // use first device in array + } + else { // otherwise + // find the device by id in this.devices + let index = this.devices.findIndex(device => { + if (device.id === options.id) { return true; } + }); + currentDevice = this.devices[index] + } + // Add data to command - if ('gwId' in requests[this.type].status.command) { - requests[this.type].status.command.gwId = this.id; + if ('gwId' in requests[currentDevice.type].status.command) { + requests[currentDevice.type].status.command.gwId = currentDevice.id; } - if ('devId' in requests[this.type].status.command) { - requests[this.type].status.command.devId = this.id; + if ('devId' in requests[currentDevice.type].status.command) { + requests[currentDevice.type].status.command.devId = currentDevice.id; } // Create byte buffer from hex data - const thisData = Buffer.from(JSON.stringify(requests[this.type].status.command)); - const buffer = this._constructBuffer(thisData, 'status'); + const thisData = Buffer.from(JSON.stringify(requests[currentDevice.type].status.command)); + const buffer = this._constructBuffer(currentDevice.type, thisData, 'status'); - this._send(buffer).then(data => { - // Extract returned JSON - try { - data = data.toString(); - data = data.slice(data.indexOf('{'), data.lastIndexOf('}') + 1); - data = JSON.parse(data); - return callback(null, data.dps['1']); - } catch (err) { - return callback(err, null); - } + return new Promise((resolve, reject) => { + this._send(currentDevice.ip, buffer).then(data => { + // Extract returned JSON + data = this._extractJSON(data); + + if (options != undefined && options.schema == true) { + resolve(data); + } + else { + resolve(data.dps['1']) + } + }); }); }; @@ -138,12 +187,13 @@ TuyaDevice.prototype.setStatus = function (on, callback) { /** * Sends a query to the device. * @private +* @param {String} ip - IP of device * @param {Buffer} buffer - buffer of data * @returns {Promise} - returned data */ -TuyaDevice.prototype._send = function (buffer) { +TuyaDevice.prototype._send = function (ip, buffer) { return new Promise((resolve, reject) => { - retryConnect.to({port: 6668, host: this.ip, retryOptions: {retries: 5}}, (error, client) => { + retryConnect.to({port: 6668, host: ip, retryOptions: {retries: 5}}, (error, client) => { if (error) { reject(error); } @@ -161,19 +211,20 @@ TuyaDevice.prototype._send = function (buffer) { }; /** -* Constructs a protocol-complient buffer given data and command. +* Constructs a protocol-complient buffer given device type, data, and command. * @private +* @param {String} type - type of device * @param {String} data - data to put in buffer * @param {String} command - command (status, on, off, etc.) * @returns {Buffer} buffer - buffer of data */ -TuyaDevice.prototype._constructBuffer = function (data, command) { +TuyaDevice.prototype._constructBuffer = function (type, data, command) { // Construct prefix of packet according to protocol - const prefixLength = (data.toString('hex').length + requests[this.type].suffix.length) / 2; - const prefix = requests[this.type].prefix + requests[this.type][command].hexByte + '000000' + prefixLength.toString(16); + const prefixLength = (data.toString('hex').length + requests[type].suffix.length) / 2; + const prefix = requests[type].prefix + requests[type][command].hexByte + '000000' + prefixLength.toString(16); // Create final buffer: prefix + data + suffix - return Buffer.from(prefix + data.toString('hex') + requests[this.type].suffix, 'hex'); + return Buffer.from(prefix + data.toString('hex') + requests[type].suffix, 'hex'); }; /** @@ -203,38 +254,6 @@ TuyaDevice.prototype.getSchema = function () { }); }; -/** -* Attempts to autodiscover devices (i.e. translate device ID to IP). -* @param {Array} IDs - can be a single ID or an array of IDs -* @returns {Promise} devices - discovered devices -*/ -TuyaDevice.prototype.discoverDevices = function (ids, callback) { - // Create new listener if it hasn't already been created - if (this.listener == undefined) { - this.listener = dgram.createSocket('udp4'); - this.listener.bind(6666); - } - - const discoveredDevices = []; - - // If input is '...' change it to ['...'] for ease of use - if (typeof (ids) === 'string') { - ids = [ids]; - } - - return new Promise((resolve, reject) => { - this.listener.on('message', (message, info) => { - if (discoveredDevices.length < ids.length) { - if (ids.includes(this._extractJSON(message).gwId)) { - discoveredDevices.push(this._extractJSON(message)); - } - } else { // All IDs have been resolved - resolve(discoveredDevices); - } - }); - }); -}; - /** * Extracts JSON from a raw buffer and returns it as an object. * @param {Buffer} buffer of data @@ -242,7 +261,22 @@ TuyaDevice.prototype.discoverDevices = function (ids, callback) { */ TuyaDevice.prototype._extractJSON = function (data) { data = data.toString(); - data = data.slice(data.indexOf('{'), data.lastIndexOf('"}') + 2); + + // Find the # of occurrences of '{' and make that # match with the # of occurrences of '}' + var leftBrackets = stringOccurrence(data, '{'); + let occurrences = 0; + let currentIndex = 0; + + while (occurrences < leftBrackets) { + let index = data.indexOf('}', currentIndex + 1); + if (index != -1) { + currentIndex = index; + occurrences ++; + } + } + + data = data.slice(data.indexOf('{'), currentIndex + 1); + console.log(data) data = JSON.parse(data); return data; }; diff --git a/package-lock.json b/package-lock.json index bd69179..620ce66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,11 +4,6 @@ "lockfileVersion": 1, "requires": true, "dependencies": { - "@codetheweb/recon": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@codetheweb/recon/-/recon-1.0.0.tgz", - "integrity": "sha512-UfvQcgDTLaQDXJheZ2Ucq2cQP6vHz+BJUNDDgcSDXuGyGXnBshj4M4w9Umtkt5fyKJP5hVMzAiFhOXEEcqPUaQ==" - }, "JSONStream": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.1.tgz", @@ -2251,8 +2246,7 @@ "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=", - "dev": true + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "escope": { "version": "3.6.0", @@ -6284,6 +6278,11 @@ "extend-shallow": "2.0.1" } }, + "regex-occurrence": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/regex-occurrence/-/regex-occurrence-1.0.0.tgz", + "integrity": "sha1-me586aDaPwVm4D32iZuATDbtDvo=" + }, "regexpu-core": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-2.0.0.tgz", @@ -6923,6 +6922,15 @@ "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", "dev": true }, + "string-occurrence": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/string-occurrence/-/string-occurrence-1.2.0.tgz", + "integrity": "sha1-YFS+js9K9IfSHlIC7SUaaTAwt2A=", + "requires": { + "escape-string-regexp": "1.0.5", + "regex-occurrence": "1.0.0" + } + }, "string-template": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz", @@ -7760,11 +7768,6 @@ } } }, - "wait-until": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/wait-until/-/wait-until-0.0.2.tgz", - "integrity": "sha1-eoq671WQ2XD9RGl9Q2tXYePq7b4=" - }, "websocket-driver": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.0.tgz", diff --git a/package.json b/package.json index 0ccb13b..95d7e42 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "homepage": "https://github.com/codetheweb/tuyapi#readme", "dependencies": { "net-retry-connect": "^0.1.1", - "node-forge": "^0.7.1" + "node-forge": "^0.7.1", + "string-occurrence": "^1.2.0" }, "devDependencies": { "documentation": "^5.3.3",