Skip to content

Commit

Permalink
Cleaned up autodiscovery, combined schema() and getStatus() => get()
Browse files Browse the repository at this point in the history
  • Loading branch information
codetheweb committed Jan 11, 2018
1 parent a14621c commit 60b9c85
Show file tree
Hide file tree
Showing 4 changed files with 136 additions and 97 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ See the [docs](docs/API.md).
4. Support arbitrary control schemes for devices as self-reported.
5. Use Promises for all functions?
6. Autodiscovery of devices?
7. Make the JSON parser more reliable.

## Contributors

Expand Down
202 changes: 118 additions & 84 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
const dgram = require('dgram');
const forge = require('node-forge');
const retryConnect = require('net-retry-connect');
const stringOccurrence = require('string-occurrence');

// Import requests for devices
const requests = require('./requests.json');
Expand All @@ -22,68 +23,116 @@ const requests = require('./requests.json');
*/
function TuyaDevice(options) {
this.devices = [];
const needIP = [];

// If argument is [{id: '', key: ''}]
if (options.constructor === Array) {
options.forEach(function (device) {
if (device.ip === undefined) {
needIP.push(device.id);
} else {
this.devices.push(device);
}
});

this.discoverDevices(needIP).then(devices => {
this.devices.push(devices);
});
this.devices = options;
}

// If argument is {id: '', key: ''}
else if (options.constructor === Object) {
if (options.ip === undefined) {
this.discoverDevices(options.id).then(device => {
this.devices.push(device);
});
} else {
this.devices.push({
type: options.type || 'outlet',
ip: options.ip,
port: options.port || 6668,
key: options.key,
cipher: forge.cipher.createCipher('AES-ECB', options.key),
version: options.version || 3.1
});
}
this.devices = [options];
}

// standardize devices array
for (var i = 0; i < this.devices.length; i++) {
if (this.devices[i].type === undefined) { this.devices[i].type = 'outlet'; }
if (this.devices[i].port === undefined) { this.devices[i].port = 6668; }
if (this.devices[i].version === undefined) { this.devices[i].version = 3.1; }

// create cipher from key
this.devices[i].cipher = forge.cipher.createCipher('AES-ECB', this.devices[i].key);
}
}

/**
* Gets the device's current status.
* Resolves IDs stored in class to IPs.
* @returns {Promise<Boolean>} - true if IPs were found and devices are ready to be used
*/
TuyaDevice.prototype.resolveIds = function() {
// Create new listener if it hasn't already been created
if (this.listener == undefined) {
this.listener = dgram.createSocket('udp4');
this.listener.bind(6666);
}

// find devices that need an IP
var needIP = [];
for (var i = 0; i < this.devices.length; i++) {
if (this.devices[i].ip == undefined) {
needIP.push(this.devices[i].id);
}
}

// todo: add timeout for when IP cannot be found, then reject(with error)
// add IPs to devices in array and return true
return new Promise((resolve, reject) => {
this.listener.on('message', (message, info) => {
let thisId = this._extractJSON(message).gwId;

if (needIP.length > 0) {
if (needIP.includes(thisId)) {
var deviceIndex = this.devices.findIndex(device => {
if (device.id === thisId) { return true; }
});

this.devices[deviceIndex].ip = this._extractJSON(message).ip;

needIP.splice(needIP.indexOf(thisId), 1);
}
}
else { // all devices have been resolved
this.listener.removeAllListeners();
resolve(true);
}
})
});
};

/**
* Gets the device's current status. Defaults to returning only the first 'dps', but by setting {schema: true} you can get everything.
* @param {string} ID - optional, ID of device. Defaults to first device.
* @param {function(error, result)} callback
*/
TuyaDevice.prototype.getStatus = function (callback) {
TuyaDevice.prototype.get = function (options) {
var currentDevice;

// If no ID is provided
if (options === undefined || options.id === undefined) {
currentDevice = this.devices[0]; // use first device in array
}
else { // otherwise
// find the device by id in this.devices
let index = this.devices.findIndex(device => {
if (device.id === options.id) { return true; }
});
currentDevice = this.devices[index]
}

// Add data to command
if ('gwId' in requests[this.type].status.command) {
requests[this.type].status.command.gwId = this.id;
if ('gwId' in requests[currentDevice.type].status.command) {
requests[currentDevice.type].status.command.gwId = currentDevice.id;
}
if ('devId' in requests[this.type].status.command) {
requests[this.type].status.command.devId = this.id;
if ('devId' in requests[currentDevice.type].status.command) {
requests[currentDevice.type].status.command.devId = currentDevice.id;
}

// Create byte buffer from hex data
const thisData = Buffer.from(JSON.stringify(requests[this.type].status.command));
const buffer = this._constructBuffer(thisData, 'status');
const thisData = Buffer.from(JSON.stringify(requests[currentDevice.type].status.command));
const buffer = this._constructBuffer(currentDevice.type, thisData, 'status');

this._send(buffer).then(data => {
// Extract returned JSON
try {
data = data.toString();
data = data.slice(data.indexOf('{'), data.lastIndexOf('}') + 1);
data = JSON.parse(data);
return callback(null, data.dps['1']);
} catch (err) {
return callback(err, null);
}
return new Promise((resolve, reject) => {
this._send(currentDevice.ip, buffer).then(data => {
// Extract returned JSON
data = this._extractJSON(data);

if (options != undefined && options.schema == true) {
resolve(data);
}
else {
resolve(data.dps['1'])
}
});
});
};

Expand Down Expand Up @@ -138,12 +187,13 @@ TuyaDevice.prototype.setStatus = function (on, callback) {
/**
* Sends a query to the device.
* @private
* @param {String} ip - IP of device
* @param {Buffer} buffer - buffer of data
* @returns {Promise<string>} - returned data
*/
TuyaDevice.prototype._send = function (buffer) {
TuyaDevice.prototype._send = function (ip, buffer) {
return new Promise((resolve, reject) => {
retryConnect.to({port: 6668, host: this.ip, retryOptions: {retries: 5}}, (error, client) => {
retryConnect.to({port: 6668, host: ip, retryOptions: {retries: 5}}, (error, client) => {
if (error) {
reject(error);
}
Expand All @@ -161,19 +211,20 @@ TuyaDevice.prototype._send = function (buffer) {
};

/**
* Constructs a protocol-complient buffer given data and command.
* Constructs a protocol-complient buffer given device type, data, and command.
* @private
* @param {String} type - type of device
* @param {String} data - data to put in buffer
* @param {String} command - command (status, on, off, etc.)
* @returns {Buffer} buffer - buffer of data
*/
TuyaDevice.prototype._constructBuffer = function (data, command) {
TuyaDevice.prototype._constructBuffer = function (type, data, command) {
// Construct prefix of packet according to protocol
const prefixLength = (data.toString('hex').length + requests[this.type].suffix.length) / 2;
const prefix = requests[this.type].prefix + requests[this.type][command].hexByte + '000000' + prefixLength.toString(16);
const prefixLength = (data.toString('hex').length + requests[type].suffix.length) / 2;
const prefix = requests[type].prefix + requests[type][command].hexByte + '000000' + prefixLength.toString(16);

// Create final buffer: prefix + data + suffix
return Buffer.from(prefix + data.toString('hex') + requests[this.type].suffix, 'hex');
return Buffer.from(prefix + data.toString('hex') + requests[type].suffix, 'hex');
};

/**
Expand Down Expand Up @@ -203,46 +254,29 @@ TuyaDevice.prototype.getSchema = function () {
});
};

/**
* Attempts to autodiscover devices (i.e. translate device ID to IP).
* @param {Array} IDs - can be a single ID or an array of IDs
* @returns {Promise<object>} devices - discovered devices
*/
TuyaDevice.prototype.discoverDevices = function (ids, callback) {
// Create new listener if it hasn't already been created
if (this.listener == undefined) {
this.listener = dgram.createSocket('udp4');
this.listener.bind(6666);
}

const discoveredDevices = [];

// If input is '...' change it to ['...'] for ease of use
if (typeof (ids) === 'string') {
ids = [ids];
}

return new Promise((resolve, reject) => {
this.listener.on('message', (message, info) => {
if (discoveredDevices.length < ids.length) {
if (ids.includes(this._extractJSON(message).gwId)) {
discoveredDevices.push(this._extractJSON(message));
}
} else { // All IDs have been resolved
resolve(discoveredDevices);
}
});
});
};

/**
* Extracts JSON from a raw buffer and returns it as an object.
* @param {Buffer} buffer of data
* @returns {Object} extracted object
*/
TuyaDevice.prototype._extractJSON = function (data) {
data = data.toString();
data = data.slice(data.indexOf('{'), data.lastIndexOf('"}') + 2);

// Find the # of occurrences of '{' and make that # match with the # of occurrences of '}'
var leftBrackets = stringOccurrence(data, '{');
let occurrences = 0;
let currentIndex = 0;

while (occurrences < leftBrackets) {
let index = data.indexOf('}', currentIndex + 1);
if (index != -1) {
currentIndex = index;
occurrences ++;
}
}

data = data.slice(data.indexOf('{'), currentIndex + 1);
console.log(data)
data = JSON.parse(data);
return data;
};
Expand Down
27 changes: 15 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"homepage": "https://github.com/codetheweb/tuyapi#readme",
"dependencies": {
"net-retry-connect": "^0.1.1",
"node-forge": "^0.7.1"
"node-forge": "^0.7.1",
"string-occurrence": "^1.2.0"
},
"devDependencies": {
"documentation": "^5.3.3",
Expand Down

0 comments on commit 60b9c85

Please sign in to comment.