UNPKG

miio

Version:

Control Mi Home devices, such as Mi Robot Vacuums, Mi Air Purifiers, Mi Smart Home Gateway (Aqara) and more

559 lines (465 loc) 13.9 kB
'use strict'; const EventEmitter = require('events'); const dgram = require('dgram'); const debug = require('debug'); const Packet = require('./packet'); const tokens = require('./tokens'); const safeishJSON = require('./safeishJSON'); const PORT = 54321; const ERRORS = { '-5001': (method, args, err) => err.message === 'invalid_arg' ? 'Invalid argument' : err.message, '-5005': (method, args, err) => err.message === 'params error' ? 'Invalid argument' : err.message, '-10000': (method) => 'Method `' + method + '` is not supported' }; /** * 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; } // Set the model if provided if(! device.model && options.model) { device.model = options.model; } // 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); const address = this._socket.address(); this.debug('Network bound to port', address.port); }); // 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.debug('Using manual token:', t.toString('hex')); 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.debug('Loading token from storage, device hides token and no token set via options'); this.autoToken = false; promise = tokens.get(this.id) .then(token => this.token = Buffer.from(token, 'hex')); } else { if(this.tokenChanged) { this.autoToken = false; } else { this.autoToken = true; this.debug('Using automatic token:', this.packet.token.toString('hex')); } 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; } if(this.packet.token) { // 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'; e.device = this; throw e; } else { const e = new Error('Could not connect to device, token needs to be specified'); e.code = 'missing-token'; e.device = this; 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 = safeishJSON(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 request = { 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; } return new Promise((resolve, reject) => { let resolved = false; // Handler for incoming messages const promise = { resolve: res => { resolved = true; this.promises.delete(request.id); resolve(res); }, reject: err => { resolved = true; this.promises.delete(request.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(); } 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; // Assign the identifier before each send let id; if(request.id) { /* * This is a failure, increase the last id. Should * increase the chances of the new request to * succeed. Related to issues with the vacuum * not responding such as described in issue #94. */ id = this.lastId + 100; // Make sure to remove the failed promise this.promises.delete(request.id); } else { id = this.lastId + 1; } // Check that the id hasn't rolled over if(id >= 10000) { this.lastId = id = 1; } else { this.lastId = id; } // Assign the identifier request.id = id; // Store reference to the promise so reply can be received this.promises.set(id, promise); // Create the JSON and send it const json = JSON.stringify(request); 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(); }); } }