From d7789b58c06d9d8d682f88a12e769a763b6f2d98 Mon Sep 17 00:00:00 2001 From: Andreas Holstenson Date: Thu, 27 Apr 2017 11:35:05 +0200 Subject: [PATCH] Adding `miio.devices` to discover and connect to devices and sub-devices --- README.md | 53 +++++++++++++++-- cli/index.js | 15 +++-- lib/connectToDevice.js | 28 +++++++++ lib/createDevice.js | 18 ++++++ lib/discovery.js | 127 ++++++++++++++++++++++++++++++++++++++++- lib/index.js | 56 ++++++------------ 6 files changed, 245 insertions(+), 52 deletions(-) create mode 100644 lib/connectToDevice.js create mode 100644 lib/createDevice.js diff --git a/README.md b/README.md index b501827..accdac3 100644 --- a/README.md +++ b/README.md @@ -103,15 +103,56 @@ and common use cases. ## Discovering devices -Use `miio.browser()` to look for devices on the local network. This method of -discovery will tell you directly if a device reveals its token and can be -auto-connected to. It will not tell you the model of devices until they are -connected to via `miio.device()`. +Use `miio.devices()` to look for and connect to devices on the local network. +This method of discovery will tell you directly if a device reveals its token +and can be auto-connected to. If you do not want to automatically connect to +devices you can use `miio.browse()` instead. -Example: +Example using `miio.devices()`: ```javascript -const browser = miio.browser({ +const devices = miio.devices({ + cacheTime: 300 // 5 minutes. Default is 1800 seconds (30 minutes) +}); + +browser.on('available', reg => { + if(! reg.token) { + console.log(reg.id, 'hides its token'); + return; + } + + const device = reg.device; + if(! device) { + console.log(reg.id, 'could not be connected to'); + return; + } + + // Do something useful with the device +}); + +browser.on('unavailable', reg => { + if(! reg.device) return; + + // Do whatever you need here +}); + +browser.on('error', err => { + // err.device points to info about the device + console.log('Something went wrong connecting to device', err); +}); +``` + +`miio.devices()` supports these options: + +* `cacheTime`, the maximum amount of seconds a device can be unreachable before it becomes unavailable. Default: `1800` +* `useTokenStorage`, if tokens should be fetched from storage (see device management). Default: `true` +* `filter`, function used to filter what devices are connected to. Default: `reg => true` +* `skipSubDevices`, if sub devices on Aqara gateways should be skipped. Default: `false` + +Example using `miio.browse()`: + +```javascript +const browser = miio.browse({ cacheTime: 300 // 5 minutes. Default is 1800 seconds (30 minutes) }); diff --git a/cli/index.js b/cli/index.js index 4c21f5f..5c08cad 100755 --- a/cli/index.js +++ b/cli/index.js @@ -7,7 +7,7 @@ const chalk = require('chalk'); const Packet = require('../lib/packet'); const Device = require('../lib/device'); -const { Browser } = require('../lib/discovery'); +const { Browser, Devices } = require('../lib/discovery'); const Tokens = require('../lib/tokens'); const models = require('../lib/models'); @@ -32,7 +32,7 @@ function log() { if(args.discover) { info('Discovering devices. Press Ctrl+C to stop.') log(); - const browser = new Browser({ + const browser = new Devices({ cacheTime: 60, useTokenStorage: true }); @@ -40,11 +40,18 @@ if(args.discover) { const supported = reg.model && reg.type; log(chalk.bold('Device ID:'), reg.id); log(chalk.bold('Model info:'), reg.model || 'Unknown', reg.type ? chalk.dim('(' + reg.type + ')') : ''); - log(chalk.bold('Address:'), reg.address, (reg.hostname ? chalk.dim('(' + reg.hostname + ')') : '')); + + if(reg.address) { + log(chalk.bold('Address:'), reg.address, (reg.hostname ? chalk.dim('(' + reg.hostname + ')') : '')); + } else if(reg.parent) { + log(chalk.bold('Address:'), 'Owned by', reg.parent.id); + } if(reg.token) { log(chalk.bold('Token:'), reg.token, reg.autoToken ? chalk.green('via auto-token') : chalk.yellow('via stored token')); - } else { + } else if(! reg.parent) { log(chalk.bold('Token:'), '???') + } else { + log(chalk.bold('Token:'), chalk.green('Automatic via parent device')); } log(chalk.bold('Support:'), reg.model ? (supported ? chalk.green('At least basic') : chalk.yellow('Generic')) : chalk.yellow('Unknown')); log(); diff --git a/lib/connectToDevice.js b/lib/connectToDevice.js new file mode 100644 index 0000000..fc23d75 --- /dev/null +++ b/lib/connectToDevice.js @@ -0,0 +1,28 @@ +'use strict'; + +const createDevice = require('./createDevice'); + +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.destroy(); + device = createDevice(Object.assign({}, options, { + model: data.model, + token: data.token + })); + } + }) + .then(() => { + return device.init() + .then(() => device); + }) + .catch(err => { + device.destroy(); + throw err; + }); +}; diff --git a/lib/createDevice.js b/lib/createDevice.js new file mode 100644 index 0000000..ca1e3c6 --- /dev/null +++ b/lib/createDevice.js @@ -0,0 +1,18 @@ +'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/discovery.js b/lib/discovery.js index 13d6ed2..7749af2 100644 --- a/lib/discovery.js +++ b/lib/discovery.js @@ -9,6 +9,8 @@ const infoFromHostname = require('./infoFromHostname'); const Packet = require('./packet'); const Tokens = require('./tokens'); +const connectToDevice = require('./connectToDevice'); + const PORT = 54321; class Browser { @@ -30,8 +32,8 @@ class Browser { this._events.on(event, cb); } - off(event, cb) { - this._events.off(event, cb); + removeListener(event, cb) { + this._events.removeListener(event, cb); } start() { @@ -162,4 +164,125 @@ class Browser { } } +class Devices { + constructor(options) { + this._events = new EventEmitter(); + + this._filter = options && options.filter; + this._skipSubDevices = options && options.skipSubDevices; + this._devices = {}; + + this._browser = new Browser(options); + this._browser.on('available', this._serviceAvailable.bind(this)); + this._browser.on('unavailable', this._serviceUnavailable.bind(this)); + } + + on(event, cb) { + this._events.on(event, cb); + } + + removeListener(event, cb) { + this._events.removeListener(event, cb); + } + + start() { + this._browser.start(); + } + + stop() { + this._browser.stop(); + } + + _serviceAvailable(service) { + if(this._filter && ! this._filter(service)) { + // Filter does not match this device + return; + } + + let reg = this._devices[service.id]; + if(! reg) { + reg = this._devices[service.id] = Object.assign({ + device: null + }, service); + } + + // Return if we are already connecting to this device + if(reg.connectionPromise) return; + + if(reg.token) { + // This device has a token so it's possible to connect to it + reg.connectionPromise = connectToDevice(service) + .then(device => { + reg.device = device; + this._events.emit('available', reg); + + if(device.type === 'gateway') { + this._bindSubDevices(device); + } + }) + .catch(err => { + reg.error = err; + this._events.emit('available', reg); + + err.device = service; + this._events.emit('error', err); + }) + .then(() => { + delete reg.connectionPromise; + }); + } else { + // There is no token so emit even directly + this._events.emit('available', reg); + } + } + + _serviceUnavailable(service) { + const reg = this._devices[service.id]; + if(! reg) return; + + if(reg.device) { + reg.device.destroy(); + } + delete this._devices[service.id]; + this._events.emit('unavailable', reg); + + Object.keys(this._devices).forEach(key => { + const subReg = this._devices[key]; + if(subReg.parent && subReg.parent.id == service.id) { + // This device belongs to the service being removed + delete this._devices[key]; + subReg.device.destroy(); + this._events.emit('unavailable', subReg); + } + }); + } + + _bindSubDevices(device) { + if(this._skipSubDevices) return; + + device.on('deviceAvailable', sub => { + const reg = { + id: sub.id, + model: sub.model, + type: sub.type, + + parent: device, + device: sub + }; + + if(this._filter && ! this._filter(reg)) { + // Filter does not match sub device + return; + } + + // Register and emit event + this._devices[sub.id] = reg; + this._events.emit('available', reg); + }); + + device.on('deviceUnavailable', sub => this._serviceUnavailable(sub)); + } +} + module.exports.Browser = Browser; +module.exports.Devices = Devices; diff --git a/lib/index.js b/lib/index.js index 3f6a8cf..3b84fed 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4,7 +4,7 @@ const discovery = require('./discovery'); const Device = require('./device'); module.exports.Device = Device; -const models = module.exports.models = require('./models'); +module.exports.models = require('./models'); /** * Resolve a device from the given options. @@ -14,31 +14,7 @@ const models = module.exports.models = require('./models'); * * `port`, optional port number, if not specified the default 54321 will be used * * `token`, optional token of the device */ -module.exports.device = function(options) { - if(! options.address) throw new Error('Address to device is required'); - - // Create a temporary device and fetch info from device - const temp = new Device(options); - return temp.call('miIO.info') - .then(data => { - temp.destroy(); - return createDevice({ - id: options.id, - address: options.address, - port: options.port, - model: data.model, - token: data.token - }); - }) - .then(device => { - return device.init() - .then(() => device); - }) - .catch(err => { - temp.destroy(); - throw err; - }); -}; +module.exports.device = require('./connectToDevice'); /** * Create a device from the given options. This will either create a @@ -50,25 +26,25 @@ module.exports.device = function(options) { * * `model`, optional model if known, allows a more specific type to be returned * * `token`, optional token of the device */ -const createDevice = module.exports.createDevice = 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; -}; +module.exports.createDevice = require('./createDevice'); /** * Extract information about a device from its hostname on the local network. */ -module.exports.infoFromHostname = require('./infoFromHostname.js'); +module.exports.infoFromHostname = require('./infoFromHostname'); +/** + * Browse for devices available on the network. Will not automatically + * connect to them. + */ module.exports.browse = function(options) { return new discovery.Browser(options || {}); }; + +/** + * Get access to all devices on the current network. Will find and connect to + * devices automatically. + */ +module.exports.devices = function(options) { + return new discovery.Devices(options || {}); +};