New Case Study:See how Anthropic automated 95% of dependency reviews with Socket.Learn More
Socket
Sign inDemoInstall
Socket

tuyapi

Package Overview
Dependencies
Maintainers
2
Versions
69
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

tuyapi - npm Package Compare versions

Comparing version 3.2.3 to 4.0.0

786

index.js

@@ -6,3 +6,3 @@ // Import packages

const timeout = require('p-timeout');
const retry = require('retry');
const pRetry = require('p-retry');
const debug = require('debug')('TuyAPI');

@@ -14,67 +14,8 @@

function resolveId(device, options) {
// Create new listener
const listener = dgram.createSocket('udp4');
listener.bind(6666);
debug('Finding IP for device ' + device.id);
// Find IP for device
return timeout(new Promise((resolve, reject) => { // Timeout
listener.on('message', message => {
debug('Received UDP message.');
let dataRes;
try {
dataRes = Parser.parse(message);
} catch (error) {
debug(error);
return;
}
debug('UDP data:');
debug(dataRes.data);
const thisId = dataRes.data.gwId;
if (device.id === thisId && dataRes.data) {
// Add IP
device.ip = dataRes.data.ip;
// Change product key if neccessary
device.productKey = dataRes.data.productKey;
// Change protocol version if necessary
device.version = dataRes.data.version;
// Cleanup
listener.close();
listener.removeAllListeners();
resolve(true);
}
});
listener.on('error', err => reject(err));
}), options.timeout * 1000, () => {
// Have to do this so we exit cleanly
listener.close();
listener.removeAllListeners();
// eslint-disable-next-line max-len
return Promise.reject(new Error('resolveIds() timed out. Is the device powered on and the ID correct?'));
});
}
let resolveIdQueue = Promise.resolve();
function serialResolveId(device, options) {
const promise = resolveIdQueue.catch(() => {}).then(() => {
return resolveId(device, options);
});
resolveIdQueue = promise;
return promise;
}
/**
* Represents a Tuya device.
*
* You *must* pass either an IP or an ID. If
* you're experiencing problems when only passing
* one, try passing both if possible.
* @class

@@ -84,14 +25,11 @@ * @param {Object} options

* @param {Number} [options.port=6668] port of device
* @param {String} options.id ID of device (also called `devId`)
* @param {String} [options.id] ID of device (also called `devId`)
* @param {String} [options.gwID=''] gateway ID (not needed for most devices),
* if omitted assumed to be the same as `options.id`
* @param {String} options.key encryption key of device (also called `localKey`)
* @param {String} options.productKey product key of device
* @param {String} [options.productKey] product key of device (currently unused)
* @param {Number} [options.version=3.1] protocol version
* @param {Boolean} [options.persistentConnection=false]
* whether or not to use a persistent socket with heartbeat packets
* @example
* const tuya = new TuyaDevice({id: 'xxxxxxxxxxxxxxxxxxxx',
* key: 'xxxxxxxxxxxxxxxx',
* persistentConnection: true})
* key: 'xxxxxxxxxxxxxxxx'})
*/

@@ -102,17 +40,27 @@ class TuyaDevice extends EventEmitter {

// Set device to user-passed options
this.device = options;
// Defaults
if (this.device.id === undefined) {
throw new Error('ID is missing from device.');
// Default version (necessary for later checks)
if (this.device.version === undefined) {
this.device.version = 3.1;
}
if (this.device.gwID === undefined) {
this.device.gwID = this.device.id;
// Check arguments
if (!(this.checkIfValidString(this.device.id) ||
this.checkIfValidString(this.device.ip))) {
throw new TypeError('ID and IP are missing from device.');
}
if (this.device.key === undefined) {
throw new Error('Encryption key is missing from device.');
if (this.checkIfValidString(this.device.key) && this.device.key.length === 16) {
// Create cipher from key
this.device.cipher = new Cipher({
key: this.device.key,
version: this.device.version
});
} else {
throw new TypeError('Key is missing or incorrect.');
}
// Defaults
if (this.device.port === undefined) {

@@ -122,18 +70,11 @@ this.device.port = 6668;

if (this.device.version === undefined) {
this.device.version = 3.1;
if (this.device.gwID === undefined) {
this.device.gwID = this.device.id;
}
if (this.device.persistentConnection === undefined) {
this.device.persistentConnection = false;
}
// Contains array of found devices when calling .find()
this.foundDevices = [];
// Create cipher from key
this.device.cipher = new Cipher({
key: this.device.key,
version: this.device.version
});
// Private instance variables
// Private variables
// Socket connected state

@@ -143,62 +84,15 @@ this._connected = false;

this._responseTimeout = 5; // Seconds
this._connectTimeout = 1; // Seconds
this._connectTimeout = 5; // Seconds
this._pingPongPeriod = 10; // Seconds
this._persistentConnectionStopped = true;
}
/**
* Resolves ID stored in class to IP. If you didn't
* pass an IP to the constructor, you must call
* this before doing anything else.
* @param {Object} [options]
* @param {Number} [options.timeout=10]
* how long, in seconds, to wait for device
* to be resolved before timeout error is thrown
* @example
* tuya.resolveIds().then(() => console.log('ready!'))
* @returns {Promise<Boolean>}
* true if IP was found and device is ready to be used
*/
resolveId(options) {
// Set default options
options = options ? options : {};
if (options.timeout === undefined) {
options.timeout = 10;
}
if (this.device.ip !== undefined) {
debug('No IPs to search for');
return Promise.resolve(true);
}
return serialResolveId(this.device, options);
}
/**
* @deprecated since v3.0.0. Will be removed in v4.0.0. Use resolveId() instead.
*/
resolveIds(options) {
// eslint-disable-next-line max-len
console.warn('resolveIds() is deprecated since v3.0.0. Will be removed in v4.0.0. Use resolveId() instead.');
return this.resolveId(options);
}
/**
* Gets a device's current status.
* Defaults to returning only the value of the first DPS index.
* If `returnAsEvent = true`, all options are ignored and
* all data returned from device is emitted as event.
* @param {Object} [options]
* @param {Boolean} [options.schema]
* true to return entire schema of device
* true to return entire list of properties from device
* @param {Number} [options.dps=1]
* DPS index to return
* @param {Boolean} [options.returnAsEvent=false]
* true to emit `data` event when result is returned, false
* to return Promise
* @example
* // get all properties and emit event with data
* tuya.get({returnAsEvent: true});
* @example
* // get first, default property from device

@@ -212,4 +106,4 @@ * tuya.get().then(status => console.log(status))

* tuya.get({schema: true}).then(data => console.log(data))
* @returns {Promise<Object>}
* returns boolean if no options are provided, otherwise returns object of results
* @returns {Promise<Boolean|Object>}
* returns boolean if single property is requested, otherwise returns object of results
*/

@@ -225,3 +119,4 @@ get(options) {

debug('Payload: ', payload);
debug('GET Payload:');
debug(payload);

@@ -234,20 +129,30 @@ // Create byte buffer

// Send request and parse response
return new Promise((resolve, reject) => {
this._send(buffer, 10, options.returnAsEvent).then(data => {
if (options.returnAsEvent) {
return resolve();
}
try {
// Send request
this._send(buffer).then(() => {
// Runs when data event is emitted
const resolveGet = data => {
// Remove self listener
this.removeListener('data', resolveGet);
if (typeof data === 'string') {
reject(data);
} else if (options.schema === true) {
resolve(data);
} else if (options.dps) {
resolve(data.dps[options.dps]);
} else {
resolve(data.dps['1']);
}
}).catch(error => {
if (options.schema === true) {
// Return whole response
resolve(data);
} else if (options.dps) {
// Return specific property
resolve(data.dps[options.dps]);
} else {
// Return first property by default
resolve(data.dps['1']);
}
};
// Add listener
this.on('data', resolveGet);
});
} catch (error) {
reject(error);
});
}
});

@@ -266,6 +171,6 @@ }

* // set default property
* tuya.set({set: true}).then(() => console.log('device was changed'))
* tuya.set({set: true}).then(() => console.log('device was turned on'))
* @example
* // set custom property
* tuya.set({dps: 2, set: true}).then(() => console.log('device was changed'))
* tuya.set({dps: 2, set: false}).then(() => console.log('device was turned off'))
* @example

@@ -282,2 +187,8 @@ * // set multiple properties

set(options) {
// Check arguments
if (options === undefined || Object.entries(options).length === 0) {
throw new TypeError('No arguments were passed.');
}
// Defaults
let dps = {};

@@ -297,5 +208,7 @@

// Get time
const now = new Date();
const timeStamp = (parseInt(now.getTime() / 1000, 10)).toString();
// Construct payload
const payload = {

@@ -309,3 +222,3 @@ devId: this.device.id,

debug('Payload:', this.device.ip);
debug('SET Payload:');
debug(payload);

@@ -325,2 +238,4 @@

const thisData = Buffer.from(this.device.version + md5 + data);
// Encode into packet
const buffer = Parser.encode({

@@ -331,9 +246,22 @@ data: thisData,

// Send request to change status
// Send request and wait for response
return new Promise((resolve, reject) => {
this._send(buffer, 7, false).then(() => {
resolve(true);
}).catch(error => {
try {
// Send request
this._send(buffer).then(() => {
// Runs when data event is emitted
const resolveSet = _ => {
// Remove self listener
this.removeListener('data', resolveSet);
// Return true
resolve(true);
};
// Add listener to data event
this.on('data', resolveSet);
});
} catch (error) {
reject(error);
});
}
});

@@ -343,12 +271,11 @@ }

/**
* Sends a query to a device. Helper
* function that wraps ._sendUnwrapped()
* in a retry operation.
* Sends a query to a device. Helper function
* that connects to a device if necessary and
* wraps the entire operation in a retry.
* @private
* @param {String} ip IP of device
* @param {Buffer} buffer buffer of data
* @param {Boolean} returnAsEvent return result as event or as resolved promise
* @returns {Promise<string>} returned data
* @returns {Promise<Boolean>} `true` if query was successfully sent
*/
_send(buffer, expectedResponseCommandByte, returnAsEvent) {
_send(buffer) {
// Check for IP
if (typeof this.device.ip === 'undefined') {

@@ -358,99 +285,18 @@ throw new TypeError('Device missing IP address.');

const operation = retry.operation({
retries: 4,
factor: 1.5
});
// Retry up to 5 times
return pRetry(async () => {
// Send data
this.client.write(buffer);
return new Promise((resolve, reject) => {
operation.attempt(currentAttempt => {
debug('Send attempt', currentAttempt);
this._sendUnwrapped(buffer, expectedResponseCommandByte, returnAsEvent).then(
(result, commandByte) => {
resolve(result, commandByte);
}).catch(error => {
if (operation.retry(error)) {
return;
}
reject(operation.mainError());
});
});
});
return true;
}, {retries: 5});
}
/**
* Sends a query to a device.
* Sends a heartbeat ping to the device
* @private
* @param {Buffer} buffer buffer of data
* @param {Boolean} returnAsEvent return result as event or as resolved promise
* @returns {Promise<string>} returned data
*/
_sendUnwrapped(buffer, expectedResponseCommandByte, returnAsEvent) {
debug('Sending this data:', buffer.toString('hex'));
_sendPing() {
debug(`Pinging ${this.device.ip}`);
return new Promise((resolve, reject) => {
if (!returnAsEvent) {
this.dataResolver = (data, commandByte) => { // Delayed resolving of promise
if (expectedResponseCommandByte !== commandByte) {
reject(new Error('Returned command byte did not match expected byte.'));
}
if (this._sendTimeout) {
clearTimeout(this._sendTimeout);
}
if (!this.device.persistentConnection) {
this.disconnect();
}
resolve(data, commandByte);
return true;
};
this.dataRejector = err => {
if (this._sendTimeout) {
clearTimeout(this._sendTimeout);
}
debug('Error event from socket.');
// eslint-disable-next-line max-len
err.message = 'Error communicating with device. Make sure nothing else is trying to control it or connected to it.';
return reject(err);
};
}
this.connect().then(() => {
if (this.pingpongTimeout) {
clearTimeout(this.pingpongTimeout);
this.pingpongTimeout = null;
}
// Transmit data
this.client.write(buffer);
this._sendTimeout = setTimeout(() => {
if (this.client) {
this.client.destroy();
}
this.dataResolver = null;
this.dataRejector = null;
return reject(new Error('Timeout waiting for response'));
}, this._responseTimeout * 1000);
if (returnAsEvent) {
resolve();
}
});
});
}
/**
* Sends a ping to the device
* @private
* @returns {Promise<string>} returned data
*/
__sendPing() {
debug('PING', this.device.ip, this.client ? this.client.destroyed : true);
// Create byte buffer

@@ -461,11 +307,11 @@ const buffer = Parser.encode({

});
debug('PingPong: ' + buffer.toString('hex'));
this._sendUnwrapped(buffer, 9, true);
// Send ping
this._send(buffer);
}
/**
* Connects to the device, use to initally
* open a socket when using a persistent connection.
* @returns {Promise<Boolean>}
* Connects to the device. Can be called even
* if device is already connected.
* @returns {Promise<Boolean>} `true` if connect succeeds
* @emits TuyaDevice#connected

@@ -477,109 +323,68 @@ * @emits TuyaDevice#disconnected

connect() {
this._persistentConnectionStopped = false;
if (!this.client) {
this.client = new net.Socket();
if (!this.isConnected()) {
return new Promise((resolve, reject) => {
this.client = new net.Socket();
// Attempt to connect
debug('Connect', this.device.ip);
this.client.connect(this.device.port, this.device.ip);
// Attempt to connect
debug(`Connecting to ${this.device.ip}...`);
this.client.connect(this.device.port, this.device.ip);
// Default connect timeout is ~1 minute,
// 10 seconds is a more reasonable default
// since `retry` is used.
this.client.setTimeout(this._connectTimeout * 1000, () => {
/**
* Emitted on socket error, usually a
* result of a connection timeout.
* Also emitted on parsing errors.
* @event TuyaDevice#error
* @property {Error} error error event
*/
this.client.emit('error', new Error('connection timed out'));
this.client.destroy();
});
// Send data when connected
this.client.on('connect', () => {
debug('Socket connected.');
this._connected = true;
// Remove connect timeout
this.client.setTimeout(0);
if (this.device.persistentConnection) {
// Default connect timeout is ~1 minute,
// 5 seconds is a more reasonable default
// since `retry` is used.
this.client.setTimeout(this._connectTimeout * 1000, () => {
/**
* Emitted when socket is connected
* to device. This event may be emitted
* multiple times within the same script,
* so don't use this as a trigger for your
* initialization code.
* @event TuyaDevice#connected
* Emitted on socket error, usually a
* result of a connection timeout.
* Also emitted on parsing errors.
* @event TuyaDevice#error
* @property {Error} error error event
*/
this.emit('connected');
// this.emit('error', new Error('connection timed out'));
this.client.destroy();
reject(new Error('connection timed out'));
});
if (this.pingpongTimeout) {
clearTimeout(this.pingpongTimeout);
this.pingpongTimeout = null;
}
// Add event listeners to socket
this.pingpongTimeout = setTimeout(() => {
this.__sendPing();
}, this._pingPongPeriod * 1000);
// Parse response data
this.client.on('data', data => {
debug('Received response');
debug(data.toString('hex'));
this.get({returnAsEvent: true});
}
});
// Response was received, so stop waiting
clearTimeout(this._sendTimeout);
// Parse response data
this.client.on('data', data => {
debug('Received data back:', this.client.remoteAddress);
debug(data.toString('hex'));
let dataRes;
try {
dataRes = Parser.parse(data);
} catch (error) {
debug(error);
this.emit('error', error);
return;
}
clearTimeout(this._sendTimeout);
data = dataRes.data;
if (this.pingpongTimeout) {
clearTimeout(this.pingpongTimeout);
this.pingpongTimeout = null;
}
if (typeof data === 'object') {
debug('Parsed response data:');
debug(data);
} else if (typeof data === 'undefined') {
if (dataRes.commandByte === 0x09) { // PONG received
debug('Pong', this.device.ip);
return;
}
this.pingpongTimeout = setTimeout(() => {
this.__sendPing();
}, this._pingPongPeriod * 1000);
if (dataRes.commandByte === 0x07) { // Set succeeded
debug('Set succeeded.');
return;
}
let dataRes;
try {
dataRes = Parser.parse(data);
} catch (error) {
debug(error);
this.emit('error', error);
return;
}
data = dataRes.data;
if (typeof data === 'object') {
debug('Data:', this.client.remoteAddress, data, dataRes.commandByte);
} else if (typeof data === 'undefined') {
if (dataRes.commandByte === 0x09) { // PONG received
debug('PONG', this.device.ip, this.client ? this.client.destroyed : true);
return;
debug(`Undefined data with command byte ${dataRes.commandByte}`);
} else { // Message is encrypted
data = this.device.cipher.decrypt(data);
debug('Decrypted response data:');
debug(data);
}
debug('undefined', this.client.remoteAddress, data, dataRes.commandByte);
} else { // Message is encrypted
// eslint-disable-next-line max-len
debug('decrypt', this.client.remoteAddress, this.device.cipher.decrypt(data), dataRes.commandByte);
data = this.device.cipher.decrypt(data);
}
if (this.dataResolver) {
if (this.dataResolver(data, dataRes.commandByte)) {
this.dataResolver = null;
this.dataRejector = null;
return;
}
}
if (this.device.persistentConnection && data) {
/**

@@ -594,51 +399,70 @@ * Emitted when data is returned from device.

this.emit('data', data, dataRes.commandByte);
} else {
debug('Response undelivered.');
}
});
});
// Handle errors
this.client.on('error', err => {
debug('Error event from socket.', this.device.ip, err);
if (this.dataRejector) {
this.dataRejector(err);
this.dataRejector = null;
this.dataResolver = null;
} else if (this.device.persistentConnection) {
// Handle errors
this.client.on('error', err => {
debug('Error event from socket.', this.device.ip, err);
this.emit('error', new Error('Error from socket'));
}
this.client.destroy();
});
this.client.destroy();
});
// Handle errors
this.client.on('close', () => {
debug('Socket closed:', this.device.ip);
// Handle socket closure
this.client.on('close', () => {
debug(`Socket closed: ${this.device.ip}`);
this._connected = false;
this._connected = false;
/**
* Emitted when a socket is disconnected
* from device. Not an exclusive event:
* `error` and `disconnected` may be emitted
* at the same time if, for example, the device
* goes off the network.
* @event TuyaDevice#disconnected
*/
this.emit('disconnected');
this.client.destroy();
this.client = null;
if (this.pingpongTimeout) {
clearTimeout(this.pingpongTimeout);
this.pingpongTimeout = null;
}
/**
* Emitted when a socket is disconnected
* from device. Not an exclusive event:
* `error` and `disconnected` may be emitted
* at the same time if, for example, the device
* goes off the network.
* @event TuyaDevice#disconnected
*/
this.emit('disconnected');
this.client.destroy();
if (this.device.persistentConnection && !this._persistentConnectionStopped) {
setTimeout(() => {
this.connect();
}, 1000);
}
if (this.pingpongTimeout) {
clearTimeout(this.pingpongTimeout);
this.pingpongTimeout = null;
}
});
this.client.on('connect', () => {
debug('Socket connected.');
this._connected = true;
// Remove connect timeout
this.client.setTimeout(0);
/**
* Emitted when socket is connected
* to device. This event may be emitted
* multiple times within the same script,
* so don't use this as a trigger for your
* initialization code.
* @event TuyaDevice#connected
*/
this.emit('connected');
// Periodically send heartbeat ping
this.pingpongTimeout = setTimeout(() => {
this._sendPing();
}, this._pingPongPeriod * 1000);
// Automatically ask for current state so we
// can emit a `data` event as soon as possible
this.get();
// Return
resolve(true);
});
});
}
// Return if already connected
return Promise.resolve(true);

@@ -649,5 +473,3 @@ }

* Disconnects from the device, use to
* close the socket or exit gracefully
* when using a persistent connection.
* @returns {Promise<Boolean>}
* close the socket and exit gracefully.
*/

@@ -657,5 +479,10 @@ disconnect() {

this._persistentConnectionStopped = true;
this._connected = false;
// Clear timeouts
clearTimeout(this._sendTimeout);
clearTimeout(this._connectTimeout);
clearTimeout(this._responseTimeout);
clearTimeout(this.pingpongTimeout);
if (!this.client) {

@@ -670,4 +497,4 @@ return;

* Returns current connection status to device.
* @returns {Boolean}
* (`true` if connected, `false` otherwise.)
* @returns {Boolean}
*/

@@ -677,4 +504,157 @@ isConnected() {

}
/**
* Checks a given input string.
* @private
* @param {String} input input string
* @returns {Boolean}
* `true` if is string and length != 0, `false` otherwise.
*/
checkIfValidString(input) {
if (input === undefined || typeof input !== typeof 'string' || input.length === 0) {
return false;
}
return true;
}
/**
* @deprecated since v3.0.0. Will be removed in v4.0.0. Use find() instead.
*/
resolveId(options) {
// eslint-disable-next-line max-len
console.warn('resolveId() is deprecated since v4.0.0. Will be removed in v5.0.0. Use find() instead.');
return this.find(options);
}
/**
* Finds an ID or IP, depending on what's missing.
* If you didn't pass an ID or IP to the constructor,
* you must call this before anything else.
* @param {Object} [options]
* @param {Boolean} [options.all]
* true to return array of all found devices
* @param {Number} [options.timeout=10]
* how long, in seconds, to wait for device
* to be resolved before timeout error is thrown
* @example
* tuya.find().then(() => console.log('ready!'))
* @returns {Promise<Boolean|Array>}
* true if ID/IP was found and device is ready to be used
*/
find(options) {
// Set default options
options = options ? options : {};
// Default timeout of 10 seconds
if (options.timeout === undefined) {
options.timeout = 10;
}
if (this.checkIfValidString(this.device.id) &&
this.checkIfValidString(this.device.ip)) {
// Don't need to do anything
debug('IP and ID are already both resolved.');
return Promise.resolve(true);
}
// Create new listener
const listener = dgram.createSocket('udp4');
listener.bind(6666);
debug(`Finding missing IP ${this.device.ip} or ID ${this.device.id}`);
// Find IP for device
return timeout(new Promise((resolve, reject) => { // Timeout
listener.on('message', message => {
debug('Received UDP message.');
let dataRes;
try {
dataRes = Parser.parse(message);
} catch (error) {
debug(error);
reject(error);
}
debug('UDP data:');
debug(dataRes.data);
const thisID = dataRes.data.gwId;
const thisIP = dataRes.data.ip;
// Add to array if it doesn't exist
if (!this.foundDevices.some(e => (e.id === thisID && e.ip === thisIP))) {
this.foundDevices.push({id: thisID, ip: thisIP});
}
if (!options.all &&
(this.device.id === thisID || this.device.ip === thisIP) &&
dataRes.data) {
// Add IP
this.device.ip = dataRes.data.ip;
// Add ID
this.device.id = dataRes.data.gwId;
// Update gwID if required
if (this.device.gwID === undefined) {
this.device.gwID = dataRes.data.gwId;
}
// Change product key if neccessary
this.device.productKey = dataRes.data.productKey;
// Change protocol version if necessary
this.device.version = dataRes.data.version;
// Cleanup
listener.close();
listener.removeAllListeners();
resolve(true);
}
});
listener.on('error', err => {
reject(err);
});
}), options.timeout * 1000, () => {
// Have to do this so we exit cleanly
listener.close();
listener.removeAllListeners();
// Return all devices
if (options.all) {
return this.foundDevices;
}
// Otherwise throw error
// eslint-disable-next-line max-len
throw new Error('find() timed out. Is the device powered on and the ID or IP correct?');
});
}
/**
* Toggles a boolean property.
* @param {Number} [property=1] property to toggle
* @returns {Promise<Boolean>} the resulting state
*/
async toggle(property) {
property = property === undefined ? '1' : property.toString();
try {
// Get status
const status = await this.get({dps: property});
// Set to opposite
await this.set({set: !status, dps: property});
// Return new status
return await this.get({dps: property});
} catch (error) {
throw error;
}
}
}
module.exports = TuyaDevice;

@@ -13,74 +13,75 @@ const forge = require('node-forge');

*/
function TuyaCipher(options) {
this.cipher = forge.cipher.createCipher('AES-ECB', options.key);
this.decipher = forge.cipher.createDecipher('AES-ECB', options.key);
this.version = options.version;
}
class TuyaCipher {
constructor(options) {
this.cipher = forge.cipher.createCipher('AES-ECB', options.key);
this.decipher = forge.cipher.createDecipher('AES-ECB', options.key);
this.version = options.version;
}
/**
* Encrypts data.
* @param {Object} options
* @param {String} options.data data to encrypt
* @param {Boolean} [options.base64=true] `true` to return result in Base64
* @example
* TuyaCipher.encrypt({data: 'hello world'})
* @returns {Buffer|String} returns Buffer unless options.base64 is true
*/
TuyaCipher.prototype.encrypt = function (options) {
this.cipher.start({iv: ''});
this.cipher.update(forge.util.createBuffer(options.data, 'utf8'));
this.cipher.finish();
/**
* Encrypts data.
* @param {Object} options
* @param {String} options.data data to encrypt
* @param {Boolean} [options.base64=true] `true` to return result in Base64
* @example
* TuyaCipher.encrypt({data: 'hello world'})
* @returns {Buffer|String} returns Buffer unless options.base64 is true
*/
encrypt(options) {
this.cipher.start({iv: ''});
this.cipher.update(forge.util.createBuffer(options.data, 'utf8'));
this.cipher.finish();
if (options.base64 !== false) {
return forge.util.encode64(this.cipher.output.data);
if (options.base64 !== false) {
return forge.util.encode64(this.cipher.output.data);
}
return this.cipher.output;
}
return this.cipher.output;
};
/**
* Decrypts data.
* @param {String} data to decrypt
* @returns {Object|String}
* returns object if data is JSON, else returns string
*/
decrypt(data) {
if (data.indexOf(this.version.toString()) !== -1) {
// Data has version number and is encoded in base64
/**
* Decrypts data.
* @param {String} data to decrypt
* @returns {Object|String}
* returns object if data is JSON, else returns string
*/
TuyaCipher.prototype.decrypt = function (data) {
if (data.indexOf(this.version.toString()) !== -1) {
// Data has version number and is encoded in base64
// Remove prefix of version number and MD5 hash
data = data.slice(19);
// Remove prefix of version number and MD5 hash
data = data.slice(19);
// Decode data
data = forge.util.decode64(data);
}
// Decode data
data = forge.util.decode64(data);
}
// Turn data into Buffer
data = forge.util.createBuffer(data);
// Turn data into Buffer
data = forge.util.createBuffer(data);
this.decipher.start({iv: ''});
this.decipher.update(data);
this.decipher.finish();
this.decipher.start({iv: ''});
this.decipher.update(data);
this.decipher.finish();
const result = this.decipher.output.data;
const result = this.decipher.output.data;
// Try to parse data as JSON,
// otherwise return as string.
try {
return JSON.parse(result);
} catch (error) {
return result;
}
}
// Try to parse data as JSON,
// otherwise return as string.
try {
return JSON.parse(result);
} catch (error) {
return result;
/**
* Calculates a MD5 hash.
* @param {String} data to hash
* @returns {String} last 8 characters of hash of data
*/
md5(data) {
const md5hash = forge.md.md5.create().update(data).digest().toHex();
return md5hash.toString().toLowerCase().substr(8, 16);
}
};
/**
* Calculates a MD5 hash.
* @param {String} data to hash
* @returns {String} last 8 characters of hash of data
*/
TuyaCipher.prototype.md5 = function (data) {
const md5hash = forge.md.md5.create().update(data).digest().toHex();
return md5hash.toString().toLowerCase().substr(8, 16);
};
}
module.exports = TuyaCipher;

@@ -8,157 +8,159 @@ const debug = require('debug')('TuyAPI:MessageParser');

*/
function MessageParser() {
this._parsed = false;
this._buff = Buffer.alloc(0);
this._payloadSize = undefined;
this._data = undefined;
this._leftOver = undefined;
this._commandByte = undefined;
}
/**
* Append data to current buffer.
* @param {Buffer} buff data to append
* @private
*/
MessageParser.prototype._append = function (buff) {
this._buff = Buffer.concat([this._buff, buff]);
};
/**
* Parse current buffer stored in instance.
* @returns {Boolean} true if successfully parsed
* @private
*/
MessageParser.prototype._parse = function () {
if (this._parsed) {
return true;
class MessageParser {
constructor() {
this._parsed = false;
this._buff = Buffer.alloc(0);
this._payloadSize = undefined;
this._data = undefined;
this._leftOver = undefined;
this._commandByte = undefined;
}
// Check for length
if (this._buff.length < 16) {
debug('Packet too small. Length:', this._buff.length);
return false;
/**
* Append data to current buffer.
* @param {Buffer} buff data to append
* @private
*/
_append(buff) {
this._buff = Buffer.concat([this._buff, buff]);
}
// Check for prefix
const prefix = this._buff.readUInt32BE(0);
/**
* Parse current buffer stored in instance.
* @returns {Boolean} true if successfully parsed
* @private
*/
_parse() {
if (this._parsed) {
return true;
}
if (prefix !== 0x000055AA) {
throw new Error('Magic prefix mismatch: ' + this._buff.toString('hex'));
}
// Check for length
if (this._buff.length < 16) {
debug('Packet too small. Length:', this._buff.length);
return false;
}
// Check for suffix
const suffix = this._buff.readUInt32BE(this._buff.length - 4);
// Check for prefix
const prefix = this._buff.readUInt32BE(0);
if (suffix !== 0x0000AA55) {
throw new Error('Magic suffix mismatch: ' + this._buff.toString('hex'));
}
if (prefix !== 0x000055AA) {
throw new Error('Magic prefix mismatch: ' + this._buff.toString('hex'));
}
// Get payload size
if (!this._payloadSize) {
this._payloadSize = this._buff.readUInt32BE(12);
}
// Check for suffix
const suffix = this._buff.readUInt32BE(this._buff.length - 4);
this._commandByte = this._buff.readUInt8(11);
if (suffix !== 0x0000AA55) {
throw new Error('Magic suffix mismatch: ' + this._buff.toString('hex'));
}
// Check for payload
if (this._buff.length - 8 < this._payloadSize) {
debug('Packet missing payload.', this._buff.length, this._payloadSize);
this._data = '';
return false;
}
// Get payload size
if (!this._payloadSize) {
this._payloadSize = this._buff.readUInt32BE(12);
}
// Slice off CRC and suffix
this._data = this._buff.slice(0, this._buff.length - 8);
this._commandByte = this._buff.readUInt8(11);
// Slice off begining of packet, remainder is payload
this._data = this._data.slice(this._data.length - this._payloadSize + 8);
// Check for payload
if (this._buff.length - 8 < this._payloadSize) {
debug('Packet missing payload.', this._buff.length, this._payloadSize);
this._data = '';
return false;
}
// Remove 0 padding from payload
let done = false;
while (done === false) {
if (this._data[0] === 0) {
this._data = this._data.slice(1);
} else {
done = true;
// Slice off CRC and suffix
this._data = this._buff.slice(0, this._buff.length - 8);
// Slice off begining of packet, remainder is payload
this._data = this._data.slice(this._data.length - this._payloadSize + 8);
// Remove 0 padding from payload
let done = false;
while (done === false) {
if (this._data[0] === 0) {
this._data = this._data.slice(1);
} else {
done = true;
}
}
return true;
}
return true;
};
/**
* Attempt to parse data to JSON.
* @returns {Object} result
* @returns {String|Buffer|Object} result.data decoded data, if available in response
* @returns {Number} result.commandByte command byte from decoded data
* @private
*/
_decode() {
const result = {
commandByte: this._commandByte
};
// It's possible for packets to be valid
// and yet contain no data.
if (this._data.length === 0) {
return result;
}
/**
* Attempt to parse data to JSON.
* @returns {Object} result
* @returns {String|Buffer|Object} result.data decoded data, if available in response
* @returns {Number} result.commandByte command byte from decoded data
* @private
*/
MessageParser.prototype._decode = function () {
const result = {
commandByte: this._commandByte
};
// It's possible for packets to be valid
// and yet contain no data.
if (this._data.length === 0) {
// Try to parse data as JSON.
// If error, return as string.
try {
result.data = JSON.parse(this._data);
} catch (error) { // Data is encrypted
result.data = this._data.toString('ascii');
}
return result;
}
// Try to parse data as JSON.
// If error, return as string.
try {
result.data = JSON.parse(this._data);
} catch (error) { // Data is encrypted
result.data = this._data.toString('ascii');
}
/**
* Encode data (usually an object) into
* a protocol-compliant form that a device
* can understand.
* @param {Object} options
* @param {String|Buffer|Object} options.data data to encode
* @param {Number} options.commandByte command byte
* @returns {Buffer} binary payload
* @private
*/
_encode(options) {
// Ensure data is a Buffer
let payload;
return result;
};
if (options.data instanceof Buffer) {
payload = options.data;
} else {
if (typeof options.data !== 'string') {
options.data = JSON.stringify(options.data);
}
/**
* Encode data (usually an object) into
* a protocol-compliant form that a device
* can understand.
* @param {Object} options
* @param {String|Buffer|Object} options.data data to encode
* @param {Number} options.commandByte command byte
* @returns {Buffer} binary payload
* @private
*/
MessageParser.prototype._encode = function (options) {
// Ensure data is a Buffer
let payload;
if (options.data instanceof Buffer) {
payload = options.data;
} else {
if (typeof options.data !== 'string') {
options.data = JSON.stringify(options.data);
payload = Buffer.from(options.data);
}
payload = Buffer.from(options.data);
}
// Generate prefix (including command and length bytes)
const prefix = Buffer.from('000055aa00000000000000' +
(options.commandByte < 16 ? '0' : '') +
options.commandByte.toString(16), 'hex');
// Generate prefix (including command and length bytes)
const prefix = Buffer.from('000055aa00000000000000' +
(options.commandByte < 16 ? '0' : '') +
options.commandByte.toString(16), 'hex');
// Suffix is static
const suffix = Buffer.from('0000aa55', 'hex');
// Suffix is static
const suffix = Buffer.from('0000aa55', 'hex');
// As devices don't seem to care,
// just use an empty CRC for now.
const crc32Buffer = Buffer.from('00000000', 'hex');
// As devices don't seem to care,
// just use an empty CRC for now.
const crc32Buffer = Buffer.from('00000000', 'hex');
// Calculate length (everything past length byte)
const len = Buffer.allocUnsafe(4);
len.writeInt32BE(Buffer.concat([payload, crc32Buffer, suffix]).length, 0);
// Calculate length (everything past length byte)
const len = Buffer.allocUnsafe(4);
len.writeInt32BE(Buffer.concat([payload, crc32Buffer, suffix]).length, 0);
// Concat buffers
const concatBuffer = Buffer.concat([prefix, len, payload, crc32Buffer, suffix]);
// Concat buffers
const concatBuffer = Buffer.concat([prefix, len, payload, crc32Buffer, suffix]);
return concatBuffer;
}
}
return concatBuffer;
};
/**

@@ -172,3 +174,3 @@ * Static wrapper for lower-level MessageParser

*/
MessageParser.parse = function (data) {
function parse(data) {
const p = new MessageParser();

@@ -178,3 +180,3 @@ p._append(data);

return p._decode();
};
}

@@ -189,7 +191,7 @@ /**

*/
MessageParser.encode = function (options) {
function encode(options) {
const p = new MessageParser();
return p._encode({data: options.data, commandByte: options.commandByte});
};
}
module.exports = {parse: MessageParser.parse, encode: MessageParser.encode};
module.exports = {parse, encode};
{
"name": "tuyapi",
"version": "3.2.3",
"version": "4.0.0",
"description": "An easy-to-use API for devices that use Tuya's cloud services",
"main": "index.js",
"files": [
"lib/**/*",
"index.js"
],
"scripts": {

@@ -10,3 +14,4 @@ "test": "xo --quiet && ava",

"document": "documentation build index.js -f html -o docs --config documentation.yml",
"pushtags": "git push origin master --tags"
"prepublishOnly": "npm test",
"preversion": "npm test"
},

@@ -37,4 +42,4 @@ "repository": {

"node-forge": "^0.8.0",
"p-timeout": "^2.0.1",
"retry": "^0.10.1"
"p-retry": "^3.0.1",
"p-timeout": "^2.0.1"
},

@@ -45,3 +50,3 @@ "devDependencies": {

"documentation": "9.1.1",
"nyc": "13.2.0",
"nyc": "13.3.0",
"xo": "0.24.0"

@@ -48,0 +53,0 @@ },

@@ -16,36 +16,51 @@ # TuyAPI 🌧 🔌

See the [setup instructions](docs/SETUP.md) for how to find the needed parameters.
These examples should report the current status, set the default property to the opposite of what it currently is, then report the changed status.
They will need to be adapted if your device does not have a boolean property at index 1 (i.e. it doesn't have an on/off property).
### Asynchronous (event based, recommended)
```javascript
const TuyAPI = require('tuyapi');
const device = new TuyAPI({
id: 'xxxxxxxxxxxxxxxxxxxx',
key: 'xxxxxxxxxxxxxxxx',
ip: 'xxx.xxx.xxx.xxx',
persistentConnection: true});
key: 'xxxxxxxxxxxxxxxx'});
device.on('connected',() => {
console.log('Connected to device.');
let stateHasChanged = false;
// Find device on network
device.find().then(() => {
// Connect to device
device.connect();
});
device.on('disconnected',() => {
// Add event listeners
device.on('connected', () => {
console.log('Connected to device!');
});
device.on('disconnected', () => {
console.log('Disconnected from device.');
});
device.on('error', error => {
console.log('Error!', error);
});
device.on('data', data => {
console.log('Data from device:', data);
const status = data.dps['1'];
console.log(`Boolean status of default property: ${data.dps['1']}.`);
console.log('Current status:', status);
// Set default property to opposite
if (!stateHasChanged) {
device.set({set: !(data.dps['1'])});
device.set({set: !status}).then(result => {
console.log('Result of setting status:', result);
});
// Otherwise we'll be stuck in an endless
// loop of toggling the state.
stateHasChanged = true;
}
});
device.on('error',(err) => {
console.log('Error: ' + err);
});
device.connect();
// Disconnect after 10 seconds

@@ -61,24 +76,22 @@ setTimeout(() => { device.disconnect(); }, 10000);

id: 'xxxxxxxxxxxxxxxxxxxx',
key: 'xxxxxxxxxxxxxxxx',
ip: 'xxx.xxx.xxx.xxx'});
key: 'xxxxxxxxxxxxxxxx'});
device.get().then(status => {
console.log('Status:', status);
(async () => {
await device.find();
device.set({set: !status}).then(result => {
console.log('Result of setting status to ' + !status + ': ' + result);
let status = await device.get();
device.get().then(status => {
console.log('New status:', status);
return;
});
});
});
```
console.log(`Current status: ${status}.`);
This should report the current status, set the device to the opposite of what it currently is, then report the changed status. The above examples will work with smart plugs; they may need some tweaking for other types of devices.
await device.set({set: !status});
See the [setup instructions](docs/SETUP.md) for how to find the needed parameters.
status = await device.get();
console.log(`New status: ${status}.`);
device.disconnect();
})();
```
## 📝 Notes

@@ -89,3 +102,3 @@ - Only one TCP connection can be in use with a device at once. If using this, do not have the app on your phone open.

## 📓 Docs
## 📓 Documentation

@@ -109,8 +122,19 @@ See the [docs](https://codetheweb.github.io/tuyapi/index.html).

- [dresende](https://github.com/dresende)
- [kaveet](https://github.com/kaveet)
- [johnyorke](https://github.com/johnyorke)
- [jpillora](https://github.com/jpillora)
- [neojski](https://github.com/neojski)
- [unparagoned](https://github.com/unparagoned)
(If you're not on the above list, open a PR.)
## Related
### Flash alternative firmware
- [tuya-convert](https://github.com/ct-Open-Source/tuya-convert) a project that allows you to flash custom firmware OTA on devices
### Ports
- [python-tuya](https://github.com/clach04/python-tuya) a Python port by [clach04](https://github.com/clach04)
- [m4rcus.TuyaCore](https://github.com/Marcus-L/m4rcus.TuyaCore) a .NET port by [Marcus-L](https://github.com/Marcus-L)
- [TuyaKit](https://github.com/eppz/.NET.Library.TuyaKit) a .NET port by [eppz](https://github.com/eppz)

@@ -117,0 +141,0 @@ ### Projects built with TuyAPI

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc