From c39cbf0e65e493e2987fb0cad4fc4ea8a232e45d Mon Sep 17 00:00:00 2001 From: Andreas Holstenson Date: Thu, 18 Jan 2018 13:02:11 +0100 Subject: [PATCH] Refactoring network code The network code for devices is now handled by the file `network.js` that manages a single socket and information about all devices. This change implements a cache containing information about device models and tokens making connections faster and simpler. Direct device creation has been removed and the only supported way to get a device is now to use `miio.device(opts)` or `miio.devices()`. --- README.md | 22 -- cli/index.js | 5 +- example.js | 20 +- lib/connectToDevice.js | 49 +-- lib/createDevice.js | 18 - lib/device.js | 326 ++++------------- lib/devices/gateway/developer-api.js | 2 +- lib/discovery.js | 73 ++-- lib/index.js | 17 +- lib/network.js | 499 +++++++++++++++++++++++++++ lib/packet.js | 60 ++-- lib/tokens.js | 2 +- 12 files changed, 660 insertions(+), 433 deletions(-) delete mode 100644 lib/createDevice.js create mode 100644 lib/network.js diff --git a/README.md b/README.md index d1f911d..a5c362b 100644 --- a/README.md +++ b/README.md @@ -227,28 +227,6 @@ added along side the older one. [Reporting issues](docs/reporting-issues.md) contains information that is useful for making any issue you want to report easier to fix. -## Advanced: Skip model and token checks - -The `miio.device` function will return a promise that checks that we can -communicate with the device and what model it is. If you wish to skip this -step and just create a reference to a device use `miio.createDevice`: - -```javascript -const device = miio.createDevice({ - address: '192.168.100.8', - token: 'token-as-hex', - model: 'zhimi.airpurifier.m1' -}); -``` - -You will need to call `device.init()` manually to initialize the device: - -```javascript -device.init() - .then(() => /* device is ready for commands */) - .catch(console.error); -``` - ## Advanced: Raw API-usage and calling the Xiaomi miIO-method directly It's possible to call any method directly on a device without using the diff --git a/cli/index.js b/cli/index.js index 77c6ee1..442a814 100755 --- a/cli/index.js +++ b/cli/index.js @@ -8,14 +8,11 @@ const chalk = require('chalk'); const Packet = require('../lib/packet'); const Device = require('../lib/device'); const { Browser, Devices } = require('../lib/discovery'); -const Tokens = require('../lib/tokens'); +const tokens = require('../lib/tokens'); const models = require('../lib/models'); -const createDevice = require('../lib/createDevice'); const deviceFinder = require('./device-finder'); -const tokens = new Tokens(); - function info() { console.log(chalk.bgWhite.black(' INFO '), Array.prototype.join.call(arguments, ' ')); } diff --git a/example.js b/example.js index 040be32..65d8b45 100644 --- a/example.js +++ b/example.js @@ -4,22 +4,8 @@ const miio = require('./lib'); // Create a new device over the given address miio.device({ - address: '192.168.100.8' + address: 'ipHere', }).then(device => { + console.log('Connected to device'); console.log(device); - console.log(device.metadata); - - console.log(device.pm2_5); - - /* - console.log('connected', device.modes()); - return device.mode('silent'); - - if(device.metadata.hasCapability('power')) { - console.log('power is currently', device.power()); - return device.togglePower(); - }*/ - -}) -.then(p => console.log('got', p)) -.catch(console.error); +}).catch(err => console.log('Error occurred:', err)); diff --git a/lib/connectToDevice.js b/lib/connectToDevice.js index 4a74f78..e0e4f3f 100644 --- a/lib/connectToDevice.js +++ b/lib/connectToDevice.js @@ -1,30 +1,39 @@ 'use strict'; -const createDevice = require('./createDevice'); +const network = require('./network'); + +const Device = require('./device'); +const models = require('./models'); module.exports = function(options) { - let device = createDevice(options); - return device.call('miIO.info') - .then(data => { - if(options.model) { - // If the model was specified we reuse the device instance - } else { - // If the model was automatically discovered recreate the device - device._fastDestroy(); + let handle = network.ref(); + + // Connecting to a device via IP, ask the network if it knows about it + return network.findDeviceViaAddress(options) + .then(device => { + const deviceHandle = { + ref: network.ref(), + api: device + }; - device = createDevice(Object.assign({}, options, { - model: data.model, - token: data.token - })); + // Try to resolve the correct model, otherwise use the generic device + const d = models[device.model]; + if(! d) { + return new Device(deviceHandle); + } else { + return new d(deviceHandle); } }) - .then(() => device.init()) - .catch(err => { - // In case initialization was skipped - device._fastDestroy(); + .catch(e => { + // Error handling - make sure to always release the handle + handle.release(); + + throw e; + }) + .then(device => { + // Make sure to release the handle + handle.release(); - // Perform full destroy - device.destroy(); - throw err; + return device.init(); }); }; diff --git a/lib/createDevice.js b/lib/createDevice.js deleted file mode 100644 index ca1e3c6..0000000 --- a/lib/createDevice.js +++ /dev/null @@ -1,18 +0,0 @@ -'use strict'; - -const Device = require('./device'); -const models = require('./models'); - -module.exports = function(options) { - if(! options.address) throw new Error('Address to device is required'); - - const d = models[options.model]; - let device; - if(! d) { - device = new Device(options); - } else { - device = new d(options); - } - - return device; -}; diff --git a/lib/device.js b/lib/device.js index 9baeb78..f8e4e77 100644 --- a/lib/device.js +++ b/lib/device.js @@ -1,7 +1,7 @@ 'use strict'; const isDeepEqual = require('deep-equal'); -const { Thing } = require('abstract-things'); +const { Thing, Polling } = require('abstract-things'); const dgram = require('dgram'); const Packet = require('./packet'); @@ -16,7 +16,7 @@ const ERRORS = { '-10000': (method) => 'Method `' + method + '` is not supported' }; -module.exports = Thing.type(BaseAppliance => class MiioAppliance extends BaseAppliance { +module.exports = Thing.type(Parent => class extends Parent.with(Polling) { static get type() { return 'miio'; } @@ -39,259 +39,64 @@ module.exports = Thing.type(BaseAppliance => class MiioAppliance extends BaseApp .done(); } - constructor(options) { + constructor(handle) { super(); - this.id = 'miio:' + (options.id || '[' + options.address + ']'); - this.miioModel = options.model || 'unknown'; - this.capabilities = []; - - this.address = options.address; - this.port = options.port || 54321; - this.writeOnly = options.writeOnly || false; - - this.packet = new Packet(); - if(typeof options.token === 'string') { - this.packet.token = Buffer.from(options.token, 'hex'); - } else if(options.token instanceof Buffer) { - this.packet.token = options.token; - } - - this.socket = dgram.createSocket('udp4'); - this.socket.on('message', this._onMessage.bind(this)); - - this._id = 0; - this._promises = {}; - this._hasFailedToken = false; + this.handle = handle; + this.id = 'miio:' + handle.api.id; + this.miioModel = handle.api.model; this._properties = {}; this._propertiesToMonitor = []; this._propertyDefinitions = {}; this._reversePropertyDefinitions = {}; - this._loadProperties = this._loadProperties.bind(this); + this.poll = this.poll.bind(this); + // Set up polling to destroy device if unreachable for 5 minutes + //this.updateMaxPollFailures(10); this.management = new DeviceManagement(this); } - initCallback() { - // Default setup involves activating monitoring - return super.initCallback() - .then(() => this.monitor()); - } - hasCapability(name) { return this.capabilities.indexOf(name) >= 0; } - _onMessage(msg) { - try { - this.packet.raw = msg; - } catch(ex) { - this.debug('<- Unable to parse packet', ex); - return; - } - - if(this._tokenResolve) { - this.debug('<-', 'Handshake reply:', this.packet.checksum); - this.packet.handleHandshakeReply(); - - this._lastToken = Date.now(); - - this._tokenResolve(); - } else { - let data = this.packet.data; - if(! data) { - this.debug('<-', null); - return; - } - - // Handle null-terminated strings - if(data[data.length - 1] === 0) { - data = data.slice(0, data.length - 1); - } - - // Parse and handle the JSON message - let str = data.toString('utf8'); - - // Remove non-printable characters to invalid JSON from devices - str = str.replace(/[\x00-\x09\x0B-\x0C\x0E-\x1F\x7F-\x9F]/g, ''); // eslint-disable-line - - this.debug('<- Message: `' + str + '`'); - try { - let object = JSON.parse(str); - - const p = this._promises[object.id]; - if(! p) return; - if(typeof object.result !== 'undefined') { - p.resolve(object.result); - } else { - p.reject(object.error); - } - } catch(ex) { - this.debug('<- Invalid JSON', ex); - } - } - } - - _ensureToken() { - if(! this.packet.needsHandshake) { - return Promise.resolve(true); - } - - if(this._hasFailedToken) { - return Promise.reject(new Error('Token could not be auto-discovered')); - } - - if(this._tokenPromise) { - this.debug('Using existing promise'); - return this._tokenPromise; - } - - this._tokenPromise = new Promise((resolve, reject) => { - this.debug('-> Handshake'); - this.packet.handshake(); - const data = this.packet.raw; - this.socket.send(data, 0, data.length, this.port, this.address, err => err && reject(err)); - this._tokenResolve = () => { - delete this._tokenPromise; - delete this._tokenResolve; - clearTimeout(this._tokenTimeout); - delete this._tokenTimeout; - - if(this.packet.token) { - resolve(true); - } else { - this._hasFailedToken = true; - reject(new Error('Token could not be auto-discovered')); - } - }; - - // Reject in 1 second - this._tokenTimeout = setTimeout(() => { - delete this._tokenPromise; - delete this._tokenResolve; - delete this._tokenTimeout; - - const err = new Error('Handshake timeout'); - err.code = 'timeout'; - reject(err); - }, 1500); - }); - return this._tokenPromise; - } - + /** + * Public API: Call a miio method. + * + * @param {*} method + * @param {*} args + */ miioCall(method, args) { return this.call(method, args); } + /** + * Call a raw method on the device. + * + * @param {*} method + * @param {*} args + * @param {*} options + */ call(method, args, options) { - if(typeof args === 'undefined') { - args = []; - } - - const id = this._id = this._id === 10000 ? 1 : this._id + 1; - const request = { - id: id, - method: method, - params: args - }; - - if(options && options.sid) { - // If we have a sub-device set it (used by Lumi Smart Home Gateway) - request.sid = options.sid; - } - - const json = JSON.stringify(request); - - return new Promise((resolve, reject) => { - let resolved = false; - const promise = this._promises[id] = { - resolve: res => { - resolved = true; - delete this._promises[id]; - - if(options && options.refresh) { - // Special case for loading properties after setting values - // - delay a bit to make sure the device has time to respond - - setTimeout(() => { - const properties = Array.isArray(options.refresh) ? options.refresh : this._propertiesToMonitor; - - this._loadProperties(properties) - .then(() => resolve(res)) - .catch(() => resolve(res)); - - }, (options && options.refreshDelay) || 50); - } else { - resolve(res); - } - }, - reject: err => { - resolved = true; - delete this._promises[id]; - - if(! (err instanceof Error) && typeof err.code !== 'undefined') { - const code = err.code; - - const handler = ERRORS[code]; - let msg; - if(handler) { - msg = handler(method, args, err.message); - } else { - msg = err.message || err.toString(); - } - - err = new Error(msg); - err.code = code; - } - reject(err); - } - }; - - let retriesLeft = (options && options.retries) || 5; - const retry = () => { - if(retriesLeft-- > 0) { - send(); + return this.handle.api.call(method, args, options) + .then(res => { + if(options && options.refresh) { + // Special case for loading properties after setting values + // - delay a bit to make sure the device has time to respond + return new Promise(resolve => setTimeout(() => { + const properties = Array.isArray(options.refresh) ? options.refresh : this._propertiesToMonitor; + + this._loadProperties(properties) + .then(() => resolve(res)) + .catch(() => resolve(res)); + + }, (options && options.refreshDelay) || 50)); } else { - const err = new Error('Call to device timed out'); - err.code = 'timeout'; - promise.reject(err); + return res; } - }; - - const send = () => { - if(resolved) return; - - this._ensureToken() - .catch(err => { - if(err.code === 'timeout') { - this.debug('<- Handshake timed out'); - retry(); - return false; - } else { - throw err; - } - }) - .then(token => { - // Token has timed out - handled via retry - if(! token) return; - - this.debug('-> (' + retriesLeft + ')', json); - this.packet.data = Buffer.from(json, 'utf8'); - - const data = this.packet.raw; - - this.socket.send(data, 0, data.length, this.port, this.address, err => err && promise.reject(err)); - - // Queue a retry in 2 seconds - setTimeout(retry, 2000); - }) - .catch(promise.reject); - }; - - send(); - }); + }); } /** @@ -322,20 +127,12 @@ module.exports = Thing.type(BaseAppliance => class MiioAppliance extends BaseApp } /** - * Indicate that you want to monitor defined properties. + * Map and add a property to an object. + * + * @param {object} result + * @param {string} name + * @param {*} value */ - monitor(interval) { - if(this._propertiesToMonitor.length === 0 || this.writeOnly) { - // No properties or write only, resolve without doing anything - return Promise.resolve(); - } - - clearInterval(this._propertyMonitor); - - this._propertyMonitor = setInterval(this._loadProperties, interval || this._monitorInterval || 30000); - return this._loadProperties(); - } - _pushProperty(result, name, value) { const def = this._propertyDefinitions[name]; if(! def) { @@ -347,22 +144,23 @@ module.exports = Thing.type(BaseAppliance => class MiioAppliance extends BaseApp } } + poll(isInitial) { + // Polling involves simply calling load properties + return this._loadProperties(); + } + _loadProperties(properties) { if(typeof properties === 'undefined') { properties = this._propertiesToMonitor; } - if(properties.length === 0 || this.writeOnly) return Promise.resolve(); + if(properties.length === 0) return Promise.resolve(); return this.loadProperties(properties) .then(values => { Object.keys(values).forEach(key => { this.setProperty(key, values[key]); }); - }) - .catch(err => { - this.debug('Unable to load properties', err && err.stack ? err.stack : err); - throw err; }); } @@ -395,10 +193,6 @@ module.exports = Thing.type(BaseAppliance => class MiioAppliance extends BaseApp } } - stopMonitoring() { - clearInterval(this._propertyMonitor); - } - property(key) { return this._properties[key]; } @@ -407,10 +201,18 @@ module.exports = Thing.type(BaseAppliance => class MiioAppliance extends BaseApp return Object.assign({}, this._properties); } + /** + * Public API to get properties defined by the device. + */ miioProperties() { return this.properties; } + /** + * Get several properties at once. + * + * @param {array} props + */ getProperties(props) { const result = {}; props.forEach(key => { @@ -419,6 +221,11 @@ module.exports = Thing.type(BaseAppliance => class MiioAppliance extends BaseApp return result; } + /** + * Load properties from the device. + * + * @param {*} props + */ loadProperties(props) { // Rewrite property names to device internal ones props = props.map(key => this._reversePropertyDefinitions[key] || key); @@ -435,21 +242,16 @@ module.exports = Thing.type(BaseAppliance => class MiioAppliance extends BaseApp } /** - * Destroy this instance, this instance will no longer be able to - * communicate with the remote device it represents. + * Callback for performing destroy tasks for this device. */ destroyCallback() { return super.destroyCallback() .then(() => { - this.stopMonitoring(); - this.socket.close(); + // Release the reference to the network + this.handle.ref.release(); }); } - _fastDestroy() { - this.socket.close(); - } - /** * Check that the current result is equal to the string `ok`. */ diff --git a/lib/devices/gateway/developer-api.js b/lib/devices/gateway/developer-api.js index 622e527..2093a1d 100644 --- a/lib/devices/gateway/developer-api.js +++ b/lib/devices/gateway/developer-api.js @@ -42,7 +42,7 @@ module.exports = class DeveloperApi extends EventEmitter { } destroy() { - this.socket.destroy(); + this.socket.close(); } send(data) { diff --git a/lib/discovery.js b/lib/discovery.js index 8ff1a6c..d50e6de 100644 --- a/lib/discovery.js +++ b/lib/discovery.js @@ -1,15 +1,16 @@ 'use strict'; -const { BasicDiscovery, TimedDiscovery, search, addService, removeService } = require('tinkerhub-discovery'); +const { TimedDiscovery, BasicDiscovery, search, addService, removeService } = require('tinkerhub-discovery'); const { Children } = require('abstract-things'); +const { ThingDiscovery } = require('abstract-things/discovery'); const util = require('util'); const dgram = require('dgram'); const dns = require('dns'); +const network = require('./network'); const infoFromHostname = require('./infoFromHostname'); const Packet = require('./packet'); -const Tokens = require('./tokens'); const connectToDevice = require('./connectToDevice'); @@ -28,12 +29,12 @@ const Browser = module.exports.Browser = class Browser extends TimedDiscovery { }); if(typeof options.useTokenStorage !== 'undefined' ? options.useTokenStorage : true) { - this.tokens = new Tokens(); + this.tokens = require('./tokens'); } this.manualTokens = options.tokens || {}; + this[tryAdd] = this[tryAdd].bind(this); - this._packet = new Packet(); this.start(); } @@ -42,40 +43,8 @@ const Browser = module.exports.Browser = class Browser extends TimedDiscovery { } start() { - this._socket = dgram.createSocket('udp4'); - this._socket.bind(); - this._socket.on('listening', () => this._socket.setBroadcast(true)); - this._socket.on('message', (msg, rinfo) => { - const buf = Buffer.from(msg); - this._packet.raw = buf; - let token = this._packet.checksum.toString('hex'); - if(token.match(/^[fF0]+$/)) { - token = null; - } - - const id = String(this._packet.deviceId); - if(! token && this.tokens) { - this.tokens.get(id) - .then(storedToken => { - this[tryAdd]({ - id: id, - address: rinfo.address, - port: rinfo.port, - token: storedToken || this._manualToken(id), - autoToken: false - }); - }); - } else { - // Token could be discovered or no token storage - this[tryAdd]({ - id: id, - address: rinfo.address, - port: rinfo.port, - token: token || this._manualToken(id), - autoToken: true - }); - } - }); + this.handle = network.ref(); + network.on('device', this[tryAdd]); super.start(); } @@ -83,21 +52,23 @@ const Browser = module.exports.Browser = class Browser extends TimedDiscovery { stop() { super.stop(); - this.socket.close(); + network.removeListener('device', this[tryAdd]); + this.handle.release(); } [search]() { - this._packet.handshake(); - const data = Buffer.from(this._packet.raw); - this._socket.send(data, 0, data.length, PORT, '255.255.255.255'); - - // Broadcast an extra time in 500 milliseconds in case the first brodcast misses a few devices - setTimeout(() => { - this._socket.send(data, 0, data.length, PORT, '255.255.255.255'); - }, 500); + network.search(); } - [tryAdd](service) { + [tryAdd](device) { + const service = { + id: device.id, + address: device.address, + port: device.port, + token: device.token || this._manualToken(id), + autoToken: device.autoToken + }; + const add = () => this[addService](service); // Give us five seconds to try resolve some extras for new devices @@ -138,7 +109,11 @@ class Devices extends BasicDiscovery { this._browser = new Browser(options) .map(reg => { - return connectToDevice(reg) + return connectToDevice({ + address: reg.address, + port: reg.port, + token: reg.token + }) .then(device => { reg.device = device; return reg; diff --git a/lib/index.js b/lib/index.js index 3b84fed..e9b80db 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3,7 +3,10 @@ const discovery = require('./discovery'); const Device = require('./device'); -module.exports.Device = Device; +/** + * Get information about the models supported. Can be used to extend the models + * supported. + */ module.exports.models = require('./models'); /** @@ -16,18 +19,6 @@ module.exports.models = require('./models'); */ module.exports.device = require('./connectToDevice'); -/** - * Create a device from the given options. This will either create a - * specific device if the type is known, or a generic device if it is unknown. - * - * Options: - * * `address`, **required** the address to the device as an IP or hostname - * * `port`, optional port number, if not specified the default 54391 will be used - * * `model`, optional model if known, allows a more specific type to be returned - * * `token`, optional token of the device - */ -module.exports.createDevice = require('./createDevice'); - /** * Extract information about a device from its hostname on the local network. */ diff --git a/lib/network.js b/lib/network.js new file mode 100644 index 0000000..a24718c --- /dev/null +++ b/lib/network.js @@ -0,0 +1,499 @@ +'use strict'; + +const EventEmitter = require('events'); +const dgram = require('dgram'); + +const debug = require('debug'); + +const Packet = require('./packet'); +const tokens = require('./tokens'); + +const PORT = 54321; + +/** + * Class for keeping track of the current network of devices. This is used to + * track a few things: + * + * 1) Mapping between adresses and device identifiers. Used when connecting to + * a device directly via IP or hostname. + * + * 2) Mapping between id and detailed device info such as the model. + * + */ +class Network extends EventEmitter { + constructor() { + super(); + + this.packet = new Packet(true); + + this.addresses = new Map(); + this.devices = new Map(); + + this.references = 0; + this.debug = debug('miio:network'); + } + + search() { + this.packet.handshake(); + const data = Buffer.from(this.packet.raw); + this.socket.send(data, 0, data.length, PORT, '255.255.255.255'); + + // Broadcast an extra time in 500 milliseconds in case the first brodcast misses a few devices + setTimeout(() => { + this.socket.send(data, 0, data.length, PORT, '255.255.255.255'); + }, 500); + } + + findDevice(id, rinfo) { + // First step, check if we know about the device based on id + let device = this.devices.get(id); + if(! device && rinfo) { + // If we have info about the address, try to resolve again + device = this.addresses.get(rinfo.address); + + if(! device) { + // No device found, keep track of this one + device = new DeviceInfo(this, id, rinfo.address, rinfo.port); + this.devices.set(id, device); + this.addresses.set(rinfo.address, device); + + return device; + } + } + + return device; + } + + findDeviceViaAddress(options) { + if(! this.socket) { + throw new Error('Implementation issue: Using network without a reference'); + } + + let device = this.addresses.get(options.address); + if(! device) { + // No device was found at the address, try to discover it + device = new DeviceInfo(this, null, options.address, options.port || PORT); + this.addresses.set(options.address, device); + } + + // Update the token if we have one + if(typeof options.token === 'string') { + device.token = Buffer.from(options.token, 'hex'); + } else if(options.token instanceof Buffer) { + device.token = options.token; + } + + // Perform a handshake with the device to see if we can connect + return device.handshake() + .catch(err => { + if(err.code === 'missing-token') { + // Supress missing tokens - enrich should take care of that + return; + } + + throw err; + }) + .then(() => { + if(! this.devices.has(device.id)) { + // This is a new device, keep track of it + this.devices.set(device.id, device); + + return device; + } else { + // Sanity, make sure that the device in the map is returned + return this.devices.get(device.id); + } + }) + .then(device => { + /* + * After the handshake, call enrich which will fetch extra + * information such as the model. It will also try to check + * if the provided token (or the auto-token) works correctly. + */ + return device.enrich(); + }) + .then(() => device); + } + + createSocket() { + this._socket = dgram.createSocket('udp4'); + + // Bind the socket and when it is ready mark it for broadcasting + this._socket.bind(); + this._socket.on('listening', () => this._socket.setBroadcast(true)); + + // On any incoming message, parse it, update the discovery + this._socket.on('message', (msg, rinfo) => { + const buf = Buffer.from(msg); + try { + this.packet.raw = buf; + } catch(ex) { + this.debug('Could not handle incoming message'); + return; + } + + if(! this.packet.deviceId) { + this.debug('No device identifier in incoming packet'); + return; + } + + const device = this.findDevice(this.packet.deviceId, rinfo); + device.onMessage(buf); + + if(! this.packet.data) { + if(! device.enriched) { + // This is the first time we see this device + device.enrich() + .then(() => { + this.emit('device', device); + }) + .catch(err => { + this.emit('device', device); + }); + } else { + this.emit('device', device); + } + } + }); + } + + list() { + return this.devices.values(); + } + + /** + * Get a reference to the network. Helps with locking of a socket. + */ + ref() { + this.debug('Grabbing reference to network'); + this.references++; + this.updateSocket(); + + let released = false; + let self = this; + return { + release() { + if(released) return; + + self.debug('Releasing reference to network'); + + released = true; + self.references--; + + self.updateSocket(); + } + }; + } + + /** + * Update wether the socket is available or not. Instead of always keeping + * a socket we track if it is available to allow Node to exit if no + * discovery or device is being used. + */ + updateSocket() { + if(this.references === 0) { + // No more references, kill the socket + if(this._socket) { + this.debug('Network no longer active, destroying socket'); + this._socket.close(); + this._socket = null; + } + } else if(this.references === 1 && ! this._socket) { + // This is the first reference, create the socket + this.debug('Making network active, creating socket'); + this.createSocket(); + } + } + + get socket() { + if(! this._socket) { + throw new Error('Network communication is unavailable, device might be destroyed'); + } + + return this._socket; + } +} + +module.exports = new Network(); + +class DeviceInfo { + constructor(parent, id, address, port) { + this.parent = parent; + this.packet = new Packet(); + + this.address = address; + this.port = port; + + // Tracker for all promises associated with this device + this.promises = new Map(); + this.lastId = 0; + + this.id = id; + this.debug = id ? debug('thing:miio:' + id) : debug('thing:miio:pending'); + + // Get if the token has been manually changed + this.tokenChanged = false; + } + + get token() { + return this.packet.token; + } + + set token(t) { + this.packet.token = t; + this.tokenChanged = true; + } + + /** + * Enrich this device with detailed information about the model. This will + * simply call miIO.info. + */ + enrich() { + if(! this.id) { + throw new Error('Device has no identifier yet, handshake needed'); + } + + if(this.model && ! this.tokenChanged && this.packet.token) { + // This device has model info and a valid token + return Promise.resolve(); + } + + if(this.enrichPromise) { + // If enrichment is already happening + return this.enrichPromise; + } + + // Check if there is a token available, otherwise try to resolve it + let promise; + if(! this.packet.token) { + // No automatic token found - see if we have a stored one + this.autoToken = false; + promise = tokens.get(this.id) + .then(token => this.token = Buffer.from(token, 'hex')); + } else { + this.autoToken = true; + promise = Promise.resolve(); + } + + return this.enrichPromise = promise + .then(() => this.call('miIO.info')) + .then(data => { + this.enriched = true; + this.model = data.model; + this.tokenChanged = false; + + this.enrichPromise = null; + }) + .catch(err => { + this.enrichPromise = null; + this.enriched = true; + + if(err.code === 'missing-token') { + // Rethrow some errors + throw err; + } + + // Could not call the info method, this might be either a timeout or a token problem + const e = new Error('Could not connect to device, token might be wrong'); + e.code = 'connection-failure'; + throw e; + }); + } + + onMessage(msg) { + try { + this.packet.raw = msg; + } catch(ex) { + this.debug('<- Unable to parse packet', ex); + return; + } + + let data = this.packet.data; + if(data == null) { + this.debug('<-', 'Handshake reply:', this.packet.checksum); + this.packet.handleHandshakeReply(); + + if(this.handshakeResolve) { + this.handshakeResolve(); + } + } else { + // Handle null-terminated strings + if(data[data.length - 1] === 0) { + data = data.slice(0, data.length - 1); + } + + // Parse and handle the JSON message + let str = data.toString('utf8'); + + // Remove non-printable characters to help with invalid JSON from devices + str = str.replace(/[\x00-\x09\x0B-\x0C\x0E-\x1F\x7F-\x9F]/g, ''); // eslint-disable-line + + this.debug('<- Message: `' + str + '`'); + try { + let object = JSON.parse(str); + + const p = this.promises.get(object.id); + if(! p) return; + if(typeof object.result !== 'undefined') { + p.resolve(object.result); + } else { + p.reject(object.error); + } + } catch(ex) { + this.debug('<- Invalid JSON', ex); + } + } + } + + handshake() { + if(! this.packet.needsHandshake) { + return Promise.resolve(this.token); + } + + // If a handshake is already in progress use it + if(this.handshakePromise) { + return this.handshakePromise; + } + + return this.handshakePromise = new Promise((resolve, reject) => { + // Create and send the handshake data + this.packet.handshake(); + const data = this.packet.raw; + this.parent.socket.send(data, 0, data.length, this.port, this.address, err => err && reject(err)); + + // Handler called when a reply to the handshake is received + this.handshakeResolve = () => { + clearTimeout(this.handshakeTimeout); + this.handshakeResolve = null; + this.handshakeTimeout = null; + this.handshakePromise = null; + + if(this.id != this.packet.deviceId) { + // Update the identifier if needed + this.id = this.packet.deviceId; + this.debug = debug('thing:miio:' + this.id); + this.debug('Identifier of device updated'); + } + + if(this.packet.token) { + resolve(); + } else { + const err = new Error('Could not connect to device, token needs to be specified'); + err.code = 'missing-token'; + reject(err); + } + }; + + // Timeout for the handshake + this.handshakeTimeout = setTimeout(() => { + this.handshakeResolve = null; + this.handshakeTimeout = null; + this.handshakePromise = null; + + const err = new Error('Could not connect to device, handshake timeout'); + err.code = 'timeout'; + reject(err); + }, 2000); + }); + } + + call(method, args, options) { + if(typeof args === 'undefined') { + args = []; + } + + const id = this.lastId = this.lastId === 10000 ? 1 : this.lastId + 1; + const request = { + id: id, + method: method, + params: args + }; + + if(options && options.sid) { + // If we have a sub-device set it (used by Lumi Smart Home Gateway) + request.sid = options.sid; + } + + const json = JSON.stringify(request); + + return new Promise((resolve, reject) => { + let resolved = false; + + // Handler for incoming messages + const promise = { + resolve: res => { + resolved = true; + this.promises.delete(id); + + resolve(res); + }, + reject: err => { + resolved = true; + this.promises.delete(id); + + if(! (err instanceof Error) && typeof err.code !== 'undefined') { + const code = err.code; + + const handler = ERRORS[code]; + let msg; + if(handler) { + msg = handler(method, args, err.message); + } else { + msg = err.message || err.toString(); + } + + err = new Error(msg); + err.code = code; + } + reject(err); + } + }; + + // Keep track of the promise info + this.promises.set(id, promise); + + let retriesLeft = (options && options.retries) || 5; + const retry = () => { + if(retriesLeft-- > 0) { + send(); + } else { + const err = new Error('Call to device timed out'); + err.code = 'timeout'; + promise.reject(err); + } + }; + + const send = () => { + if(resolved) return; + + this.handshake() + .catch(err => { + if(err.code === 'timeout') { + this.debug('<- Handshake timed out'); + retry(); + return false; + } else { + throw err; + } + }) + .then(token => { + // Token has timed out - handled via retry + if(! token) return; + + this.debug('-> (' + retriesLeft + ')', json); + this.packet.data = Buffer.from(json, 'utf8'); + + const data = this.packet.raw; + + this.parent.socket.send(data, 0, data.length, this.port, this.address, err => err && promise.reject(err)); + + // Queue a retry in 2 seconds + setTimeout(retry, 2000); + }) + .catch(promise.reject); + }; + + send(); + }); + } +} diff --git a/lib/packet.js b/lib/packet.js index 62cbd6a..54d420b 100644 --- a/lib/packet.js +++ b/lib/packet.js @@ -1,10 +1,12 @@ 'use strict'; const crypto = require('crypto'); -const debug = require('debug')('miio.packet'); +const debug = require('debug')('miio:packet'); class Packet { - constructor() { + constructor(discovery = false) { + this.discovery = discovery; + this.header = Buffer.alloc(2 + 2 + 4 + 4 + 4 + 16); this.header[0] = 0x21; this.header[1] = 0x31; @@ -104,32 +106,38 @@ class Packet { const encrypted = msg.slice(32); - if(encrypted.length > 0) { - if(! this._token) { - debug('<- No token set, unable to handle packet'); - this.data = null; - return; - } - - const digest = crypto.createHash('md5') - .update(this.header.slice(0, 16)) - .update(this._token) - .update(encrypted) - .digest(); - - const checksum = this.checksum; - if(! checksum.equals(digest)) { - debug('<- Invalid packet, checksum was', checksum, 'should be', digest); - this.data = null; + if(this.discovery) { + // This packet is only intended to be used for discovery + this.data = encrypted.length > 0; + } else { + // Normal packet, decrypt data + if(encrypted.length > 0) { + if(! this._token) { + debug('<- No token set, unable to handle packet'); + this.data = null; + return; + } + + const digest = crypto.createHash('md5') + .update(this.header.slice(0, 16)) + .update(this._token) + .update(encrypted) + .digest(); + + const checksum = this.checksum; + if(! checksum.equals(digest)) { + debug('<- Invalid packet, checksum was', checksum, 'should be', digest); + this.data = null; + } else { + let decipher = crypto.createDecipheriv('aes-128-cbc', this._tokenKey, this._tokenIV); + this.data = Buffer.concat([ + decipher.update(encrypted), + decipher.final() + ]); + } } else { - let decipher = crypto.createDecipheriv('aes-128-cbc', this._tokenKey, this._tokenIV); - this.data = Buffer.concat([ - decipher.update(encrypted), - decipher.final() - ]); + this.data = null; } - } else { - this.data = null; } } diff --git a/lib/tokens.js b/lib/tokens.js index ee3799b..02cb200 100644 --- a/lib/tokens.js +++ b/lib/tokens.js @@ -126,4 +126,4 @@ class Tokens { } } -module.exports = Tokens; +module.exports = new Tokens();