homebridge-miot
Version:
Homebridge plugin for devices supporting the miot protocol
466 lines (386 loc) • 13 kB
JavaScript
const EventEmitter = require('events');
const crypto = require('crypto');
const dgram = require('dgram');
const PORT = 54321;
const HANDSHAKE_TIMEOUT = 5000;
const DEFAULT_TIMEOUT = 4000;
const DEFAULT_RETRIES = 2;
const RECOVERABLE_ERRORS = [-30001, -9999];
class MiioProtocol extends EventEmitter {
constructor(logger) {
super();
this.logger = logger;
this._devices = new Map();
this.init();
}
init() {
this.createSocket();
}
destroy() {
this.destroySocket();
}
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.logger.deepDebug(`(Protocol) Server listening ${address.address}:${address.port}`);
});
// On any incoming message, parse it, update the discovery
this._socket.on('message', (msg, rinfo) => {
this._onMessage(rinfo.address, msg);
});
this._socket.on('error', error => {
this.logger.debug(`(Protocol) Socket error: ${error}`);
});
}
destroySocket() {
if (this._socket) {
this._socket.close();
}
}
getDevices() {
return Array.from(this._devices.keys()).map(address => {
return {
address,
...this.getDevice(address),
};
});
}
hasDevice(address) {
return this._devices.has(address);
}
getDevice(address) {
if (!this._devices.has(address)) {
this._devices.set(address, {});
}
const device = this._devices.get(address);
if (!device._lastId) {
device._lastId = 0;
}
if (!device._promises) {
device._promises = new Map();
}
return device;
}
setDevice(address, data) {
const device = {
...data
};
if (device.token) {
device._token = Buffer.from(device.token, 'hex');
device._tokenKey = crypto.createHash('md5').update(device._token).digest();
device._tokenIV = crypto
.createHash('md5')
.update(device._tokenKey)
.update(device._token)
.digest();
}
this._devices.set(address, device);
}
updateDevice(address, data) {
const device = Object.assign(this.getDevice(address), data);
this.setDevice(address, device);
}
async getDeviceInfo(address) {
const device = this.getDevice(address);
if (device) {
return device.deviceInfo;
}
}
_onMessage(address, msg) {
try {
const data = this._decryptMessage(address, msg);
if (data === null) {
// handshake
this.logger.deepDebug(`(Protocol) ${address} -> Handshake reply`);
this._onHandshake(address);
} else {
this.logger.deepDebug(`(Protocol) ${address} -> Data: ${data}`);
this._onData(address, data);
}
} catch (err) {
this.logger.debug(`(Protocol) ${address} -> Unable to parse packet: ${err}`);
}
}
_decryptMessage(address, msg) {
const device = this.getDevice(address);
const deviceId = msg.readUInt32BE(8);
const stamp = msg.readUInt32BE(12);
const checksum = msg.slice(16, 32);
const encrypted = msg.slice(32);
// update stamps
if (stamp > 0) {
device._serverStamp = stamp;
device._serverStampTime = Date.now();
}
// handshake
if (encrypted.length === 0) {
// update the device id if for some reason different
if (deviceId !== device.did) {
device.did = deviceId;
}
if (!checksum.toString('hex').match(/^[fF0]+$/)) {
// const token = checksum;
}
// no data in a handshake
return null;
}
if (!device._token) {
throw new Error(`Missing token of device ${deviceId} - ${address}`);
}
const digest = crypto
.createHash('md5')
.update(msg.slice(0, 16))
.update(device._token)
.update(encrypted)
.digest();
if (!checksum.equals(digest)) {
throw new Error(`Invalid packet, checksum was ${checksum} should be ${digest}`);
}
const decipher = crypto.createDecipheriv('aes-128-cbc', device._tokenKey, device._tokenIV);
const data = Buffer.concat([
decipher.update(encrypted),
decipher.final()
]);
return data;
}
_encryptMessage(address, data) {
const device = this.getDevice(address);
if (!device._token || !device.did) {
throw new Error(`${address} <- Missing token or deviceId for send command`);
}
const header = Buffer.alloc(2 + 2 + 4 + 4 + 4 + 16);
header.writeInt16BE(0x2131);
// Encrypt the data
const cipher = crypto.createCipheriv('aes-128-cbc', device._tokenKey, device._tokenIV);
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
// Set the length
header.writeUInt16BE(32 + encrypted.length, 2);
// Unknown
header.writeUInt32BE(0x00000000, 4);
// Stamp
if (device._serverStampTime) {
const secondsPassed = Math.floor((Date.now() - device._serverStampTime) / 1000);
header.writeUInt32BE(device._serverStamp + secondsPassed, 12);
} else {
header.writeUInt32BE(0xffffffff, 12);
}
// Device ID
header.writeUInt32BE(Number(device.did), 8);
// MD5 Checksum
const digest = crypto
.createHash('md5')
.update(header.slice(0, 16))
.update(device._token)
.update(encrypted)
.digest();
digest.copy(header, 16);
this.logger.deepDebug(`(Protocol) ${address} <- ${header}`);
return Buffer.concat([header, encrypted]);
}
_onHandshake(address) {
const device = this.getDevice(address);
if (device._handshakeResolve) {
device._handshakeResolve();
}
}
_onData(address, msg) {
// Handle null-terminated strings
if (msg[msg.length - 1] === 0) {
msg = msg.slice(0, msg.length - 1);
}
// Parse and handle the JSON message
let str = msg.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.logger.deepDebug(`(Protocol) ${address} -> Message: ${str}`);
try {
const data = JSON.parse(str);
const device = this.getDevice(address);
device.lastExecTime = data.exe_time; // keep track how much did the execeution take
const p = device._promises.get(data.id);
if (!p) return;
if (typeof data.result !== 'undefined') {
p.resolve(data.result);
} else {
p.reject(data.error);
}
} catch (err) {
this.logger.debug(`(Protocol) ${address} -> Invalid JSON: ${err}`);
}
}
_socketSend(msg, address, port = PORT) {
return new Promise((resolve, reject) => {
this._socket.send(msg, 0, msg.length, port, address, err => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
_handshake(address) {
const msg = Buffer.from('21310020ffffffffffffffffffffffffffffffffffffffffffffffffffffffff', 'hex');
return this._socketSend(msg, address);
}
_send(address, json) {
const msg = this._encryptMessage(address, Buffer.from(JSON.stringify(json), 'utf8'));
return this._socketSend(msg, address);
}
async handshake(address) {
if (!address) {
throw new Error('Missing address for handshake');
}
this.logger.deepDebug(`(Protocol) Start handshake ${address}`);
const device = this.getDevice(address);
// Handshake if we never received a message or it has been longer then 120 seconds since last received message
const needsHandshake = !device._serverStampTime || (Date.now() - device._serverStampTime) > 120000;
if (!needsHandshake) {
return Promise.resolve();
}
// If a handshake is already in progress use it
if (device._handshakePromise) {
return device._handshakePromise;
}
device._handshakePromise = new Promise((resolve, reject) => {
this._handshake(address).catch(reject);
device._handshakeResolve = () => {
// eslint-disable-next-line no-shadow
clearTimeout(device._handshakeTimeout);
device._handshakeResolve = null;
device._handshakeTimeout = null;
device._handshakePromise = null;
resolve();
};
// Timeout for the handshake
device._handshakeTimeout = setTimeout(() => {
device._handshakeResolve = null;
device._handshakeTimeout = null;
device._handshakePromise = null;
const err = new Error('Could not connect to device, handshake timeout');
err.code = 'timeout';
reject(err);
}, HANDSHAKE_TIMEOUT);
});
return device._handshakePromise;
}
async send(address, method, params = [], options = {}) {
this.logger.deepDebug(`(Protocol) Call ${address}: ${method} - ${JSON.stringify(params)} - ${JSON.stringify(options)}`);
const request = {
method,
params,
};
const device = this.getDevice(address);
let requestTimeout = options.timeout && options.timeout >= 0 ? options.timeout : DEFAULT_TIMEOUT;
return new Promise((resolve, reject) => {
let resolved = false;
let retriesLeft = options.retries && options.retries >= 0 ? options.retries : DEFAULT_RETRIES;
// Handler for retries
const retry = () => {
if (resolved) return;
if (retriesLeft-- > 0) {
// eslint-disable-next-line no-use-before-define
send();
} else {
this.logger.debug(`(Protocol) ${address} <- Reached maximum number of retries, giving up ${method} - ${JSON.stringify(params)}`);
const err = new Error('Call to device timed out');
err.code = 'timeout';
promise.reject(err);
}
};
// Handler for incoming messages
const promise = {
resolve: res => {
resolved = true;
device._promises.delete(request.id);
resolve(res);
},
reject: err => {
device._promises.delete(request.id);
if (!(err instanceof Error) && typeof err.code !== 'undefined') {
const {
code,
message
} = err;
err = new Error(message);
err.code = code;
}
if (RECOVERABLE_ERRORS.includes(err.code)) {
this.logger.deepDebug(`(Protocol) ${address} <- Receving recoverable error: (${err.code}) ${err.message}, retries left: ${retriesLeft}`);
retry();
} else {
this.logger.deepDebug(`(Protocol) ${address} <- Error during send! (${err.code}) ${err.message} | Request: ${JSON.stringify(request)}`);
resolved = true;
reject(err);
}
},
};
// Handler for the actual send
const send = () => {
this.handshake(address)
.catch(err => {
if (err.code === 'timeout') {
this.logger.debug(`(Protocol) ${address} <- Handshake timed out`);
retry();
return false;
}
throw err;
})
.then(() => {
// 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.
*/
id = device._lastId + 100;
// Make sure to remove the failed promise
device._promises.delete(request.id);
} else {
id = device._lastId + 1;
}
// Check that the id hasn't rolled over
if (id >= 10000) {
id = 1;
}
device._lastId = id;
// Assign the identifier
request.id = id;
// Store reference to the promise so reply can be received
device._promises.set(id, promise);
this.logger.deepDebug(`(Protocol) ${address} <- (${retriesLeft}) ${JSON.stringify(request)}`);
// Create the JSON and send it
this._send(address, request).catch(promise.reject);
// Queue a retry
setTimeout(retry, requestTimeout);
})
.catch(promise.reject);
};
send();
});
}
async getInfo(address, params = {
timeout: 5000, // increase to 5000 for dev info
retries: 3 // 3 tries should be enough
}) {
const device = this.getDevice(address);
return new Promise((resolve, reject) => {
this.send(address, 'miIO.info', params).then(result => {
const deviceInfo = {
...result
};
device.deviceInfo = deviceInfo;
resolve(device.deviceInfo);
}).catch(err => {
reject(err);
})
});
}
}
module.exports = MiioProtocol;