├── .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 | --------------------------------------------------------------------------------