Skip to content

Commit

Permalink
First version of Miio library
Browse files Browse the repository at this point in the history
  • Loading branch information
aholstenson committed Mar 19, 2017
0 parents commit 8ec64ce
Show file tree
Hide file tree
Showing 7 changed files with 261 additions and 0 deletions.
15 changes: 15 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
root = true

[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false

[package.json]
indent_style = space
indent_size = 2
20 changes: 20 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"plugins": [ "node" ],
"extends": [ "eslint:recommended", "plugin:node/recommended" ],
"env": {},
"ecmaFeatures": {},
"globals": {},
"rules": {
"node/no-unsupported-features": [
"error",
{
"version": 6
}
],
"no-irregular-whitespace": 2,
"quotes": [
2,
"single"
]
}
}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
npm-debug.log
18 changes: 18 additions & 0 deletions example.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use strict';

const { Device } = require('./index');

// Create a new device over the given address
const device = new Device('192.168.100.8');

// Call any method via call
device.call('set_mode', [ 'silent' ])
.then(console.log)
.catch(console.error);

// Or use some of the built-in methods
device.setPower(false);

device.getProperties([ 'power', 'mode', 'aqi', 'temp_dec', 'humidity' ])
.then(console.log)
.catch(console.error);
100 changes: 100 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
'use strict';

const dgram = require('dgram');
const Packet = require('./packet');

class Device {
constructor(address) {
this.address = address;

this.packet = new Packet();
this.socket = dgram.createSocket('udp4');
this.socket.on('message', this._onMessage.bind(this));

this._id = 0;
this._promises = {};
}

_onMessage(msg) {
this.packet.raw = msg;

if(this._tokenResolve) {
this.packet.token = this.packet.checksum;
this._tokenResolve();
this._lastToken = Date.now();
this._tokenResolve = null;
this._tokenPromise = null;
} else {
let str = this.packet.data.toString('utf8');
let object = JSON.parse(str);

const p = this._promises[object.id];
if(! p) return;
if(object.result) {
p.resolve(object.result);
} else {
p.reject(object.error);
}

delete this._promises[object.id];
}
}

_ensureToken() {
if(this._lastToken > Date.now() - 60000) {
return Promise.resolve();
}

if(this._tokenPromise) {
return this._tokenPromise;
}

this._tokenPromise = new Promise((resolve, reject) => {
const data = this.packet.raw;
this.socket.send(data, 0, data.length, 54321, this.address, err => err && reject(err));
this._tokenResolve = resolve;
});
return this._tokenPromise;
}

call(method, params) {
return this._ensureToken()
.then(() => {
this._id = this._id == 10000 ? 1 : this._id + 1;
this.packet.data = Buffer.from(JSON.stringify({
id: this._id,
method: method,
params: params
}), 'utf8');

const data = this.packet.raw;

return new Promise((resolve, reject) => {
this._promises[this._id] = {
resolve: resolve,
reject: reject
};

this.socket.send(data, 0, data.length, 54321, this.address, err => err && reject(err));
});
});
}

setPower(on) {
return this.call('set_power', [ on ? 'on' : 'off '])
.then(() => on);
}

getProperties(props) {
return this.call('get_prop', props)
.then(result => {
const obj = {};
for(let i=0; i<result.length; i++) {
obj[props[i]] = result[i];
}
return obj;
});
}
}

module.exports.Device = Device;
23 changes: 23 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "miio",
"version": "0.1.0",
"license": "MIT",
"description": "Library for controlling MIIO devices such as the Mi Air Purifier",
"repository": "aholstenson/miio",
"main": "lib/index.js",
"keywords": [
"xiaomi",
"mi",
"miio"
],
"scripts": {
"prepublish": "node_modules/.bin/eslint lib",
"test": "node_modules/.bin/eslint lib"
},
"dependencies": {
},
"devDependencies": {
"eslint": "^3.11.1",
"eslint-plugin-node": "^3.0.5"
}
}
83 changes: 83 additions & 0 deletions packet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
'use strict';

const crypto = require('crypto');

class Packet {
constructor() {
this.header = Buffer.alloc(2 + 2 + 4 + 4 + 4 + 16);
this.header[0] = 0x21;
this.header[1] = 0x31;

for(let i=4; i<32; i++) {
this.header[i] = 0xff;
}
}

handshake() {
this.data = null;
}

get raw() {
if(this.data) {
if(! this._token) {
throw new Error('Token is required to send commands');
}

// Encrypt the data
let cipher = crypto.createCipheriv('aes-128-cbc', this._tokenKey, this._tokenIV);
let encrypted = Buffer.concat([
cipher.update(this.data),
cipher.final()
]);

// Set the length
this.header.writeInt16BE(32 + encrypted.length, 2);

// Calculate the checksum
let digest = crypto.createHash('md5')
.update(this.header.slice(0, 16))
.update(this._token)
.update(encrypted)
.digest();
digest.copy(this.header, 16);

return Buffer.concat([ this.header, encrypted ]);
} else {
this.header.writeInt16BE(32, 2);

for(let i=4; i<32; i++) {
this.header[i] = 0xff;
}

return this.header;
}
}

set raw(msg) {
this.header = msg.slice(0, 32);
const encrypted = msg.slice(32);
if(encrypted.length > 0) {
let decipher = crypto.createDecipheriv('aes-128-cbc', this._tokenKey, this._tokenIV);
this.data = Buffer.concat([
decipher.update(encrypted),
decipher.final()
]);
}
}

get token() {
return this._token;
}

set token(t) {
this._token = t;
this._tokenKey = crypto.createHash('md5').update(t).digest();
this._tokenIV = crypto.createHash('md5').update(this._tokenKey).update(t).digest();
}

get checksum() {
return this.header.slice(16);
}
}

module.exports = Packet;

0 comments on commit 8ec64ce

Please sign in to comment.