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 4.0.4 to 5.0.0

lib/utils.js

242

index.js

@@ -10,4 +10,4 @@ // Import packages

// Helpers
const Cipher = require('./lib/cipher');
const Parser = require('./lib/message-parser');
const {isValidString} = require('./lib/utils');
const {MessageParser, CommandType} = require('./lib/message-parser');

@@ -37,3 +37,2 @@ /**

super();
// Set device to user-passed options

@@ -43,13 +42,16 @@ this.device = {ip, port, id, gwID, key, productKey, version};

// Check arguments
if (!(this.checkIfValidString(id) ||
this.checkIfValidString(ip))) {
if (!(isValidString(id) ||
isValidString(ip))) {
throw new TypeError('ID and IP are missing from device.');
}
if (!this.checkIfValidString(key) || key.length !== 16) {
// Check key
if (!isValidString(this.device.key) || this.device.key.length !== 16) {
throw new TypeError('Key is missing or incorrect.');
}
// Create cipher from key
this.device.cipher = new Cipher({key, version});
// Handles encoding/decoding, encrypting/decrypting messages
this.device.parser = new MessageParser({
key: this.device.key,
version: this.device.version});

@@ -67,2 +69,7 @@ // Contains array of found devices when calling .find()

this._pingPongPeriod = 10; // Seconds
this._currentSequenceN = 0;
this._resolvers = {};
this._waitingForSetToResolve = false;
}

@@ -100,5 +107,6 @@

// Create byte buffer
const buffer = Parser.encode({
const buffer = this.device.parser.encode({
data: payload,
commandByte: 10 // 0x0a
commandByte: CommandType.DP_QUERY,
sequenceN: ++this._currentSequenceN
});

@@ -110,22 +118,13 @@

// Send request
this._send(buffer).then(() => {
// Runs when data event is emitted
const resolveGet = data => {
// Remove self listener
this.removeListener('data', resolveGet);
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);
this._send(buffer).then(data => {
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']);
}
});

@@ -160,3 +159,3 @@ } catch (error) {

* }}).then(() => console.log('device was changed'))
* @returns {Promise<Boolean>} - returns `true` if the command succeeded
* @returns {Promise<Object>} - returns response from device
*/

@@ -199,38 +198,18 @@ set(options) {

// Encrypt data
const data = this.device.cipher.encrypt({
data: JSON.stringify(payload)
});
// Create MD5 signature
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(this.device.version + md5 + data);
// Encode into packet
const buffer = Parser.encode({
data: thisData,
commandByte: 7 // 0x07
const buffer = this.device.parser.encode({
data: payload,
encrypted: true, // Set commands must be encrypted
commandByte: CommandType.CONTROL,
sequenceN: ++this._currentSequenceN
});
// Send request and wait for response
this._waitingForSetToResolve = true;
return new Promise((resolve, reject) => {
try {
// Send request
this._send(buffer).then(() => {
// Runs when data event is emitted
const resolveSet = _ => {
// Remove self listener
this.removeListener('data', resolveSet);
this._send(buffer);
// Return true
resolve(true);
};
// Add listener to data event
this.on('data', resolveSet);
});
this._setResolver = resolve;
} catch (error) {

@@ -248,3 +227,3 @@ reject(error);

* @param {Buffer} buffer buffer of data
* @returns {Promise<Boolean>} `true` if query was successfully sent
* @returns {Promise<Any>} returned data for request
*/

@@ -258,7 +237,14 @@ _send(buffer) {

// Retry up to 5 times
return pRetry(async () => {
// Send data
this.client.write(buffer);
return pRetry(() => {
return new Promise((resolve, reject) => {
try {
// Send data
this.client.write(buffer);
return true;
// Add resolver function
this._resolvers[this._currentSequenceN] = data => resolve(data);
} catch (error) {
reject(error);
}
});
}, {retries: 5});

@@ -275,5 +261,6 @@ }

// Create byte buffer
const buffer = Parser.encode({
const buffer = this.device.parser.encode({
data: Buffer.allocUnsafe(0),
commandByte: 9 // 0x09
commandByte: CommandType.HEART_BEAT,
sequenceN: ++this._currentSequenceN
});

@@ -323,11 +310,8 @@

this.client.on('data', data => {
debug('Received response');
debug(data.toString('hex'));
debug(`Received data: ${data.toString('hex')}`);
// Response was received, so stop waiting
clearTimeout(this._sendTimeout);
let packets;
let dataRes;
try {
dataRes = Parser.parse(data);
packets = this.device.parser.parse(data);
} catch (error) {

@@ -339,34 +323,8 @@ debug(error);

data = dataRes.data;
packets.forEach(packet => {
debug('Parsed:');
debug(packet);
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;
}
if (dataRes.commandByte === 0x07) { // Set succeeded
debug('Set succeeded.');
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);
}
/**
* Emitted when data is returned from device.
* @event TuyaDevice#data
* @property {Object} data received data
* @property {Number} commandByte
* commandByte of result
* (e.g. 7=requested response, 8=proactive update from device)
*/
this.emit('data', data, dataRes.commandByte);
this._packetHandler.bind(this)(packet);
});
});

@@ -443,2 +401,45 @@

_packetHandler(packet) {
// Response was received, so stop waiting
clearTimeout(this._sendTimeout);
if (packet.commandByte === CommandType.HEART_BEAT) {
debug(`Pong from ${this.device.ip}`);
// Remove resolver
delete this._resolvers[packet.sequenceN];
return;
}
/**
* Emitted when data is returned from device.
* @event TuyaDevice#data
* @property {Object} data received data
* @property {Number} commandByte
* commandByte of result
* (e.g. 7=requested response, 8=proactive update from device)
* @property {Number} sequenceN the packet sequence number
*/
this.emit('data', packet.payload, packet.commandByte, packet.sequenceN);
// Status response to SET command
if (packet.sequenceN === 0 &&
packet.commandByte === CommandType.STATUS &&
this._waitingForSetToResolve) {
this._setResolver(packet.payload);
// Remove resolver
this._setResolver = undefined;
return;
}
// Call data resolver for sequence number
if (packet.sequenceN in this._resolvers) {
this._resolvers[packet.sequenceN](packet.payload);
// Remove resolver
delete this._resolvers[packet.sequenceN];
}
}
/**

@@ -476,13 +477,2 @@ * Disconnects from the device, use to

/**
* Checks a given input string.
* @private
* @param {String} input input string
* @returns {Boolean}
* `true` if is string and length != 0, `false` otherwise.
*/
checkIfValidString(input) {
return typeof input === 'string' && input.length > 0;
}
/**
* @deprecated since v3.0.0. Will be removed in v4.0.0. Use find() instead.

@@ -512,4 +502,4 @@ */

find({timeout = 10, all = false} = {}) {
if (this.checkIfValidString(this.device.id) &&
this.checkIfValidString(this.device.ip)) {
if (isValidString(this.device.id) &&
isValidString(this.device.ip)) {
// Don't need to do anything

@@ -533,3 +523,3 @@ debug('IP and ID are already both resolved.');

try {
dataRes = Parser.parse(message);
dataRes = this.device.parser.parse(message)[0];
} catch (error) {

@@ -541,6 +531,6 @@ debug(error);

debug('UDP data:');
debug(dataRes.data);
debug(dataRes);
const thisID = dataRes.data.gwId;
const thisIP = dataRes.data.ip;
const thisID = dataRes.payload.gwId;
const thisIP = dataRes.payload.ip;

@@ -554,15 +544,15 @@ // Add to array if it doesn't exist

(this.device.id === thisID || this.device.ip === thisIP) &&
dataRes.data) {
dataRes.payload) {
// Add IP
this.device.ip = dataRes.data.ip;
this.device.ip = dataRes.payload.ip;
// Add ID and gwID
this.device.id = dataRes.data.gwId;
this.device.gwID = dataRes.data.gwId;
this.device.id = dataRes.payload.gwId;
this.device.gwID = dataRes.payload.gwId;
// Change product key if neccessary
this.device.productKey = dataRes.data.productKey;
this.device.productKey = dataRes.payload.productKey;
// Change protocol version if necessary
this.device.version = dataRes.data.version;
this.device.version = dataRes.payload.version;

@@ -569,0 +559,0 @@ // Cleanup

const crypto = require('crypto');
/**
* Class for encrypting and decrypting payloads.
* Low-level class for encrypting and decrypting payloads.
* @class
* @private
* @param {Object} options

@@ -16,3 +15,3 @@ * @param {String} options.key localKey of cipher

this.key = options.key;
this.version = options.version;
this.version = options.version.toString();
}

@@ -45,3 +44,3 @@

* Decrypts data.
* @param {String} data to decrypt
* @param {String|Buffer} data to decrypt
* @returns {Object|String}

@@ -54,3 +53,3 @@ * returns object if data is JSON, else returns string

if (data.indexOf(this.version.toString()) !== -1) {
if (data.indexOf(this.version) === 0) {
// Data has version number and is encoded in base64

@@ -57,0 +56,0 @@

@@ -73,3 +73,3 @@ /* Reverse engineered by kueblc */

* Computes a Tuya flavored CRC32
* @param {Iterable} data
* @param {Iterable} bytes
* @returns {Number} Tuya CRC32

@@ -76,0 +76,0 @@ */

@@ -1,2 +0,2 @@

const debug = require('debug')('TuyAPI:MessageParser');
const Cipher = require('./cipher');
const crc = require('./crc');

@@ -7,140 +7,293 @@

/**
* Parse a packet from a device into a
* payload and command type
* @param {Buffer} data packet to parse
* @returns {Object} result
* @returns {String|Buffer|Object} result.data decoded data, if available in response
* @returns {Number} result.commandByte command byte from decoded data
* Human-readable definitions
* of command bytes.
* @readonly
* @private
*/
const CommandType = {
UDP: 0,
AP_CONFIG: 1,
ACTIVE: 2,
BIND: 3,
RENAME_GW: 4,
RENAME_DEVICE: 5,
UNBIND: 6,
CONTROL: 7,
STATUS: 8,
HEART_BEAT: 9,
DP_QUERY: 10,
QUERY_WIFI: 11,
TOKEN_BIND: 12,
CONTROL_NEW: 13,
ENABLE_WIFI: 14,
DP_QUERY_NEW: 16,
SCENE_EXECUTE: 17,
UDP_NEW: 19,
AP_CONFIG_NEW: 20,
LAN_GW_ACTIVE: 240,
LAN_SUB_DEV_REQUEST: 241,
LAN_DELETE_SUB_DEV: 242,
LAN_REPORT_SUB_DEV: 243,
LAN_SCENE: 244,
LAN_PUBLISH_CLOUD_CONFIG: 245,
LAN_PUBLISH_APP_CONFIG: 246,
LAN_EXPORT_APP_CONFIG: 247,
LAN_PUBLISH_SCENE_PANEL: 248,
LAN_REMOVE_GW: 249,
LAN_CHECK_GW_UPDATE: 250,
LAN_GW_UPDATE: 251,
LAN_SET_GW_CHANNEL: 252
};
/**
* A complete packet.
* @typedef {Object} Packet
* @property {Buffer|Object|String} payload
* Buffer if hasn't been decoded, object or
* string if it has been
* @property {Buffer} leftover
* bytes adjacent to the parsed packet
* @property {Number} commandByte
* @property {Number} sequenceN
*/
/**
* Low-level class for parsing packets.
* @class
* @param {Object} options
* @param {String} options.key localKey of cipher
* @param {Number} [options.version=3.1] protocol version
* @example
* const parser = new MessageParser({key: 'xxxxxxxxxxxxxxxx', version: 3.1})
*/
function parse(data) {
// Check for length
// At minimum requires: prefix (4), sequence (4), command (4), length (4),
// CRC (4), and suffix (4) for 24 total bytes
// Messages from the device also include return code (4), for 28 total bytes
if (data.length < 24) {
throw new Error('Packet too small. Length: ' + data.length);
}
class MessageParser {
constructor(options) {
// Defaults
options = options ? options : {};
options.version = options.version ? options.version : '3.1';
// Check for prefix
const prefix = data.readUInt32BE(0);
if (options.key && options.key.length !== 16) {
throw new TypeError('Incorrect key format');
}
if (prefix !== 0x000055AA) {
throw new Error('Magic prefix mismatch: ' + data.toString('hex'));
if (options.key && options.version) {
this.cipher = new Cipher(options);
this.key = options.key;
this.version = options.version;
}
}
// Get the command type
const commandByte = data.readUInt32BE(8);
/**
* Parses a Buffer of data containing at least
* one complete packet at the begining of the buffer.
* Will return multiple packets if necessary.
* @param {Buffer} buffer of data to parse
* @returns {Packet} packet of data
*/
parsePacket(buffer) {
// Check for length
// At minimum requires: prefix (4), sequence (4), command (4), length (4),
// CRC (4), and suffix (4) for 24 total bytes
// Messages from the device also include return code (4), for 28 total bytes
if (buffer.length < 24) {
throw new TypeError(`Packet too short. Length: ${buffer.length}.`);
}
// Get payload size
const payloadSize = data.readUInt32BE(12);
// Check for prefix
const prefix = buffer.readUInt32BE(0);
// Check for payload
if (data.length < HEADER_SIZE + payloadSize) {
throw new Error('Packet missing payload: ' + data.toString('hex'));
}
if (prefix !== 0x000055AA) {
throw new TypeError(`Prefix does not match: ${buffer.toString('hex')}`);
}
// Get the return code, 0 = success
// This field is only present in messages from the devices
// Absent in messages sent to device
const returnCode = data.readUInt32BE(16);
// Check for extra data
let leftover = false;
// Get the payload
// Adjust for messages lacking a return code
let payload;
if (returnCode & 0xFFFFFF00) {
payload = data.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 8);
} else {
payload = data.slice(HEADER_SIZE + 4, HEADER_SIZE + payloadSize - 8);
}
const suffixLocation = buffer.indexOf('0000AA55', 0, 'hex');
// Check CRC
const expectedCrc = data.readInt32BE(HEADER_SIZE + payloadSize - 8);
const computedCrc = crc(data.slice(0, payloadSize + 8));
if (suffixLocation !== buffer.length - 4) {
leftover = buffer.slice(suffixLocation + 4);
buffer = buffer.slice(0, suffixLocation + 4);
}
if (expectedCrc !== computedCrc) {
throw new Error('CRC mismatch: ' + data.toString('hex'));
}
// Check for suffix
const suffix = buffer.readUInt32BE(buffer.length - 4);
// Check for suffix
const suffix = data.readUInt32BE(HEADER_SIZE + payloadSize - 4);
if (suffix !== 0x0000AA55) {
throw new TypeError(`Suffix does not match: ${buffer.toString('hex')}`);
}
if (suffix !== 0x0000AA55) {
throw new Error('Magic suffix mismatch: ' + data.toString('hex'));
// Get sequence number
const sequenceN = buffer.readUInt32BE(4);
// Get payload size
const payloadSize = buffer.readUInt32BE(12);
// Get command byte
const commandByte = buffer.readUInt8(11);
// Check for payload
if (buffer.length - 8 < payloadSize) {
throw new TypeError(`Packet missing payload: payload has length ${payloadSize}.`);
}
// Get the return code, 0 = success
// This field is only present in messages from the devices
// Absent in messages sent to device
const returnCode = buffer.readUInt32BE(16);
// Get the payload
// Adjust for messages lacking a return code
let payload;
if (returnCode & 0xFFFFFF00) {
payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 8);
} else {
payload = buffer.slice(HEADER_SIZE + 4, HEADER_SIZE + payloadSize - 8);
}
// Check CRC
const expectedCrc = buffer.readInt32BE(HEADER_SIZE + payloadSize - 8);
const computedCrc = crc(buffer.slice(0, payloadSize + 8));
if (expectedCrc !== computedCrc) {
// eslint-disable-next-line max-len
throw new Error(`CRC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${buffer.toString('hex')}`);
}
return {payload, leftover, commandByte, sequenceN};
}
// Check for leftovers
if (data.length > HEADER_SIZE + payloadSize) {
debug(data.length - HEADER_SIZE - payloadSize, 'bytes left over');
// Skip the leftovers for now
/**
* Attempts to decode a given payload into
* an object or string.
* @param {Buffer} data to decode
* @returns {Object|String}
* object if payload is JSON, otherwise string
*/
getPayload(data) {
if (data.length === 0) {
return false;
}
// Try to parse data as JSON.
// If error, return as string.
try {
data = JSON.parse(data);
} catch (error) { // Data is encrypted
data = data.toString('ascii');
try {
if (!this.cipher) {
throw new Error('Missing key or version in constructor.');
}
data = this.cipher.decrypt(data);
} catch (donothing) {}
}
return data;
}
// Attempt to parse data to JSON.
const result = {
commandByte
};
// It's possible for packets to be valid
// and yet contain no data.
if (payload.length === 0) {
return result;
/**
* Recursive function to parse
* a series of packets. Perfer using
* the parse() wrapper over using this
* directly.
* @private
* @param {Buffer} buffer to parse
* @param {Array} packets that have been parsed
* @returns {Array.<Packet>} array of parsed packets
*/
parseRecursive(buffer, packets) {
const result = this.parsePacket(buffer);
result.payload = this.getPayload(result.payload);
packets.push(result);
if (result.leftover) {
return this.parseRecursive(result.leftover, packets);
}
return packets;
}
// Try to parse data as JSON.
// If error, return as string.
try {
result.data = JSON.parse(payload);
} catch (error) { // Data is encrypted
result.data = payload.toString('ascii');
/**
* Given a buffer potentially containing
* multiple packets, this parses and returns
* all of them.
* @param {Buffer} buffer to parse
* @returns {Array.<Packet>} parsed packets
*/
parse(buffer) {
const packets = this.parseRecursive(buffer, []);
return packets;
}
return result;
}
/**
* Encodes a payload into a
* Tuya-protocol-complient packet.
* @param {Object} options
* @param {Buffer|String|Object} options.data data to encode
* @param {Boolean} options.encrypted whether or not to encrypt the data
* @param {Number} options.commandByte
* command byte of packet (use CommandType definitions)
* @param {Number} [options.sequenceN] optional, sequence number
* @returns {Buffer}
*/
encode(options) {
// Ensure data is a Buffer
let payload;
/**
* 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
*/
function encode(options) {
// Ensure data is a Buffer
let payload;
// Encrypt data if necessary
if (options.encrypted) {
payload = this.cipher.encrypt({
data: JSON.stringify(options.data)
});
if (options.data instanceof Buffer) {
payload = options.data;
} else {
if (typeof options.data === 'string') {
// Create MD5 signature
const md5 = this.cipher.md5('data=' + payload +
'||lpv=' + this.version +
'||' + this.key);
// Create byte buffer from hex data
payload = Buffer.from(this.version + md5 + payload);
} else if (options.data instanceof Buffer) {
payload = options.data;
} else {
payload = JSON.stringify(options.data);
if (typeof options.data !== 'string') {
options.data = JSON.stringify(options.data);
}
payload = Buffer.from(options.data);
}
payload = Buffer.from(payload);
}
// Check command byte
if (Object.values(CommandType).indexOf(options.commandByte) === -1) {
throw new TypeError('Command byte not defined.');
}
// Ensure commandByte is a Number
if (typeof options.commandByte === 'string') {
options.commandByte = parseInt(options.commandByte, 16);
}
// Allocate buffer with room for payload + 24 bytes for
// prefix, sequence, command, length, crc, and suffix
const buffer = Buffer.alloc(payload.length + 24);
// Allocate buffer with room for payload + 24 bytes for
// prefix, sequence, command, length, crc, and suffix
const buffer = Buffer.alloc(payload.length + 24);
// Add prefix, command, and length
buffer.writeUInt32BE(0x000055AA, 0);
buffer.writeUInt32BE(options.commandByte, 8);
buffer.writeUInt32BE(payload.length + 8, 12);
// Add prefix, command, and length
// Skip sequence number, currently not used
buffer.writeUInt32BE(0x000055AA, 0);
buffer.writeUInt32BE(options.commandByte, 8);
buffer.writeUInt32BE(payload.length + 8, 12);
if (options.sequenceN) {
buffer.writeUInt32BE(options.sequenceN, 4);
}
// Add payload, crc, and suffix
payload.copy(buffer, 16);
buffer.writeInt32BE(crc(payload), payload.length + 16);
buffer.writeUInt32BE(0x0000AA55, payload.length + 20);
// Add payload, crc, and suffix
payload.copy(buffer, 16);
buffer.writeInt32BE(crc(buffer.slice(0, payload.length + 16)), payload.length + 16);
buffer.writeUInt32BE(0x0000AA55, payload.length + 20);
return buffer;
return buffer;
}
}
module.exports = {parse, encode};
module.exports = {MessageParser, CommandType};
{
"name": "tuyapi",
"version": "4.0.4",
"version": "5.0.0",
"description": "An easy-to-use API for devices that use Tuya's cloud services",

@@ -42,9 +42,10 @@ "main": "index.js",

"p-retry": "4.1.0",
"p-timeout": "3.0.0"
"p-timeout": "3.1.0"
},
"devDependencies": {
"@tuyapi/stub": "0.1.2",
"@tuyapi/stub": "0.1.3",
"ava": "1.4.1",
"clone": "2.1.2",
"coveralls": "3.0.3",
"delay": "4.2.0",
"documentation": "9.3.1",

@@ -51,0 +52,0 @@ "nyc": "13.3.0",

@@ -106,7 +106,2 @@ # TuyAPI 🌧 🔌

## TODO
1. Document details of protocol
2. Figure out correct CRC algorithm
## Contributing

@@ -159,2 +154,3 @@

- [tuyadump](https://github.com/py60800/tuyadump) a Go project to decode device traffic in real time
- [tuya-mqtt](https://github.com/TheAgentK/tuya-mqtt) a simple MQTT interface for TuyAPI

@@ -161,0 +157,0 @@

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