Comparing version 7.4.0 to 7.5.0
461
index.js
@@ -22,3 +22,3 @@ // Import packages | ||
* @class | ||
* @param {Object} options | ||
* @param {Object} options Options object | ||
* @param {String} [options.ip] IP of device | ||
@@ -61,2 +61,3 @@ * @param {Number} [options.port=6668] port of device | ||
// Set device to user-passed options | ||
version = version.toString(); | ||
this.device = {ip, port, id, gwID, key, productKey, version}; | ||
@@ -111,2 +112,5 @@ this.globalOptions = { | ||
this._dpRefreshIds = [4, 5, 6, 18, 19, 20]; | ||
this._tmpLocalKey = null; | ||
this._tmpRemoteKey = null; | ||
this.sessionKey = null; | ||
} | ||
@@ -117,3 +121,3 @@ | ||
* Defaults to returning only the value of the first DPS index. | ||
* @param {Object} [options] | ||
* @param {Object} [options] Options object | ||
* @param {Boolean} [options.schema] | ||
@@ -137,3 +141,3 @@ * true to return entire list of properties from device | ||
*/ | ||
get(options = {}) { | ||
async get(options = {}) { | ||
const payload = { | ||
@@ -151,4 +155,3 @@ gwId: this.device.gwID, | ||
debug('GET Payload:'); | ||
debug(payload); | ||
const commandByte = this.device.version === '3.4' ? CommandType.DP_QUERY_NEW : CommandType.DP_QUERY; | ||
@@ -158,35 +161,41 @@ // Create byte buffer | ||
data: payload, | ||
commandByte: CommandType.DP_QUERY, | ||
commandByte, | ||
sequenceN: ++this._currentSequenceN | ||
}); | ||
// Send request and parse response | ||
return new Promise((resolve, reject) => { | ||
// 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.dps ? options.dps : 1, | ||
set: null | ||
}; | ||
data = await this.set(setOptions); | ||
} | ||
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); | ||
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 first property by default | ||
resolve(data.dps['1']); | ||
} | ||
}) | ||
.catch(reject); | ||
}); | ||
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 | ||
}; | ||
data = await this.set(setOptions); | ||
} | ||
if (typeof data !== 'object' || options.schema === true) { | ||
// Return whole response | ||
return data; | ||
} else if (options.dps) { | ||
// Return specific property | ||
return data.dps[options.dps]; | ||
} else { | ||
// Return first property by default | ||
return data.dps['1']; | ||
} | ||
} | ||
@@ -197,3 +206,3 @@ | ||
* Defaults to returning all values. | ||
* @param {Object} [options] | ||
* @param {Object} [options] Options object | ||
* @param {Boolean} [options.schema] | ||
@@ -275,3 +284,3 @@ * true to return entire list of properties from device | ||
* Sets a property on a device. | ||
* @param {Object} options | ||
* @param {Object} options Options object | ||
* @param {Number} [options.dps=1] DPS index to set | ||
@@ -317,3 +326,3 @@ * @param {*} [options.set] value to set | ||
// Defaults | ||
let dps = {}; | ||
let dps; | ||
@@ -335,3 +344,3 @@ if (options.multiple === true) { | ||
// Get time | ||
const timeStamp = parseInt(new Date() / 1000, 10); | ||
const timeStamp = parseInt(Date.now() / 1000, 10); | ||
@@ -355,5 +364,31 @@ // Construct payload | ||
if (this.device.version === '3.4') { | ||
/* | ||
{ | ||
"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; | ||
} | ||
debug('SET Payload:'); | ||
debug(payload); | ||
const commandByte = this.device.version === '3.4' ? CommandType.CONTROL_NEW : CommandType.CONTROL; | ||
// Encode into packet | ||
@@ -363,3 +398,3 @@ const buffer = this.device.parser.encode({ | ||
encrypted: true, // Set commands must be encrypted | ||
commandByte: CommandType.CONTROL, | ||
commandByte, | ||
sequenceN: ++this._currentSequenceN | ||
@@ -456,2 +491,58 @@ }); | ||
/** | ||
* 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(); | ||
} | ||
// Automatically ask for current state so we | ||
// can emit a `data` event as soon as possible | ||
if (this.globalOptions.issueGetOnConnect) { | ||
this.get(); | ||
} | ||
// Resolve | ||
if (this.connectPromise) { | ||
this.connectPromise.resolve(true); | ||
delete this.connectPromise; | ||
} | ||
} | ||
/** | ||
* Connects to the device. Can be called even | ||
@@ -466,141 +557,134 @@ * if device is already connected. | ||
connect() { | ||
if (!this.isConnected()) { | ||
return new Promise((resolve, reject) => { | ||
let resolvedOrRejected = false; | ||
this.client = new net.Socket(); | ||
if (this.isConnected()) { | ||
// Return if already connected | ||
return Promise.resolve(true); | ||
} | ||
// Attempt to connect | ||
debug(`Connecting to ${this.device.ip}...`); | ||
this.client.connect(this.device.port, this.device.ip); | ||
if (this.connectPromise) { | ||
// If a connect approach still in progress simply return same Promise | ||
return this.connectPromise; | ||
} | ||
// Default connect timeout is ~1 minute, | ||
// 5 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.emit('error', new Error('connection timed out')); | ||
this.client.destroy(); | ||
this.emit('error', new Error('connection timed out')); | ||
if (!resolvedOrRejected) { | ||
reject(new Error('connection timed out')); | ||
resolvedOrRejected = true; | ||
} | ||
}); | ||
this.createDeferredConnectPromise(); | ||
// Add event listeners to socket | ||
this.client = new net.Socket(); | ||
// Parse response data | ||
this.client.on('data', data => { | ||
debug(`Received data: ${data.toString('hex')}`); | ||
// Default connect timeout is ~1 minute, | ||
// 5 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.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; | ||
} | ||
}); | ||
let packets; | ||
// Add event listeners to socket | ||
try { | ||
packets = this.device.parser.parse(data); | ||
// Parse response data | ||
this.client.on('data', data => { | ||
debug(`Received data: ${data.toString('hex')}`); | ||
if (this.nullPayloadOnJSONError) { | ||
for (const packet of packets) { | ||
if (packet.payload && packet.payload === 'json obj data unvalid') { | ||
this.emit('error', packet.payload); | ||
let packets; | ||
packet.payload = { | ||
dps: { | ||
1: null, | ||
2: null, | ||
3: null, | ||
101: null, | ||
102: null, | ||
103: null | ||
} | ||
}; | ||
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; | ||
} | ||
} | ||
} catch (error) { | ||
debug(error); | ||
this.emit('error', error); | ||
return; | ||
} | ||
packets.forEach(packet => { | ||
debug('Parsed:'); | ||
debug(packet); | ||
packets.forEach(packet => { | ||
debug('Parsed:'); | ||
debug(packet); | ||
this._packetHandler.bind(this)(packet); | ||
}); | ||
}); | ||
this._packetHandler.bind(this)(packet); | ||
}); | ||
}); | ||
// Handle errors | ||
this.client.on('error', err => { | ||
debug('Error event from socket.', this.device.ip, err); | ||
// 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)); | ||
this.emit('error', new Error('Error from socket: ' + err.message)); | ||
if (!this._connected && !resolvedOrRejected) { | ||
reject(err); | ||
resolvedOrRejected = true; | ||
} | ||
if (!this._connected && this.connectPromise) { | ||
this.connectPromise.reject(err); | ||
delete this.connectPromise; | ||
} | ||
this.client.destroy(); | ||
}); | ||
this.client.destroy(); | ||
}); | ||
// Handle socket closure | ||
this.client.on('close', () => { | ||
debug(`Socket closed: ${this.device.ip}`); | ||
// Handle socket closure | ||
this.client.on('close', () => { | ||
debug(`Socket closed: ${this.device.ip}`); | ||
this.disconnect(); | ||
}); | ||
this.disconnect(); | ||
}); | ||
this.client.on('connect', async () => { | ||
debug('Socket connected.'); | ||
this.client.on('connect', async () => { | ||
debug('Socket connected.'); | ||
this._connected = true; | ||
// Remove connect timeout | ||
this.client.setTimeout(0); | ||
// Remove connect timeout | ||
this.client.setTimeout(0); | ||
if (this.device.version === '3.4') { | ||
// 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 | ||
}); | ||
/** | ||
* 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'); | ||
debug('Protocol 3.4: Negotiate Session Key - Send Msg 0x03'); | ||
this.client.write(buffer); | ||
} catch (error) { | ||
debug('Error binding key for protocol 3.4: ' + error); | ||
} | ||
// Periodically send heartbeat ping | ||
this._pingPongInterval = setInterval(async () => { | ||
await this._sendPing(); | ||
}, this._pingPongPeriod * 1000); | ||
return; | ||
} | ||
// Automatically ask for dp_refresh so we | ||
// can emit a `dp_refresh` event as soon as possible | ||
if (this.globalOptions.issueRefreshOnConnect) { | ||
this.refresh(); | ||
} | ||
this._finishConnect(); | ||
}); | ||
// Automatically ask for current state so we | ||
// can emit a `data` event as soon as possible | ||
if (this.globalOptions.issueGetOnConnect) { | ||
this.get(); | ||
} | ||
debug(`Connecting to ${this.device.ip}...`); | ||
this.client.connect(this.device.port, this.device.ip); | ||
// Return | ||
if (!resolvedOrRejected) { | ||
resolve(true); | ||
resolvedOrRejected = true; | ||
} | ||
}); | ||
}); | ||
} | ||
// Return if already connected | ||
return Promise.resolve(true); | ||
return this.connectPromise; | ||
} | ||
@@ -612,2 +696,53 @@ | ||
// Protocol 3.4 - Response to Msg 0x03 | ||
if (packet.commandByte === CommandType.SESS_KEY_NEG_RES) { | ||
if (!this.connectPromise) { | ||
debug('Protocol 3.4: 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: Local Random Key: ' + this._tmpLocalKey.toString('hex')); | ||
debug('Protocol 3.4: Remote Random Key: ' + this._tmpRemoteKey.toString('hex')); | ||
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]; | ||
} | ||
this.sessionKey = this.device.parser.cipher._encrypt34({data: this.sessionKey}); | ||
debug('Protocol 3.4: Session Key: ' + this.sessionKey.toString('hex')); | ||
debug('Protocol 3.4: Initialization done'); | ||
this.device.parser.cipher.setSessionKey(this.sessionKey); | ||
this.device.key = this.sessionKey; | ||
return this._finishConnect(); | ||
} | ||
if (packet.commandByte === CommandType.HEART_BEAT) { | ||
@@ -626,3 +761,7 @@ debug(`Pong from ${this.device.ip}`); | ||
if (packet.commandByte === CommandType.CONTROL && packet.payload === false) { | ||
if ( | ||
( | ||
packet.commandByte === CommandType.CONTROL || | ||
packet.commandByte === CommandType.CONTROL_NEW | ||
) && packet.payload === false) { | ||
debug('Got SET ack.'); | ||
@@ -652,2 +791,3 @@ return; | ||
debug('Received DATA packet'); | ||
debug('data: ' + packet.commandByte + ' : ' + (Buffer.isBuffer(packet.payload) ? packet.payload.toString('hex') : JSON.stringify(packet.payload))); | ||
/** | ||
@@ -666,5 +806,8 @@ * Emitted when data is returned from device. | ||
// Status response to SET command | ||
if (packet.sequenceN === 0 && | ||
packet.commandByte === CommandType.STATUS && | ||
typeof this._setResolver === 'function') { | ||
// 3.4 response sequenceN is not '0' just next TODO verify | ||
if (/* Former code: packet.sequenceN === 0 && */ | ||
packet.commandByte === CommandType.STATUS && | ||
typeof this._setResolver === 'function' | ||
) { | ||
this._setResolver(packet.payload); | ||
@@ -698,2 +841,3 @@ | ||
this._connected = false; | ||
this.device.parser.cipher.setSessionKey(null); | ||
@@ -733,2 +877,4 @@ // Clear timeouts | ||
* @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. | ||
*/ | ||
@@ -744,3 +890,3 @@ resolveId(options) { | ||
* you must call this before anything else. | ||
* @param {Object} [options] | ||
* @param {Object} [options] Options object | ||
* @param {Boolean} [options.all] | ||
@@ -823,3 +969,4 @@ * true to return array of all found devices | ||
key: this.device.key, | ||
version: this.device.version}); | ||
version: this.device.version | ||
}); | ||
} | ||
@@ -826,0 +973,0 @@ |
const crypto = require('crypto'); | ||
/** | ||
* Low-level class for encrypting and decrypting payloads. | ||
* @class | ||
* @param {Object} options | ||
* @param {Object} options - Options for the cipher. | ||
* @param {String} options.key localKey of cipher | ||
@@ -14,2 +13,3 @@ * @param {Number} options.version protocol version | ||
constructor(options) { | ||
this.sessionKey = null; | ||
this.key = options.key; | ||
@@ -20,4 +20,12 @@ this.version = options.version.toString(); | ||
/** | ||
* Sets the session key used for Protocol 3.4 | ||
* @param {Buffer} sessionKey Session key | ||
*/ | ||
setSessionKey(sessionKey) { | ||
this.sessionKey = sessionKey; | ||
} | ||
/** | ||
* Encrypts data. | ||
* @param {Object} options | ||
* @param {Object} options Options for encryption | ||
* @param {String} options.data data to encrypt | ||
@@ -30,4 +38,19 @@ * @param {Boolean} [options.base64=true] `true` to return result in Base64 | ||
encrypt(options) { | ||
const cipher = crypto.createCipheriv('aes-128-ecb', this.key, ''); | ||
if (this.version === '3.4') { | ||
return this._encrypt34(options); | ||
} | ||
return this._encryptPre34(options); | ||
} | ||
/** | ||
* Encrypt data for protocol 3.3 and before | ||
* @param {Object} options Options for encryption | ||
* @param {String} options.data data to encrypt | ||
* @param {Boolean} [options.base64=true] `true` to return result in Base64 | ||
* @returns {Buffer|String} returns Buffer unless options.base64 is true | ||
*/ | ||
_encryptPre34(options) { | ||
const cipher = crypto.createCipheriv('aes-128-ecb', this.getKey(), ''); | ||
let encrypted = cipher.update(options.data, 'utf8', 'base64'); | ||
@@ -45,2 +68,23 @@ encrypted += cipher.final('base64'); | ||
/** | ||
* Encrypt data for protocol 3.4 | ||
* @param {Object} options Options for encryption | ||
* @param {String} options.data data to encrypt | ||
* @param {Boolean} [options.base64=true] `true` to return result in Base64 | ||
* @returns {Buffer|String} returns Buffer unless options.base64 is true | ||
*/ | ||
_encrypt34(options) { | ||
const cipher = crypto.createCipheriv('aes-128-ecb', this.getKey(), null); | ||
cipher.setAutoPadding(false); | ||
const encrypted = cipher.update(options.data); | ||
cipher.final(); | ||
// Default base64 enable TODO: check if this is needed? | ||
// if (options.base64 === false) { | ||
// return Buffer.from(encrypted, 'base64'); | ||
// } | ||
return encrypted; | ||
} | ||
/** | ||
* Decrypts data. | ||
@@ -52,2 +96,16 @@ * @param {String|Buffer} data to decrypt | ||
decrypt(data) { | ||
if (this.version === '3.4') { | ||
return this._decrypt34(data); | ||
} | ||
return this._decryptPre34(data); | ||
} | ||
/** | ||
* Decrypts data for protocol 3.3 and before | ||
* @param {String|Buffer} data to decrypt | ||
* @returns {Object|String} | ||
* returns object if data is JSON, else returns string | ||
*/ | ||
_decryptPre34(data) { | ||
// Incoming data format | ||
@@ -57,4 +115,4 @@ let format = 'buffer'; | ||
if (data.indexOf(this.version) === 0) { | ||
if (this.version === '3.3') { | ||
// Remove 3.3 header | ||
if (this.version === '3.3' || this.version === '3.2') { | ||
// Remove 3.3/3.2 header | ||
data = data.slice(15); | ||
@@ -74,3 +132,3 @@ } else { | ||
try { | ||
const decipher = crypto.createDecipheriv('aes-128-ecb', this.key, ''); | ||
const decipher = crypto.createDecipheriv('aes-128-ecb', this.getKey(), ''); | ||
result = decipher.update(data, format, 'utf8'); | ||
@@ -92,2 +150,43 @@ result += decipher.final('utf8'); | ||
/** | ||
* Decrypts data for protocol 3.4 | ||
* @param {String|Buffer} data to decrypt | ||
* @returns {Object|String} | ||
* returns object if data is JSON, else returns string | ||
*/ | ||
_decrypt34(data) { | ||
let result; | ||
try { | ||
const decipher = crypto.createDecipheriv('aes-128-ecb', this.getKey(), null); | ||
decipher.setAutoPadding(false); | ||
result = decipher.update(data); | ||
decipher.final(); | ||
// Remove padding | ||
result = result.slice(0, (result.length - result[result.length - 1])); | ||
} catch (_) { | ||
throw new Error('Decrypt failed'); | ||
} | ||
// Try to parse data as JSON, | ||
// otherwise return as string. | ||
// 3.4 protocol | ||
// {"protocol":4,"t":1632405905,"data":{"dps":{"101":true},"cid":"00123456789abcde"}} | ||
try { | ||
if (result.indexOf(this.version) === 0) { | ||
result = result.slice(15); | ||
} | ||
const res = JSON.parse(result); | ||
if ('data' in res) { | ||
const resData = res.data; | ||
resData.t = res.t; | ||
return resData; // Or res.data // for compatibility with tuya-mqtt | ||
} | ||
return res; | ||
} catch (_) { | ||
return result; | ||
} | ||
} | ||
/** | ||
* Calculates a MD5 hash. | ||
@@ -101,3 +200,28 @@ * @param {String} data to hash | ||
} | ||
/** | ||
* Gets the key used for encryption/decryption | ||
* @returns {String} sessionKey (if set for protocol 3.4) or key | ||
*/ | ||
getKey() { | ||
return this.sessionKey === null ? this.key : this.sessionKey; | ||
} | ||
/** | ||
* Returns the HMAC for the current key (sessionKey if set for protocol 3.4 or key) | ||
* @param {Buffer} data data to hash | ||
* @returns {Buffer} HMAC | ||
*/ | ||
hmac(data) { | ||
return crypto.createHmac('sha256', this.getKey()).update(data, 'utf8').digest(); // .digest('hex'); | ||
} | ||
/** | ||
* Returns 16 random bytes | ||
* @returns {Buffer} Random bytes | ||
*/ | ||
random() { | ||
return crypto.randomBytes(16); | ||
} | ||
} | ||
module.exports = TuyaCipher; |
@@ -73,3 +73,3 @@ /* Reverse engineered by kueblc */ | ||
* Computes a Tuya flavored CRC32 | ||
* @param {Iterable} bytes | ||
* @param {Iterable} bytes The bytes to compute the CRC32 for | ||
* @returns {Number} Tuya CRC32 | ||
@@ -76,0 +76,0 @@ */ |
@@ -9,2 +9,3 @@ const Cipher = require('./cipher'); | ||
* of command bytes. | ||
* See also https://github.com/tuya/tuya-iotos-embeded-sdk-wifi-ble-bk7231n/blob/master/sdk/include/lan_protocol.h | ||
* @readonly | ||
@@ -17,5 +18,8 @@ * @private | ||
ACTIVE: 2, | ||
BIND: 3, | ||
RENAME_GW: 4, | ||
RENAME_DEVICE: 5, | ||
BIND: 3, // ?? leave in for backward compatibility | ||
SESS_KEY_NEG_START: 3, // negotiate session key | ||
RENAME_GW: 4, // ?? leave in for backward compatibility | ||
SESS_KEY_NEG_RES: 4, // negotiate session key response | ||
RENAME_DEVICE: 5, // ?? leave in for backward compatibility | ||
SESS_KEY_NEG_FINISH: 5,// finalize session key negotiation | ||
UNBIND: 6, | ||
@@ -32,5 +36,7 @@ CONTROL: 7, | ||
SCENE_EXECUTE: 17, | ||
DP_REFRESH: 18, | ||
DP_REFRESH: 18, // Request refresh of DPS UPDATEDPS / LAN_QUERY_DP | ||
UDP_NEW: 19, | ||
AP_CONFIG_NEW: 20, | ||
BOARDCAST_LPV34: 35, | ||
LAN_EXT_STREAM: 40, | ||
LAN_GW_ACTIVE: 240, | ||
@@ -66,3 +72,3 @@ LAN_SUB_DEV_REQUEST: 241, | ||
* @class | ||
* @param {Object} options | ||
* @param {Object} options Options | ||
* @param {String} options.key localKey of cipher | ||
@@ -93,3 +99,3 @@ * @param {Number} [options.version=3.1] protocol version | ||
* Parses a Buffer of data containing at least | ||
* one complete packet at the begining of the buffer. | ||
* one complete packet at the beginning of the buffer. | ||
* Will return multiple packets if necessary. | ||
@@ -155,3 +161,9 @@ * @param {Buffer} buffer of data to parse | ||
if (returnCode & 0xFFFFFF00) { | ||
payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 8); | ||
if (this.version === '3.4') { | ||
payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 0x24); | ||
} else { | ||
payload = buffer.slice(HEADER_SIZE, HEADER_SIZE + payloadSize - 8); | ||
} | ||
} else if (this.version === '3.4') { | ||
payload = buffer.slice(HEADER_SIZE + 4, HEADER_SIZE + payloadSize - 0x24); | ||
} else { | ||
@@ -162,7 +174,16 @@ 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 (this.version === '3.4' && (commandByte !== CommandType.UDP || commandByte !== CommandType.UDP_NEW)) { | ||
const expectedCrc = buffer.slice(HEADER_SIZE + payloadSize - 0x24, buffer.length - 4).toString('hex'); | ||
const computedCrc = this.cipher.hmac(buffer.slice(0, HEADER_SIZE + payloadSize - 0x24)).toString('hex'); | ||
if (expectedCrc !== computedCrc) { | ||
throw new Error(`CRC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${buffer.toString('hex')}`); | ||
if (expectedCrc !== computedCrc) { | ||
throw new Error(`HMAC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${buffer.toString('hex')}`); | ||
} | ||
} else { | ||
const expectedCrc = buffer.readInt32BE(HEADER_SIZE + payloadSize - 8); | ||
const computedCrc = crc(buffer.slice(0, payloadSize + 8)); | ||
if (expectedCrc !== computedCrc) { | ||
throw new Error(`CRC mismatch: expected ${expectedCrc}, was ${computedCrc}. ${buffer.toString('hex')}`); | ||
} | ||
} | ||
@@ -239,11 +260,8 @@ | ||
parse(buffer) { | ||
const packets = this.parseRecursive(buffer, []); | ||
return packets; | ||
return this.parseRecursive(buffer, []); | ||
} | ||
/** | ||
* Encodes a payload into a | ||
* Tuya-protocol-complient packet. | ||
* @param {Object} options | ||
* Encodes a payload into a Tuya-protocol-compliant packet. | ||
* @param {Object} options Options for encoding | ||
* @param {Buffer|String|Object} options.data data to encode | ||
@@ -254,3 +272,3 @@ * @param {Boolean} options.encrypted whether or not to encrypt the data | ||
* @param {Number} [options.sequenceN] optional, sequence number | ||
* @returns {Buffer} | ||
* @returns {Buffer} Encoded Buffer | ||
*/ | ||
@@ -272,7 +290,25 @@ encode(options) { | ||
if (this.version === '3.4') { | ||
return this._encode34(options); | ||
} | ||
return this._encodePre34(options); | ||
} | ||
/** | ||
* Encodes a payload into a Tuya-protocol-compliant packet for protocol version 3.3 and below. | ||
* @param {Object} options Options for encoding | ||
* @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} Encoded Buffer | ||
*/ | ||
_encodePre34(options) { | ||
// Construct payload | ||
let payload = options.data; | ||
// Protocol 3.3 is always encrypted | ||
if (this.version === '3.3') { | ||
// Protocol 3.3 and 3.2 is always encrypted | ||
if (this.version === '3.3' || this.version === '3.2') { | ||
// Encrypt data | ||
@@ -330,4 +366,66 @@ payload = this.cipher.encrypt({ | ||
} | ||
/** | ||
* Encodes a payload into a Tuya-protocol-complient packet for protocol version 3.4 | ||
* @param {Object} options Options for encoding | ||
* @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} Encoded Buffer | ||
*/ | ||
_encode34(options) { | ||
let payload = options.data; | ||
if (options.commandByte !== CommandType.DP_QUERY && | ||
options.commandByte !== CommandType.HEART_BEAT && | ||
options.commandByte !== CommandType.DP_QUERY_NEW && | ||
options.commandByte !== CommandType.SESS_KEY_NEG_START && | ||
options.commandByte !== CommandType.SESS_KEY_NEG_FINISH && | ||
options.commandByte !== CommandType.DP_REFRESH) { | ||
// Add 3.4 header | ||
// check this: mqc_very_pcmcd_mcd(int a1, unsigned int a2) | ||
const buffer = Buffer.alloc(payload.length + 15); | ||
Buffer.from('3.4').copy(buffer, 0); | ||
payload.copy(buffer, 15); | ||
payload = buffer; | ||
} | ||
// ? if (payload.length > 0) { // is null messages need padding - PING work without | ||
const padding = 0x10 - (payload.length & 0xF); | ||
const buf34 = Buffer.alloc((payload.length + padding), padding); | ||
payload.copy(buf34); | ||
payload = buf34; | ||
// } | ||
payload = this.cipher.encrypt({ | ||
data: payload | ||
}); | ||
payload = Buffer.from(payload); | ||
// Allocate buffer with room for payload + 24 bytes for | ||
// prefix, sequence, command, length, crc, and suffix | ||
const buffer = Buffer.alloc(payload.length + 52); | ||
// Add prefix, command, and length | ||
buffer.writeUInt32BE(0x000055AA, 0); | ||
buffer.writeUInt32BE(options.commandByte, 8); | ||
buffer.writeUInt32BE(payload.length + 0x24, 12); | ||
if (options.sequenceN) { | ||
buffer.writeUInt32BE(options.sequenceN, 4); | ||
} | ||
// Add payload, crc, and suffix | ||
payload.copy(buffer, 16); | ||
const calculatedCrc = this.cipher.hmac(buffer.slice(0, payload.length + 16));// & 0xFFFFFFFF; | ||
calculatedCrc.copy(buffer, payload.length + 16); | ||
buffer.writeUInt32BE(0x0000AA55, payload.length + 48); | ||
return buffer; | ||
} | ||
} | ||
module.exports = {MessageParser, CommandType}; |
{ | ||
"name": "tuyapi", | ||
"version": "7.4.0", | ||
"version": "7.5.0", | ||
"description": "An easy-to-use API for devices that use Tuya's cloud services", | ||
@@ -41,5 +41,5 @@ "main": "index.js", | ||
"dependencies": { | ||
"debug": "4.1.1", | ||
"p-queue": "6.6.1", | ||
"p-retry": "4.2.0", | ||
"debug": "^4.3.4", | ||
"p-queue": "6.6.2", | ||
"p-retry": "4.6.2", | ||
"p-timeout": "3.2.0" | ||
@@ -51,6 +51,6 @@ }, | ||
"clone": "2.1.2", | ||
"coveralls": "3.0.9", | ||
"delay": "4.3.0", | ||
"documentation": "^12.1.4", | ||
"nyc": "15.0.0", | ||
"coveralls": "3.1.1", | ||
"delay": "4.4.1", | ||
"documentation": "^12.3.0", | ||
"nyc": "15.1.0", | ||
"xo": "0.25.4" | ||
@@ -57,0 +57,0 @@ }, |
68788
1589
+ Added@types/retry@0.12.0(transitive)
+ Addeddebug@4.4.0(transitive)
+ Addedp-queue@6.6.2(transitive)
+ Addedp-retry@4.6.2(transitive)
+ Addedretry@0.13.1(transitive)
- Removed@types/retry@0.12.5(transitive)
- Removeddebug@4.1.1(transitive)
- Removedp-queue@6.6.1(transitive)
- Removedp-retry@4.2.0(transitive)
- Removedretry@0.12.0(transitive)
Updateddebug@^4.3.4
Updatedp-queue@6.6.2
Updatedp-retry@4.6.2