@demirdeniz/tuyapi-newgen
Version:
An easy-to-use New Gen API for devices that use Tuya's cloud services (updated with Tuya 3.5 protocol)
1,174 lines (1,011 loc) • 38.8 kB
JavaScript
// Import packages
const dgram = require('dgram');
const net = require('net');
const {EventEmitter} = require('events');
const pTimeout = require('p-timeout');
const pRetry = require('p-retry');
const {default: PQueue} = require('p-queue');
const debug = require('debug')('TuyAPI-NewGen');
// Helpers
const {isValidString} = require('./lib/utils');
const {MessageParser, CommandType} = require('./lib/message-parser');
const {UDP_KEY} = require('./lib/config');
/**
* 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
* @param {Object} options Options object
* @param {String} [options.ip] IP of device
* @param {Number} [options.port=6668] port of device
* @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 (currently unused)
* @param {Number} [options.version=3.1] protocol version
* @param {Boolean} [options.nullPayloadOnJSONError=false] if true, emits a data event
* containing a payload of null values for on-device JSON parsing errors
* @param {Boolean} [options.issueGetOnConnect=true] if true, sends GET request after
* connection is established. This should probably be `false` in synchronous usage.
* @param {Boolean} [options.issueRefreshOnConnect=false] if true, sends DP_REFRESH request after
* connection is established. This should probably be `false` in synchronous usage.
* @param {Boolean} [options.issueRefreshOnPing=false] if true, sends DP_REFRESH and GET request after
* every ping. This should probably be `false` in synchronous usage.
* @param {Boolean} [options.enableDebug=false] if true, will print debug messages to console.
* @example
* const tuya = new TuyaDevice({id: 'xxxxxxxxxxxxxxxxxxxx',
* key: 'xxxxxxxxxxxxxxxx'})
*/
class TuyaDevice extends EventEmitter {
constructor({
ip,
port = 6668,
id,
gwID = id,
key,
productKey,
version = 3.3,
nullPayloadOnJSONError = false,
issueGetOnConnect = true,
issueRefreshOnConnect = false,
issueRefreshOnPing = false,
KeepAlive = true,
initialDelay = 5000,
socketTimeout = 5000,
HeartBeatInterval = 25,
enableDebug = false
} = {}) {
super();
// Set device to user-passed options
version = version.toString();
this.device = {ip, port, id, gwID, key, productKey, version};
this.globalOptions = {
issueGetOnConnect,
issueRefreshOnConnect,
issueRefreshOnPing,
KeepAlive,
initialDelay,
socketTimeout,
HeartBeatInterval,
enableDebug
};
this.nullPayloadOnJSONError = nullPayloadOnJSONError;
// Check arguments
if (!(isValidString(id) ||
isValidString(ip))) {
throw new TypeError('ID and IP are missing from device.');
}
// Check key
if (!isValidString(this.device.key) || this.device.key.length !== 16) {
throw new TypeError('Key is missing or incorrect.');
}
// Handles encoding/decoding, encrypting/decrypting messages
this.device.parser = new MessageParser({
key: this.device.key,
version: this.device.version
});
// Contains array of found devices when calling .find()
this.foundDevices = [];
if (this.enableDebug) {
require('debug').enable('TuyAPI-NewGen');
process.env.DEBUG = "TuyAPI-NewGen";
debug("<<<<<<<<********** Debug Enabled **********>>>>>>>>>");
console.log("<<<<<< tried to enable debug lof >>>>>>");
} else
console.log("<<<<<< !!! debug log is NOT enabled !!! >>>>>>");
// Private instance variables
// Socket connected state
this._connected = false;
this._responseTimeout = 2; // Seconds
this._connectTimeout = this.globalOptions.socketTimeout; // milli-Seconds
this._pingPongPeriod = this.globalOptions.HeartBeatInterval; // Seconds
//this._keepAlive = this.globalOptions.KeepAlive; //true;
//this._initialDelay = this.globalOptions.initialDelay; //milli-seconds
this._pingPongTimeout = null;
this._lastPingAt = new Date();
this._currentSequenceN = 0;
this._resolvers = {};
this._setQueue = new PQueue({
concurrency: 1
});
// List of dps which needed CommandType.DP_REFRESH (command 18) to force refresh their values.
// Power data - DP 19 on some 3.1/3.3 devices, DP 5 for some 3.1 devices.
this._dpRefreshIds = [4, 5, 6, 18, 19, 20];
this._tmpLocalKey = null;
this._tmpRemoteKey = null;
this.sessionKey = null;
}
/**
* Gets a device's current status.
* Defaults to returning only the value of the first DPS index.
* @param {Object} [options] Options object
* @param {Boolean} [options.schema]
* true to return entire list of properties from device
* @param {Number} [options.dps=1]
* DPS index to return
* @param {String} [options.cid]
* if specified, use device id of zigbee gateway and cid of subdevice to get its status
* @example
* // get first, default property from device
* tuya.get().then(status => console.log(status))
* @example
* // 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<Boolean|Object>}
* returns boolean if single property is requested, otherwise returns object of results
*/
async get(options = {}) {
const payload = {
gwId: this.device.gwID,
devId: this.device.id,
t: Math.round(new Date().getTime() / 1000).toString(),
dps: {},
uid: this.device.id
};
if (options.cid) {
payload.cid = options.cid;
}
const commandByte = this.device.version === '3.4' || this.device.version === '3.5' ? CommandType.DP_QUERY_NEW : CommandType.DP_QUERY;
// Create byte buffer
const buffer = this.device.parser.encode({
data: payload,
commandByte,
sequenceN: ++this._currentSequenceN
});
let data;
// Send request to read data - should work in most cases beside Protocol 3.2
if (this.device.version !== '3.2') {
debug('GET Payload:');
debug(payload);
data = await this._send(buffer);
}
// If data read failed with defined error messages or device uses Protocol 3.2 we need to read differently
if (
this.device.version === '3.2' ||
data === 'json obj data unvalid' || data === 'data format error' /* || data === 'devid not found' */
) {
// Some devices don't respond to DP_QUERY so, for DPS get commands, fall
// back to using SEND with null value. This appears to always work as
// long as the DPS key exist on the device.
// For schema there's currently no fallback options
debug('GET needs to use SEND instead of DP_QUERY to get data');
const setOptions = {
dps: options.dps ? options.dps : 1,
set: null,
isSetCallToGetData: true
};
data = await this.set(setOptions);
}
if (typeof data !== 'object' || options.schema === true) {
// Return whole response
return data;
}
if (options.dps) {
// Return specific property
return data.dps[options.dps];
}
// Return first property by default
return data.dps['1'];
}
/**
* Refresh a device's current status.
* Defaults to returning all values.
* @param {Object} [options] Options object
* @param {Boolean} [options.schema]
* true to return entire list of properties from device
* @param {Number} [options.dps=1]
* DPS index to return
* @param {String} [options.cid]
* if specified, use device id of zigbee gateway and cid of subdevice to refresh its status
* @param {Array.Number} [options.requestedDPS=[4,5,6,18,19,20]]
* only set this if you know what you're doing
* @example
* // get first, default property from device
* tuya.refresh().then(status => console.log(status))
* @example
* // get second property from device
* tuya.refresh({dps: 2}).then(status => console.log(status))
* @example
* // get all available data from device
* tuya.refresh({schema: true}).then(data => console.log(data))
* @returns {Promise<Object>}
* returns object of results
*/
refresh(options = {}) {
const payload = {
gwId: this.device.gwID,
devId: this.device.id,
t: Math.round(new Date().getTime() / 1000).toString(),
dpId: options.requestedDPS ? options.requestedDPS : this._dpRefreshIds,
uid: this.device.id
};
if (options.cid) {
payload.cid = options.cid;
}
debug('GET Payload (refresh):');
debug(payload);
const sequenceN = ++this._currentSequenceN;
// Create byte buffer
const buffer = this.device.parser.encode({
data: payload,
commandByte: CommandType.DP_REFRESH,
sequenceN
});
// Send request and parse response
return new Promise((resolve, reject) => {
this._expectRefreshResponseForSequenceN = sequenceN;
// Send request
this._send(buffer).then(async data => {
if (data === 'json obj data unvalid') {
// Some devices don't respond to DP_QUERY so, for DPS get commands, fall
// back to using SEND with null value. This appears to always work as
// long as the DPS key exist on the device.
// For schema there's currently no fallback options
const setOptions = {
dps: options.requestedDPS ? options.requestedDPS : this._dpRefreshIds,
set: null,
isSetCallToGetData: true
};
data = await this.set(setOptions);
}
if (typeof data !== 'object' || options.schema === true) {
// Return whole response
resolve(data);
} else if (options.dps) {
// Return specific property
resolve(data.dps[options.dps]);
} else {
// Return all dps by default
resolve(data.dps);
}
})
.catch(reject);
});
}
/**
* Sets a property on a device.
* @param {Object} options Options object
* @param {Number} [options.dps=1] DPS index to set
* @param {*} [options.set] value to set
* @param {String} [options.cid]
* if specified, use device id of zigbee gateway and cid of subdevice to set its property
* @param {Boolean} [options.multiple=false]
* Whether or not multiple properties should be set with options.data
* @param {Boolean} [options.isSetCallToGetData=false]
* Wether or not the set command is used to get data
* @param {Object} [options.data={}] Multiple properties to set at once. See above.
* @param {Boolean} [options.shouldWaitForResponse=true] see
* [#420](https://github.com/codetheweb/tuyapi/issues/420) and
* [#421](https://github.com/codetheweb/tuyapi/pull/421) for details
* @example
* // set default property
* tuya.set({set: true}).then(() => console.log('device was turned on'))
* @example
* // set custom property
* tuya.set({dps: 2, set: false}).then(() => console.log('device was turned off'))
* @example
* // set multiple properties
* tuya.set({
* multiple: true,
* data: {
* '1': true,
* '2': 'white'
* }}).then(() => console.log('device was changed'))
* @example
* // set custom property for a specific (virtual) deviceId
* tuya.set({
* dps: 2,
* set: false,
* devId: '04314116cc50e346566e'
* }).then(() => console.log('device was turned off'))
* @returns {Promise<Object>} - returns response from device
*/
set(options) {
// Check arguments
if (options === undefined || Object.entries(options).length === 0) {
throw new TypeError('No arguments were passed.');
}
// Defaults
let dps;
if (options.multiple === true) {
dps = options.data;
} else if (options.dps === undefined) {
dps = {
1: options.set
};
} else {
dps = {
[options.dps.toString()]: options.set
};
}
options.shouldWaitForResponse = typeof options.shouldWaitForResponse === 'undefined' ? true : options.shouldWaitForResponse;
// When set has only null values then it is used to get data
if (!options.isSetCallToGetData) {
options.isSetCallToGetData = true;
Object.keys(dps).forEach(key => {
options.isSetCallToGetData = options.isSetCallToGetData && dps[key] === null;
});
}
// Get time
const timeStamp = parseInt(Date.now() / 1000, 10);
// Construct payload
let payload = {
t: timeStamp,
dps
};
if (options.cid) {
payload.cid = options.cid;
} else {
payload = {
devId: options.devId || this.device.id,
gwId: this.device.gwID,
uid: '',
...payload
};
}
if (this.device.version === '3.4' || this.device.version === '3.5') {
/*
{
"data": {
"cid": "xxxxxxxxxxxxxxxx",
"ctype": 0,
"dps": {
"1": "manual"
}
},
"protocol": 5,
"t": 1633243332
}
*/
payload = {
data: {
ctype: 0,
...payload
},
protocol: 5,
t: timeStamp
};
delete payload.data.t;
}
//if (options.shouldWaitForResponse && this._setResolver) {
// throw new Error('A set command is already in progress. Can not issue a second one that also should return a response.');
//}
debug('SET Payload:');
debug(payload);
const commandByte = this.device.version === '3.4' || this.device.version === '3.5' ? CommandType.CONTROL_NEW : CommandType.CONTROL;
const sequenceN = ++this._currentSequenceN;
// Encode into packet
const buffer = this.device.parser.encode({
data: payload,
encrypted: true, // Set commands must be encrypted
commandByte,
sequenceN
});
// Make sure we only resolve or reject once
let resolvedOrRejected = false;
// Queue this request and limit concurrent set requests to one
return this._setQueue.add(() => pTimeout(new Promise((resolve, reject) => {
if (options.shouldWaitForResponse && this._setResolver) {
throw new Error('A set command is already in progress. Can not issue a second one that also should return a response.');
}
// Send request and wait for response
try {
if (this.device.version === '3.5') {
this._currentSequenceN++;
}
// Send request
this._send(buffer).catch(error => {
if (options.shouldWaitForResponse && !resolvedOrRejected) {
resolvedOrRejected = true;
reject(error);
}
});
if (options.shouldWaitForResponse) {
this._setResolver = data => {
if (!resolvedOrRejected) {
resolvedOrRejected = true;
resolve(data);
}
};
this._setResolveAllowGet = options.isSetCallToGetData;
} else {
resolvedOrRejected = true;
resolve();
}
} catch (error) {
resolvedOrRejected = true;
reject(error);
}
}), this._responseTimeout * 2500, () => {
// Only gets here on timeout so clear resolver function and emit error
this._setResolver = undefined;
this._setResolveAllowGet = undefined;
delete this._resolvers[sequenceN];
this._expectRefreshResponseForSequenceN = undefined;
this.emit(
'error',
'Timeout waiting for status response from device id: ' + this.device.id
);
if (!resolvedOrRejected) {
resolvedOrRejected = true;
throw new Error('Timeout waiting for status response from device id: ' + this.device.id);
}
}));
}
/**
* 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 {Buffer} buffer buffer of data
* @returns {Promise<any>} returned data for request
*/
_send(buffer) {
const sequenceNo = this._currentSequenceN;
// Retry up to 5 times
return pRetry(() => {
return new Promise((resolve, reject) => {
// Send data
this.connect().then(() => {
try {
this.client.write(buffer);
// Add resolver function
this._resolvers[sequenceNo] = data => resolve(data);
} catch (error) {
reject(error);
}
})
.catch(error => reject(error));
});
}, {
onFailedAttempt: error => {
debug(`Attempt ${error.attemptNumber} failed. There are ${error.retriesLeft} retries left.`);
}, retries: 5});
}
/**
* Sends a heartbeat ping to the device
* @private
*/
async _sendPing() {
debug(`Pinging ${this.device.ip}`);
// Create byte buffer
const buffer = this.device.parser.encode({
data: Buffer.allocUnsafe(0),
commandByte: CommandType.HEART_BEAT,
sequenceN: ++this._currentSequenceN
});
// Check for response
const now = new Date();
if (this._pingPongTimeout === null) {
// If we do not expect a pong from a former ping, we need to set a timeout
this._pingPongTimeout = setTimeout(() => {
if (this._lastPingAt < now) {
this.disconnect();
}
}, this._responseTimeout * 1000);
} else {
debug('There was no response to the last ping.');
}
// Send ping
this.client.write(buffer);
if (this.globalOptions.issueRefreshOnPing) {
this.refresh();
this.get();
}
}
/**
* Create a deferred promise that resolves as soon as the connection is established.
*/
createDeferredConnectPromise() {
let res;
let rej;
this.connectPromise = new Promise((resolve, reject) => {
res = resolve;
rej = reject;
});
this.connectPromise.resolve = res;
this.connectPromise.reject = rej;
}
/**
* Finish connecting and resolve
*/
_finishConnect() {
this._connected = true;
/**
* 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._pingPongInterval = setInterval(async () => {
await this._sendPing();
}, this._pingPongPeriod * 1000);
// Automatically ask for dp_refresh so we
// can emit a `dp_refresh` event as soon as possible
if (this.globalOptions.issueRefreshOnConnect) {
this.refresh().catch(error => {
debug('Error refreshing on connect: ' + error);
this.emit('error', error);
});
}
// Automatically ask for current state so we
// can emit a `data` event as soon as possible
if (this.globalOptions.issueGetOnConnect) {
this.get().catch(error => {
debug('Error getting on connect: ' + error);
this.emit('error', error);
});
}
// Resolve
if (this.connectPromise) {
this.connectPromise.resolve(true);
delete this.connectPromise;
}
}
/**
* Connects to the device. Can be called even
* if device is already connected.
* @returns {Promise<Boolean>} `true` if connect succeeds
* @emits TuyaDevice#connected
* @emits TuyaDevice#disconnected
* @emits TuyaDevice#data
* @emits TuyaDevice#error
*/
connect() {
if (this.enableDebug) {
require('debug').enable('TuyAPI-NewGen');
process.env.DEBUG = "TuyAPI-NewGen";
debug("<<<<<<<<********** Debug Enabled 2 **********>>>>>>>>>");
console.log("<<<<<< tried to enable debug logging 2 >>>>>>");
} else
console.log("******debug flag is false *****");
if (this.isConnected()) {
// Return if already connected
return Promise.resolve(true);
}
if (this.connectPromise) {
// If a connect approach still in progress simply return same Promise
return this.connectPromise;
}
this.createDeferredConnectPromise();
this.client = new net.Socket();
// Default connect timeout is ~1 minute,
// 5 seconds is a more reasonable default
// since `retry` is used.
this.client.setTimeout(this._connectTimeout, () => {
/**
* 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('error', new Error('connection timed out'));
this.client.destroy();
this.emit('error', new Error('connection timed out'));
if (this.connectPromise) {
this.connectPromise.reject(new Error('connection timed out'));
delete this.connectPromise;
}
});
// enable/disable TCP KeepAlive on socket with initial delay in ms (even though there is a pingPong every 10s) -- rethink this change
if(this.globalOptions.KeepAlive)
this.client.setKeepAlive(this.globalOptions.KeepAlive, this.globalOptions.initialDelay);
// Add event listeners to socket
// Parse response data
this.client.on('data', data => {
debug(`Received data: ${data.toString('hex')}`);
let packets;
try {
packets = this.device.parser.parse(data);
if (this.nullPayloadOnJSONError) {
for (const packet of packets) {
if (packet.payload && packet.payload === 'json obj data unvalid') {
this.emit('error', packet.payload);
packet.payload = {
dps: {
1: null,
2: null,
3: null,
101: null,
102: null,
103: null
}
};
}
}
}
} catch (error) {
debug(error);
this.emit('error', error);
return;
}
packets.forEach(packet => {
debug('Parsed:');
debug(packet);
this._packetHandler.bind(this)(packet);
});
});
// Handle errors
this.client.on('error', err => {
debug('Error event from socket.', this.device.ip, err);
this.emit('error', new Error('Error from socket: ' + err.message));
if (!this._connected && this.connectPromise) {
this.connectPromise.reject(err);
delete this.connectPromise;
}
this.client.destroy();
});
// Handle socket closure
this.client.on('close', () => {
debug(`Socket closed: ${this.device.ip}`);
this.disconnect();
});
this.client.on('connect', async () => {
debug('Socket connected.');
// Remove connect timeout
this.client.setTimeout(0);
if (this.device.version === '3.4' || this.device.version === '3.5') {
// Negotiate session key then emit 'connected'
// 16 bytes random + 32 bytes hmac
try {
this._tmpLocalKey = this.device.parser.cipher.random();
const buffer = this.device.parser.encode({
data: this._tmpLocalKey,
encrypted: true,
commandByte: CommandType.SESS_KEY_NEG_START,
sequenceN: ++this._currentSequenceN
});
debug('Protocol 3.4, 3.5: Negotiate Session Key - Send Msg 0x03');
this.client.write(buffer);
} catch (error) {
debug('Error binding key for protocol 3.4, 3.5: ' + error);
}
return;
}
this._finishConnect();
});
debug(`Connecting to ${this.device.ip}...`);
this.client.connect(this.device.port, this.device.ip);
return this.connectPromise;
}
_packetHandler(packet) {
// Response was received, so stop waiting
clearTimeout(this._sendTimeout);
// Protocol 3.4, 3.5 - Response to Msg 0x03
if (packet.commandByte === CommandType.SESS_KEY_NEG_RES) {
if (!this.connectPromise) {
debug('Protocol 3.4, 3.5: Ignore Key exchange message because no connection in progress.');
return;
}
// 16 bytes _tmpRemoteKey and hmac on _tmpLocalKey
this._tmpRemoteKey = packet.payload.subarray(0, 16);
debug('Protocol 3.4, 3.5: Local Random Key: ' + this._tmpLocalKey.toString('hex'));
debug('Protocol 3.4, 3.5: Remote Random Key: ' + this._tmpRemoteKey.toString('hex'));
if (this.device.version === '3.4' || this.device.version === '3.5') {
this._currentSequenceN = packet.sequenceN - 1;
}
const calcLocalHmac = this.device.parser.cipher.hmac(this._tmpLocalKey).toString('hex');
const expLocalHmac = packet.payload.slice(16, 16 + 32).toString('hex');
if (expLocalHmac !== calcLocalHmac) {
const err = new Error(`HMAC mismatch(keys): expected ${expLocalHmac}, was ${calcLocalHmac}. ${packet.payload.toString('hex')}`);
if (this.connectPromise) {
this.connectPromise.reject(err);
delete this.connectPromise;
}
this.emit('error', err);
return;
}
// Send response 0x05
const buffer = this.device.parser.encode({
data: this.device.parser.cipher.hmac(this._tmpRemoteKey),
encrypted: true,
commandByte: CommandType.SESS_KEY_NEG_FINISH,
sequenceN: ++this._currentSequenceN
});
this.client.write(buffer);
// Calculate session key
this.sessionKey = Buffer.from(this._tmpLocalKey);
for (let i = 0; i < this._tmpLocalKey.length; i++) {
this.sessionKey[i] = this._tmpLocalKey[i] ^ this._tmpRemoteKey[i];
}
if (this.device.version === '3.4') {
this.sessionKey = this.device.parser.cipher._encrypt34({data: this.sessionKey});
} else if (this.device.version === '3.5') {
this.sessionKey = this.device.parser.cipher._encrypt35({data: this.sessionKey, iv: this._tmpLocalKey});
}
debug('Protocol 3.4, 3.5: Session Key: ' + this.sessionKey.toString('hex'));
debug('Protocol 3.4, 3.5: Initialization done');
this.device.parser.cipher.setSessionKey(this.sessionKey);
this.device.key = this.sessionKey;
return this._finishConnect();
}
if (packet.commandByte === CommandType.HEART_BEAT) {
debug(`Pong from ${this.device.ip}`);
/**
* Emitted when a heartbeat ping is returned.
* @event TuyaDevice#heartbeat
*/
this.emit('heartbeat');
clearTimeout(this._pingPongTimeout); // ????
this._pingPongTimeout = null; ///// ????
this._lastPingAt = new Date();
return;
}
if (
(
packet.commandByte === CommandType.CONTROL ||
packet.commandByte === CommandType.CONTROL_NEW
) && packet.payload === false) {
debug('Got SET ack.');
return;
}
// Returned DP refresh response is always empty. Device respond with command 8 without dps 1 instead.
if (packet.commandByte === CommandType.DP_REFRESH) {
// If we did not get any STATUS packet, we need to resolve the promise.
if (typeof this._setResolver === 'function') {
debug('Received DP_REFRESH empty response packet without STATUS packet from set command - resolve');
this._setResolver(packet.payload);
// Remove resolver
this._setResolver = undefined;
this._setResolveAllowGet = undefined;
delete this._resolvers[packet.sequenceN];
this._expectRefreshResponseForSequenceN = undefined;
} else if (packet.sequenceN in this._resolvers) {
// Call data resolver for sequence number
debug('Received DP_REFRESH response packet - resolve');
this._resolvers[packet.sequenceN](packet.payload);
// Remove resolver
delete this._resolvers[packet.sequenceN];
this._expectRefreshResponseForSequenceN = undefined;
} else if (this._expectRefreshResponseForSequenceN && this._expectRefreshResponseForSequenceN in this._resolvers) {
debug('Received DP_REFRESH response packet without data - resolve');
this._resolvers[this._expectRefreshResponseForSequenceN](packet.payload);
// Remove resolver
delete this._resolvers[this._expectRefreshResponseForSequenceN];
this._expectRefreshResponseForSequenceN = undefined;
} else {
debug('Received DP_REFRESH response packet - no resolver found for sequence number' + packet.sequenceN);
}
return;
}
if (packet.commandByte === CommandType.STATUS && packet.payload && packet.payload.dps && typeof packet.payload.dps[1] === 'undefined') {
debug('Received DP_REFRESH packet.');
/**
* Emitted when dp_refresh data is proactive returned from device, omitting dps 1
* Only changed dps are returned.
* @event TuyaDevice#dp-refresh
* @property {Object} data received data
* @property {Number} commandByte
* commandByte of result( 8=proactive update from device)
* @property {Number} sequenceN the packet sequence number
*/
this.emit('dp-refresh', packet.payload, packet.commandByte, packet.sequenceN);
} else {
debug('Received DATA packet');
debug('data: ' + packet.commandByte + ' : ' + (Buffer.isBuffer(packet.payload) ? packet.payload.toString('hex') : JSON.stringify(packet.payload)));
/**
* 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.commandByte === CommandType.STATUS &&
typeof this._setResolver === 'function'
) {
this._setResolver(packet.payload);
// Remove resolver
this._setResolver = undefined;
this._setResolveAllowGet = undefined;
delete this._resolvers[packet.sequenceN];
this._expectRefreshResponseForSequenceN = undefined;
return;
}
// Status response to SET command which was used to GET data and returns DP_QUERY response
if (
packet.commandByte === CommandType.DP_QUERY &&
typeof this._setResolver === 'function' &&
this._setResolveAllowGet === true
) {
this._setResolver(packet.payload);
// Remove resolver
this._setResolver = undefined;
this._setResolveAllowGet = undefined;
delete this._resolvers[packet.sequenceN];
this._expectRefreshResponseForSequenceN = 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];
this._expectRefreshResponseForSequenceN = undefined;
}
}
/**
* Disconnects from the device, use to
* close the socket and exit gracefully.
*/
disconnect() {
if (!this._connected) {
return;
}
debug('Disconnect');
this._connected = false;
this.device.parser.cipher.setSessionKey(null);
// Clear timeouts
clearTimeout(this._sendTimeout);
clearTimeout(this._connectTimeout);
clearTimeout(this._responseTimeout);
clearInterval(this._pingPongInterval);
clearTimeout(this._pingPongTimeout);
if (this.client) {
this.client.destroy();
}
/**
* 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');
}
/**
* Returns current connection status to device.
* @returns {Boolean}
* (`true` if connected, `false` otherwise.)
*/
isConnected() {
return this._connected;
}
/**
* @deprecated since v3.0.0. Will be removed in v4.0.0. Use find() instead.
* @param {Object} options Options object
* @returns {Promise<Boolean|Array>} Promise that resolves to `true` if device is found, `false` otherwise.
*/
resolveId(options) {
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] Options object
* @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({timeout = 10, all = false} = {}) {
if (isValidString(this.device.id) &&
isValidString(this.device.ip)) {
// Don't need to do anything
debug('IP and ID are already both resolved.');
return Promise.resolve(true);
}
// Create new listeners
const listener = dgram.createSocket({type: 'udp4', reuseAddr: true});
listener.bind(6666);
const listenerEncrypted = dgram.createSocket({type: 'udp4', reuseAddr: true});
listenerEncrypted.bind(6667);
const broadcastHandler = (resolve, reject) => message => {
debug('Received UDP message.');
const parser = new MessageParser({key: UDP_KEY, version: this.device.version});
let dataRes;
try {
dataRes = parser.parse(message)[0];
} catch (error) {
debug(error);
reject(error);
}
debug('UDP data:');
debug(dataRes);
const thisID = dataRes.payload.gwId;
const thisIP = dataRes.payload.ip;
// Try auto determine power data - DP 19 on some 3.1/3.3 devices, DP 5 for some 3.1 devices
const thisDPS = dataRes.payload.dps;
if (thisDPS && typeof thisDPS[19] === 'undefined') {
this._dpRefreshIds = [4, 5, 6];
} else {
this._dpRefreshIds = [18, 19, 20];
}
// 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 (!all &&
(this.device.id === thisID || this.device.ip === thisIP) &&
dataRes.payload) {
// Add IP
this.device.ip = dataRes.payload.ip;
// Add ID and gwID
this.device.id = dataRes.payload.gwId;
this.device.gwID = dataRes.payload.gwId;
// Change product key if neccessary
this.device.productKey = dataRes.payload.productKey;
// Change protocol version if necessary
if (this.device.version !== dataRes.payload.version) {
this.device.version = dataRes.payload.version;
// Update the parser
this.device.parser = new MessageParser({
key: this.device.key,
version: this.device.version
});
}
// console log the found device
console.log(`<<<<< ****** Found device ip=${this.device.ip} id=${this.device.id} version = ${this.device.version} ****** >>>>>`);
// Cleanup
listener.close();
listener.removeAllListeners();
listenerEncrypted.close();
listenerEncrypted.removeAllListeners();
resolve(true);
}
};
debug(`Finding missing IP ${this.device.ip} or ID ${this.device.id}`);
// Find IP for device
return pTimeout(new Promise((resolve, reject) => { // Timeout
listener.on('message', broadcastHandler(resolve, reject));
listener.on('error', err => {
reject(err);
});
listenerEncrypted.on('message', broadcastHandler(resolve, reject));
listenerEncrypted.on('error', err => {
reject(err);
});
}), timeout * 1000, () => {
// Have to do this so we exit cleanly
listener.close();
listener.removeAllListeners();
listenerEncrypted.close();
listenerEncrypted.removeAllListeners();
// Return all devices
if (all) {
return this.foundDevices;
}
// Otherwise throw error
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 = '1') {
property = property.toString();
// Get status
const status = await this.get({dps: property});
// Set to opposite
await this.set({set: !status, dps: property});
// Return new status
return this.get({dps: property});
}
}
module.exports = TuyaDevice;