Skip to content

Commit

Permalink
Bump dependencies, add support for v3.3 firmware (#216)
Browse files Browse the repository at this point in the history
* Update dependency coveralls to v3.0.4 (#206)

* Update dependency ava to v2.1.0 (#211)

* Update dependency documentation to v11.0.1 (#213)

* Update dependency delay to v4.3.0 (#212)

* Protocol 3.3 and encrypted discovery (#214)

* Add check for set resolver

* Don't emit event on set ACK

* Don't emit event on set ACK

* Hacked in support for protocol 3.3 and new UDP broadcast

* MessageParser.decode now attempts to parse JSON after decryption
Cipher.decrypt now always outputs a string, as MessageParser handles JSON parsing
Updated cipher tests to account for this API change
Tests now pass

* Rollback cipher changes for now -- saving breaking changes for v6.0.0
Minor version bump to v5.1.0 as a result of the added protocol support

* Fixed 3.1 producing a buffer on decryption failure
Fixed 3.3 crc being taken before the payload is written

* Removed debug used during testing

* Removed redundant try catch block, as it throws the errors it catches

* Moved some shared code from encode31() and encode33() to shared code path in encode()

* Updated tests for improved coverage, including 3.3 protocol code paths

* Refactored out encode31 and encode33 entirely as their differences could be contained to payload treatment

* Ensure that version is a string
Switched constructor to terse defaults pattern

* Moved UDP key to its own file
Added note about using protocol 3.3 without find()
  • Loading branch information
codetheweb authored Jun 16, 2019
1 parent 2fb72e8 commit 94f24f1
Show file tree
Hide file tree
Showing 9 changed files with 290 additions and 166 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const device = new TuyAPI({
## 📝 Notes
- 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.
- Some devices ship with older firmware that may not work with `tuyapi`. If you're experiencing issues, please try updating the device's firmware in the official app.
- Newer firmware may use protocol 3.3. If you are not using `find()`, you will need to manually pass `version: 3.3` to the constructor.


## 📓 Documentation
Expand Down
112 changes: 65 additions & 47 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -497,66 +497,88 @@ class TuyaDevice extends EventEmitter {
return Promise.resolve(true);
}

// Create new listener
// Create new listeners
const listener = dgram.createSocket({type: 'udp4', reuseAddr: true});
listener.bind(6666);

debug(`Finding missing IP ${this.device.ip} or ID ${this.device.id}`);
const listenerEncrypted = dgram.createSocket({type: 'udp4', reuseAddr: true});
listenerEncrypted.bind(6667);

// Find IP for device
return pTimeout(new Promise((resolve, reject) => { // Timeout
listener.on('message', message => {
debug('Received UDP message.');
const broadcastHandler = (resolve, reject) => message => {
debug('Received UDP message.');

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

debug('UDP data:');
debug(dataRes);
debug('UDP data:');
debug(dataRes);

const thisID = dataRes.payload.gwId;
const thisIP = dataRes.payload.ip;
const thisID = dataRes.payload.gwId;
const thisIP = dataRes.payload.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});
}
// 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;
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;
// 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 product key if neccessary
this.device.productKey = dataRes.payload.productKey;

// Change protocol version if necessary
// Change protocol version if necessary
if (this.device.version !== dataRes.payload.version) {
this.device.version = dataRes.payload.version;

// Cleanup
listener.close();
listener.removeAllListeners();
resolve(true);
// Update the parser
this.device.parser = new MessageParser({
key: this.device.key,
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) {
Expand All @@ -577,18 +599,14 @@ class TuyaDevice extends EventEmitter {
async toggle(property = '1') {
property = property.toString();

try {
// Get status
const status = await this.get({dps: property});
// Get status
const status = await this.get({dps: property});

// Set to opposite
await this.set({set: !status, 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;
}
// Return new status
return this.get({dps: property});
}
}

Expand Down
37 changes: 28 additions & 9 deletions lib/cipher.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
const crypto = require('crypto');
const config = require('./config');

const UDP_KEY = crypto.createHash('md5').update(config.udpKey, 'utf8').digest();

/**
* Low-level class for encrypting and decrypting payloads.
Expand Down Expand Up @@ -49,19 +52,35 @@ class TuyaCipher {
let format = 'buffer';

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

// Remove prefix of version number and MD5 hash
data = data.slice(19);
if (this.version === '3.3') {
// Remove 3.3 header
data = data.slice(15);
} else {
// Data has version number and is encoded in base64

// Decode incoming data as base64
format = 'base64';
// Remove prefix of version number and MD5 hash
data = data.slice(19).toString();
// Decode incoming data as base64
format = 'base64';
}
}

// Decrypt data
const decipher = crypto.createDecipheriv('aes-128-ecb', this.key, '');
let result = decipher.update(data, format, 'utf8');
result += decipher.final('utf8');
let result;
try {
const decipher = crypto.createDecipheriv('aes-128-ecb', this.key, '');
result = decipher.update(data, format, 'utf8');
result += decipher.final('utf8');
} catch (error) {
// Try the universal key, in case it's a new UDP message format
try {
const decipher = crypto.createDecipheriv('aes-128-ecb', UDP_KEY, '');
result = decipher.update(data, format, 'utf8');
result += decipher.final('utf8');
} catch (error) {
throw new Error('Decrypt failed');
}
}

// Try to parse data as JSON,
// otherwise return as string.
Expand Down
1 change: 1 addition & 0 deletions lib/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"udpKey":"yGAdlopoPVldABfn"}
107 changes: 63 additions & 44 deletions lib/message-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,18 @@ const CommandType = {
* const parser = new MessageParser({key: 'xxxxxxxxxxxxxxxx', version: 3.1})
*/
class MessageParser {
constructor(options) {
// Defaults
options = options ? options : {};
options.version = options.version ? options.version : '3.1';

if (options.key && options.key.length !== 16) {
throw new TypeError('Incorrect key format');
}
constructor({key, version = 3.1} = {}) {
// Ensure the version is a string
version = version.toString();
this.version = version;

if (key) {
if (key.length !== 16) {
throw new TypeError('Incorrect key format');
}

if (options.key && options.version) {
this.cipher = new Cipher(options);
this.key = options.key;
this.version = options.version;
// Create a Cipher if we have a valid key
this.cipher = new Cipher({key, version});
}
}

Expand Down Expand Up @@ -125,12 +124,12 @@ class MessageParser {
// Get sequence number
const sequenceN = buffer.readUInt32BE(4);

// Get command byte
const commandByte = buffer.readUInt32BE(8);

// Get payload size
const payloadSize = buffer.readUInt32BE(12);

// Get command byte
const commandByte = buffer.readUInt8(11);

// Check for payload
if (buffer.length - 8 < payloadSize) {
throw new TypeError(`Packet missing payload: payload has length ${payloadSize}.`);
Expand Down Expand Up @@ -174,20 +173,23 @@ class MessageParser {
return false;
}

// Try to parse data as JSON.
// If error, return as string.
// Try to decrypt data first.
try {
data = JSON.parse(data);
} catch (error) { // Data is encrypted
data = data.toString('ascii');
if (!this.cipher) {
throw new Error('Missing key or version in constructor.');
}

try {
if (!this.cipher) {
throw new Error('Missing key or version in constructor.');
}
data = this.cipher.decrypt(data);
} catch (error) {
data = data.toString('utf8');
}

data = this.cipher.decrypt(data);
} catch (donothing) {}
// Try to parse data as JSON.
// If error, return as string.
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (error) { }
}

return data;
Expand Down Expand Up @@ -242,13 +244,43 @@ class MessageParser {
* @returns {Buffer}
*/
encode(options) {
// Ensure data is a Buffer
let payload;
// Check command byte
if (Object.values(CommandType).indexOf(options.commandByte) === -1) {
throw new TypeError('Command byte not defined.');
}

// Encrypt data if necessary
if (options.encrypted) {
// Convert Objects to Strings, Strings to Buffers
if (!(options.data instanceof Buffer)) {
if (typeof options.data !== 'string') {
options.data = JSON.stringify(options.data);
}

options.data = Buffer.from(options.data);
}

// Construct payload
let payload = options.data;

// Protocol 3.3 is always encrypted
if (this.version === '3.3') {
// Encrypt data
payload = this.cipher.encrypt({
data: payload,
base64: false
});

// Check if we need an extended header, only for certain CommandTypes
if (options.commandByte !== CommandType.DP_QUERY) {
// Add 3.3 header
const buffer = Buffer.alloc(payload.length + 15);
Buffer.from('3.3').copy(buffer, 0);
payload.copy(buffer, 15);
payload = buffer;
}
} else if (options.encrypted) {
// Protocol 3.1 and below, only encrypt data if necessary
payload = this.cipher.encrypt({
data: JSON.stringify(options.data)
data: payload
});

// Create MD5 signature
Expand All @@ -258,19 +290,6 @@ class MessageParser {

// Create byte buffer from hex data
payload = Buffer.from(this.version + md5 + payload);
} else 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);
}

// Check command byte
if (Object.values(CommandType).indexOf(options.commandByte) === -1) {
throw new TypeError('Command byte not defined.');
}

// Allocate buffer with room for payload + 24 bytes for
Expand Down
Loading

0 comments on commit 94f24f1

Please sign in to comment.