node-miio
Version:
Control Mi Home devices, such as Mi Robot Vacuums, Mi Air Purifiers, Mi Smart Home Gateway (Aqara) and more
330 lines (288 loc) • 8.98 kB
JavaScript
'use strict';
const debug = require('debug');
const Packet = require('./packet');
const safeishJSON = require('./safeishJSON');
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 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.
*/
async 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.packet.token) {
if (this.tokenChanged) {
this.autoToken = false;
} else {
this.autoToken = true;
this.debug('Using automatic token:', this.packet.token.toString('hex'));
}
}
if (!this.enrichPromise) {
this.enrichPromise = this.call('miIO.info');
}
try {
const { model } = await this.enrichPromise;
this.model = model;
this.tokenChanged = false;
} catch (err) {
if (err.code === 'missing-token') {
err.device = this;
throw err;
} else 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;
}
} finally {
this.enriched = true;
this.enrichPromise = null;
}
}
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
// eslint-disable-next-line no-control-regex
str = str.replace(/[\x00-\x09\x0B-\x0C\x0E-\x1F\x7F-\x9F]/g, '');
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);
}
}
}
async handshake() {
if (!this.packet.needsHandshake) {
return Promise.resolve(this.token);
}
// If a handshake is already in progress use it
if (!this.handshakePromise) {
this.handshakePromise = this._sendHandshakePackage();
}
try {
return await Promise.race([this.handshakePromise, this._setTimeout()]);
} catch (err) {
if (err.code === 'timeout') {
this.debug('<- Handshake timed out');
}
throw err;
} finally {
this.handshakeResolve = null;
this.handshakePromise = null;
}
}
async _sendHandshakePackage() {
const waitForResponse = this._waitForHandshakeResponse();
// Create and send the handshake data
this.packet.handshake();
await this._sendPacket();
return await waitForResponse;
}
async _sendPacket() {
return await new Promise((resolve, reject) => {
const data = this.packet.raw;
this.parent.socket.send(
data,
0,
data.length,
this.port,
this.address,
(err) => (err ? reject(err) : resolve())
);
});
}
async _waitForHandshakeResponse() {
return await new Promise((resolve, reject) => {
// Handler called when a reply to the handshake is received
this.handshakeResolve = () => {
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(this.token);
} else {
const err = new Error(
'Could not connect to device, token needs to be specified'
);
err.code = 'missing-token';
reject(err);
}
};
});
}
async _setTimeout() {
await new Promise((resolve, reject) =>
setTimeout(() => {
const err = new Error('Could not connect to device, handshake timeout');
err.code = 'timeout';
reject(err);
}, 2000)
);
}
async call(method, params = [], options = {}) {
const { retries = 5 } = options;
return await this._retryOnTimeout(retries, async (retriesLeft) => {
await this.handshake(); // Ensure the handshake is done
const request = {
id: this._nextId(retries === retriesLeft), // Assign the identifier
method,
params,
sid: options.sid, // If we have a sub-device set it (used by Lumi Smart Home Gateway)
};
try {
// Enqueue promise listener to this request ID
const waitForResponse = this._waitForResponse(request.id);
// Create the JSON and send it
const json = JSON.stringify(request);
this.debug('-> (' + retriesLeft + ')', json);
this.packet.data = Buffer.from(json, 'utf8');
await this._sendPacket();
return await waitForResponse;
} catch (err) {
if (!(err instanceof Error) && typeof err.code !== 'undefined') {
const code = err.code;
const handler = ERRORS[code];
const msg = handler
? handler(method, params, err)
: err.message || err.toString();
const newErr = new Error(msg);
newErr.code = code;
throw newErr;
}
throw err;
} finally {
this.promises.delete(request.id);
}
});
}
async _waitForResponse(requestId) {
return new Promise((resolve, reject) => {
// Store reference to the promise so reply can be received
this.promises.set(requestId, { resolve, reject });
});
}
/**
* Retries the action defined in `actionPromiseFn` as many times as `retries`,
* only if the action fails due to a timeout.
* @param retries Max number of attempts
* @param actionPromiseFn Method that returns a promise to repeat
* @private
*/
async _retryOnTimeout(retries = 5, actionPromiseFn) {
while (retries > 0) {
try {
const result = await Promise.race([
actionPromiseFn(retries),
this._setTimeout(),
]);
return result;
} catch (err) {
if (err.code !== 'timeout') {
throw err;
}
retries--;
}
}
this.debug('Reached maximum number of retries, giving up');
const maxRetriesError = new Error('Call to device timed out');
maxRetriesError.code = 'timeout';
throw maxRetriesError;
}
_nextId(isFirstAttempt) {
let id;
if (isFirstAttempt) {
id = this.lastId + 1;
} else {
/*
* 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
* https://github.com/aholstenson/miio/issues/94.
*/
id = this.lastId + 100;
}
// Check that the id hasn't rolled over
if (id >= 10000) {
this.lastId = id = 1;
} else {
this.lastId = id;
}
return id;
}
}
module.exports = DeviceInfo;