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 |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Mixed license
License(Experimental) Package contains multiple licenses.
Found 1 instance in 1 package
Non-permissive License
License(Experimental) A license not known to be considered permissive was found.
Found 1 instance in 1 package
0
100
151
33423
6
775
3
+ Addedp-retry@^3.0.1
+ Addedp-retry@3.0.1(transitive)
+ Addedretry@0.12.0(transitive)
- Removedretry@^0.10.1
- Removedretry@0.10.1(transitive)