├── .gitignore
├── lib
├── models.js
├── placeholder.js
├── safeishJSON.js
├── devices
│ ├── hublux.js
│ └── capabilities
│ │ └── sensor.js
├── infoFromHostname.js
├── index.js
├── connectToDevice.js
├── management.js
├── tokens.js
├── discovery.js
├── packet.js
├── device.js
└── network.js
├── package.json
├── README.md
├── LICENSE
├── config.schema.json
├── index.js
└── obtain_token.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | node_modules
--------------------------------------------------------------------------------
/lib/models.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const AqaraHub = require('./devices/hublux');
4 |
5 | module.exports = {
6 | 'lumi.gateway.aqhm01': AqaraHub,
7 | 'lumi.gateway.aqhm02': AqaraHub
8 | };
9 |
--------------------------------------------------------------------------------
/lib/placeholder.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const { Thing } = require('abstract-things');
4 | const MiioApi = require('./device');
5 |
6 | module.exports = class extends Thing.with(MiioApi) {
7 |
8 | static get type() {
9 | return 'placeholder';
10 | }
11 |
12 | };
13 |
--------------------------------------------------------------------------------
/lib/safeishJSON.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = function(str) {
4 | try {
5 | return JSON.parse(str);
6 | } catch(ex) {
7 | // Case 1: Load for subdevices fail as they return empty values
8 | str = str.replace('[,]', '[null,null]');
9 | // for aqara body sensor (lumi.motion.aq2)
10 | str = str.replace('[,,]', '[null,null,null]');
11 |
12 | return JSON.parse(str);
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/lib/devices/hublux.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const { Thing } = require('abstract-things');
4 |
5 | const MiioApi = require('../device');
6 | const { Illuminance } = require('./capabilities/sensor');
7 |
8 | module.exports = class extends Thing.with(MiioApi, Illuminance) {
9 | static get type() {
10 | return 'aqara:hub';
11 | }
12 |
13 | constructor(options) {
14 | super(options);
15 |
16 | this.defineProperty('illumination', {
17 | name: 'illuminance'
18 | });
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/lib/infoFromHostname.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = function(hostname) {
4 | // Extract info via hostname structure
5 | const m = /(.+)_miio(\d+)/g.exec(hostname);
6 | if(! m) {
7 | // Fallback for rockrobo - might break in the future
8 | if(/rockrobo/g.exec(hostname)) {
9 | return {
10 | model: 'rockrobo.vacuum.v1',
11 | type: 'vacuum'
12 | };
13 | }
14 |
15 | return null;
16 | }
17 |
18 | const model = m[1].replace(/-/g, '.');
19 |
20 | return {
21 | model: model,
22 | id: m[2]
23 | };
24 | };
25 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "homebridge-aqara-hub-lux",
3 | "version": "1.2.0",
4 | "keywords": [
5 | "homebridge",
6 | "homebridge-plugin",
7 | "aqara",
8 | "xiaomi"
9 | ],
10 | "dependencies": {
11 | "abstract-things": "^0.9.0",
12 | "appdirectory": "^0.1.0",
13 | "chalk": "^2.3.0",
14 | "debug": "^3.1.0",
15 | "deep-equal": "^1.0.1",
16 | "mkdirp": "^0.5.1",
17 | "tinkerhub-discovery": "^0.3.1"
18 | },
19 | "engines": {
20 | "homebridge": ">=1.0.0",
21 | "node": ">=10.17.0"
22 | },
23 | "author": "Krzysztof Pintscher",
24 | "license": "MIT",
25 | "bugs": {
26 | "url": "https://github.com/niou-ns/homebridge-aqara-hub-lux/issues"
27 | },
28 | "homepage": "https://github.com/niou-ns/homebridge-aqara-hub-lux#readme"
29 | }
30 |
--------------------------------------------------------------------------------
/lib/devices/capabilities/sensor.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const { Thing } = require('abstract-things');
4 | const { Illuminance } = require('abstract-things/sensors');
5 |
6 | function bind(Type, updateName, property) {
7 | return Thing.mixin(Parent => class extends Parent.with(Type) {
8 | propertyUpdated(key, value) {
9 | if(key === property) {
10 | this[updateName](value);
11 | }
12 |
13 | super.propertyUpdated(key, value);
14 | }
15 | });
16 | }
17 |
18 | module.exports.Illuminance = bind(Illuminance, 'updateIlluminance', 'illuminance');
19 |
20 | /**
21 | * Setup sensor support for a device.
22 | */
23 | function mixin(device, options) {
24 | if(device.capabilities.indexOf('sensor') < 0) {
25 | device.capabilities.push('sensor');
26 | }
27 |
28 | device.capabilities.push(options.name);
29 | Object.defineProperty(device, options.name, {
30 | get: function() {
31 | return this.property(options.name);
32 | }
33 | });
34 | }
35 |
36 | module.exports.extend = mixin;
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # homebridge-aqara-hub-lux
2 |
3 | Plugin can access Aqara Gateway in order to add it's lux sensor to Homekit via homebridge.
4 | Works with gateway which identify itself as lumi.gateway.aqhm01 (China version) and lumi.gateway.aqhm02 (EU version).
5 |
6 | Code uses "lite" version of miio by @aholstenson. I had to modify some parts of that library in order to access gateway, so I've removed unused parts.
7 |
8 | Config example:
9 | ```
10 | "accessories": [
11 | {
12 | "name": "Aqara Hub",
13 | "accessory": "AqaraHubLux",
14 | "ip": "192.168.0.XXX",
15 | "token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
16 | "interval": 60,
17 | "unitFactor": 0.1
18 | }
19 | ]
20 | ```
21 | In order to obtain the token, please follow @Maxmudjon instruction described here (except method 2).
22 |
23 | Thanks esgie for unit factor idea!
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Krzysztof Pintscher
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/lib/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const discovery = require('./discovery');
4 |
5 | /**
6 | * Get information about the models supported. Can be used to extend the models
7 | * supported.
8 | */
9 | module.exports.models = require('./models');
10 |
11 | /**
12 | * Resolve a device from the given options.
13 | *
14 | * Options:
15 | * * `address`, **required** the address to the device as an IP or hostname
16 | * * `port`, optional port number, if not specified the default 54321 will be used
17 | * * `token`, optional token of the device
18 | */
19 | module.exports.device = require('./connectToDevice');
20 |
21 | /**
22 | * Extract information about a device from its hostname on the local network.
23 | */
24 | module.exports.infoFromHostname = require('./infoFromHostname');
25 |
26 | /**
27 | * Browse for devices available on the network. Will not automatically
28 | * connect to them.
29 | */
30 | module.exports.browse = function(options) {
31 | return new discovery.Browser(options || {});
32 | };
33 |
34 | /**
35 | * Get access to all devices on the current network. Will find and connect to
36 | * devices automatically.
37 | */
38 | module.exports.devices = function(options) {
39 | return new discovery.Devices(options || {});
40 | };
41 |
--------------------------------------------------------------------------------
/lib/connectToDevice.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const network = require('./network');
4 |
5 | const Device = require('./device');
6 | const Placeholder = require('./placeholder');
7 | const models = require('./models');
8 |
9 | module.exports = function(options) {
10 | let handle = network.ref();
11 |
12 | // Connecting to a device via IP, ask the network if it knows about it
13 | return network.findDeviceViaAddress(options)
14 | .then(device => {
15 | const deviceHandle = {
16 | ref: network.ref(),
17 | api: device
18 | };
19 |
20 | // Try to resolve the correct model, otherwise use the generic device
21 | const d = models[device.model];
22 | if(! d) {
23 | return new Device(deviceHandle);
24 | } else {
25 | return new d(deviceHandle);
26 | }
27 | })
28 | .catch(e => {
29 | if((e.code === 'missing-token' || e.code === 'connection-failure') && options.withPlaceholder) {
30 | const deviceHandle = {
31 | ref: network.ref(),
32 | api: e.device
33 | };
34 |
35 | return new Placeholder(deviceHandle);
36 | }
37 |
38 | // Error handling - make sure to always release the handle
39 | handle.release();
40 |
41 | e.device = null;
42 | throw e;
43 | })
44 | .then(device => {
45 | // Make sure to release the handle
46 | handle.release();
47 |
48 | return device.init();
49 | });
50 | };
51 |
--------------------------------------------------------------------------------
/config.schema.json:
--------------------------------------------------------------------------------
1 | {
2 | "pluginAlias": "AqaraHubLux",
3 | "pluginType": "accessory",
4 | "schema": {
5 | "type": "object",
6 | "properties": {
7 | "name": {
8 | "title": "Name",
9 | "type": "string",
10 | "default": "Aqara Hub",
11 | "minLength": 1,
12 | "required": true
13 | },
14 | "ip": {
15 | "title": "IP",
16 | "type": "string",
17 | "required": true
18 | },
19 | "token": {
20 | "title": "Token",
21 | "type": "string",
22 | "required": true
23 | },
24 | "interval": {
25 | "title": "Refresh interval",
26 | "type": "integer",
27 | "description": "In seconds.",
28 | "default": 60,
29 | "required": true
30 | },
31 | "unitFactor": {
32 | "title": "Unit factor",
33 | "type": "number",
34 | "description": "Hub provides value x10",
35 | "default": 0.1,
36 | "required": true
37 | }
38 | }
39 | },
40 | "layout": [
41 | {
42 | "type": "flex",
43 | "flex-flow": "row wrap",
44 | "items": ["name", "interval"]
45 | },
46 | {
47 | "type": "flex",
48 | "flex-flow": "row wrap",
49 | "items": ["ip", "unitFactor"]
50 | },
51 | {
52 | "type": "flex",
53 | "flex-flow": "row wrap",
54 | "items": ["token"]
55 | }
56 | ]
57 | }
58 |
--------------------------------------------------------------------------------
/lib/management.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const tokens = require('./tokens');
4 |
5 | /**
6 | * Management of a device. Supports quering it for information and changing
7 | * the WiFi settings.
8 | */
9 | class DeviceManagement {
10 | constructor(device) {
11 | this.api = device.handle.api;
12 | }
13 |
14 | get model() {
15 | return this.api.model;
16 | }
17 |
18 | get token() {
19 | const token = this.api.token;
20 | return token ? token.toString('hex') : null;
21 | }
22 |
23 | get autoToken() {
24 | return this.api.autoToken;
25 | }
26 |
27 | get address() {
28 | return this.api.address;
29 | }
30 |
31 | /**
32 | * Get information about this device. Includes model info, token and
33 | * connection information.
34 | */
35 | info() {
36 | return this.api.call('miIO.info');
37 | }
38 |
39 | /**
40 | * Update the wireless settings of this device. Needs `ssid` and `passwd`
41 | * to be set in the options object.
42 | *
43 | * `uid` can be set to associate the device with a Mi Home user id.
44 | */
45 | updateWireless(options) {
46 | if(typeof options.ssid !== 'string') {
47 | throw new Error('options.ssid must be a string');
48 | }
49 | if(typeof options.passwd !== 'string') {
50 | throw new Error('options.passwd must be a string');
51 | }
52 |
53 | return this.api.call('miIO.config_router', options)
54 | .then(result => {
55 | if(result !== 0 && result !== 'OK' && result !== 'ok') {
56 | throw new Error('Failed updating wireless');
57 | }
58 | return true;
59 | });
60 | }
61 |
62 | /**
63 | * Get the wireless state of this device. Includes if the device is
64 | * online and counters for things such as authentication failures and
65 | * connection success and failures.
66 | */
67 | wirelessState() {
68 | return this.api.call('miIO.wifi_assoc_state');
69 | }
70 |
71 | /**
72 | * Update the token used to connect to this device.
73 | *
74 | * @param {string|Buffer} token
75 | */
76 | updateToken(token) {
77 | if(token instanceof Buffer) {
78 | token = token.toString('hex');
79 | } else if(typeof token !== 'string') {
80 | return Promise.reject(new Error('Token must be a hex-string or a Buffer'));
81 | }
82 |
83 | // Lazily imported to solve recursive dependencies
84 | const connectToDevice = require('./connectToDevice');
85 |
86 | return connectToDevice({
87 | address: this.address,
88 | port: this.port,
89 | token: token
90 | }).then(device => {
91 | // Connection to device could be performed
92 | return tokens.update(this.api.id, token)
93 | .then(() => device.destroy())
94 | .then(() => true);
95 | }).catch(err => {
96 | // Connection to device failed with the token
97 | return false;
98 | });
99 | }
100 | }
101 |
102 | module.exports = DeviceManagement;
103 |
--------------------------------------------------------------------------------
/lib/tokens.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 |
6 | const mkdirp = require('mkdirp');
7 | const AppDirectory = require('appdirectory');
8 | const dirs = new AppDirectory('miio');
9 |
10 | const CHECK_TIME = 1000;
11 | const MAX_STALE_TIME = 120000;
12 |
13 | const debug = require('debug')('miio:tokens');
14 |
15 | /**
16 | * Shared storage for tokens of devices. Keeps a simple JSON file synced
17 | * with tokens connected to device ids.
18 | */
19 | class Tokens {
20 | constructor() {
21 | this._file = path.join(dirs.userData(), 'tokens.json');
22 | this._data = {};
23 | this._lastSync = 0;
24 | }
25 |
26 | get(deviceId) {
27 | const now = Date.now();
28 | const diff = now - this._lastSync;
29 |
30 | if(diff > CHECK_TIME) {
31 | return this._loadAndGet(deviceId);
32 | }
33 |
34 | return Promise.resolve(this._get(deviceId));
35 | }
36 |
37 | _get(deviceId) {
38 | return this._data[deviceId];
39 | }
40 |
41 | _loadAndGet(deviceId) {
42 | return this._load()
43 | .then(() => this._get(deviceId))
44 | .catch(() => null);
45 | }
46 |
47 | _load() {
48 | if(this._loading) return this._loading;
49 |
50 | return this._loading = new Promise((resolve, reject) => {
51 | debug('Loading token storage from', this._file);
52 | fs.stat(this._file, (err, stat) => {
53 | if(err) {
54 | delete this._loading;
55 | if(err.code === 'ENOENT') {
56 | debug('Token storage does not exist');
57 | this._lastSync = Date.now();
58 | resolve(this._data);
59 | } else {
60 | reject(err);
61 | }
62 |
63 | return;
64 | }
65 |
66 | if(! stat.isFile()) {
67 | // tokens.json does not exist
68 | delete this._loading;
69 | reject(new Error('tokens.json exists but is not a file'));
70 | } else if(Date.now() - this._lastSync > MAX_STALE_TIME || stat.mtime.getTime() > this._lastSync) {
71 | debug('Loading tokens');
72 | fs.readFile(this._file, (err, result) => {
73 | this._data = JSON.parse(result.toString());
74 | this._lastSync = Date.now();
75 | delete this._loading;
76 | resolve(this._data);
77 | });
78 | } else {
79 | delete this._loading;
80 | this._lastSync = Date.now();
81 | resolve(this._data);
82 | }
83 | });
84 | });
85 | }
86 |
87 | update(deviceId, token) {
88 | return this._load()
89 | .then(() => {
90 | this._data[deviceId] = token;
91 |
92 | if(this._saving) {
93 | this._dirty = true;
94 | return this._saving;
95 | }
96 |
97 | return this._saving = new Promise((resolve, reject) => {
98 | const save = () => {
99 | debug('About to save tokens');
100 | fs.writeFile(this._file, JSON.stringify(this._data, null, 2), (err) => {
101 | if(err) {
102 | reject(err);
103 | } else {
104 | if(this._dirty) {
105 | debug('Redoing save due to multiple updates');
106 | this._dirty = false;
107 | save();
108 | } else {
109 | delete this._saving;
110 | resolve();
111 | }
112 | }
113 | });
114 | };
115 |
116 | mkdirp(dirs.userData(), (err) => {
117 | if(err) {
118 | reject(err);
119 | return;
120 | }
121 |
122 | save();
123 | });
124 | });
125 | });
126 | }
127 | }
128 |
129 | module.exports = new Tokens();
130 |
--------------------------------------------------------------------------------
/lib/discovery.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const { TimedDiscovery, BasicDiscovery, search, addService, removeService } = require('tinkerhub-discovery');
4 | const { Children } = require('abstract-things');
5 |
6 | const util = require('util');
7 | const dns = require('dns');
8 |
9 | const network = require('./network');
10 | const infoFromHostname = require('./infoFromHostname');
11 |
12 | const connectToDevice = require('./connectToDevice');
13 |
14 | const tryAdd = Symbol('tryAdd');
15 |
16 | const Browser = module.exports.Browser = class Browser extends TimedDiscovery {
17 | static get type() {
18 | return 'miio';
19 | }
20 |
21 | constructor(options) {
22 | super({
23 | maxStaleTime: (options.cacheTime || 1800) * 1000
24 | });
25 |
26 | if(typeof options.useTokenStorage !== 'undefined' ? options.useTokenStorage : true) {
27 | this.tokens = require('./tokens');
28 | }
29 |
30 | this.manualTokens = options.tokens || {};
31 | this[tryAdd] = this[tryAdd].bind(this);
32 |
33 | this.start();
34 | }
35 |
36 | _manualToken(id) {
37 | return this.manualTokens[id] || null;
38 | }
39 |
40 | start() {
41 | this.handle = network.ref();
42 | network.on('device', this[tryAdd]);
43 |
44 | super.start();
45 | }
46 |
47 | stop() {
48 | super.stop();
49 |
50 | network.removeListener('device', this[tryAdd]);
51 | this.handle.release();
52 | }
53 |
54 | [search]() {
55 | network.search();
56 | }
57 |
58 | [tryAdd](device) {
59 | const service = {
60 | id: device.id,
61 | address: device.address,
62 | port: device.port,
63 | token: device.token || this._manualToken(device.id),
64 | autoToken: device.autoToken,
65 |
66 | connect: function(options={}) {
67 | return connectToDevice(Object.assign({
68 | address: this.address,
69 | port: this.port,
70 | model: this.model
71 | }, options));
72 | }
73 | };
74 |
75 | const add = () => this[addService](service);
76 |
77 | // Give us five seconds to try resolve some extras for new devices
78 | setTimeout(add, 5000);
79 |
80 | dns.lookupService(service.address, service.port, (err, hostname) => {
81 | if(err || ! hostname) {
82 | add();
83 | return;
84 | }
85 |
86 | service.hostname = hostname;
87 | const info = infoFromHostname(hostname);
88 | if(info) {
89 | service.model = info.model;
90 | }
91 |
92 | add();
93 | });
94 | }
95 |
96 | [util.inspect.custom]() {
97 | return 'MiioBrowser{}';
98 | }
99 | };
100 |
101 | class Devices extends BasicDiscovery {
102 | static get type() {
103 | return 'miio:devices';
104 | }
105 |
106 | constructor(options) {
107 | super();
108 |
109 | this._filter = options && options.filter;
110 | this._skipSubDevices = options && options.skipSubDevices;
111 |
112 | this._browser = new Browser(options)
113 | .map(reg => {
114 | return connectToDevice({
115 | address: reg.address,
116 | port: reg.port,
117 | model: reg.model,
118 | withPlaceholder: true
119 | });
120 | });
121 |
122 | this._browser.on('available', s => {
123 | this[addService](s);
124 |
125 | if(s instanceof Children) {
126 | this._bindSubDevices(s);
127 | }
128 | });
129 |
130 | this._browser.on('unavailable', s => {
131 | this[removeService](s);
132 | });
133 | }
134 |
135 | start() {
136 | super.start();
137 |
138 | this._browser.start();
139 | }
140 |
141 | stop() {
142 | super.stop();
143 |
144 | this._browser.stop();
145 | }
146 |
147 | [util.inspect.custom]() {
148 | return 'MiioDevices{}';
149 | }
150 |
151 | _bindSubDevices(device) {
152 | if(this._skipSubDevices) return;
153 |
154 | const handleAvailable = sub => {
155 | if(! sub.miioModel) return;
156 |
157 | const reg = {
158 | id: sub.internalId,
159 | model: sub.model,
160 | type: sub.type,
161 |
162 | parent: device,
163 | device: sub
164 | };
165 |
166 | if(this._filter && ! this._filter(reg)) {
167 | // Filter does not match sub device
168 | return;
169 | }
170 |
171 | // Register and emit event
172 | this[addService](sub);
173 | };
174 |
175 | device.on('thing:available', handleAvailable);
176 | device.on('thing:unavailable', sub => this[removeService](sub.id));
177 |
178 | // Register initial devices
179 | for(const child of device.children()) {
180 | handleAvailable(child);
181 | }
182 | }
183 | }
184 |
185 | module.exports.Devices = Devices;
186 |
--------------------------------------------------------------------------------
/lib/packet.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const crypto = require('crypto');
4 | const debug = require('debug')('miio:packet');
5 |
6 | class Packet {
7 | constructor(discovery = false) {
8 | this.discovery = discovery;
9 |
10 | this.header = Buffer.alloc(2 + 2 + 4 + 4 + 4 + 16);
11 | this.header[0] = 0x21;
12 | this.header[1] = 0x31;
13 |
14 | for(let i=4; i<32; i++) {
15 | this.header[i] = 0xff;
16 | }
17 |
18 | this._serverStampTime = 0;
19 | this._token = null;
20 | }
21 |
22 | handshake() {
23 | this.data = null;
24 | }
25 |
26 | handleHandshakeReply() {
27 | if(this._token === null) {
28 | const token = this.checksum;
29 | if(token.toString('hex').match(/^[fF0]+$/)) {
30 | // Device did not return its token so we set our token to null
31 | this._token = null;
32 | } else {
33 | this.token = this.checksum;
34 | }
35 | }
36 | }
37 |
38 | get needsHandshake() {
39 | /*
40 | * Handshake if we:
41 | * 1) do not have a token
42 | * 2) it has been longer then 120 seconds since last received message
43 | */
44 | return ! this._token || (Date.now() - this._serverStampTime) > 120000;
45 | }
46 |
47 | get raw() {
48 | if(this.data) {
49 | // Send a command to the device
50 | if(! this._token) {
51 | throw new Error('Token is required to send commands');
52 | }
53 |
54 | for(let i=4; i<8; i++) {
55 | this.header[i] = 0x00;
56 | }
57 |
58 | // Update the stamp to match server
59 | if(this._serverStampTime) {
60 | const secondsPassed = Math.floor(Date.now() - this._serverStampTime) / 1000;
61 | this.header.writeUInt32BE(this._serverStamp + secondsPassed, 12);
62 | }
63 |
64 | // Encrypt the data
65 | let cipher = crypto.createCipheriv('aes-128-cbc', this._tokenKey, this._tokenIV);
66 | let encrypted = Buffer.concat([
67 | cipher.update(this.data),
68 | cipher.final()
69 | ]);
70 |
71 | // Set the length
72 | this.header.writeUInt16BE(32 + encrypted.length, 2);
73 |
74 | // Calculate the checksum
75 | let digest = crypto.createHash('md5')
76 | .update(this.header.slice(0, 16))
77 | .update(this._token)
78 | .update(encrypted)
79 | .digest();
80 | digest.copy(this.header, 16);
81 |
82 | debug('->', this.header);
83 | return Buffer.concat([ this.header, encrypted ]);
84 | } else {
85 | // Handshake
86 | this.header.writeUInt16BE(32, 2);
87 |
88 | for(let i=4; i<32; i++) {
89 | this.header[i] = 0xff;
90 | }
91 |
92 | debug('->', this.header);
93 | return this.header;
94 | }
95 | }
96 |
97 | set raw(msg) {
98 | msg.copy(this.header, 0, 0, 32);
99 | debug('<-', this.header);
100 |
101 | const stamp = this.stamp;
102 | if(stamp > 0) {
103 | // If the device returned a stamp, store it
104 | this._serverStamp = this.stamp;
105 | this._serverStampTime = Date.now();
106 | }
107 |
108 | const encrypted = msg.slice(32);
109 |
110 | if(this.discovery) {
111 | // This packet is only intended to be used for discovery
112 | this.data = encrypted.length > 0;
113 | } else {
114 | // Normal packet, decrypt data
115 | if(encrypted.length > 0) {
116 | if(! this._token) {
117 | debug('<- No token set, unable to handle packet');
118 | this.data = null;
119 | return;
120 | }
121 |
122 | const digest = crypto.createHash('md5')
123 | .update(this.header.slice(0, 16))
124 | .update(this._token)
125 | .update(encrypted)
126 | .digest();
127 |
128 | const checksum = this.checksum;
129 | if(! checksum.equals(digest)) {
130 | debug('<- Invalid packet, checksum was', checksum, 'should be', digest);
131 | this.data = null;
132 | } else {
133 | let decipher = crypto.createDecipheriv('aes-128-cbc', this._tokenKey, this._tokenIV);
134 | this.data = Buffer.concat([
135 | decipher.update(encrypted),
136 | decipher.final()
137 | ]);
138 | }
139 | } else {
140 | this.data = null;
141 | }
142 | }
143 | }
144 |
145 | get token() {
146 | return this._token;
147 | }
148 |
149 | set token(t) {
150 | this._token = Buffer.from(t);
151 | this._tokenKey = crypto.createHash('md5').update(t).digest();
152 | this._tokenIV = crypto.createHash('md5').update(this._tokenKey).update(t).digest();
153 | }
154 |
155 | get checksum() {
156 | return this.header.slice(16);
157 | }
158 |
159 | get deviceId() {
160 | return this.header.readUInt32BE(8);
161 | }
162 |
163 | get stamp() {
164 | return this.header.readUInt32BE(12);
165 | }
166 | }
167 |
168 | module.exports = Packet;
169 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const miioLite = require('./lib/index');
4 |
5 | const PLUGIN_NAME = "homebridge-aqara-hub-lux";
6 | const ACCESSORY_NAME = "AqaraHubLux";
7 | let Service, Characteristic;
8 |
9 | module.exports = (homebridge) => {
10 | Service = homebridge.hap.Service;
11 | Characteristic = homebridge.hap.Characteristic;
12 | homebridge.registerAccessory(PLUGIN_NAME, ACCESSORY_NAME, AqaraHubLux);
13 | }
14 |
15 | class AqaraHubLux {
16 |
17 | constructor(log, config) {
18 | this.log = log;
19 | this.ip = config.ip;
20 | this.token = config.token;
21 | this.name = config.name ? config.name : 'Aqara Hub Lux';
22 | this.interval = config.interval ? config.interval * 1000 : 60000;
23 | this.unitFactor = config.unitFactor ? config.unitFactor : 0.1;
24 | this.device = {};
25 |
26 | if (!this.ip) {
27 | throw new Error('Your must provide IP address of the Aqara Gateway.');
28 | }
29 |
30 | if (!this.token) {
31 | throw new Error('Your must provide token of the Aqara Gateway.');
32 | }
33 |
34 | this.setServices();
35 | this.connect()
36 | .catch(() => { /* Silent error, will retry to connect */ });
37 | }
38 |
39 | setServices() {
40 | this.service = new Service.LightSensor(this.name);
41 | this.service.getCharacteristic(Characteristic.CurrentAmbientLightLevel)
42 | .on('get', this.getCurrentLux.bind(this));
43 |
44 | this.serviceInfo = new Service.AccessoryInformation();
45 | this.serviceInfo
46 | .setCharacteristic(Characteristic.Manufacturer, 'Aqara');
47 | this.serviceInfo
48 | .setCharacteristic(Characteristic.Model, 'Gateway');
49 | }
50 |
51 | async getCurrentLux(callback) {
52 | if (!this.device.illuminance) {
53 | callback(null, 0);
54 | } else {
55 | const illuminance = await this.callForIlluminance();
56 | this.log.debug('Current lux:', illuminance.value);
57 | this.log.info('Current calculated lux:', this.calculateIlluminance(illuminance));
58 | callback(null, this.calculateIlluminance(illuminance));
59 | }
60 | }
61 |
62 | updateCurrentLux(illuminance) {
63 | this.log.debug('Update lux:', illuminance.value);
64 | this.log.info('Update lux with calculated value:', this.calculateIlluminance(illuminance));
65 | this.service.getCharacteristic(Characteristic.CurrentAmbientLightLevel).updateValue(this.calculateIlluminance(illuminance));
66 | }
67 |
68 | async callForIlluminance() {
69 | this.log.debug('Try to call for illuminance...');
70 | try {
71 | const illuminance = await this.device.miioCall('get_device_prop', ["lumi.0", "illumination"]);
72 | this.log.debug('Check lux:', illuminance[0]);
73 | return { value: illuminance[0] };
74 | } catch (error) {
75 | this.log.error(error);
76 | }
77 | }
78 |
79 | calculateIlluminance(illumimance) {
80 | return Math.round(illumimance.value * this.unitFactor * 100) / 100;
81 | }
82 |
83 | async illumimanceInterval() {
84 | let illumimance = await this.callForIlluminance();
85 | if (this.calculateIlluminance(illumimance) !== this.service.getCharacteristic(Characteristic.CurrentAmbientLightLevel).value) {
86 | this.log.debug('Update called from interval function.');
87 | this.updateCurrentLux(illumimance);
88 | }
89 | }
90 |
91 | async connect() {
92 | const that = this;
93 | try {
94 | this.device = await miioLite.device({
95 | address: this.ip,
96 | token: this.token
97 | });
98 | if (!this.device.matches('type:aqara:hub')) {
99 | this.log.error('Device discovered at %s is not Aqara Gateway', this.ip);
100 | return;
101 | }
102 |
103 | this.log.info('Discovered Aqara Gateway (%s) at %s', this.device.miioModel, this.ip);
104 | this.log.info('Model : ' + this.device.miioModel);
105 | this.log.info('Illuminance : ' + this.device.property('illuminance'));
106 |
107 | this.device.on('illuminanceChanged', value => this.updateCurrentLux(value));;
108 | setInterval(() => {
109 | this.illumimanceInterval();
110 | }, this.interval);
111 | } catch(err) {
112 | this.log.error('Failed to discover Aqara Gateway at %s', this.ip);
113 | this.log.error('Will retry after 30 seconds');
114 | setTimeout(() => {
115 | that.connect();
116 | }, 30000);
117 | }
118 | }
119 |
120 | getServices() {
121 | const { service, serviceInfo } = this;
122 | return [ service, serviceInfo ];
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/lib/device.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const util = require('util');
4 | const isDeepEqual = require('deep-equal');
5 | const { Thing, Polling } = require('abstract-things');
6 |
7 | const DeviceManagement = require('./management');
8 |
9 | const IDENTITY_MAPPER = v => v;
10 |
11 | module.exports = Thing.type(Parent => class extends Parent.with(Polling) {
12 | static get type() {
13 | return 'miio';
14 | }
15 |
16 | static availableAPI(builder) {
17 | builder.action('miioModel')
18 | .description('Get the model identifier of this device')
19 | .returns('string')
20 | .done();
21 |
22 | builder.action('miioProperties')
23 | .description('Get properties of this device')
24 | .returns('string')
25 | .done();
26 |
27 | builder.action('miioCall')
28 | .description('Execute a raw miio-command to the device')
29 | .argument('string', false, 'The command to run')
30 | .argument('array', true, 'Arguments of the command')
31 | .done();
32 | }
33 |
34 | constructor(handle) {
35 | super();
36 |
37 | this.handle = handle;
38 | this.id = 'miio:' + handle.api.id;
39 | this.miioModel = handle.api.model;
40 |
41 | this._properties = {};
42 | this._propertiesToMonitor = [];
43 | this._propertyDefinitions = {};
44 | this._reversePropertyDefinitions = {};
45 |
46 | this.poll = this.poll.bind(this);
47 | // Set up polling to destroy device if unreachable for 5 minutes
48 | this.updateMaxPollFailures(10);
49 |
50 | this.management = new DeviceManagement(this);
51 | }
52 |
53 | /**
54 | * Public API: Call a miio method.
55 | *
56 | * @param {*} method
57 | * @param {*} args
58 | */
59 | miioCall(method, args) {
60 | return this.call(method, args);
61 | }
62 |
63 | /**
64 | * Call a raw method on the device.
65 | *
66 | * @param {*} method
67 | * @param {*} args
68 | * @param {*} options
69 | */
70 | call(method, args, options) {
71 | return this.handle.api.call(method, args, options)
72 | .then(res => {
73 | if(options && options.refresh) {
74 | // Special case for loading properties after setting values
75 | // - delay a bit to make sure the device has time to respond
76 | return new Promise(resolve => setTimeout(() => {
77 | const properties = Array.isArray(options.refresh) ? options.refresh : this._propertiesToMonitor;
78 |
79 | this._loadProperties(properties)
80 | .then(() => resolve(res))
81 | .catch(() => resolve(res));
82 |
83 | }, (options && options.refreshDelay) || 50));
84 | } else {
85 | return res;
86 | }
87 | });
88 | }
89 |
90 | /**
91 | * Define a property and how the value should be mapped. All defined
92 | * properties are monitored if #monitor() is called.
93 | */
94 | defineProperty(name, def) {
95 | this._propertiesToMonitor.push(name);
96 |
97 | if(typeof def === 'function') {
98 | def = {
99 | mapper: def
100 | };
101 | } else if(typeof def === 'undefined') {
102 | def = {
103 | mapper: IDENTITY_MAPPER
104 | };
105 | }
106 |
107 | if(! def.mapper) {
108 | def.mapper = IDENTITY_MAPPER;
109 | }
110 |
111 | if(def.name) {
112 | this._reversePropertyDefinitions[def.name] = name;
113 | }
114 | this._propertyDefinitions[name] = def;
115 | }
116 |
117 | /**
118 | * Map and add a property to an object.
119 | *
120 | * @param {object} result
121 | * @param {string} name
122 | * @param {*} value
123 | */
124 | _pushProperty(result, name, value) {
125 | const def = this._propertyDefinitions[name];
126 | if(! def) {
127 | result[name] = value;
128 | } else if(def.handler) {
129 | def.handler(result, value);
130 | } else {
131 | result[def.name || name] = def.mapper(value);
132 | }
133 | }
134 |
135 | poll(isInitial) {
136 | // Polling involves simply calling load properties
137 | return this._loadProperties();
138 | }
139 |
140 | _loadProperties(properties) {
141 | if(typeof properties === 'undefined') {
142 | properties = this._propertiesToMonitor;
143 | }
144 |
145 | if(properties.length === 0) return Promise.resolve();
146 |
147 | return this.loadProperties(properties)
148 | .then(values => {
149 | Object.keys(values).forEach(key => {
150 | this.setProperty(key, values[key]);
151 | });
152 | });
153 | }
154 |
155 | setProperty(key, value) {
156 | const oldValue = this._properties[key];
157 |
158 | if(! isDeepEqual(oldValue, value)) {
159 | this._properties[key] = value;
160 | this.debug('Property', key, 'changed from', oldValue, 'to', value);
161 |
162 | this.propertyUpdated(key, value, oldValue);
163 | }
164 | }
165 |
166 | propertyUpdated(key, value, oldValue) {
167 | }
168 |
169 | setRawProperty(name, value) {
170 | const def = this._propertyDefinitions[name];
171 | if(! def) return;
172 |
173 | if(def.handler) {
174 | const result = {};
175 | def.handler(result, value);
176 | Object.keys(result).forEach(key => {
177 | this.setProperty(key, result[key]);
178 | });
179 | } else {
180 | this.setProperty(def.name || name, def.mapper(value));
181 | }
182 | }
183 |
184 | property(key) {
185 | return this._properties[key];
186 | }
187 |
188 | get properties() {
189 | return Object.assign({}, this._properties);
190 | }
191 |
192 | /**
193 | * Public API to get properties defined by the device.
194 | */
195 | miioProperties() {
196 | return this.properties;
197 | }
198 |
199 | /**
200 | * Get several properties at once.
201 | *
202 | * @param {array} props
203 | */
204 | getProperties(props) {
205 | const result = {};
206 | props.forEach(key => {
207 | result[key] = this._properties[key];
208 | });
209 | return result;
210 | }
211 |
212 | /**
213 | * Load properties from the device.
214 | *
215 | * @param {*} props
216 | */
217 | loadProperties(props) {
218 | // Rewrite property names to device internal ones
219 | props = props.map(key => this._reversePropertyDefinitions[key] || key);
220 |
221 | // Call get_prop to map everything
222 | return this.call('get_prop', props)
223 | .then(result => {
224 | const obj = {};
225 | for(let i=0; i {
238 | // Release the reference to the network
239 | this.handle.ref.release();
240 | });
241 | }
242 |
243 | [util.inspect.custom](depth, options) {
244 | if(depth === 0) {
245 | return options.stylize('MiioDevice', 'special')
246 | + '[' + this.miioModel + ']';
247 | }
248 |
249 | return options.stylize('MiioDevice', 'special')
250 | + ' {\n'
251 | + ' model=' + this.miioModel + ',\n'
252 | + ' types=' + Array.from(this.metadata.types).join(', ') + ',\n'
253 | + ' capabilities=' + Array.from(this.metadata.capabilities).join(', ')
254 | + '\n}';
255 | }
256 |
257 | /**
258 | * Check that the current result is equal to the string `ok`.
259 | */
260 | static checkOk(result) {
261 | if(! result || (typeof result === 'string' && result.toLowerCase() !== 'ok')) {
262 | throw new Error('Could not perform operation');
263 | }
264 |
265 | return null;
266 | }
267 | });
268 |
--------------------------------------------------------------------------------
/obtain_token.md:
--------------------------------------------------------------------------------
1 | # Obtain Mi Home device token - by @Maxmudjon
2 | Use any of these methods to obtain the device token for the supported miio devices.
3 |
4 | ## Method 1 - Obtain device token for miio devices that hide their token after setup
5 | Use one of these methods to obtain the device token for devices that hide their tokens after setup in the Mi Home App (like the Mi Robot Vacuum Cleaner with firmware 3.3.9_003077 or higher). This is usually the case for most Mi Home devices. The latest versions of the Mi Home smartphone app dont hold the token anymore so before you begin with any of these methods you will need to install an older version of the smartphone app. Version 5.0.19 works for sure with the 1st gen Vacuum Robot, for the 2nd gen (S50) you should try version 3.3.9_5.0.30. Android users can find older version of the app [here](https://www.apkmirror.com/apk/xiaomi-inc/mihome/).
6 |
7 | ### Android users
8 | #### Rooted Android Phones
9 | * Setup your Android device with the Mi Home app version 5.0.19 or lower
10 | * Install [aSQLiteManager](https://play.google.com/store/apps/details?id=dk.andsen.asqlitemanager) on your phone
11 | * Use a file browser with granted root privilege and browse to /data/data/com.xiaomi.smarthome/databases/
12 | * Copy miio2.db to an accessable location
13 | * Open your copy of miio2.db with aSQLiteManager and execute the query "select token from devicerecord where localIP is '192.168.0.1'" where you replace the IP address with the IP address of the device you want to get the token from. It will show you the 32 character device token for your Mi Home device.
14 |
15 | #### Non-Rooted Android Phones
16 | ##### Extract token from log file
17 | This method will only work when you install the Mi Home app version v5.4.54. You can find it [here](https://android-apk.org/com.xiaomi.smarthome/43397902-mi-home/). It looks like Xiaomi made a mistake in this app version where the log file written to internal memory exposes the device tokens of your Xiaomi miio devices.
18 | * Setup your Android device with the Mi Home app version 5.4.54
19 | * Log in with you Xiaomi account
20 | * Use a file explorer to navigate to /sdcard/SmartHome/logs/Plug_Devicemanager/
21 | * Look for a log file named yyyy-mm-dd.txt and open it with a file editor
22 | * Search for a string similar to this with you device name and token
23 | ```
24 | {"did":"117383849","token":"90557f1373xxxxxxx8314a74d547b5","longitude":"x","latitude":"y","name":"Mi Robot Vacuum","pid":"0","localip":"192.168.88.68","mac":"40:31:3C:AA:BB:CC","ssid":"Your AP Name","bssid":"E4:8D:8C:EE:FF:GG","parent_id":"","parent_model":"","show_mode":1,"model":"rockrobo.vacuum.v1","adminFlag":1,"shareFlag":0,"permitLevel":16,"isOnline":true,"desc":"Zoned cleanup","extra":{"isSetPincode":0,"fw_version":"3.3.9_003460","needVerifyCode":0,"isPasswordEncrypt":0},"event":{"event.back_to_dock":"{\"timestamp\":1548817566,\"value\":[0]}
25 | ```
26 | * Copy the token from this string and you are done.
27 |
28 | ##### Extract token from a backup on Android phones that allow non-encrypted backups
29 | * Setup your Android device with the Mi Home app
30 | * Enable developer mode and USB debugging on your phone and connect it to your computer
31 | * Get the ADB tool
32 | - for Windows: https://developer.android.com/studio/releases/platform-tools.html
33 | - for Mac: `brew install adb`
34 | * Create a backup of the Mi Home app:
35 | - for Windows: `.\adb backup -noapk com.xiaomi.smarthome -f mi-home-backup.ab`
36 | - for Mac: `adb backup -noapk com.xiaomi.smarthome -f mi-home-backup.ab`
37 | * On your phone you must confirm the backup. Do not enter any password and press button to make the backup
38 | * (Windows Only) Get ADB Backup Extractor and install it: https://sourceforge.net/projects/adbextractor/
39 | * Extract all files from the backup on your computer:
40 | - for Windows: `java.exe -jar ../android-backup-extractor/abe.jar unpack mi-home-backup.ab backup.tar`
41 | - for Mac & Unix: `( printf "\x1f\x8b\x08\x00\x00\x00\x00\x00" ; tail -c +25 mi-home-backup.ab) | tar xfvz -`
42 | * Unzip the ".tar" file
43 | * Open /com.xiaomi.smarthome/db/miio2.db with a SQLite browser (for instance http://sqlitebrowser.org/)
44 | * Execute the query "select token from devicerecord where localIP is '192.168.0.1'" where you replace the IP address with the IP address of the Mi Home device you want to get the token from. It will show you the 32 character device token for your Mi Home device.
45 |
46 | ##### Extract token from a backup on Android phones that do not allow non-encrypted backups
47 | * Use the steps from above but install Java and use [backup extractor](https://github.com/nelenkov/android-backup-extractor) to extract the encrypted backup.
48 | ```
49 | $ java -jar abe-all.jar unpack mi-home-backup.ab unpack mi-home-backup.tar
50 | This backup is encrypted, please provide the password
51 | Password:
52 |
53 | # extract without header trick
54 | $ tar -zxf mi-home-backup.tar
55 |
56 | # db file is accessible
57 | $ ls apps/com.xiaomi.smarthome/db/
58 | geofencing.db google_app_measurement.db miio.db miio2.db mistat.db
59 | geofencing.db-journal google_app_measurement.db-journal miio.db-journal miio2.db-journal mistat.db-journal
60 | ```
61 |
62 | ### iOS users
63 | ### Non-Jailbroken iOS users
64 | * Setup your iOS device with the Mi Home app
65 | * Create an unencrypted backup of your iOS device on your computer using iTunes. In case you are unable to disable encryption you probably have a profile preventing this that enforces certain security policies (like work related accounts). Delete these profiles or use another iOS device to continu.
66 | * Install iBackup Viewer from [here](http://www.imactools.com/iphonebackupviewer/) (another tool that was suggested can be found [here](https://github.com/richinfante/iphonebackuptools)).
67 | * Navigate to your BACKUPS and find the name of your iOS device in the list. Open this backup by clicking the triangle in front of it and then click on raw data.
68 | * Sort the view by name and find the folder com.xiaomi.mihome and highlight it (it's somewhere at the end). After highlighting it click on the cockwheel above the results and select "Save selected files" from here and choose a location to save the files.
69 | * Navigate to the com.xiaomi.mihome folder which you just saved somewhere and inside this folder navigate to the /Documents/ subfolder. In this folder there is a file named _mihome.sqlite where your userid is specific for your account.
70 | * Open this file with a SQLite browser (for instance http://sqlitebrowser.org/)
71 | * Execute the query "select ZTOKEN from ZDEVICE where ZLOCALIP is '192.168.0.1'" where you replace the IP address with the IP address of the Mi Home device you want to get the token from. It will show you the 32 character device token for your Mi Home device.
72 | * The latest Mi Home app store the tokens encrypted into a 96 character key and require an extra step to decode this into the actual token. Visit [this](http://aes.online-domain-tools.com/) website and enter the details as shown below:
73 | ** __Input type:__ text
74 | * __Input text (hex):__ your 96 character key
75 | * __Selectbox Plaintext / Hex:__ Hex
76 | * __Function:__ AES
77 | * __Mode:__ ECB
78 | * __Key (hex):__ 00000000000000000000000000000000
79 | * __Selectbox Plaintext / Hex:__ Hex
80 | * Hit the decrypt button. Your token are the first two lines of the right block of code. These two lines should contain a token of 32 characters and should be the correct token for your device.
81 | * If this tutorial did not work for you, [here](https://github.com/mediter/miio/blob/master/docs/ios-token-without-reset.md) is another that might work.
82 |
83 | ## Jailbroken iOS users
84 | * Setup your iOS device with the Mi Home app
85 | * Use something like Forklift sFTP to connect to your iOS device and copy this file to your computer: /var/mobile/Containers/Data/Application/[UUID]/Documents/USERID_mihome.sqlite (where UUID is a specific number for your device)
86 | * username: root
87 | * IP address: your phones IP address
88 | * password: alpine (unless you changed it something else)
89 | * Open this file with a SQLite browser (for instance http://sqlitebrowser.org/)
90 | * Execute the query "select ZTOKEN from ZDEVICE where ZLOCALIP is '192.168.0.1'" where you replace the IP address with the IP address of the Mi Home device you want to get the token from. It will show you the 32 character device token for your Mi Home device.
91 | * The latest Mi Home app store the tokens encrypted into a 96 character key and require an extra step to decode this into the actual token. Visit [this](http://aes.online-domain-tools.com/) website and enter the details as shown below:
92 | * __Input type:__ text
93 | * __Input text (hex):__ your 96 character key
94 | * __Selectbox Plaintext / Hex:__ Hex
95 | * __Function:__ AES
96 | * __Mode:__ ECB
97 | * __Key (hex):__ 00000000000000000000000000000000
98 | * __Selectbox Plaintext / Hex:__ Hex
99 | * Hit the decrypt button. Your token are the first two lines of the right block of code. These two lines should contain a token of 32 characters and should be the correct token for your device.
100 |
--------------------------------------------------------------------------------
/lib/network.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const EventEmitter = require('events');
4 | const dgram = require('dgram');
5 |
6 | const debug = require('debug');
7 |
8 | const Packet = require('./packet');
9 | const tokens = require('./tokens');
10 |
11 | const safeishJSON = require('./safeishJSON');
12 |
13 | const PORT = 54321;
14 |
15 | const ERRORS = {
16 | '-5001': (method, args, err) => err.message === 'invalid_arg' ? 'Invalid argument' : err.message,
17 | '-5005': (method, args, err) => err.message === 'params error' ? 'Invalid argument' : err.message,
18 | '-10000': (method) => 'Method `' + method + '` is not supported'
19 | };
20 |
21 |
22 | /**
23 | * Class for keeping track of the current network of devices. This is used to
24 | * track a few things:
25 | *
26 | * 1) Mapping between adresses and device identifiers. Used when connecting to
27 | * a device directly via IP or hostname.
28 | *
29 | * 2) Mapping between id and detailed device info such as the model.
30 | *
31 | */
32 | class Network extends EventEmitter {
33 | constructor() {
34 | super();
35 |
36 | this.packet = new Packet(true);
37 |
38 | this.addresses = new Map();
39 | this.devices = new Map();
40 |
41 | this.references = 0;
42 | this.debug = debug('miio:network');
43 | }
44 |
45 | search() {
46 | this.packet.handshake();
47 | const data = Buffer.from(this.packet.raw);
48 | this.socket.send(data, 0, data.length, PORT, '255.255.255.255');
49 |
50 | // Broadcast an extra time in 500 milliseconds in case the first brodcast misses a few devices
51 | setTimeout(() => {
52 | this.socket.send(data, 0, data.length, PORT, '255.255.255.255');
53 | }, 500);
54 | }
55 |
56 | findDevice(id, rinfo) {
57 | // First step, check if we know about the device based on id
58 | let device = this.devices.get(id);
59 | if(! device && rinfo) {
60 | // If we have info about the address, try to resolve again
61 | device = this.addresses.get(rinfo.address);
62 |
63 | if(! device) {
64 | // No device found, keep track of this one
65 | device = new DeviceInfo(this, id, rinfo.address, rinfo.port);
66 | this.devices.set(id, device);
67 | this.addresses.set(rinfo.address, device);
68 |
69 | return device;
70 | }
71 | }
72 |
73 | return device;
74 | }
75 |
76 | findDeviceViaAddress(options) {
77 | if(! this.socket) {
78 | throw new Error('Implementation issue: Using network without a reference');
79 | }
80 |
81 | let device = this.addresses.get(options.address);
82 | if(! device) {
83 | // No device was found at the address, try to discover it
84 | device = new DeviceInfo(this, null, options.address, options.port || PORT);
85 | this.addresses.set(options.address, device);
86 | }
87 |
88 | // Update the token if we have one
89 | if(typeof options.token === 'string') {
90 | device.token = Buffer.from(options.token, 'hex');
91 | } else if(options.token instanceof Buffer) {
92 | device.token = options.token;
93 | }
94 |
95 | // Set the model if provided
96 | if(! device.model && options.model) {
97 | device.model = options.model;
98 | }
99 |
100 | // Perform a handshake with the device to see if we can connect
101 | return device.handshake()
102 | .catch(err => {
103 | if(err.code === 'missing-token') {
104 | // Supress missing tokens - enrich should take care of that
105 | return;
106 | }
107 |
108 | throw err;
109 | })
110 | .then(() => {
111 | if(! this.devices.has(device.id)) {
112 | // This is a new device, keep track of it
113 | this.devices.set(device.id, device);
114 |
115 | return device;
116 | } else {
117 | // Sanity, make sure that the device in the map is returned
118 | return this.devices.get(device.id);
119 | }
120 | })
121 | .then(device => {
122 | /*
123 | * After the handshake, call enrich which will fetch extra
124 | * information such as the model. It will also try to check
125 | * if the provided token (or the auto-token) works correctly.
126 | */
127 | return device.enrich();
128 | })
129 | .then(() => device);
130 | }
131 |
132 | createSocket() {
133 | this._socket = dgram.createSocket('udp4');
134 |
135 | // Bind the socket and when it is ready mark it for broadcasting
136 | this._socket.bind();
137 | this._socket.on('listening', () => {
138 | this._socket.setBroadcast(true);
139 |
140 | const address = this._socket.address();
141 | this.debug('Network bound to port', address.port);
142 | });
143 |
144 | // On any incoming message, parse it, update the discovery
145 | this._socket.on('message', (msg, rinfo) => {
146 | const buf = Buffer.from(msg);
147 | try {
148 | this.packet.raw = buf;
149 | } catch(ex) {
150 | this.debug('Could not handle incoming message');
151 | return;
152 | }
153 |
154 | if(! this.packet.deviceId) {
155 | this.debug('No device identifier in incoming packet');
156 | return;
157 | }
158 |
159 | const device = this.findDevice(this.packet.deviceId, rinfo);
160 | device.onMessage(buf);
161 |
162 | if(! this.packet.data) {
163 | if(! device.enriched) {
164 | // This is the first time we see this device
165 | device.enrich()
166 | .then(() => {
167 | this.emit('device', device);
168 | })
169 | .catch(err => {
170 | this.emit('device', device);
171 | });
172 | } else {
173 | this.emit('device', device);
174 | }
175 | }
176 | });
177 | }
178 |
179 | list() {
180 | return this.devices.values();
181 | }
182 |
183 | /**
184 | * Get a reference to the network. Helps with locking of a socket.
185 | */
186 | ref() {
187 | this.debug('Grabbing reference to network');
188 | this.references++;
189 | this.updateSocket();
190 |
191 | let released = false;
192 | let self = this;
193 | return {
194 | release() {
195 | if(released) return;
196 |
197 | self.debug('Releasing reference to network');
198 |
199 | released = true;
200 | self.references--;
201 |
202 | self.updateSocket();
203 | }
204 | };
205 | }
206 |
207 | /**
208 | * Update wether the socket is available or not. Instead of always keeping
209 | * a socket we track if it is available to allow Node to exit if no
210 | * discovery or device is being used.
211 | */
212 | updateSocket() {
213 | if(this.references === 0) {
214 | // No more references, kill the socket
215 | if(this._socket) {
216 | this.debug('Network no longer active, destroying socket');
217 | this._socket.close();
218 | this._socket = null;
219 | }
220 | } else if(this.references === 1 && ! this._socket) {
221 | // This is the first reference, create the socket
222 | this.debug('Making network active, creating socket');
223 | this.createSocket();
224 | }
225 | }
226 |
227 | get socket() {
228 | if(! this._socket) {
229 | throw new Error('Network communication is unavailable, device might be destroyed');
230 | }
231 |
232 | return this._socket;
233 | }
234 | }
235 |
236 | module.exports = new Network();
237 |
238 | class DeviceInfo {
239 | constructor(parent, id, address, port) {
240 | this.parent = parent;
241 | this.packet = new Packet();
242 |
243 | this.address = address;
244 | this.port = port;
245 |
246 | // Tracker for all promises associated with this device
247 | this.promises = new Map();
248 | this.lastId = 0;
249 |
250 | this.id = id;
251 | this.debug = id ? debug('thing:miio:' + id) : debug('thing:miio:pending');
252 |
253 | // Get if the token has been manually changed
254 | this.tokenChanged = false;
255 | }
256 |
257 | get token() {
258 | return this.packet.token;
259 | }
260 |
261 | set token(t) {
262 | this.debug('Using manual token:', t.toString('hex'));
263 | this.packet.token = t;
264 | this.tokenChanged = true;
265 | }
266 |
267 | /**
268 | * Enrich this device with detailed information about the model. This will
269 | * simply call miIO.info.
270 | */
271 | enrich() {
272 | if(! this.id) {
273 | throw new Error('Device has no identifier yet, handshake needed');
274 | }
275 |
276 | if(this.model && ! this.tokenChanged && this.packet.token) {
277 | // This device has model info and a valid token
278 | return Promise.resolve();
279 | }
280 |
281 | if(this.enrichPromise) {
282 | // If enrichment is already happening
283 | return this.enrichPromise;
284 | }
285 |
286 | // Check if there is a token available, otherwise try to resolve it
287 | let promise;
288 | if(! this.packet.token) {
289 | // No automatic token found - see if we have a stored one
290 | this.debug('Loading token from storage, device hides token and no token set via options');
291 | this.autoToken = false;
292 | promise = tokens.get(this.id)
293 | .then(token => {
294 | this.debug('Using stored token:', token);
295 | this.packet.token = Buffer.from(token, 'hex');
296 | this.tokenChanged = true;
297 | });
298 | } else {
299 | if(this.tokenChanged) {
300 | this.autoToken = false;
301 | } else {
302 | this.autoToken = true;
303 | this.debug('Using automatic token:', this.packet.token.toString('hex'));
304 | }
305 | promise = Promise.resolve();
306 | }
307 |
308 | return this.enrichPromise = promise
309 | .then(() => this.call('miIO.info'))
310 | .then(data => {
311 | this.enriched = true;
312 | this.model = data.model;
313 | this.tokenChanged = false;
314 |
315 | this.enrichPromise = null;
316 | })
317 | .catch(err => {
318 | this.enrichPromise = null;
319 | this.enriched = true;
320 |
321 | if(err.code === 'missing-token') {
322 | // Rethrow some errors
323 | err.device = this;
324 | throw err;
325 | }
326 |
327 | if(this.packet.token) {
328 | // Could not call the info method, this might be either a timeout or a token problem
329 | const e = new Error('Could not connect to device, token might be wrong');
330 | e.code = 'connection-failure';
331 | e.device = this;
332 | throw e;
333 | } else {
334 | const e = new Error('Could not connect to device, token needs to be specified');
335 | e.code = 'missing-token';
336 | e.device = this;
337 | throw e;
338 | }
339 | });
340 | }
341 |
342 | onMessage(msg) {
343 | try {
344 | this.packet.raw = msg;
345 | } catch(ex) {
346 | this.debug('<- Unable to parse packet', ex);
347 | return;
348 | }
349 |
350 | let data = this.packet.data;
351 | if(data === null) {
352 | this.debug('<-', 'Handshake reply:', this.packet.checksum);
353 | this.packet.handleHandshakeReply();
354 |
355 | if(this.handshakeResolve) {
356 | this.handshakeResolve();
357 | }
358 | } else {
359 | // Handle null-terminated strings
360 | if(data[data.length - 1] === 0) {
361 | data = data.slice(0, data.length - 1);
362 | }
363 |
364 | // Parse and handle the JSON message
365 | let str = data.toString('utf8');
366 |
367 | // Remove non-printable characters to help with invalid JSON from devices
368 | str = str.replace(/[\x00-\x09\x0B-\x0C\x0E-\x1F\x7F-\x9F]/g, ''); // eslint-disable-line
369 |
370 | this.debug('<- Message: `' + str + '`');
371 | try {
372 | let object = safeishJSON(str);
373 |
374 | const p = this.promises.get(object.id);
375 | if(! p) return;
376 | if(typeof object.result !== 'undefined') {
377 | p.resolve(object.result);
378 | } else {
379 | p.reject(object.error);
380 | }
381 | } catch(ex) {
382 | this.debug('<- Invalid JSON', ex);
383 | }
384 | }
385 | }
386 |
387 | handshake() {
388 | if(! this.packet.needsHandshake) {
389 | return Promise.resolve(this.token);
390 | }
391 |
392 | // If a handshake is already in progress use it
393 | if(this.handshakePromise) {
394 | return this.handshakePromise;
395 | }
396 |
397 | return this.handshakePromise = new Promise((resolve, reject) => {
398 | // Create and send the handshake data
399 | this.packet.handshake();
400 | const data = this.packet.raw;
401 | this.parent.socket.send(data, 0, data.length, this.port, this.address, err => err && reject(err));
402 |
403 | // Handler called when a reply to the handshake is received
404 | this.handshakeResolve = () => {
405 | clearTimeout(this.handshakeTimeout);
406 | this.handshakeResolve = null;
407 | this.handshakeTimeout = null;
408 | this.handshakePromise = null;
409 |
410 | if(this.id !== this.packet.deviceId) {
411 | // Update the identifier if needed
412 | this.id = this.packet.deviceId;
413 | this.debug = debug('thing:miio:' + this.id);
414 | this.debug('Identifier of device updated');
415 | }
416 |
417 | if(this.packet.token) {
418 | resolve();
419 | } else {
420 | const err = new Error('Could not connect to device, token needs to be specified');
421 | err.code = 'missing-token';
422 | reject(err);
423 | }
424 | };
425 |
426 | // Timeout for the handshake
427 | this.handshakeTimeout = setTimeout(() => {
428 | this.handshakeResolve = null;
429 | this.handshakeTimeout = null;
430 | this.handshakePromise = null;
431 |
432 | const err = new Error('Could not connect to device, handshake timeout');
433 | err.code = 'timeout';
434 | reject(err);
435 | }, 2000);
436 | });
437 | }
438 |
439 | call(method, args, options) {
440 | if(typeof args === 'undefined') {
441 | args = [];
442 | }
443 |
444 | const request = {
445 | method: method,
446 | params: args
447 | };
448 |
449 | if(options && options.sid) {
450 | // If we have a sub-device set it (used by Lumi Smart Home Gateway)
451 | request.sid = options.sid;
452 | }
453 |
454 | return new Promise((resolve, reject) => {
455 | let resolved = false;
456 |
457 | // Handler for incoming messages
458 | const promise = {
459 | resolve: res => {
460 | resolved = true;
461 | this.promises.delete(request.id);
462 |
463 | resolve(res);
464 | },
465 | reject: err => {
466 | resolved = true;
467 | this.promises.delete(request.id);
468 |
469 | if(! (err instanceof Error) && typeof err.code !== 'undefined') {
470 | const code = err.code;
471 |
472 | const handler = ERRORS[code];
473 | let msg;
474 | if(handler) {
475 | msg = handler(method, args, err.message);
476 | } else {
477 | msg = err.message || err.toString();
478 | }
479 |
480 | err = new Error(msg);
481 | err.code = code;
482 | }
483 | reject(err);
484 | }
485 | };
486 |
487 | let retriesLeft = (options && options.retries) || 5;
488 | const retry = () => {
489 | if(retriesLeft-- > 0) {
490 | send();
491 | } else {
492 | this.debug('Reached maximum number of retries, giving up');
493 | const err = new Error('Call to device timed out');
494 | err.code = 'timeout';
495 | promise.reject(err);
496 | }
497 | };
498 |
499 | const send = () => {
500 | if(resolved) return;
501 |
502 | this.handshake()
503 | .catch(err => {
504 | if(err.code === 'timeout') {
505 | this.debug('<- Handshake timed out');
506 | retry();
507 | return false;
508 | } else {
509 | throw err;
510 | }
511 | })
512 | .then(token => {
513 | // Token has timed out - handled via retry
514 | if(! token) return;
515 |
516 | // Assign the identifier before each send
517 | let id;
518 | if(request.id) {
519 | /*
520 | * This is a failure, increase the last id. Should
521 | * increase the chances of the new request to
522 | * succeed. Related to issues with the vacuum
523 | * not responding such as described in issue #94.
524 | */
525 | id = this.lastId + 100;
526 |
527 | // Make sure to remove the failed promise
528 | this.promises.delete(request.id);
529 | } else {
530 | id = this.lastId + 1;
531 | }
532 |
533 | // Check that the id hasn't rolled over
534 | if(id >= 10000) {
535 | this.lastId = id = 1;
536 | } else {
537 | this.lastId = id;
538 | }
539 |
540 | // Assign the identifier
541 | request.id = id;
542 |
543 | // Store reference to the promise so reply can be received
544 | this.promises.set(id, promise);
545 |
546 | // Create the JSON and send it
547 | const json = JSON.stringify(request);
548 | this.debug('-> (' + retriesLeft + ')', json);
549 | this.packet.data = Buffer.from(json, 'utf8');
550 |
551 | const data = this.packet.raw;
552 |
553 | this.parent.socket.send(data, 0, data.length, this.port, this.address, err => err && promise.reject(err));
554 |
555 | // Queue a retry in 2 seconds
556 | setTimeout(retry, 2000);
557 | })
558 | .catch(promise.reject);
559 | };
560 |
561 | send();
562 | });
563 | }
564 | }
565 |
--------------------------------------------------------------------------------