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 2.0.3 to 3.0.0

.travis.yml

447

index.js

@@ -1,14 +0,11 @@

'use strict';
// Import packages
const dgram = require('dgram');
const net = require('net');
const stringOccurrence = require('string-occurrence');
const timeout = require('p-timeout');
const forge = require('node-forge');
const retry = require('retry');
const debug = require('debug')('TuyAPI');
// Import requests for devices
const requests = require('./requests.json');
// Helpers
const Cipher = require('./lib/cipher');
const Parser = require('./lib/message-parser');

@@ -18,61 +15,63 @@ /**

* @class
* @param {Object} options - options for constructing a TuyaDevice
* @param {String} [options.type='outlet'] - type of device
* @param {String} [options.ip] - IP of device
* @param {Number} [options.port=6668] - port of device
* @param {String} options.id - ID of device
* @param {String} [options.uid=''] - UID of device
* @param {String} options.key - encryption key of device
* @param {Number} [options.version=3.1] - protocol version
* @param {Object} options
* @param {String} [options.ip] IP of device
* @param {Number} [options.port=6668] port of device
* @param {String} options.id ID of device
* @param {String} options.key encryption key of device
* @param {Number} [options.version=3.1] protocol version
* @example
* const tuya = new TuyaDevice({id: 'xxxxxxxxxxxxxxxxxxxx', key: 'xxxxxxxxxxxxxxxx'})
* @example
* const tuya = new TuyaDevice([
* {id: 'xxxxxxxxxxxxxxxxxxxx', key: 'xxxxxxxxxxxxxxxx'},
* {id: 'xxxxxxxxxxxxxxxxxxxx', key: 'xxxxxxxxxxxxxxxx'}])
*/
function TuyaDevice(options) {
this.devices = [];
this.device = options;
if (options.constructor === Array) { // If argument is [{id: '', key: ''}]
this.devices = options;
} else if (options.constructor === Object) { // If argument is {id: '', key: ''}
this.devices = [options];
// Defaults
if (this.device.id === undefined) {
throw new Error('ID is missing from device.');
}
if (this.device.key === undefined) {
throw new Error('Encryption key is missing from device.');
}
if (this.device.port === undefined) {
this.device.port = 6668;
}
if (this.device.version === undefined) {
this.device.version = 3.1;
}
// Standardize devices array
for (let i = 0; i < this.devices.length; i++) {
if (this.devices[i].id === undefined) {
throw new Error('ID is missing from device.');
}
if (this.devices[i].key === undefined) {
throw new Error('Encryption key is missing from device with ID ' + this.devices[i].id + '.');
}
if (this.devices[i].type === undefined) {
this.devices[i].type = 'outlet';
}
if (this.devices[i].uid === undefined) {
this.devices[i].uid = '';
}
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.device.cipher = new Cipher({key: this.device.key, version: this.device.version});
// Create cipher from key
this.devices[i].cipher = forge.cipher.createCipher('AES-ECB', this.devices[i].key);
}
this._responseTimeout = 5; // In seconds
debug('Device(s): ');
debug(this.devices);
debug('Device: ');
debug(this.device);
}
/**
* Resolves IDs stored in class to IPs. If you didn't pass IPs to the constructor,
* you must call this before doing anything else.
* @returns {Promise<Boolean>} - true if IPs were found and devices are ready to be used
* 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
*/
TuyaDevice.prototype.resolveIds = function () {
TuyaDevice.prototype.resolveId = function (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);
}
// Create new listener

@@ -82,33 +81,23 @@ this.listener = dgram.createSocket('udp4');

// Find devices that need an IP
const needIP = [];
for (let i = 0; i < this.devices.length; i++) {
if (this.devices[i].ip === undefined) {
needIP.push(this.devices[i].id);
}
}
debug('Finding IP for device ' + this.device.id);
debug('Finding IP for devices ' + needIP);
// Add IPs to devices in array
return timeout(new Promise(resolve => { // Timeout
// Find IP for device
return timeout(new Promise((resolve, reject) => { // Timeout
this.listener.on('message', message => {
debug('Received UDP message.');
const thisId = this._extractJSON(message).gwId;
const data = Parser.parse(message);
if (needIP.length > 0) {
if (needIP.includes(thisId)) {
const deviceIndex = this.devices.findIndex(device => {
if (device.id === thisId) {
return true;
}
return false;
});
debug('UDP data:');
debug(data);
this.devices[deviceIndex].ip = this._extractJSON(message).ip;
const thisId = data.gwId;
needIP.splice(needIP.indexOf(thisId), 1);
}
} else { // All devices have been resolved
if (this.device.id === thisId) {
// Add IP
this.device.ip = data.ip;
// Change protocol version if necessary
this.device.version = data.version;
// Cleanup
this.listener.close();

@@ -119,7 +108,10 @@ this.listener.removeAllListeners();

});
}), 10000, () => {
this.listener.on('error', err => reject(err));
}), options.timeout * 1000, () => {
// Have to do this so we exit cleanly
this.listener.close();
this.listener.removeAllListeners();
throw new Error('resolveIds() timed out. Is the device ID correct and is the device powered on?');
// eslint-disable-next-line max-len
throw new Error('resolveIds() timed out. Is the device powered on and the ID correct?');
});

@@ -129,57 +121,47 @@ };

/**
* Gets a device's current status. Defaults to returning only the value of the first result,
* but by setting {schema: true} you can get everything.
* @param {Object} [options] - optional options for getting data
* @param {String} [options.id] - ID of device
* @param {Boolean} [options.schema] - true to return entire schema, not just the first result
* @deprecated since v3.0.0. Will be removed in v4.0.0. Use resolveId() instead.
*/
TuyaDevice.prototype.resolveIds = function (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.
* @param {Object} [options]
* @param {Boolean} [options.schema]
* true to return entire schema of device
* @param {Number} [options.dps=1]
* DPS index to return
* @example
* // get status for device with one property
* // get first, default property from device
* tuya.get().then(status => console.log(status))
* @example
* // get status for specific device with one property
* tuya.get({id: 'xxxxxxxxxxxxxxxxxxxx'}).then(status => console.log(status))
* // get second property from device
* tuya.get({dps: 2}).then(status => console.log(status))
* @example
* // get all available data from device
* 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<Object>}
* returns boolean if no options are provided, otherwise returns object of results
*/
TuyaDevice.prototype.get = function (options) {
let currentDevice;
// Set empty object as default
options = options ? options : {};
// 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
const index = this.devices.findIndex(device => {
if (device.id === options.id) {
return true;
}
return false;
});
currentDevice = this.devices[index];
}
const payload = {gwId: this.device.id, devId: this.device.id};
// Add data to command
if ('gwId' in requests[currentDevice.type].status.command) {
requests[currentDevice.type].status.command.gwId = currentDevice.id;
}
if ('devId' in requests[currentDevice.type].status.command) {
requests[currentDevice.type].status.command.devId = currentDevice.id;
}
debug('Payload: ', payload);
debug('Payload: ');
debug(requests[currentDevice.type].status.command);
// Create byte buffer
const buffer = Parser.encode({data: payload, commandByte: '0a'});
// Create byte buffer from hex data
const thisData = Buffer.from(JSON.stringify(requests[currentDevice.type].status.command));
const buffer = this._constructBuffer(currentDevice.type, thisData, 'status');
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) {
this._send(this.device.ip, buffer).then(data => {
if (options.schema === true) {
resolve(data);
} else if (options.dps) {
resolve(data.dps[options.dps]);
} else {

@@ -196,77 +178,50 @@ resolve(data.dps['1']);

* Sets a property on a device.
* @param {Object} options - options for setting properties
* @param {String} [options.id] - ID of device
* @param {Boolean} options.set - `true` for on, `false` for off
* @param {Number} [options.dps] - dps index to change
* @param {Object} options
* @param {Number} [options.dps=1] DPS index to set
* @param {*} options.set value to set
* @example
* // set default property on default device
* // set default property
* tuya.set({set: true}).then(() => console.log('device was changed'))
* @example
* // set custom property on non-default device
* tuya.set({id: 'xxxxxxxxxxxxxxxxxxxx', 'dps': 2, set: true}).then(() => console.log('device was changed'))
* // set custom property
* tuya.set({dps: 2, set: true}).then(() => console.log('device was changed'))
* @returns {Promise<Boolean>} - returns `true` if the command succeeded
*/
TuyaDevice.prototype.set = function (options) {
let currentDevice;
let dps = {};
// 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
const index = this.devices.findIndex(device => {
if (device.id === options.id) {
return true;
}
return false;
});
currentDevice = this.devices[index];
if (options.dps === undefined) {
dps = {1: options.set};
} else {
dps = {[options.dps.toString()]: options.set};
}
const thisRequest = requests[currentDevice.type].set.command;
// Add data to command
const now = new Date();
if ('gwId' in thisRequest) {
thisRequest.gwId = currentDevice.id;
}
if ('devId' in thisRequest) {
thisRequest.devId = currentDevice.id;
}
if ('uid' in thisRequest) {
thisRequest.uid = currentDevice.uid;
}
if ('t' in thisRequest) {
thisRequest.t = (parseInt(now.getTime() / 1000, 10)).toString();
}
const timeStamp = (parseInt(now.getTime() / 1000, 10)).toString();
if (options.dps === undefined) {
thisRequest.dps = {1: options.set};
} else {
thisRequest.dps = {[options.dps.toString()]: options.set};
}
const payload = {
devId: this.device.id,
uid: '',
t: timeStamp,
dps
};
debug('Payload: ');
debug(thisRequest);
debug('Payload:');
debug(payload);
// Encrypt data
currentDevice.cipher.start({iv: ''});
currentDevice.cipher.update(forge.util.createBuffer(JSON.stringify(thisRequest), 'utf8'));
currentDevice.cipher.finish();
const data = this.device.cipher.encrypt({data: JSON.stringify(payload)});
// Encode binary data to Base64
const data = forge.util.encode64(currentDevice.cipher.output.data);
// Create MD5 signature
const preMd5String = 'data=' + data + '||lpv=' + currentDevice.version + '||' + currentDevice.key;
const md5hash = forge.md.md5.create().update(preMd5String).digest().toHex();
const md5 = md5hash.toString().toLowerCase().substr(8, 16);
const md5 = this.device.cipher.md5('data=' + data +
'||lpv=' + this.device.version +
'||' + this.device.key);
// Create byte buffer from hex data
const thisData = Buffer.from(currentDevice.version + md5 + data);
const buffer = this._constructBuffer(currentDevice.type, thisData, 'set');
const thisData = Buffer.from(this.device.version + md5 + data);
const buffer = Parser.encode({data: thisData, commandByte: '07'});
// Send request to change status
return new Promise((resolve, reject) => {
this._send(currentDevice.ip, buffer).then(() => {
this._send(this.device.ip, buffer).then(() => {
resolve(true);

@@ -280,40 +235,32 @@ }).catch(err => {

/**
* Sends a query to a device.
* Sends a query to a device. Helper
* function that wraps ._sendUnwrapped()
* in a retry operation.
* @private
* @param {String} ip - IP of device
* @param {Buffer} buffer - buffer of data
* @returns {Promise<string>} - returned data
* @param {String} ip IP of device
* @param {Buffer} buffer buffer of data
* @returns {Promise<string>} returned data
*/
TuyaDevice.prototype._send = function (ip, buffer) {
debug('Sending this data: ', buffer.toString('hex'));
if (typeof ip === 'undefined') {
throw new TypeError('Device missing IP address.');
}
const operation = retry.operation({
retries: 4,
factor: 1.5
});
return new Promise((resolve, reject) => {
const client = new net.Socket();
const connectOperation = retry.operation();
operation.attempt(currentAttempt => {
debug('Socket attempt', currentAttempt);
client.on('error', error => {
if (!connectOperation.retry(error)) {
reject(error);
}
});
this._sendUnwrapped(ip, buffer).then(result => {
resolve(result);
}).catch(error => {
if (operation.retry(error)) {
return;
}
connectOperation.attempt(() => {
client.connect(6668, ip, () => {
const writeOperation = retry.operation();
writeOperation.attempt(() => {
client.write(buffer);
client.on('data', data => {
client.destroy();
debug('Received data back.');
resolve(data);
});
client.on('error', error => {
error.message = 'Error communicating with device. Make sure nothing else is trying to control it or connected to it.';
console.log('here');
if (!writeOperation.retry(error)) {
reject(error);
}
});
});
reject(operation.mainError());
});

@@ -325,47 +272,69 @@ });

/**
* Constructs a protocol-complient buffer given device type, data, and command.
* Sends a query to a device.
* @private
* @param {String} type - type of device
* @param {String} data - data to put in buffer
* @param {String} command - command (status || set)
* @returns {Buffer} buffer - buffer of data
* @param {String} ip IP of device
* @param {Buffer} buffer buffer of data
* @returns {Promise<string>} returned data
*/
TuyaDevice.prototype._constructBuffer = function (type, data, command) {
// Construct prefix of packet according to protocol
const prefixLength = (data.toString('hex').length + requests[type].suffix.length) / 2;
const prefix = requests[type].prefix + requests[type][command].hexByte + '000000' + prefixLength.toString(16);
TuyaDevice.prototype._sendUnwrapped = function (ip, buffer) {
debug('Sending this data: ', buffer.toString('hex'));
// Create final buffer: prefix + data + suffix
return Buffer.from(prefix + data.toString('hex') + requests[type].suffix, 'hex');
};
const client = new net.Socket();
/**
* Extracts JSON from a raw buffer and returns it as an object.
* @private
* @param {Buffer} data - buffer of data
* @returns {Object} extracted object
*/
TuyaDevice.prototype._extractJSON = function (data) {
debug('Parsing this data to JSON: ', data.toString('hex'));
return new Promise((resolve, reject) => {
// Attempt to connect
client.connect(6668, ip);
data = data.toString();
// Default connect timeout is ~1 minute,
// 10 seconds is a more reasonable default
// since `retry` is used.
client.setTimeout(1000, () => {
client.emit('error', new Error('connection timed out'));
client.destroy();
});
// Find the # of occurrences of '{' and make that # match with the # of occurrences of '}'
const leftBrackets = stringOccurrence(data, '{');
let occurrences = 0;
let currentIndex = 0;
// Send data when connected
client.on('connect', () => {
debug('Socket connected.');
while (occurrences < leftBrackets) {
const index = data.indexOf('}', currentIndex + 1);
if (index !== -1) {
currentIndex = index;
occurrences++;
}
}
// Remove connect timeout
client.setTimeout(0);
data = data.slice(data.indexOf('{'), currentIndex + 1);
data = JSON.parse(data);
return data;
// Transmit data
client.write(buffer);
this._sendTimeout = setTimeout(() => {
client.destroy();
reject(new Error('Timeout waiting for response'));
}, this._responseTimeout * 1000);
});
// Parse response data
client.on('data', data => {
debug('Received data back:');
debug(data.toString('hex'));
clearTimeout(this._sendTimeout);
client.destroy();
data = Parser.parse(data);
if (typeof data === 'object' || typeof data === 'undefined') {
resolve(data);
} else { // Message is encrypted
resolve(this.device.cipher.decrypt(data));
}
});
// Handle errors
client.on('error', err => {
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.';
reject(err);
});
});
};
module.exports = TuyaDevice;
{
"name": "tuyapi",
"version": "2.0.3",
"description": "An easy-to-use API for devices that use Tuya's cloud services (currently only supports smart plugs)",
"version": "3.0.0",
"description": "An easy-to-use API for devices that use Tuya's cloud services",
"main": "index.js",
"scripts": {
"style": "xo --space --fix",
"document": "documentation build index.js -f md | (echo 'Docs \n=========' && cat) > docs/API.md",
"test": "xo --quiet && ava",
"cover": "nyc npm test && nyc report --reporter=text-lcov | coveralls",
"document": "documentation build index.js -f html -o docs",
"pushtags": "git push origin master --tags"

@@ -19,3 +20,7 @@ },

"plug",
"jinvoo"
"jinvoo",
"switch",
"api",
"socket",
"protocol"
],

@@ -29,13 +34,37 @@ "author": "Max Isom <codetheweb@icloud.com> (https://maxisom.me)",

"dependencies": {
"crc": "^3.5.0",
"debug": "^3.1.0",
"net-retry-connect": "^0.1.1",
"node-forge": "^0.7.1",
"p-timeout": "^2.0.1",
"retry": "^0.10.1",
"string-occurrence": "^1.2.0"
"retry": "^0.10.1"
},
"devDependencies": {
"ava": "^0.25.0",
"coveralls": "^3.0.1",
"documentation": "^5.3.3",
"xo": "^0.18.2"
"nyc": "^12.0.2",
"xo": "^0.21.1"
},
"xo": {
"space": true,
"ignores": [
"docs"
],
"rules": {
"max-len": [
"error",
{
"code": 90
}
],
"indent": [
"error",
2,
{
"ObjectExpression": "first",
"ArrayExpression": "first"
}
]
}
}
}

@@ -1,5 +0,8 @@

# TuyAPI 🌧 🔌 [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo)
# TuyAPI 🌧 🔌
[![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo)
[![Build Status](https://travis-ci.org/codetheweb/tuyapi.svg?branch=master)](https://travis-ci.org/codetheweb/tuyapi)
[![Coverage Status](https://coveralls.io/repos/github/codetheweb/tuyapi/badge.svg?branch=master)](https://coveralls.io/github/codetheweb/tuyapi?branch=master)
A library for communicating with devices that use the [Tuya](http://tuya.com) cloud network. These devices are branded under many different names, but if port 6668 is open on your device chances are this library will work with it.
Currently only supports smart plugs, but it should be fairly trivial to add other types of devices.

@@ -11,2 +14,3 @@ ## Installation

## Basic Usage
```javascript

@@ -17,15 +21,14 @@ const TuyaDevice = require('tuyapi');

id: 'xxxxxxxxxxxxxxxxxxxx',
key: 'xxxxxxxxxxxxxxxx'});
key: 'xxxxxxxxxxxxxxxx',
ip: 'xxx.xxx.xxx.xxx'});
tuya.resolveIds().then(() => {
tuya.get().then(status => {
console.log('Status: ' + status);
tuya.get().then(status => {
console.log('Status:', status);
tuya.set({set: !status}).then(result => {
console.log('Result of setting status to ' + !status + ': ' + result);
tuya.set({set: !status}).then(result => {
console.log('Result of setting status to ' + !status + ': ' + result);
tuya.get().then(status => {
console.log('New status: ' + status);
return;
});
tuya.get().then(status => {
console.log('New status:', status);
return;
});

@@ -42,3 +45,3 @@ });

See the [docs](docs/API.md).
See the [docs](https://codetheweb.github.io/tuyapi/index.html).

@@ -49,17 +52,27 @@ **IMPORTANT**: Only one TCP connection can be in use with a device at once. If testing this, do not have the app on your phone open.

1. Add automated tests
2. Document details of protocol
3. Retry when ECONNRESET is thrown
1. Document details of protocol
2. Figure out correct CRC algorithm
3. Keep connection open between requests
## Contributors
- [codetheweb](https://github.com/codetheweb)
- [blackrozes](https://github.com/blackrozes)
- [clach04](https://github.com/clach04)
- [jepsonrob](https://github.com/jepsonrob)
- [codetheweb](https://github.com/codetheweb)
- [blackrozes](https://github.com/blackrozes)
- [clach04](https://github.com/clach04)
- [jepsonrob](https://github.com/jepsonrob)
- [tjfontaine](https://github.com/tjfontaine)
## Related
- [homebridge-tuya](https://github.com/codetheweb/homebridge-tuya-outlet): a [Homebridge](https://github.com/nfarina/homebridge) plugin for Tuya devices
- [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)
### 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)
### Projects built with TuyAPI
- [homebridge-tuya](https://github.com/codetheweb/homebridge-tuya-outlet): a [Homebridge](https://github.com/nfarina/homebridge) plugin for Tuya devices
- [tuyaweb](https://github.com/bmachek/tuyaweb): a web interface for controlling devices by [bmachek](https://github.com/bmachek)
To add your projects to either of the above lists, please open a pull request.
[![forthebadge](https://forthebadge.com/images/badges/made-with-javascript.svg)](https://forthebadge.com)
[![forthebadge](https://forthebadge.com/images/badges/built-with-love.svg)](https://forthebadge.com)
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