├── .gitignore ├── lib ├── devices │ ├── yeelight.color.js │ ├── yeelight.mono.js │ ├── gateway │ │ ├── magnet2.js │ │ ├── sensor_ht.js │ │ ├── weather.js │ │ ├── switch2.js │ │ ├── motion.js │ │ ├── 86sw1.js │ │ ├── switch.js │ │ ├── voltage.js │ │ ├── light-channel.js │ │ ├── magnet.js │ │ ├── motion2.js │ │ ├── subdevices.js │ │ ├── ctrl_ln1.js │ │ ├── ctrl_neutral1.js │ │ ├── plug.js │ │ ├── 86sw2.js │ │ ├── ctrl_ln2.js │ │ ├── ctrl_neutral2.js │ │ ├── cube.js │ │ ├── light-mixin.js │ │ ├── developer-api.js │ │ └── subdevice.js │ ├── capabilities │ │ ├── mode.js │ │ ├── battery-level.js │ │ ├── power.js │ │ ├── colorable.js │ │ ├── dimmable.js │ │ ├── switchable-led.js │ │ ├── changeable-led-brightness.js │ │ ├── sensor.js │ │ └── buzzer.js │ ├── power-plug.js │ ├── power-strip.js │ ├── air-monitor.js │ ├── chuangmi.plug.v1.js │ ├── philips-light-bulb.js │ ├── humidifier.js │ ├── eyecare-lamp2.js │ ├── air-purifier.js │ ├── gateway.js │ ├── vacuum.js │ └── yeelight.js ├── placeholder.js ├── safeishJSON.js ├── infoFromHostname.js ├── index.js ├── connectToDevice.js ├── models.js ├── management.js ├── tokens.js ├── discovery.js ├── packet.js ├── device.js └── network.js ├── cli ├── index.js ├── commands │ ├── tokens.js │ ├── protocol.js │ ├── protocol │ │ ├── packet.js │ │ ├── call.js │ │ └── json-dump.js │ ├── discover.js │ ├── control.js │ ├── tokens │ │ └── update.js │ ├── configure.js │ └── inspect.js ├── device-finder.js └── log.js ├── example.js ├── .editorconfig ├── .eslintrc ├── package.json ├── LICENSE.md ├── docs ├── devices │ ├── gateway.md │ ├── vacuum.md │ ├── power-outlet.md │ ├── wall-switch.md │ ├── sensor.md │ ├── power-plug.md │ ├── power-strip.md │ ├── controller.md │ ├── light.md │ ├── humidifier.md │ ├── air-purifier.md │ └── README.md ├── reporting-issues.md ├── missing-devices.md ├── advanced-api.md ├── protocol.md └── management.md ├── CONTRIBUTING.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /lib/devices/yeelight.color.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Yeelight, ColorFull } = require('./yeelight'); 4 | 5 | module.exports = class YeelightColor extends Yeelight.with(ColorFull) { 6 | }; 7 | -------------------------------------------------------------------------------- /cli/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict'; 3 | 4 | const path = require('path'); 5 | require('yargs') 6 | .commandDir(path.join(__dirname, 'commands')) 7 | .recommendCommands() 8 | .demandCommand() 9 | .argv; 10 | -------------------------------------------------------------------------------- /lib/devices/yeelight.mono.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Yeelight, ColorTemperature } = require('./yeelight'); 4 | 5 | module.exports = class YeelightMono extends Yeelight.with(ColorTemperature) { 6 | constructor(options) { 7 | super(options); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /lib/devices/gateway/magnet2.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Magnet = require('./magnet'); 4 | 5 | module.exports = class Magnet2 extends Magnet { 6 | 7 | constructor(...args) { 8 | super(...args); 9 | 10 | this.miioModel = 'lumi.magnet.aq2'; 11 | } 12 | 13 | }; 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /cli/commands/tokens.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | exports.command = 'tokens '; 6 | exports.description = 'Manage tokens of devices'; 7 | exports.builder = yargs => yargs.commandDir(path.join(__dirname, 'tokens')); 8 | exports.handler = () => {}; 9 | -------------------------------------------------------------------------------- /cli/commands/protocol.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | exports.command = 'protocol '; 6 | exports.description = 'Inspect and test raw miIO-commands'; 7 | exports.builder = yargs => yargs.commandDir(path.join(__dirname, 'protocol')); 8 | exports.handler = () => {}; 9 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | const miio = require('./lib'); 4 | 5 | // Create a new device over the given address 6 | miio.device({ 7 | address: 'ipHere', 8 | }).then(device => { 9 | console.log('Connected to device'); 10 | console.log(device); 11 | }).catch(err => console.log('Error occurred:', err)); 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [package.json] 16 | indent_style = space 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /lib/devices/capabilities/mode.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing, SwitchableMode } = require('abstract-things'); 4 | 5 | module.exports = Thing.mixin(Parent => class extends Parent.with(SwitchableMode) { 6 | propertyUpdated(key, value) { 7 | if(key === 'mode') { 8 | this.updateMode(value); 9 | } 10 | 11 | super.propertyUpdated(key, value); 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /lib/devices/capabilities/battery-level.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing, BatteryLevel } = require('abstract-things'); 4 | 5 | module.exports = Thing.mixin(Parent => class extends Parent.with(BatteryLevel) { 6 | propertyUpdated(key, value) { 7 | if(key === 'batteryLevel') { 8 | this.updateBatteryLevel(value); 9 | } 10 | 11 | super.propertyUpdated(key, value); 12 | } 13 | }); 14 | 15 | -------------------------------------------------------------------------------- /lib/devices/capabilities/power.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing, SwitchablePower } = require('abstract-things'); 4 | 5 | module.exports = Thing.mixin(Parent => class extends Parent.with(SwitchablePower) { 6 | propertyUpdated(key, value) { 7 | if(key === 'power' && value !== undefined) { 8 | this.updatePower(value); 9 | } 10 | 11 | super.propertyUpdated(key, value); 12 | } 13 | }); 14 | 15 | -------------------------------------------------------------------------------- /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/capabilities/colorable.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing } = require('abstract-things'); 4 | const { Colorable } = require('abstract-things/lights'); 5 | 6 | module.exports = Thing.mixin(Parent => class extends Parent.with(Colorable) { 7 | propertyUpdated(key, value) { 8 | if(key === 'color') { 9 | this.updateColor(value); 10 | } 11 | 12 | super.propertyUpdated(key, value); 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /lib/devices/capabilities/dimmable.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing } = require('abstract-things'); 4 | const { Dimmable } = require('abstract-things/lights'); 5 | 6 | module.exports = Thing.mixin(Parent => class extends Parent.with(Dimmable) { 7 | propertyUpdated(key, value) { 8 | if(key === 'brightness') { 9 | this.updateBrightness(value); 10 | } 11 | 12 | super.propertyUpdated(key, value); 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/devices/gateway/sensor_ht.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const SubDevice = require('./subdevice'); 5 | const { Temperature, Humidity } = require('../capabilities/sensor'); 6 | const Voltage = require('./voltage'); 7 | 8 | module.exports = class SensorHT extends SubDevice.with(Temperature, Humidity, Voltage) { 9 | constructor(parent, info) { 10 | super(parent, info); 11 | 12 | this.miioModel = 'lumi.sensor_ht'; 13 | 14 | this.defineProperty('temperature', v => v / 100.0); 15 | this.defineProperty('humidity', v => v / 100.0); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ "node" ], 3 | "extends": [ "eslint:recommended", "plugin:node/recommended" ], 4 | "env": {}, 5 | "globals": {}, 6 | "rules": { 7 | "strict": [ "error", "global" ], 8 | "node/no-unsupported-features": [ 9 | "error", 10 | { 11 | "version": 6 12 | } 13 | ], 14 | "no-irregular-whitespace": 2, 15 | "quotes": [ 16 | 2, 17 | "single" 18 | ], 19 | "no-unused-vars": [ 20 | "error", 21 | { "vars": "all", "args": "none", "ignoreRestSiblings": false } 22 | ], 23 | "eqeqeq": [ "error" ], 24 | "no-throw-literal": [ "error" ], 25 | "semi": [ "error", "always" ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/devices/gateway/weather.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const SubDevice = require('./subdevice'); 5 | const { Temperature, Humidity, AtmosphericPressure } = require('../capabilities/sensor'); 6 | const Voltage = require('./voltage'); 7 | 8 | module.exports = class WeatherSensor extends SubDevice 9 | .with(Temperature, Humidity, AtmosphericPressure, Voltage) 10 | { 11 | constructor(parent, info) { 12 | super(parent, info); 13 | 14 | this.miioModel = 'lumi.weather'; 15 | 16 | this.defineProperty('temperature', v => v / 100.0); 17 | this.defineProperty('humidity', v => v / 100.0); 18 | this.defineProperty('pressure', { 19 | name: 'atmosphericPressure' 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /lib/devices/gateway/switch2.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const SubDevice = require('./subdevice'); 4 | const { Button, Actions } = require('abstract-things/controllers'); 5 | const Voltage = require('./voltage'); 6 | 7 | module.exports = class Switch extends SubDevice.with(Button, Actions, Voltage) { 8 | constructor(parent, info) { 9 | super(parent, info); 10 | 11 | this.miioModel = 'lumi.switch.v2'; 12 | 13 | this.updateActions([ 14 | 'click', 15 | 'double_click' 16 | ]); 17 | } 18 | 19 | _report(data) { 20 | super._report(data); 21 | 22 | if(typeof data.status !== 'undefined') { 23 | this.debug('Action performed:', data.status); 24 | this.emitAction(data.status); 25 | } 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /lib/devices/power-plug.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing } = require('abstract-things'); 4 | const { PowerPlug, PowerOutlet } = require('abstract-things/electrical'); 5 | const MiioApi = require('../device'); 6 | const Power = require('./capabilities/power'); 7 | 8 | module.exports = class extends Thing 9 | .with(PowerPlug, PowerOutlet, MiioApi, Power) 10 | { 11 | static get type() { 12 | return 'miio:power-plug'; 13 | } 14 | 15 | constructor(options) { 16 | super(options); 17 | 18 | this.defineProperty('power', { 19 | mapper: v => v === 'on' 20 | }); 21 | } 22 | 23 | changePower(power) { 24 | return this.call('set_power', [ power ? 'on' : 'off' ], { refresh: [ 'power' ] }); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /lib/devices/gateway/motion.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const SubDevice = require('./subdevice'); 4 | const { Motion } = require('abstract-things/sensors'); 5 | const Voltage = require('./voltage'); 6 | 7 | /** 8 | * Motion sensing device, emits the event `motion` whenever motion is detected. 9 | */ 10 | module.exports = class extends SubDevice.with(Motion, Voltage) { 11 | constructor(parent, info) { 12 | super(parent, info); 13 | 14 | this.miioModel = 'lumi.motion'; 15 | 16 | this.updateMotion(false); 17 | 18 | } 19 | 20 | _report(data) { 21 | super._report(data); 22 | 23 | if(typeof data.status !== 'undefined' && data.status === 'motion') { 24 | this.updateMotion(true, '1m'); 25 | } 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /lib/devices/gateway/86sw1.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const SubDevice = require('./subdevice'); 4 | const { WallController, Actions } = require('abstract-things/controllers'); 5 | 6 | module.exports = class Switch1 extends SubDevice.with(WallController, Actions) { 7 | constructor(parent, info) { 8 | super(parent, info); 9 | 10 | this.type = 'controller'; 11 | this.miioModel = 'lumi.86sw1'; 12 | 13 | this.updateActions([ 14 | 'click', 15 | 'double_click' 16 | ]); 17 | } 18 | 19 | _report(data) { 20 | super._report(data); 21 | 22 | if(typeof data['channel_0'] !== 'undefined') { 23 | const action = data['channel_0']; 24 | this.debug('Action performed:', action); 25 | this.emitAction(action); 26 | } 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /lib/devices/gateway/switch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const SubDevice = require('./subdevice'); 4 | const { Button, Actions } = require('abstract-things/controllers'); 5 | const Voltage = require('./voltage'); 6 | 7 | module.exports = class Switch extends SubDevice.with(Button, Actions, Voltage) { 8 | constructor(parent, info) { 9 | super(parent, info); 10 | 11 | this.miioModel = 'lumi.switch'; 12 | 13 | this.updateActions([ 14 | 'click', 15 | 'double_click', 16 | 'long_click_press', 17 | 'long_click_release' 18 | ]); 19 | } 20 | 21 | _report(data) { 22 | super._report(data); 23 | 24 | if(typeof data.status !== 'undefined') { 25 | this.debug('Action performed:', data.status); 26 | this.emitAction(data.status); 27 | } 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /lib/devices/gateway/voltage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing, BatteryLevel } = require('abstract-things'); 4 | 5 | const VOLTAGE_MIN = 2800; 6 | const VOLTAGE_MAX = 3300; 7 | 8 | /** 9 | * Mixin for subdevices that support reporting voltage and that can be 10 | * transformed into a battery level. 11 | */ 12 | module.exports = Thing.mixin(Parent => class extends Parent.with(BatteryLevel) { 13 | 14 | constructor(...args) { 15 | super(...args); 16 | 17 | this.defineProperty('voltage'); 18 | } 19 | 20 | propertyUpdated(key, value, oldValue) { 21 | if(key === 'voltage' && value) { 22 | this.updateBatteryLevel(Number((value - VOLTAGE_MIN) / (VOLTAGE_MAX - VOLTAGE_MIN) * 100).toFixed(2)); 23 | } 24 | 25 | super.propertyUpdated(key, value, oldValue); 26 | } 27 | 28 | }); 29 | -------------------------------------------------------------------------------- /lib/devices/gateway/light-channel.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Light, SwitchablePower } = require('abstract-things/lights'); 4 | 5 | /** 6 | * Representation of a power channel. 7 | */ 8 | module.exports = class LightChannel extends Light.with(SwitchablePower) { 9 | static get types() { 10 | return [ 'miio:subdevice', 'miio:power-channel', 'miio:light' ]; 11 | } 12 | 13 | constructor(parent, channel) { 14 | super(); 15 | 16 | this.id = parent.id + ':' + channel; 17 | 18 | this.parent = parent; 19 | this.channel = channel; 20 | 21 | this.updateName(); 22 | } 23 | 24 | updateName() { 25 | this.metadata.name = this.parent.name + ' - ' + this.channel; 26 | } 27 | 28 | changePower(power) { 29 | return this.parent.changePowerChannel(this.channel, power); 30 | } 31 | 32 | }; 33 | -------------------------------------------------------------------------------- /lib/devices/gateway/magnet.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const SubDevice = require('./subdevice'); 5 | const { Contact } = require('abstract-things/sensors'); 6 | const Voltage = require('./voltage'); 7 | 8 | /** 9 | * Magnet device, emits events `open` and `close` if the state changes. 10 | */ 11 | module.exports = class Magnet extends SubDevice.with(Contact, Voltage) { 12 | constructor(parent, info) { 13 | super(parent, info); 14 | 15 | this.miioModel = 'lumi.magnet'; 16 | 17 | this.defineProperty('status'); 18 | } 19 | 20 | propertyUpdated(key, value, oldValue) { 21 | if(key === 'status') { 22 | // Change the contact state 23 | const isContact = value === 'close'; 24 | this.updateContact(isContact); 25 | } 26 | 27 | super.propertyUpdated(key, value, oldValue); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /cli/commands/protocol/packet.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const log = require('../../log'); 4 | const Packet = require('../../../lib/packet'); 5 | 6 | exports.command = 'packet '; 7 | exports.description = 'Decode a miIO UDP packet'; 8 | exports.builder = { 9 | token: { 10 | required: true, 11 | description: 'Token to use for decoding' 12 | } 13 | }; 14 | 15 | exports.handler = function(argv) { 16 | const packet = new Packet(); 17 | packet.token = Buffer.from(argv.token, 'hex'); 18 | 19 | const raw = Buffer.from(argv.hexData, 'hex'); 20 | packet.raw = raw; 21 | 22 | const data = packet.data; 23 | if(! data) { 24 | log.error('Could not extract data from packet, check your token and packet data'); 25 | } else { 26 | log.plain('Hex: ', data.toString('hex')); 27 | log.plain('String: ', data.toString()); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /lib/devices/gateway/motion2.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const SubDevice = require('./subdevice'); 4 | const { Motion } = require('abstract-things/sensors'); 5 | const { Illuminance } = require('../capabilities/sensor'); 6 | const Voltage = require('./voltage'); 7 | 8 | /** 9 | * Motion sensing device, emits the event `motion` whenever motion is detected. 10 | */ 11 | module.exports = class extends SubDevice.with(Motion, Illuminance, Voltage) { 12 | constructor(parent, info) { 13 | super(parent, info); 14 | 15 | this.miioModel = 'lumi.motion.aq2'; 16 | 17 | this.defineProperty('status'); 18 | 19 | this.defineProperty('lux', { 20 | name: 'illuminance' 21 | }); 22 | } 23 | 24 | _report(data) { 25 | super._report(data); 26 | 27 | if(typeof data.status !== 'undefined' && data.status === 'motion') { 28 | this.updateMotion(true, '1m'); 29 | } 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /lib/devices/power-strip.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { PowerStrip } = require('abstract-things/electrical'); 4 | 5 | const MiioApi = require('../device'); 6 | const Power = require('./capabilities/power'); 7 | const Mode = require('./capabilities/mode'); 8 | 9 | module.exports = class extends PowerStrip 10 | .with(MiioApi, Power, Mode) 11 | { 12 | static get type() { 13 | return 'miio:power-strip'; 14 | } 15 | 16 | constructor(options) { 17 | super(options); 18 | 19 | this.defineProperty('power', { 20 | mapper: v => v === 'n' 21 | }); 22 | 23 | this.updateModes([ 24 | 'green', 25 | 'normal' 26 | ]); 27 | 28 | this.defineProperty('mode'); 29 | } 30 | 31 | changePower(power) { 32 | return this.call('set_power', [ power ? 'on' : 'off' ], { refresh: [ 'power' ] }); 33 | } 34 | 35 | changeMode(mode) { 36 | return this.call('set_power_mode', [ mode ]); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /cli/commands/discover.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const log = require('../log'); 4 | const deviceFinder = require('../device-finder'); 5 | const tokens = require('../../lib/tokens'); 6 | 7 | exports.command = 'discover'; 8 | exports.description = 'Discover devices on the local network'; 9 | exports.builder = { 10 | 'sync': { 11 | type: 'boolean', 12 | description: 'Synchronize tokens' 13 | } 14 | }; 15 | 16 | exports.handler = function(argv) { 17 | log.info('Discovering devices. Press Ctrl+C to stop.'); 18 | log.plain(); 19 | 20 | const browser = deviceFinder(); 21 | browser.on('available', device => { 22 | try { 23 | log.device(device); 24 | } catch(ex) { 25 | log.error(ex); 26 | } 27 | 28 | const mgmt = device.management; 29 | if(argv.sync && mgmt.token && mgmt.autoToken) { 30 | tokens.update(device.id, mgmt.token) 31 | .catch(err => { 32 | log.error('Could not update token for', device.id, ':', err); 33 | }); 34 | } 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miio", 3 | "version": "0.15.4", 4 | "license": "MIT", 5 | "description": "Control Mi Home devices, such as Mi Robot Vacuums, Mi Air Purifiers, Mi Smart Home Gateway (Aqara) and more", 6 | "repository": "aholstenson/miio", 7 | "main": "lib/index.js", 8 | "keywords": [ 9 | "xiaomi", 10 | "mi", 11 | "miio", 12 | "aqara", 13 | "yeelight", 14 | "mijia" 15 | ], 16 | "bin": { 17 | "miio": "./cli/index.js" 18 | }, 19 | "scripts": { 20 | "test": "node_modules/.bin/eslint ." 21 | }, 22 | "engines": { 23 | "node": ">=6.6.0" 24 | }, 25 | "dependencies": { 26 | "abstract-things": "^0.9.0", 27 | "appdirectory": "^0.1.0", 28 | "chalk": "^2.3.0", 29 | "debug": "^3.1.0", 30 | "deep-equal": "^1.0.1", 31 | "mkdirp": "^0.5.1", 32 | "tinkerhub-discovery": "^0.3.1", 33 | "yargs": "^10.1.1" 34 | }, 35 | "devDependencies": { 36 | "eslint": "^4.14.0", 37 | "eslint-plugin-node": "^5.2.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2017 Andreas Holstenson 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/devices/gateway/subdevices.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | // Switch (button) controller 5 | 1: require('./switch'), 6 | // Motion sensor 7 | 2: require('./motion'), 8 | // Magnet sensor for doors and windows 9 | 3: require('./magnet'), 10 | 11 | // TODO: What is 4, 5 and 6? plug, 86sw1 and 86sw2 are left 12 | 13 | // Light switch with two channels 14 | 7: require('./ctrl_neutral2'), 15 | // Cube controller 16 | 8: require('./cube'), 17 | // Light switch with one channel 18 | 9: require('./ctrl_neutral1'), 19 | // Temperature and Humidity sensor 20 | 10: require('./sensor_ht'), 21 | // Plug 22 | 11: require('./plug'), 23 | 24 | // Aqara Temperature/Humidity/Pressure sensor 25 | 19: require('./weather'), 26 | // Light switch (live+neutral wire version) with one channel 27 | 20: require('./ctrl_ln1'), 28 | // Light switch (live+neutral wire version) with two channels 29 | 21: require('./ctrl_ln2'), 30 | 31 | // AQ2 switch (square version) 32 | 51: require('./switch2'), 33 | // AQ2 motion sensor 34 | 52: require('./motion2'), 35 | // AQ2 manget sensor 36 | 53: require('./magnet2') 37 | }; 38 | -------------------------------------------------------------------------------- /lib/devices/gateway/ctrl_ln1.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Children } = require('abstract-things'); 4 | const { WallSwitch } = require('abstract-things/electrical'); 5 | 6 | const SubDevice = require('./subdevice'); 7 | const LightChannel = require('./light-channel'); 8 | 9 | /** 10 | * Single-channel L-N version of light switch. 11 | */ 12 | module.exports = class CtrlLN1 extends WallSwitch.with(SubDevice, Children) { 13 | 14 | static get type() { 15 | return 'miio:power-switch'; 16 | } 17 | 18 | constructor(parent, info) { 19 | super(parent, info); 20 | 21 | this.miioModel = 'lumi.ctrl_ln1'; 22 | 23 | this.defineProperty('channel_0', { 24 | name: 'powerChannel0', 25 | mapper: v => v === 'on' 26 | }); 27 | 28 | this.addChild(new LightChannel(this, 0)); 29 | } 30 | 31 | changePowerChannel(channel, power) { 32 | return this.call('toggle_ctrl_neutral', [ 'neutral_' + channel, power ? 'on' : 'off' ]); 33 | } 34 | 35 | propertyUpdated(key, value) { 36 | super.propertyUpdated(key, value); 37 | 38 | switch(key) { 39 | case 'powerChannel0': 40 | this.child('0').updatePower(value); 41 | break; 42 | } 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /lib/devices/gateway/ctrl_neutral1.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Children } = require('abstract-things'); 4 | const { WallSwitch } = require('abstract-things/electrical'); 5 | 6 | const SubDevice = require('./subdevice'); 7 | const LightChannel = require('./light-channel'); 8 | 9 | /** 10 | * Single-channel light switch. 11 | */ 12 | module.exports = class CtrlNeutral1 extends WallSwitch.with(SubDevice, Children) { 13 | 14 | static get type() { 15 | return 'miio:power-switch'; 16 | } 17 | 18 | constructor(parent, info) { 19 | super(parent, info); 20 | 21 | this.miioModel = 'lumi.ctrl_neutral1'; 22 | 23 | this.defineProperty('channel_0', { 24 | name: 'powerChannel0', 25 | mapper: v => v === 'on' 26 | }); 27 | 28 | this.addChild(new LightChannel(this, 0)); 29 | } 30 | 31 | changePowerChannel(channel, power) { 32 | return this.call('toggle_ctrl_neutral', [ 'neutral_' + channel, power ? 'on' : 'off' ]); 33 | } 34 | 35 | propertyUpdated(key, value) { 36 | super.propertyUpdated(key, value); 37 | 38 | switch(key) { 39 | case 'powerChannel0': 40 | this.child('0').updatePower(value); 41 | break; 42 | } 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /lib/devices/gateway/plug.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const { PowerPlug, PowerOutlet } = require('abstract-things/electrical'); 5 | 6 | const SubDevice = require('./subdevice'); 7 | const Power = require('../capabilities/power'); 8 | const { PowerLoad, PowerConsumed } = require('../capabilities/sensor'); 9 | 10 | /** 11 | * Wall plug. Can be turned on or off. 12 | */ 13 | module.exports = class Plug extends SubDevice 14 | .with(PowerPlug, PowerOutlet, Power, PowerLoad, PowerConsumed) 15 | { 16 | 17 | constructor(parent, info) { 18 | super(parent, info); 19 | 20 | this.miioModel = 'lumi.plug'; 21 | 22 | this.defineProperty('status', { 23 | name: 'power', 24 | mapper: v => (v === '') ? undefined : (v === 'on') 25 | }); 26 | 27 | this.defineProperty('load_voltage', { 28 | name: 'loadVoltage', 29 | mapper: parseInt 30 | }); 31 | this.defineProperty('load_power', { 32 | name: 'powerLoad', 33 | mapper: parseInt 34 | }); 35 | this.defineProperty('power_consumed', { 36 | name: 'powerConsumed', 37 | mapper: parseFloat 38 | }); 39 | } 40 | 41 | changePower(power) { 42 | return this.call('toggle_plug', [ 'neutral_0', power ? 'on' : 'off' ]); 43 | } 44 | 45 | }; 46 | -------------------------------------------------------------------------------- /lib/devices/gateway/86sw2.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const SubDevice = require('./subdevice'); 4 | const { WallController, Actions } = require('abstract-things/controllers'); 5 | 6 | module.exports = class Switch2 extends SubDevice.with(WallController, Actions) { 7 | constructor(parent, info) { 8 | super(parent, info); 9 | 10 | this.type = 'controller'; 11 | this.miioModel = 'lumi.86sw2'; 12 | 13 | this.updateActions([ 14 | 'btn0-click', 15 | 'btn0-double_click', 16 | 'btn1-click', 17 | 'btn1-double_click', 18 | 'both_click' 19 | ]); 20 | } 21 | 22 | _report(data) { 23 | super._report(data); 24 | 25 | if(typeof data['channel_0'] !== 'undefined') { 26 | const action = 'btn0-' + data['channel_0']; 27 | this.debug('Action performed:', action); 28 | this.emitAction(action); 29 | } 30 | 31 | if(typeof data['channel_1'] !== 'undefined') { 32 | const action = 'btn1-' + data['channel_1']; 33 | this.debug('Action performed:', action); 34 | this.emitAction(action); 35 | } 36 | 37 | if(typeof data['dual_channel'] !== 'undefined') { 38 | const action = data['dual_channel']; 39 | 40 | this.debug('Action performed:', action); 41 | this.emitAction(action); 42 | } 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/devices/gateway.md: -------------------------------------------------------------------------------- 1 | # Gateways 2 | 3 | * **Devices**: Mi Smart Home Gateway 2 and 3 4 | * **Model identifiers**: `lumi.gateway.v2`, `lumi.gateway.v3` 5 | 6 | Support for the Mi Smart Home Gateway that provides access to a set of smaller 7 | devices such as switches, motion detection and temperature and humidity sensors. 8 | 9 | **Note** To fully support the gateway this library will automatically enable 10 | the Local Developer API of the gateway. If it is already enabled the existing 11 | key is used but if not a new key is generated and set for the API. 12 | 13 | ## Examples 14 | 15 | ### Check if the device is a gateway 16 | 17 | ```javascript 18 | if(device.matches('type:miio:gateway')) { 19 | /* 20 | * This device is a Mi Gateway. 21 | */ 22 | } 23 | ``` 24 | 25 | ### Get child devices 26 | 27 | ```javascript 28 | const children = device.children(); 29 | for(const child of children) { 30 | // Do something with each child 31 | } 32 | ``` 33 | 34 | ## API 35 | 36 | ### Children - [`cap:children`][children] 37 | 38 | * `device.children()` - get the children of the gateway. Returns an iterable without going via a promise. 39 | * `device.child(id)` - get a child via its identifier 40 | 41 | [children]: http://abstract-things.readthedocs.io/en/latest/common/children.html 42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/devices/capabilities/switchable-led.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing, State } = require('abstract-things'); 4 | const { boolean } = require('abstract-things/values'); 5 | 6 | module.exports = Thing.mixin(Parent => class extends Parent.with(State) { 7 | static get capability() { 8 | return 'miio:switchable-led'; 9 | } 10 | 11 | static availableAPI(builder) { 12 | builder.action('led') 13 | .description('Get or set if the LED should be used') 14 | .argument('boolean', true, 'If provided, set the LED power to this value') 15 | .returns('boolean', 'If the LED is on') 16 | .done(); 17 | } 18 | 19 | propertyUpdated(key, value) { 20 | if(key === 'led') { 21 | this.updateState('led', value); 22 | } 23 | 24 | super.propertyUpdated(key, value); 25 | } 26 | 27 | /** 28 | * Get or set if the LED should be on. 29 | * 30 | * @param {boolean} power Optional power to set LED to 31 | */ 32 | led(power) { 33 | if(typeof power === 'undefined') { 34 | return this.getState('led'); 35 | } 36 | 37 | power = boolean(power); 38 | return this.changeLED(power) 39 | .then(() => this.getState('led')); 40 | } 41 | 42 | /** 43 | * Set if the LED should be on when the device is running. 44 | */ 45 | changeLED(power) { 46 | return this.call('set_led', [ power ? 'on' : 'off' ], { refresh: [ 'led' ] }) 47 | .then(() => null); 48 | } 49 | }); 50 | -------------------------------------------------------------------------------- /lib/devices/air-monitor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { ChargingState } = require('abstract-things'); 4 | const { AirMonitor } = require('abstract-things/climate'); 5 | const MiioApi = require('../device'); 6 | 7 | const Power = require('./capabilities/power'); 8 | const BatteryLevel = require('./capabilities/battery-level'); 9 | const { AQI } = require('./capabilities/sensor'); 10 | 11 | module.exports = class extends AirMonitor 12 | .with(MiioApi, Power, AQI, BatteryLevel, ChargingState) 13 | { 14 | 15 | static get type() { 16 | return 'miio:air-monitor'; 17 | } 18 | 19 | constructor(options) { 20 | super(options); 21 | 22 | // Define the power property 23 | this.defineProperty('power', v => v === 'on'); 24 | 25 | // Sensor value used for AQI (PM2.5) capability 26 | this.defineProperty('aqi'); 27 | 28 | this.defineProperty('battery', { 29 | name: 'batteryLevel' 30 | }); 31 | 32 | this.defineProperty('usb_state', { 33 | name: 'charging', 34 | mapper: v => v === 'on' 35 | }); 36 | } 37 | 38 | propertyUpdated(key, value, oldValue) { 39 | if(key === 'charging') { 40 | this.updateCharging(value); 41 | } 42 | 43 | super.propertyUpdated(key, value, oldValue); 44 | } 45 | 46 | changePower(power) { 47 | return this.call('set_power', [ power ? 'on' : 'off' ], { 48 | refresh: [ 'power', 'mode' ], 49 | refreshDelay: 200 50 | }); 51 | } 52 | 53 | }; 54 | -------------------------------------------------------------------------------- /lib/devices/capabilities/changeable-led-brightness.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing, State } = require('abstract-things'); 4 | const { string } = require('abstract-things/values'); 5 | 6 | module.exports = Thing.mixin(Parent => class extends Parent.with(State) { 7 | static get capability() { 8 | return 'miio:led-brightness'; 9 | } 10 | 11 | static availableAPI(builder) { 12 | builder.action('ledBrightness') 13 | .description('Get or set the LED brightness') 14 | .argument('string', true, 'If provided, set the LED brightness to this value') 15 | .returns('string', 'The LED brightness') 16 | .done(); 17 | } 18 | 19 | propertyUpdated(key, value) { 20 | if(key === 'ledBrightness') { 21 | this.updateState('ledBrightness', value); 22 | } 23 | 24 | super.propertyUpdated(key, value); 25 | } 26 | 27 | /** 28 | * Get or set if the LED brightness. 29 | * 30 | * @param {string} brightness The LED brightness 31 | */ 32 | ledBrightness(brightness) { 33 | if(typeof brightness === 'undefined') { 34 | return this.getState('ledBrightness'); 35 | } 36 | 37 | brightness = string(brightness); 38 | return this.changeLEDBrightness(brightness) 39 | .then(() => this.getState('ledBrightness')); 40 | } 41 | 42 | /** 43 | * Set the LED brightness. 44 | */ 45 | changeLEDBrightness(brightness) { 46 | throw new Error('changeLEDBrightness not implemented'); 47 | } 48 | }); 49 | -------------------------------------------------------------------------------- /lib/devices/gateway/ctrl_ln2.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Children } = require('abstract-things'); 4 | const { WallSwitch } = require('abstract-things/electrical'); 5 | 6 | const SubDevice = require('./subdevice'); 7 | const LightChannel = require('./light-channel'); 8 | 9 | 10 | /** 11 | * Dual-channel L-N version of light switch. 12 | */ 13 | module.exports = class CtrlLN2 extends WallSwitch.with(SubDevice, Children) { 14 | 15 | static get type() { 16 | return 'miio:power-switch'; 17 | } 18 | 19 | constructor(parent, info) { 20 | super(parent, info); 21 | 22 | this.miioModel = 'lumi.ctrl_ln2'; 23 | 24 | this.defineProperty('channel_0', { 25 | name: 'powerChannel0', 26 | mapper: v => v === 'on' 27 | }); 28 | this.defineProperty('channel_1', { 29 | name: 'powerChannel1', 30 | mapper: v => v === 'on' 31 | }); 32 | 33 | this.addChild(new LightChannel(this, 0)); 34 | this.addChild(new LightChannel(this, 1)); 35 | } 36 | 37 | changePowerChannel(channel, power) { 38 | return this.call('toggle_ctrl_neutral', [ 'neutral_' + channel, power ? 'on' : 'off' ]); 39 | } 40 | 41 | propertyUpdated(key, value) { 42 | super.propertyUpdated(key, value); 43 | 44 | switch(key) { 45 | case 'powerChannel0': 46 | this.child('0').updatePower(value); 47 | break; 48 | case 'powerChannel1': 49 | this.child('1').updatePower(value); 50 | break; 51 | } 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /lib/devices/gateway/ctrl_neutral2.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Children } = require('abstract-things'); 4 | const { WallSwitch } = require('abstract-things/electrical'); 5 | 6 | const SubDevice = require('./subdevice'); 7 | const LightChannel = require('./light-channel'); 8 | 9 | /** 10 | * Dual-channel light switch. 11 | */ 12 | module.exports = class CtrlNeutral2 extends WallSwitch.with(SubDevice, Children) { 13 | 14 | static get type() { 15 | return 'miio:power-switch'; 16 | } 17 | 18 | constructor(parent, info) { 19 | super(parent, info); 20 | 21 | this.miioModel = 'lumi.ctrl_neutral2'; 22 | 23 | this.defineProperty('channel_0', { 24 | name: 'powerChannel0', 25 | mapper: v => v === 'on' 26 | }); 27 | this.defineProperty('channel_1', { 28 | name: 'powerChannel1', 29 | mapper: v => v === 'on' 30 | }); 31 | 32 | this.addChild(new LightChannel(this, 0)); 33 | this.addChild(new LightChannel(this, 1)); 34 | } 35 | 36 | changePowerChannel(channel, power) { 37 | return this.call('toggle_ctrl_neutral', [ 'neutral_' + channel, power ? 'on' : 'off' ]); 38 | } 39 | 40 | propertyUpdated(key, value) { 41 | super.propertyUpdated(key, value); 42 | 43 | switch(key) { 44 | case 'powerChannel0': 45 | this.child('0').updatePower(value); 46 | break; 47 | case 'powerChannel1': 48 | this.child('1').updatePower(value); 49 | break; 50 | } 51 | } 52 | }; 53 | 54 | -------------------------------------------------------------------------------- /lib/devices/chuangmi.plug.v1.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing, SwitchablePower } = require('abstract-things'); 4 | const { PowerPlug, PowerOutlet } = require('abstract-things/electrical'); 5 | 6 | const MiioApi = require('../device'); 7 | const MiioPower = require('./capabilities/power'); 8 | 9 | module.exports = class extends Thing.with(PowerPlug, PowerOutlet, MiioApi, MiioPower) { 10 | static get type() { 11 | return 'miio:power-plug'; 12 | } 13 | 14 | constructor(options) { 15 | super(options); 16 | 17 | this.defineProperty('on', { 18 | name: 'power' 19 | }); 20 | 21 | this.addChild(new USBOutlet(this)); 22 | } 23 | 24 | setName(name) { 25 | return super.setName(name) 26 | .then(n => { 27 | this.child('usb').updateName(); 28 | return n; 29 | }); 30 | } 31 | 32 | propertyUpdated(key, value) { 33 | switch(key) { 34 | case 'powerChannelUsb': 35 | this.child('usb').updatePower(value); 36 | break; 37 | } 38 | } 39 | }; 40 | 41 | class USBOutlet extends PowerOutlet.with(SwitchablePower) { 42 | 43 | constructor(parent) { 44 | super(); 45 | 46 | this.id = parent.id + ':usb'; 47 | 48 | this.parent = parent; 49 | 50 | this.updateName(); 51 | } 52 | 53 | updateName() { 54 | this.metadata.name = this.parent.name + ' - USB'; 55 | } 56 | 57 | changePower(power) { 58 | return this.parent.call(power ? 'set_usb_on' : 'set_usb_off', [], { refresh: true }); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /cli/commands/control.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const log = require('../log'); 4 | const deviceFinder = require('../device-finder'); 5 | 6 | exports.command = 'control [params..]'; 7 | exports.description = 'Control a device by invoking the given method'; 8 | exports.builder = { 9 | }; 10 | 11 | exports.handler = function(argv) { 12 | let target = argv.idOrIp; 13 | log.info('Attempting to invoke', argv.method, 'on', target); 14 | 15 | let foundDevice = false; 16 | let pending = 0; 17 | const browser = deviceFinder({ 18 | filter: target 19 | }); 20 | browser.on('available', device => { 21 | if(! device[argv.method]) { 22 | log.error('The method ' + argv.method + ' is not available'); 23 | process.exit(0); // eslint-disable-line 24 | } 25 | 26 | pending++; 27 | 28 | Promise.resolve(device[argv.method](...argv.params)) 29 | .then(result => { 30 | log.plain(JSON.stringify(result, null, ' ')); 31 | }) 32 | .catch(err => { 33 | log.error('Encountered an error while controlling device'); 34 | log.plain(); 35 | log.plain('Error was:'); 36 | log.plain(err.message); 37 | }) 38 | .then(() => { 39 | pending--; 40 | process.exit(0); // eslint-disable-line 41 | }); 42 | }); 43 | 44 | const doneHandler = () => { 45 | if(pending === 0) { 46 | if(! foundDevice) { 47 | log.warn('Could not find device'); 48 | } 49 | process.exit(0); // eslint-disable-line 50 | } 51 | }; 52 | setTimeout(doneHandler, 5000); 53 | browser.on('done', doneHandler); 54 | }; 55 | -------------------------------------------------------------------------------- /cli/commands/protocol/call.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const log = require('../../log'); 4 | const deviceFinder = require('../../device-finder'); 5 | 6 | exports.command = 'call [params]'; 7 | exports.description = 'Call a raw method on a device'; 8 | exports.builder = { 9 | }; 10 | 11 | exports.handler = function(argv) { 12 | let target = argv.idOrIp; 13 | log.info('Attempting to call', argv.method, 'on', target); 14 | 15 | let foundDevice = false; 16 | let pending = 0; 17 | const browser = deviceFinder({ 18 | filter: target 19 | }); 20 | browser.on('available', device => { 21 | pending++; 22 | 23 | log.plain(); 24 | log.info('Device found, making call'); 25 | log.plain(); 26 | 27 | const parsedArgs = argv.params ? JSON.parse(argv.params) : []; 28 | device.miioCall(argv.method, parsedArgs) 29 | .then(result => { 30 | log.info('Got result:'); 31 | log.plain(JSON.stringify(result, null, ' ')); 32 | }) 33 | .catch(err => { 34 | log.error('Encountered an error while controlling device'); 35 | log.plain(); 36 | log.plain('Error was:'); 37 | log.plain(err.message); 38 | }) 39 | .then(() => { 40 | pending--; 41 | process.exit(0); // eslint-disable-line 42 | }); 43 | }); 44 | 45 | const doneHandler = () => { 46 | if(pending === 0) { 47 | if(! foundDevice) { 48 | log.warn('Could not find device'); 49 | } 50 | process.exit(0); // eslint-disable-line 51 | } 52 | }; 53 | setTimeout(doneHandler, 5000); 54 | browser.on('done', doneHandler); 55 | }; 56 | -------------------------------------------------------------------------------- /cli/commands/tokens/update.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const log = require('../../log'); 4 | const deviceFinder = require('../../device-finder'); 5 | 6 | exports.command = 'update '; 7 | exports.description = 'Update the token to use for the given device'; 8 | exports.builder = { 9 | token: { 10 | required: true, 11 | description: 'The token to set' 12 | } 13 | }; 14 | 15 | exports.handler = function(argv) { 16 | let target = argv.idOrIp; 17 | log.info('Updating token for', target); 18 | 19 | let foundDevice = false; 20 | let pending = 0; 21 | const browser = deviceFinder({ 22 | filter: target 23 | }); 24 | browser.on('available', device => { 25 | pending++; 26 | 27 | log.plain(); 28 | log.info('Connected to', device.id, ' - trying to change token'); 29 | log.plain(); 30 | 31 | device.management.updateToken(argv.token) 32 | .then(status => { 33 | if(status) { 34 | log.plain('Token has been updated'); 35 | } else { 36 | log.error('Could not update token, double-check the given token'); 37 | } 38 | }) 39 | .catch(err => { 40 | log.error('Could update token. Error was:', err.message); 41 | }) 42 | .then(() => { 43 | pending--; 44 | process.exit(0); // eslint-disable-line 45 | }); 46 | }); 47 | 48 | const doneHandler = () => { 49 | if(pending === 0) { 50 | if(! foundDevice) { 51 | log.warn('Could not find device'); 52 | } 53 | process.exit(0); // eslint-disable-line 54 | } 55 | }; 56 | setTimeout(doneHandler, 5000); 57 | browser.on('done', doneHandler); 58 | }; 59 | -------------------------------------------------------------------------------- /cli/commands/protocol/json-dump.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const chalk = require('chalk'); 5 | const log = require('../../log'); 6 | const Packet = require('../../../lib/packet'); 7 | 8 | exports.command = 'json-dump '; 9 | exports.description = 'Extract packets from a Wireshark JSON dump'; 10 | exports.builder = { 11 | token: { 12 | required: true, 13 | description: 'Token to use for decoding' 14 | } 15 | }; 16 | 17 | exports.handler = function(argv) { 18 | const data = fs.readFileSync(argv.file); 19 | const packets = JSON.parse(data.toString()); 20 | 21 | const packet = new Packet(); 22 | packet.token = Buffer.from(argv.token, 'hex'); 23 | 24 | packets.forEach(p => { 25 | const source = p._source; 26 | if(! source) return; 27 | 28 | const layers = source.layers; 29 | 30 | const udp = layers.udp; 31 | if(! udp) return; 32 | 33 | let out; 34 | if(udp['udp.dstport'] === '54321') { 35 | // Packet that is being sent to the device 36 | out = true; 37 | } else if(udp['udp.srcport'] === '54321') { 38 | // Packet coming from the device 39 | out = false; 40 | } else { 41 | // Unknown, skip it 42 | return; 43 | } 44 | 45 | 46 | const rawString = layers.data['data.data']; 47 | const raw = Buffer.from(rawString.replace(/:/g, ''), 'hex'); 48 | packet.raw = raw; 49 | 50 | log.plain(out ? chalk.bgBlue.white.bold(' -> ') : chalk.bgMagenta.white.bold(' <- '), chalk.yellow(layers.ip['ip.src']), chalk.dim('data='), packet.data ? packet.data.toString() : chalk.dim('N/A')); 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /docs/devices/vacuum.md: -------------------------------------------------------------------------------- 1 | # Robot Vacuums 2 | 3 | * **Devices**: Mi Robot Vacuum 4 | * **Model identifiers**: `rockrobo.vacuum.v1` 5 | 6 | Robot vacuums are mapped into a device of type [`vacuum`][vacuum]. The device 7 | will support many different capabilities, such as autonomous cleaning, getting 8 | the cleaning state and more. 9 | 10 | ## Examples 11 | 12 | ### Check if device is a vacuum 13 | 14 | ```javascript 15 | if(device.matches('type:vacuum')) { 16 | /* 17 | * This device is a vacuum. 18 | */ 19 | } 20 | ``` 21 | 22 | ### Check if cleaning 23 | 24 | ```javascript 25 | // Get the current cleaning state 26 | device.cleaning() 27 | .then(isCleaning => ...) 28 | .catch(...); 29 | 30 | // With async/wait 31 | const isCleaning = await device.cleaning(); 32 | ``` 33 | 34 | ### Request cleaning 35 | 36 | ```javascript 37 | // Request cleaning 38 | device.clean() 39 | .then(...) 40 | .catch(...); 41 | 42 | // With async/await 43 | await device.clean(); 44 | ``` 45 | 46 | ### Stop current cleaning 47 | 48 | ```javascript 49 | // Stop cleaning 50 | device.stop() 51 | .then(...) 52 | .catch(...); 53 | 54 | // With async/await 55 | await device.stop(); 56 | ``` 57 | 58 | ### Request spot cleaning 59 | 60 | ```javascript 61 | // Spot clean 62 | device.spotClean() 63 | .then(...) 64 | .catch(...); 65 | 66 | // With async/await 67 | await device.spotClean(); 68 | ``` 69 | 70 | ### Get the battery level 71 | 72 | ```javascript 73 | // Get the battery level 74 | device.batteryLevel() 75 | .then(level => ...) 76 | .catch(...); 77 | 78 | // With async/wait 79 | const level = await device.batteryLevel(); 80 | ``` 81 | -------------------------------------------------------------------------------- /lib/devices/capabilities/sensor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing } = require('abstract-things'); 4 | const { 5 | Temperature, 6 | RelativeHumidity, 7 | PM2_5, 8 | Illuminance, 9 | AtmosphericPressure, 10 | PowerLoad, 11 | PowerConsumed 12 | } = require('abstract-things/sensors'); 13 | 14 | function bind(Type, updateName, property) { 15 | return Thing.mixin(Parent => class extends Parent.with(Type) { 16 | propertyUpdated(key, value) { 17 | if(key === property) { 18 | this[updateName](value); 19 | } 20 | 21 | super.propertyUpdated(key, value); 22 | } 23 | }); 24 | } 25 | 26 | module.exports.Temperature = bind(Temperature, 'updateTemperature', 'temperature'); 27 | module.exports.Humidity = bind(RelativeHumidity, 'updateRelativeHumidity', 'humidity'); 28 | module.exports.Illuminance = bind(Illuminance, 'updateIlluminance', 'illuminance'); 29 | module.exports.AQI = bind(PM2_5, 'updatePM2_5', 'aqi'); 30 | module.exports.AtmosphericPressure = bind(AtmosphericPressure, 'updateAtmosphericPressure', 'atmosphericPressure'); 31 | module.exports.PowerLoad = bind(PowerLoad, 'updatePowerLoad', 'powerLoad'); 32 | module.exports.PowerConsumed = bind(PowerConsumed, 'updatePowerConsumed', 'poweConsumed'); 33 | 34 | /** 35 | * Setup sensor support for a device. 36 | */ 37 | function mixin(device, options) { 38 | if(device.capabilities.indexOf('sensor') < 0) { 39 | device.capabilities.push('sensor'); 40 | } 41 | 42 | device.capabilities.push(options.name); 43 | Object.defineProperty(device, options.name, { 44 | get: function() { 45 | return this.property(options.name); 46 | } 47 | }); 48 | } 49 | 50 | module.exports.extend = mixin; 51 | -------------------------------------------------------------------------------- /lib/devices/gateway/cube.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const SubDevice = require('./subdevice'); 4 | const { Controller, Actions } = require('abstract-things/controllers'); 5 | 6 | /** 7 | * Cube device, emits `action` with an object when someone interacts with 8 | * the cube. 9 | * 10 | * Supports the actions: 11 | * * `alert` - when the cube has been idle for a while and it wakes up 12 | * * `flip90` - when the cube is flipped 90 degrees in any direction 13 | * * `flip180` - when the cube is flipped 180 degress in any direction 14 | * * `move` - when the cube is moved on its current surface (slide) 15 | * * `shake_air` - when the cube is shaken in the air 16 | * * `free_fall` - when the cube is dropped in a free fall 17 | * * `swing` - when the cube is held in hand and the fake thrown 18 | */ 19 | module.exports = class Cube extends SubDevice.with(Controller, Actions) { 20 | constructor(parent, info) { 21 | super(parent, info); 22 | 23 | this.miioModel = 'lumi.cube'; 24 | 25 | this.updateActions([ 26 | 'alert', 27 | 'flip90', 28 | 'flip180', 29 | 'move', 30 | 'tap_twice', 31 | 'shake_air', 32 | 'free_fall', 33 | 'rotate' 34 | ]); 35 | } 36 | 37 | _report(data) { 38 | super._report(data); 39 | 40 | if(typeof data.status !== 'undefined') { 41 | this.debug('Action performed:', data.status); 42 | this.emitAction(data.status); 43 | } 44 | 45 | if(typeof data.rotate !== 'undefined') { 46 | const r = data.rotate; 47 | const idx = r.indexOf(','); 48 | const amount = parseInt(r.substring(0, idx)); 49 | this.debug('Action performed:', 'rotate', amount); 50 | this.emitAction('rotate', { 51 | amount: amount 52 | }); 53 | } 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /docs/devices/power-outlet.md: -------------------------------------------------------------------------------- 1 | # Power Outlets 2 | 3 | * **Devices**: No outlets currently supported 4 | * **Model identifiers**: No outlets currently supported 5 | 6 | The supported models of power outlets are mapped into a [`power-outlet`][power-outlet] with support for [power switching][switchable-power]. 7 | 8 | ## Examples 9 | 10 | ### Check if device is a power strip 11 | 12 | ```javascript 13 | if(device.matches('type:power-strip')) { 14 | /* 15 | * This device is a power strip. 16 | */ 17 | } 18 | ``` 19 | 20 | ### Check if powered on 21 | 22 | ```javascript 23 | // Get if the outlets on the strip have power 24 | device.power() 25 | .then(isOn => console.log('Outlet power:', isOn)) 26 | .catch(...); 27 | 28 | // Using async/await 29 | console.log('Outlet power:', await device.power()); 30 | ``` 31 | 32 | ### Power on device 33 | 34 | ```javascript 35 | // Switch the outlets on 36 | device.setPower(true) 37 | .then(...) 38 | .catch(...) 39 | 40 | // Switch on via async/await 41 | await device.power(true); 42 | ``` 43 | 44 | ## API 45 | 46 | ### Power - [`cap:power`][power] and [`cap:switchable-power`][switchable-power] 47 | 48 | * `device.power()` - get if the outlets currently have power 49 | * `device.power(boolean)` - switch if outlets have power 50 | * `device.setPower(boolean)` - switch if outlets have power 51 | * `device.on(power, isOn => ...)` - listen for power changes 52 | 53 | [power-outlet]: http://abstract-things.readthedocs.io/en/latest/electrical/outlets.html 54 | [sensor]: http://abstract-things.readthedocs.io/en/latest/sensors/index.html 55 | [power]: http://abstract-things.readthedocs.io/en/latest/common/power.html 56 | [switchable-power]: http://abstract-things.readthedocs.io/en/latest/common/switchable-power.html 57 | -------------------------------------------------------------------------------- /cli/commands/configure.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const log = require('../log'); 4 | const deviceFinder = require('../device-finder'); 5 | 6 | const tokens = require('../../lib/tokens'); 7 | 8 | exports.command = 'configure '; 9 | exports.description = 'Control a device by invoking the given method'; 10 | exports.builder = { 11 | ssid: { 12 | required: true, 13 | description: 'SSID of the WiFi network', 14 | type: 'string' 15 | }, 16 | 17 | passwd: { 18 | required: true, 19 | description: 'Password of WiFi-network', 20 | type: 'string' 21 | } 22 | }; 23 | 24 | exports.handler = function(argv) { 25 | let target = argv.idOrIp; 26 | log.info('Attempting to configure', target); 27 | 28 | let foundDevice = false; 29 | let pending = 0; 30 | const browser = deviceFinder({ 31 | filter: target 32 | }); 33 | browser.on('available', device => { 34 | pending++; 35 | 36 | log.plain(); 37 | 38 | device.management.updateWireless({ 39 | ssid: argv.ssid, 40 | passwd: argv.passwd 41 | }) 42 | .then(result => { 43 | log.plain('Updated wireless configuration'); 44 | 45 | return tokens.update(device.id, device.management.token); 46 | }) 47 | .catch(err => { 48 | log.error('Encountered an error while updating wireless'); 49 | log.plain(); 50 | log.plain('Error was:'); 51 | log.plain(err.message); 52 | }) 53 | .then(() => { 54 | pending--; 55 | process.exit(0); // eslint-disable-line 56 | }); 57 | }); 58 | 59 | const doneHandler = () => { 60 | if(pending === 0) { 61 | if(! foundDevice) { 62 | log.warn('Could not find device'); 63 | } 64 | process.exit(0); // eslint-disable-line 65 | } 66 | }; 67 | setTimeout(doneHandler, 5000); 68 | browser.on('done', doneHandler); 69 | }; 70 | -------------------------------------------------------------------------------- /docs/devices/wall-switch.md: -------------------------------------------------------------------------------- 1 | # Wall Switches 2 | 3 | * **Devices**: Aqara Light Control (Wall Switch) 4 | * **Model identifiers**: `lumi.ctrl_neutral1`, `lumi.ctrl_neutral2`, `lumi.ctrl_ln1`, `lumi.ctrl_ln2` 5 | 6 | The supported models of power strips are mapped into a [`wall-switch`][wall-switch] where individual channels are available as child devices. The 7 | child devices are [lights][light] with support for 8 | [power switching][switchable-power]. 9 | 10 | ## Examples 11 | 12 | ### Check if device is a wall switch 13 | 14 | ```javascript 15 | if(device.matches('type:wall-switch')) { 16 | /* 17 | * This device is a wall switch. 18 | */ 19 | } 20 | ``` 21 | 22 | ## Get a light channel 23 | 24 | ```javascript 25 | const light0 = device.child('0'); 26 | 27 | // For some devices 28 | const light1 = device.child('1'); 29 | ``` 30 | 31 | ## Toggle power a light channel 32 | 33 | ```javascript 34 | const light0 = device.child('0'); 35 | 36 | // Change power to on 37 | light0.power(true) 38 | .then(...) 39 | .catch(...); 40 | 41 | // With async/await 42 | await light0.power(true); 43 | ``` 44 | 45 | ## API 46 | 47 | ### Access individual power channels 48 | 49 | * `device. 50 | 51 | ## Child API 52 | 53 | ### Power - [`cap:power`][power] and [`cap:switchable-power`][switchable-power] 54 | 55 | * `device.power()` - get if the air purifier is currently active 56 | * `device.power(boolean)` - switch the air purifier on, returns a promise 57 | * `device.setPower(boolean)` - change the power state of the device, returns a promise 58 | * `device.on(power, isOn => ...)` - listen for power changes 59 | 60 | [wall-switch]: http://abstract-things.readthedocs.io/en/latest/electrical/wall-switches.html 61 | [power]: http://abstract-things.readthedocs.io/en/latest/common/power.html 62 | [switchable-power]: http://abstract-things.readthedocs.io/en/latest/common/switchable-power.html 63 | -------------------------------------------------------------------------------- /docs/devices/sensor.md: -------------------------------------------------------------------------------- 1 | # Sensor 2 | 3 | * `device.type`: `switch` 4 | * **Models**: Aqara Temperature and Humidity Sensor 5 | * **Model identifiers**: `lumi.sensor_ht` 6 | 7 | The sensor type is used for things that are primarily a sensor. The capabilities 8 | described below can be used without the type being `sensor` in which case the 9 | device will instead be marked with `sensor` as a capability. 10 | 11 | ## Basic API 12 | 13 | None. 14 | 15 | ## Capability: `temperature` 16 | 17 | For when the device supports measuring the temperature. 18 | 19 | ### Properties 20 | 21 | * `temperature` - temperature in degrees Celsius 22 | 23 | ### `device.temperature: number` 24 | 25 | Get the temperature in degrees Celsius. 26 | 27 | ## Capability: `humidity` 28 | 29 | For when the device supports measuring the relative humidity. 30 | 31 | ### Properties 32 | 33 | * `humidity` - relative humidity in percent between 0 and 100 34 | 35 | ### `device.humidity: number` 36 | 37 | Get the relative humidity in percent between 0 and 100 38 | 39 | ## Capability: `aqi` 40 | 41 | For when the device supports measuring the air quality. Most Mi Home 42 | products uses tha name `aqi` internally but seems to measure PM2.5. 43 | 44 | ### Properties 45 | 46 | * `aqi` - Air Quality Index number 47 | 48 | ### `device.aqi: number` 49 | 50 | Get the current Air Quality Index 51 | 52 | ## Capability: `illuminance` 53 | 54 | For when the device supports measuring illuminance in Lux. 55 | 56 | ### Properties 57 | 58 | * `illuminance` - the current illuminance in Lux 59 | 60 | ### `device.illuminance: number` 61 | 62 | Get the current illuminance in Lux 63 | 64 | ## Capability: `pressure` 65 | 66 | For when the device supports measuring the atmospheric pressure. 67 | 68 | ### Properties 69 | 70 | * `pressure` - Atmospheric pressure 71 | 72 | ### `device.pressure: number` 73 | 74 | Get the current atmospheric pressure in kPa 75 | -------------------------------------------------------------------------------- /lib/devices/philips-light-bulb.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { LightBulb, ColorTemperature } = require('abstract-things/lights'); 4 | const { color } = require('abstract-things/values'); 5 | const MiioApi = require('../device'); 6 | 7 | const Power = require('./capabilities/power'); 8 | const Dimmable = require('./capabilities/dimmable'); 9 | const Colorable = require('./capabilities/colorable'); 10 | 11 | const MIN_TEMP = 3000; 12 | const MAX_TEMP = 5700; 13 | 14 | module.exports = class BallLamp extends LightBulb 15 | .with(MiioApi, Power, Dimmable, Colorable, ColorTemperature) 16 | { 17 | static get type() { 18 | return 'miio:philiphs-ball-lamp'; 19 | } 20 | 21 | constructor(options) { 22 | super(options); 23 | 24 | this.defineProperty('power', { 25 | name: 'power', 26 | mapper: v => v === 'on' 27 | }); 28 | 29 | this.defineProperty('bright', { 30 | name: 'brightness', 31 | mapper: parseInt 32 | }); 33 | 34 | this.defineProperty('cct', { 35 | name: 'color', 36 | mapper: v => { 37 | v = parseInt(v); 38 | return color.temperature(MIN_TEMP + (v / 100) * (MAX_TEMP - MIN_TEMP)); 39 | } 40 | }); 41 | 42 | this.updateColorTemperatureRange(MIN_TEMP, MAX_TEMP); 43 | } 44 | 45 | changePower(power) { 46 | return this.call('set_power', [ power ? 'on' : 'off' ], { 47 | refresh: [ 'power' ] 48 | }).then(MiioApi.checkOk); 49 | } 50 | 51 | changeBrightness(brightness) { 52 | return this.call('set_bright', [ brightness ], { 53 | refresh: [ 'brightness' ] 54 | }).then(MiioApi.checkOk); 55 | } 56 | 57 | changeColor(color) { 58 | const kelvins = color.temperature.kelvins; 59 | let temp; 60 | if(kelvins <= MIN_TEMP) { 61 | temp = 1; 62 | } else if(kelvins >= MAX_TEMP) { 63 | temp = 100; 64 | } else { 65 | temp = Math.round((kelvins - MIN_TEMP) / (MAX_TEMP - MIN_TEMP) * 100); 66 | } 67 | 68 | return this.call('set_cct', [ temp ], { 69 | refresh: [ 'color'] 70 | }).then(MiioApi.checkOk); 71 | } 72 | 73 | }; 74 | -------------------------------------------------------------------------------- /lib/models.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Mapping from models into high-level devices. 5 | */ 6 | const AirMonitor = require('./devices/air-monitor'); 7 | const AirPurifier = require('./devices/air-purifier'); 8 | const Gateway = require('./devices/gateway'); 9 | 10 | const Vacuum = require('./devices/vacuum'); 11 | 12 | const PowerPlug = require('./devices/power-plug'); 13 | const PowerStrip = require('./devices/power-strip'); 14 | 15 | const Humidifier = require('./devices/humidifier'); 16 | 17 | const YeelightColor = require('./devices/yeelight.color'); 18 | const YeelightMono = require('./devices/yeelight.mono'); 19 | 20 | module.exports = { 21 | 'zhimi.airmonitor.v1': AirMonitor, 22 | 23 | // Air Purifier 1 (and Pro?) 24 | 'zhimi.airpurifier.v1': AirPurifier, 25 | 'zhimi.airpurifier.v2': AirPurifier, 26 | 'zhimi.airpurifier.v3': AirPurifier, 27 | 'zhimi.airpurifier.v6': AirPurifier, 28 | 29 | // Air Purifier 2 30 | 'zhimi.airpurifier.m1': AirPurifier, 31 | 'zhimi.airpurifier.m2': AirPurifier, 32 | 33 | // Air Purifier 2S 34 | 'zhimi.airpurifier.ma2': AirPurifier, 35 | 36 | 'zhimi.humidifier.v1': Humidifier, 37 | 38 | 'chuangmi.plug.m1': PowerPlug, 39 | 'chuangmi.plug.v1': require('./devices/chuangmi.plug.v1'), 40 | 'chuangmi.plug.v2': PowerPlug, 41 | 42 | 'rockrobo.vacuum.v1': Vacuum, 43 | 'roborock.vacuum.s5': Vacuum, 44 | 45 | 'lumi.gateway.v2': Gateway.WithLightAndSensor, 46 | 'lumi.gateway.v3': Gateway.WithLightAndSensor, 47 | 'lumi.acpartner.v1': Gateway.Basic, 48 | 'lumi.acpartner.v2': Gateway.Basic, 49 | 'lumi.acpartner.v3': Gateway.Basic, 50 | 51 | 'qmi.powerstrip.v1': PowerStrip, 52 | 'zimi.powerstrip.v2': PowerStrip, 53 | 54 | 'yeelink.light.lamp1': YeelightMono, 55 | 'yeelink.light.mono1': YeelightMono, 56 | 'yeelink.light.color1': YeelightColor, 57 | 'yeelink.light.strip1': YeelightColor, 58 | 59 | 'philips.light.sread1': require('./devices/eyecare-lamp2'), 60 | 'philips.light.bulb': require('./devices/philips-light-bulb') 61 | 62 | }; 63 | -------------------------------------------------------------------------------- /docs/reporting-issues.md: -------------------------------------------------------------------------------- 1 | # Reporting issues 2 | 3 | Any report of issues within `miio` library is welcome. This includes both 4 | bugs and feature requests, within the generic part of related to a specific 5 | device. 6 | 7 | There are a few things that can help with solving your issue. 8 | 9 | ## Missing support for a device 10 | 11 | If your device is unsupported you can check the [list of supported devices](devices/README.md) 12 | to see if your device is or can be supported. If you do not see it in the list 13 | or if it is listed as Unknown you can read more about figuring out if your 14 | [device can be supported](missing-devices.md). 15 | 16 | ## Something with your device does not work 17 | 18 | To make debugging an issue with your device easier it helps to include some 19 | debug information. You will need to have the `miio` command line interface 20 | installed to get some of this information. You can install it via `npm` in a 21 | terminal window: 22 | 23 | `npm install -g miio` 24 | 25 | The `miio` tool comes with a command for inspecting a device. You will need 26 | either the device id or address of your device. To use the inspect tool run: 27 | 28 | `miio inspect id-or-address` 29 | 30 | This will find the device on your network, connect to it and fetch some debug 31 | information. Including that information when you open an issue will be of great 32 | help. You can safely exclude the IP and token of your device and any properties 33 | that include sensitive information. 34 | 35 | If you do not know the id or address of your device, try running `miio discover` 36 | to list all devices on your network. 37 | 38 | ## Other bugs 39 | 40 | The more details you can share about a bug the better. If you have a small 41 | test case including it will be greatly appreciated. 42 | 43 | ## Feature requests 44 | 45 | Open an issue and describe the feature you want. If its related to a device 46 | you can include the same information as described in the section 47 | "Something with your device does not work". 48 | -------------------------------------------------------------------------------- /cli/device-finder.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { EventEmitter } = require('events'); 4 | const { Devices } = require('../lib/discovery'); 5 | const connectToDevice = require('../lib/connectToDevice'); 6 | 7 | const IP = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; 8 | 9 | function asFilter(filter) { 10 | if(typeof filter === 'number') { 11 | filter = String(filter); 12 | } 13 | 14 | if(typeof filter === 'string') { 15 | return reg => { 16 | // Assign management if this filter is filtering a device 17 | const mgmt = reg.management ? reg.management : reg; 18 | 19 | // Make sure to remove `miio:` prefix from ids when matching 20 | let id = String(reg.id); 21 | if(id.indexOf('miio:') === 0) { 22 | id = id.substring(5); 23 | } 24 | 25 | // Match id, address or model 26 | return id === filter || mgmt.address === filter || mgmt.model === filter; 27 | }; 28 | } else if(typeof filter === 'function') { 29 | return filter; 30 | } else { 31 | return () => true; 32 | } 33 | } 34 | 35 | function asToplevelFilter(filter) { 36 | return reg => reg.type === 'gateway' || filter(reg); 37 | } 38 | 39 | module.exports = function(options={}) { 40 | const result = new EventEmitter(); 41 | 42 | if(typeof options.filter === 'string' && options.filter.match(IP)) { 43 | // IP-address specified 44 | connectToDevice({ 45 | address: options.filter, 46 | withPlaceholder: true 47 | }) 48 | .then(device => result.emit('available', device)) 49 | .catch(() => result.emit('done')); 50 | } else { 51 | // 52 | const filter = asFilter(options.filter); 53 | const browser = new Devices({ 54 | cacheTime: options.cacheTime || 300, 55 | filter: options.filter ? asToplevelFilter(filter) : null, 56 | }); 57 | 58 | browser.on('available', device => { 59 | if(filter(device)) { 60 | result.emit('available', device); 61 | } 62 | }); 63 | browser.on('unavailable', device => result.emit('unavailable', device)); 64 | } 65 | return result; 66 | }; 67 | -------------------------------------------------------------------------------- /docs/missing-devices.md: -------------------------------------------------------------------------------- 1 | # Missing devices 2 | 3 | This library does not yet support all Mi Home devices that speak the 4 | miIO protocol. 5 | 6 | ## Can my device be supported? 7 | 8 | The command line application can help with discovering if a device speaks the 9 | miIO protocol. Get started by installing the command line application: 10 | 11 | `npm install -g miio` 12 | 13 | Run the app in discovery mode to list devices on your network: 14 | 15 | `miio discover` 16 | 17 | This will start outputting all of the devices found, with their address, 18 | identifiers, models and tokens (if found). If your device can be supported it 19 | will show up in this list. You might need to figure out if the IP of your device 20 | to be able to find it easily. 21 | 22 | If the device shows up feel free to open an issue about supporting the device. 23 | Include this information: 24 | 25 | * The name of the device model - such Mi Air Purifier or Mi Smart Power Strip 2 26 | * The model id from the output 27 | * If the token for the device displayed N/A or a hex value (don't include the hex value) 28 | 29 | ## Implementing a device 30 | 31 | If a device can be supported the next step is to figure out what commands the 32 | device supports. You can help by either forking this repository and 33 | implementing the device or by opening an issue with information about what 34 | the device seems to support. 35 | 36 | In certain cases its possible to find documentation or someone that has already 37 | done most of the work figuring out what commands a device supports. 38 | 39 | Most often this involves more advanced steps like packet capturing via a local 40 | Android emulator. Details about that can be found under [Protocol and commands](protocol.md) 41 | if you feel up for it. 42 | 43 | ## Donating devices 44 | 45 | If you really want a device to be supported it's possible to contact the author 46 | of this library to discuss donating a device. Be aware that this is not a 47 | guarantee that this library will support the device as sometimes it might 48 | not be possible to figure out how to support a device. 49 | -------------------------------------------------------------------------------- /lib/devices/capabilities/buzzer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing, State } = require('abstract-things'); 4 | const { boolean } = require('abstract-things/values'); 5 | 6 | const MiioApi = require('../../device'); 7 | 8 | module.exports = Thing.mixin(Parent => class extends Parent.with(State) { 9 | static get capability() { 10 | return 'miio:buzzer'; 11 | } 12 | 13 | static availableAPI(builder) { 14 | builder.event('buzzerChanged') 15 | .type('boolean') 16 | .description('Buzzer state has changed') 17 | .done(); 18 | 19 | builder.action('buzzer') 20 | .description('Get or set if the buzzer is active') 21 | .argument('boolean', true, 'If the device should beep') 22 | .returns('boolean', 'If the buzzer is on') 23 | .done(); 24 | 25 | builder.action('setBuzzer') 26 | .description('Set if the buzzer is active') 27 | .argument('boolean', false, 'If the device should beep') 28 | .returns('boolean', 'If the buzzer is on') 29 | .done(); 30 | 31 | builder.action('getBuzzer') 32 | .description('Get if the buzzer is active') 33 | .returns('boolean', 'If the buzzer is on') 34 | .done(); 35 | } 36 | 37 | propertyUpdated(key, value) { 38 | if(key === 'buzzer') { 39 | if(this.updateState('buzzer', value)) { 40 | this.emitEvent('buzzerChanged', value); 41 | } 42 | } 43 | 44 | super.propertyUpdated(key, value); 45 | } 46 | 47 | /** 48 | * Get or set if the buzzer is active. 49 | * 50 | * @param {boolean} active 51 | * Optional boolean to switch buzzer to. 52 | */ 53 | buzzer(active) { 54 | if(typeof active === 'undefined') { 55 | return this.getBuzzer(); 56 | } 57 | 58 | return this.setBuzzer(active); 59 | } 60 | 61 | getBuzzer() { 62 | return this.getState('buzzer'); 63 | } 64 | 65 | setBuzzer(active) { 66 | active = boolean(active); 67 | 68 | return this.changeBuzzer(active) 69 | .then(() => this.getBuzzer()); 70 | } 71 | 72 | changeBuzzer(active) { 73 | return this.call('set_buzzer', [ active ? 'on' : 'off' ], { 74 | refresh: [ 'buzzer' ] 75 | }) 76 | .then(MiioApi.checkOk); 77 | } 78 | }); 79 | -------------------------------------------------------------------------------- /docs/devices/power-plug.md: -------------------------------------------------------------------------------- 1 | # Power Plugs 2 | 3 | * **Devices**: Mi Smart Socket Plug, Aqara Plug 4 | * **Model identifiers**: `chuangmi.plug.v1`, `chuangmi.plug.v2`, `chuangmi.plug.m1`, `lumi.plug` 5 | 6 | The supported models of power plugs are mapped into a [`power-plug`][power-plug] with support for [power switching][switchable-power]. 7 | 8 | ## Examples 9 | 10 | ### Check if device is a power strip 11 | 12 | ```javascript 13 | if(device.matches('type:power-strip')) { 14 | /* 15 | * This device is a power strip. 16 | */ 17 | } 18 | ``` 19 | 20 | ### Check if powered on 21 | 22 | ```javascript 23 | // Get if the outlets on the strip have power 24 | device.power() 25 | .then(isOn => console.log('Outlet power:', isOn)) 26 | .catch(...); 27 | 28 | // Using async/await 29 | console.log('Outlet power:', await device.power()); 30 | ``` 31 | 32 | ### Power on device 33 | 34 | ```javascript 35 | // Switch the outlets on 36 | device.setPower(true) 37 | .then(...) 38 | .catch(...) 39 | 40 | // Switch on via async/await 41 | await device.power(true); 42 | ``` 43 | 44 | ## API 45 | 46 | ### Power - [`cap:power`][power] and [`cap:switchable-power`][switchable-power] 47 | 48 | * `device.power()` - get if the outlets currently have power 49 | * `device.power(boolean)` - switch if outlets have power 50 | * `device.setPower(boolean)` - switch if outlets have power 51 | * `device.on(power, isOn => ...)` - listen for power changes 52 | 53 | ## Models 54 | 55 | ### Mi Smart Socket Plug (V1) - `chuangmi.plug.v1` 56 | 57 | The V1 plug has a USB-outlet that can be controlled individually. It is made 58 | available as a child that implements [power switching][switchable-power]: 59 | 60 | ```javascript 61 | const usbOutlet = light.child('usb'); 62 | 63 | const isOn = await usbOutlet.power(); 64 | ``` 65 | 66 | [power-plug]: http://abstract-things.readthedocs.io/en/latest/electrical/plugs.html 67 | [sensor]: http://abstract-things.readthedocs.io/en/latest/sensors/index.html 68 | [power]: http://abstract-things.readthedocs.io/en/latest/common/power.html 69 | [switchable-power]: http://abstract-things.readthedocs.io/en/latest/common/switchable-power.html 70 | -------------------------------------------------------------------------------- /cli/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chalk = require('chalk'); 4 | 5 | module.exports = { 6 | indent: '', 7 | 8 | info(...args) { 9 | console.log(this.indent + chalk.bgWhite.black(' INFO '), args.join(' ')); // eslint-disable-line 10 | }, 11 | 12 | error(...args) { 13 | console.log(this.indent + chalk.bgRed.white(' ERROR '), args.join(' ')); // eslint-disable-line 14 | }, 15 | 16 | warn(...args) { 17 | console.log(this.indent + chalk.bgYellow.black(' WARNING '), args.join(' ')); // eslint-disable-line 18 | }, 19 | 20 | plain(...args) { 21 | console.log(this.indent + args.join(' ')); // eslint-disable-line 22 | }, 23 | 24 | group(g) { 25 | let indent = this.indent; 26 | this.indent += ' '; 27 | try { 28 | g(); 29 | } finally { 30 | this.indent = indent; 31 | } 32 | }, 33 | 34 | device(device, detailed=false) { 35 | const mgmt = device.management; 36 | 37 | const types = Array.from(device.metadata.types); 38 | const filteredTypes = types.filter(t => t.indexOf('miio:') === 0); 39 | const caps = Array.from(device.metadata.capabilities); 40 | 41 | this.plain(chalk.bold('Device ID:'), device.id.replace(/^miio:/, '')); 42 | this.plain(chalk.bold('Model info:'), mgmt.model || 'Unknown'); 43 | 44 | if(mgmt.address) { 45 | this.plain(chalk.bold('Address:'), mgmt.address); 46 | } else if(mgmt.parent) { 47 | this.plain(chalk.bold('Address:'), 'Owned by', mgmt.parent.id); 48 | } 49 | 50 | if(mgmt.token) { 51 | this.plain(chalk.bold('Token:'), mgmt.token, mgmt.autoToken ? chalk.green('via auto-token') : chalk.yellow('via stored token')); 52 | } else if(! mgmt.parent) { 53 | this.plain(chalk.bold('Token:'), '???'); 54 | } else { 55 | this.plain(chalk.bold('Token:'), chalk.green('Automatic via parent device')); 56 | } 57 | 58 | this.plain(chalk.bold('Support:'), mgmt.model ? (filteredTypes.length > 0 ? chalk.green('At least basic') : chalk.yellow('At least generic')) : chalk.yellow('Unknown')); 59 | 60 | if(detailed) { 61 | this.plain(); 62 | this.plain(chalk.bold('Type info:'), types.join(', ')); 63 | this.plain(chalk.bold('Capabilities:'), caps.join(', ')); 64 | } 65 | 66 | this.plain(); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /lib/devices/gateway/light-mixin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing } = require('abstract-things'); 4 | const { Light, SwitchablePower, Dimmable, Colorable } = require('abstract-things/lights'); 5 | const { color } = require('abstract-things/values'); 6 | 7 | /** 8 | * Capability that register the 9 | */ 10 | module.exports = Thing.mixin(Appliance => class extends Appliance { 11 | initCallback() { 12 | this.addExtraChild(new GatewayLight(this)); 13 | 14 | return super.initCallback(); 15 | } 16 | }); 17 | 18 | /** 19 | * Capability that is mixed in if a gateway is also a light. 20 | */ 21 | const GatewayLight = class extends Light.with(SwitchablePower, Dimmable, Colorable) { 22 | static get types() { 23 | return [ 'miio:subdevice', 'miio:gateway-light' ]; 24 | } 25 | 26 | constructor(gateway) { 27 | super(); 28 | 29 | this.model = gateway.miioModel + '.light'; 30 | this.internalId = gateway.id.substring(5) + ':light'; 31 | this.id = gateway.id + ':light'; 32 | this.gateway = gateway; 33 | 34 | // Hijack the property updated event to make control easier 35 | const propertyUpdated = this.gateway.propertyUpdated; 36 | this.gateway.propertyUpdated = (key, value, oldValue) => { 37 | propertyUpdated.call(this.gateway, key, value, oldValue); 38 | 39 | this.propertyUpdated(key, value); 40 | }; 41 | } 42 | 43 | changePower(power) { 44 | return this.changeBrightness(power ? 50 : 0); 45 | } 46 | 47 | changeBrightness(brightness, options) { 48 | const color = this.gateway.property('rgb'); 49 | const rgb = brightness << 24 | (color.red << 16) | (color.green << 8) | color.blue; 50 | 51 | return this.gateway.call('set_rgb', [ rgb >>> 0 ], { refresh: [ 'rgb' ] }); 52 | } 53 | 54 | changeColor(color, options) { 55 | color = color.rgb; 56 | const a = this.gateway.property('brightness'); 57 | const rgb = a << 24 | (color.red << 16) | (color.green << 8) | color.blue; 58 | 59 | return this.gateway.call('set_rgb', [ rgb >>> 0 ], { refresh: [ 'rgb' ] }); 60 | } 61 | 62 | propertyUpdated(key, value) { 63 | if(key === 'rgb') { 64 | this.updateColor(color.rgb(value.red, value.green, value.blue)); 65 | } else if(key === 'brightness') { 66 | //this.updateBrightness(Math.round(value / 255 * 100)); 67 | this.updateBrightness(value); 68 | this.updatePower(value > 0); 69 | } 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /docs/devices/power-strip.md: -------------------------------------------------------------------------------- 1 | # Power Strips 2 | 3 | * **Devices**: Mi Smart Power Strip 4 | * **Model identifiers**: `qmi.powerstrip.v1`, `zimi.powerstrip.v2` 5 | 6 | The two supported models of power strips are mapped into a [`power-strip`][power-strip] with support for [power switching][switchable-power]. 7 | 8 | ## Examples 9 | 10 | ### Check if device is a power strip 11 | 12 | ```javascript 13 | if(device.matches('type:power-strip')) { 14 | /* 15 | * This device is a power strip. 16 | */ 17 | } 18 | ``` 19 | 20 | ### Check if powered on 21 | 22 | ```javascript 23 | // Get if the outlets on the strip have power 24 | device.power() 25 | .then(isOn => console.log('Outlet power:', isOn)) 26 | .catch(...); 27 | 28 | // Using async/await 29 | console.log('Outlet power:', await device.power()); 30 | ``` 31 | 32 | ### Power on device 33 | 34 | ```javascript 35 | // Switch the outlets on 36 | device.setPower(true) 37 | .then(...) 38 | .catch(...) 39 | 40 | // Switch on via async/await 41 | await device.power(true); 42 | ``` 43 | 44 | ## API 45 | 46 | ### Power - [`cap:power`][power] and [`cap:switchable-power`][switchable-power] 47 | 48 | * `device.power()` - get if the outlets currently have power 49 | * `device.power(boolean)` - switch if outlets have power 50 | * `device.setPower(boolean)` - switch if outlets have power 51 | * `device.on(power, isOn => ...)` - listen for power changes 52 | 53 | ### Mode - [`cap:mode`][mode] and [`cap:switchable-mode`][switchable-mode] 54 | 55 | Power strips may support setting a certain mode, such as a special green mode. 56 | 57 | * `device.mode()` - get the current mode 58 | * `device.mode(string)` - set the current mode of the device, returns a promise 59 | * `device.setMode(string)` - set the current mode of the device, returns a promise 60 | * `device.modes()` - read-only array indicating the modes supports by the device 61 | * `device.on('modeChanged', mode => ...)` - listen for changes to the current mode 62 | 63 | The modes supported change between different models, but most devices support: 64 | 65 | * `green`, green mode 66 | * `normal`, normal mode 67 | 68 | [power-strip]: http://abstract-things.readthedocs.io/en/latest/electrical/strips.html 69 | [sensor]: http://abstract-things.readthedocs.io/en/latest/sensors/index.html 70 | [power]: http://abstract-things.readthedocs.io/en/latest/common/power.html 71 | [switchable-power]: http://abstract-things.readthedocs.io/en/latest/common/switchable-power.html 72 | [mode]: http://abstract-things.readthedocs.io/en/latest/common/mode.html 73 | [switchable-mode]: http://abstract-things.readthedocs.io/en/latest/common/switchable-mode.html 74 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/devices/controller.md: -------------------------------------------------------------------------------- 1 | # Controllers 2 | 3 | * **Devices**: Aqara Switch (round and square), Aqara Light Switch, Aqara Dual Light Switch 4 | * **Model identifiers**: `lumi.switch`, `lumi.switch.aq2`, `lumi.86sw1`, `lumi.86sw2` 5 | 6 | Controllers are devices whose primary function is to control something else. 7 | These devices will have the capability [`actions`][actions] and their primary 8 | function will be emitting events. Single button switches will be of the type 9 | [`button`][button] and other switches are translated into the type 10 | [`wall-controller`][wall-controller]. Controllers may also have the capability 11 | [`battery-level`][battery-level] if they can report their battery level. 12 | 13 | ## Examples 14 | 15 | ### Check if device supports actions 16 | 17 | ```javascript 18 | if(device.matches('cap:actions')) { 19 | /* 20 | * This device is a controller of some sort. 21 | */ 22 | } 23 | ``` 24 | 25 | ### Listen for actions 26 | 27 | ```javascript 28 | device.on('action', event => console.log('Action', event.action, 'with data', event.data)); 29 | 30 | device.on('action:idOfAction', data => ...); 31 | ``` 32 | 33 | ### List available actions 34 | 35 | ```javascript 36 | // Get the available actions 37 | device.actions() 38 | .then(actions => ...) 39 | .catch(...); 40 | 41 | // Using async/await 42 | const actions = await device.actions(); 43 | ``` 44 | 45 | ## API 46 | 47 | ### Actions - [`cap:actions`][actions] 48 | 49 | * `device.actions()` - get all of the available actions 50 | * `device.on('action', event => ...)` - listen for all actions 51 | * `device.on('action:', data => ...)` - listen for action with name `` 52 | 53 | ## Models 54 | 55 | ### Aqara Button V1 - `lumi.switch` 56 | 57 | Round button connected to a Mi Gateway. Supports the actions `click`, 58 | `double_click`, `long_click_press` and `long_click_release`. 59 | 60 | ### Aqara Button V2 - `lumi.switch.v2` 61 | 62 | Square button connected to a Mi Gateway. Supports the actions `click` 63 | and `double_click`. 64 | 65 | ### Aqara Cube - `lumi.cube` 66 | 67 | Cube connected to a Mi Gateway. Supports the actions `alert`, `flip90`, 68 | `flip180`, `move`, `tap_twice`, `shake_air`, `free_fall` and `rotate`. 69 | 70 | When the action is `rotate` the data in the event will be an object including 71 | the key `amount`. Example use: 72 | 73 | ```javascript 74 | device.on('action:rotate', data => console.log('Rotation amount:', data.amount)); 75 | ``` 76 | 77 | ### Aqara Wall Switch, one button - `lumi.86sw1` 78 | 79 | Wall Switch with one button. Supports the actions: `click` and `double_click` 80 | 81 | ### Aqara Wall Switch, two buttons - `lumi.86sw2` 82 | 83 | Wall Switch with two buttons. Suppors the actions `btn0-click`, 84 | `btn0-double_click`, `btn1-click`, `btn1-double_click` and `both_click`. 85 | 86 | [actions]: http://abstract-things.readthedocs.io/en/latest/controllers/actions.html 87 | [button]: http://abstract-things.readthedocs.io/en/latest/controllers/buttons.html 88 | [wall-controller]: http://abstract-things.readthedocs.io/en/latest/controllers/wall-controllers.html 89 | [battery-level]: http://abstract-things.readthedocs.io/en/latest/common/battery-level.html 90 | -------------------------------------------------------------------------------- /lib/devices/gateway/developer-api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EventEmitter = require('events'); 4 | const dgram = require('dgram'); 5 | const os = require('os'); 6 | 7 | const MULTICAST_ADDRESS = '224.0.0.50'; 8 | const MULTICAST_PORT = 4321; 9 | 10 | const SERVER_PORT = 9898; 11 | 12 | /** 13 | * Local Developer API for the gateway. Used to read data from the gateway 14 | * and connected devices. 15 | * 16 | * TODO: Retries for discovery 17 | */ 18 | module.exports = class DeveloperApi extends EventEmitter { 19 | constructor(parent, address) { 20 | super(); 21 | 22 | this.address = address; 23 | this.port = SERVER_PORT; 24 | 25 | // Bind a custom function instead of using directly to skip resolving debug before id is set 26 | this.debug = function() { parent.debug.apply(parent, arguments); }; 27 | 28 | this.socket = dgram.createSocket({ 29 | type: 'udp4', 30 | reuseAddr: true 31 | }); 32 | 33 | this.socket.on('message', this._onMessage.bind(this)); 34 | this.socket.on('listening', () => { 35 | // Add membership to the multicast addresss for all network interfaces 36 | const interfaces = os.networkInterfaces(); 37 | for(const name of Object.keys(interfaces)) { 38 | const addresses = interfaces[name]; 39 | 40 | for(const addr of addresses) { 41 | if(addr.family === 'IPv4') { 42 | this.socket.addMembership(MULTICAST_ADDRESS, addr.address); 43 | } 44 | } 45 | } 46 | 47 | // Broadcast a whois to find all gateways 48 | const json = JSON.stringify({ 49 | cmd: 'whois' 50 | }); 51 | this.debug('DEV BROADCAST ->', json); 52 | this.socket.send(json, 0, json.length, MULTICAST_PORT, MULTICAST_ADDRESS); 53 | 54 | // Give us one second to discover the gateway 55 | setTimeout(() => { 56 | this.debug('DEV <- Timeout for whois'); 57 | this.emit('ready'); 58 | }, 1000); 59 | }); 60 | this.socket.bind({ 61 | port: SERVER_PORT, 62 | exclusive: true 63 | }); 64 | } 65 | 66 | destroy() { 67 | this.socket.close(); 68 | } 69 | 70 | send(data) { 71 | const json = JSON.stringify(data); 72 | this.debug('DEV ->', json); 73 | 74 | this.socket.send(json, 0, json.length, this.port, this.address); 75 | } 76 | 77 | read(sid) { 78 | this.send({ 79 | cmd: 'read', 80 | sid: sid 81 | }); 82 | } 83 | 84 | _onMessage(msg) { 85 | let data; 86 | try { 87 | this.debug('DEV <-', msg.toString()); 88 | data = JSON.parse(msg.toString()); 89 | } catch(ex) { 90 | this.emit('error', ex); 91 | return; 92 | } 93 | 94 | switch(data.cmd) { 95 | case 'iam': 96 | if(data.ip === this.address) { 97 | this.port = data.port; 98 | this.sid = data.sid; 99 | 100 | this.emit('ready'); 101 | } 102 | break; 103 | case 'read_ack': 104 | case 'heartbeat': 105 | case 'report': { 106 | if(! this.sid && data.model === 'gateway') { 107 | this.sid = data.sid; 108 | } 109 | 110 | const parsed = JSON.parse(data.data); 111 | this.emit('propertiesChanged', { 112 | id: this.sid === data.sid ? '0' : data.sid, 113 | data: parsed 114 | }); 115 | 116 | this.emit('properties:' + data.sid, parsed); 117 | } 118 | } 119 | } 120 | }; 121 | -------------------------------------------------------------------------------- /docs/advanced-api.md: -------------------------------------------------------------------------------- 1 | # Advanced API 2 | 3 | ## `miio.browse()` 4 | 5 | `miio.browse()` can be used to discover devices on the local network. 6 | 7 | ```javascript 8 | const browser = miio.browse({ 9 | cacheTime: 300 // 5 minutes. Default is 1800 seconds (30 minutes) 10 | }); 11 | 12 | const devices = {}; 13 | browser.on('available', reg => { 14 | if(! reg.token) { 15 | console.log(reg.id, 'hides its token'); 16 | return; 17 | } 18 | 19 | // Directly connect to the device anyways - so use miio.devices() if you just do this 20 | reg.connect() 21 | .then(device => { 22 | devices[reg.id] = device; 23 | 24 | // Do something useful with the device 25 | }) 26 | .catch(handleErrorProperlyHere); 27 | }); 28 | 29 | browser.on('unavailable', reg => { 30 | const device = devices[reg.id]; 31 | if(! device) return; 32 | 33 | device.destroy(); 34 | delete devices[reg.id]; 35 | }) 36 | ``` 37 | 38 | You can also use mDNS for discovery, but this library does not contain a mDNS 39 | implementation. You can choose a mDNS-implementation suitable for your 40 | needs. Devices announce themselves via `_miio._udp` and should work for most 41 | devices, in certain cases you might need to restart your device to make it 42 | announce itself. 43 | 44 | ## `device.call(method, args)` - Raw API-usage and calling the Xiaomi miIO-method directly 45 | 46 | It's possible to call any method directly on a device without using the 47 | top-level API. This is useful if some aspect of your device is not yet 48 | supported by the library. 49 | 50 | ```javascript 51 | // Call any method via call 52 | device.call('set_mode', [ 'silent' ]) 53 | .then(console.log) 54 | .catch(console.error); 55 | ``` 56 | 57 | **Important**: `call` is advanced usage, the library does very little for you 58 | when using it directly. If you find yourself needing to use it, please open 59 | and issue and describe your use case so better support can be added. 60 | 61 | ## Define custom properties 62 | 63 | If you want to define some custom properties to fetch for a device or if your 64 | device is not yet supported you can easily do so: 65 | 66 | ```javascript 67 | // Define a property that should be monitored 68 | device.defineProperty('mode'); 69 | 70 | // Define that a certain property should be run through a custom conversion 71 | device.defineProperty('temp_dec', v => v / 10.0); 72 | 73 | // Fetch the last value of a monitored property 74 | const value = device.property('temp_dec'); 75 | ``` 76 | 77 | ## Device management 78 | 79 | Get information and update the wireless settings of devices via the management 80 | API. 81 | 82 | Discover the token of a device: 83 | ```javascript 84 | device.discover() 85 | .then(info => console.log(info.token)); 86 | ``` 87 | 88 | Get internal information about the device: 89 | ```javascript 90 | device.management.info() 91 | .then(console.log); 92 | ``` 93 | 94 | Update the wireless settings: 95 | ```javascript 96 | device.management.updateWireless({ 97 | ssid: 'SSID of network', 98 | passwd: 'Password of network' 99 | }).then(console.log); 100 | ``` 101 | 102 | Warning: The device will either connect to the new network or it will get stuck 103 | without being able to connect. If that happens the device needs to be reset. 104 | -------------------------------------------------------------------------------- /docs/protocol.md: -------------------------------------------------------------------------------- 1 | # Protocol and commands 2 | 3 | The base protocol is based on documentation provided by OpenMiHome. See https://github.com/OpenMiHome/mihome-binary-protocol for details. This 4 | protocol defines how packets are sent to and from the devices are structured, 5 | but does not describe the commands that can be issued to the devices. 6 | 7 | Currently the best way to figure out what commands a device supports is to 8 | run a packet capture with Wireshark between the Mi Home app and your device. 9 | The `miio` command line app can the be used together with JSON export from 10 | Wireshark to extract messages sent back and forth. 11 | 12 | First make sure you have installed the `miio` app: 13 | 14 | `npm install -g miio` 15 | 16 | ## Creating a capture 17 | 18 | ### Figure out your device token before starting 19 | 20 | If you do not have the token of your device yet, run `miio --discover` to list 21 | devices on your network and their auto-extracted tokens. If the token is not 22 | found, follow the [instructions to find device tokens](management.md). This needs 23 | to be done before you start your capture or the capture will be useless as 24 | resetting the device will generate a new token. 25 | 26 | ### Running Wireshark and Mi Home 27 | 28 | If you have knowledge about Wireshark you can run a capture however you like, 29 | but if you do not the easiest way is to use the method described in the 30 | readme of [Homey app for Xiaomi Mi Robot Vaccum Cleaner](https://github.com/jghaanstra/com.robot.xiaomi-mi) to capture the 31 | traffic. 32 | 33 | ## Analyzing the capture 34 | 35 | ### Exporting the packets as JSON 36 | 37 | The first step is to export the capture from Wireshark as a JSON file. You 38 | should be able to extract a file with all your traffic, but you might want to 39 | apply a filter in Wireshark to limit the size of the file. 40 | 41 | A good starting filter is: `udp.dstport == 54321 or udp.srcport == 54321` 42 | 43 | ### Running the `miio` app 44 | 45 | To extract messages sent back and forth and dump to your terminal: 46 | 47 | `miio protocol json-dump path/to/file.json --token tokenAsHex` 48 | 49 | ### Figuring out the output 50 | 51 | The output will be filled with output such as this: 52 | 53 | ``` 54 | -> 192.168.100.7 data={"id":10026,"method":"get_status","params":[]} 55 | <- 192.168.100.8 data={ "result": [ { "msg_ver": 4, "msg_seq": 238, "state": 6, "battery": 100, "clean_time": 21, "clean_area": 240000, "error_code": 0, "map_present": 1, "in_cleaning": 0, "fan_power": 60, "dnd_enabled": 1 } ], "id": 10026 } 56 | ``` 57 | 58 | Lines starting with `->` are messages sent to a device and `<-` are messages 59 | from a device. If the data is `N/A` it was either a handshake or the message 60 | could not be decoded due to an invalid token. 61 | 62 | The `method` and `params` property in outgoing messages can be plugged into 63 | `device.call(method, params)` to perform the same call. Go through and see what 64 | the Mi Home app calls and how the replies look. 65 | 66 | ## Testing commands 67 | 68 | The `miio` command line app can be used to control devices and test things 69 | when you are implementing or debugging your device. 70 | 71 | Use the `--control` flag to issue a command: 72 | 73 | `miio protocol call id-or-address nameOfMethod paramsAsJSON` 74 | 75 | For example to get a property from the device: 76 | 77 | `miio protocol call id-or-address get_prop '["temperature","use_time"]'` 78 | -------------------------------------------------------------------------------- /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/devices/humidifier.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { SwitchableMode } = require('abstract-things'); 4 | const { Humidifier, AdjustableTargetHumidity } = require('abstract-things/climate'); 5 | 6 | const MiioApi = require('../device'); 7 | 8 | const Power = require('./capabilities/power'); 9 | const Buzzer = require('./capabilities/buzzer'); 10 | const LEDBrightness = require('./capabilities/changeable-led-brightness'); 11 | const { Temperature, Humidity } = require('./capabilities/sensor'); 12 | 13 | /** 14 | * Abstraction over a Mi Humidifier. 15 | * 16 | */ 17 | module.exports = class extends Humidifier 18 | .with(MiioApi, Power, SwitchableMode, 19 | AdjustableTargetHumidity, Temperature, Humidity, 20 | Buzzer, LEDBrightness) 21 | { 22 | static get type() { 23 | return 'miio:humidifier'; 24 | } 25 | 26 | constructor(options) { 27 | super(options); 28 | 29 | this.defineProperty('power', v => v === 'on'); 30 | 31 | // Set the mode property and supported modes 32 | this.defineProperty('mode'); 33 | this.updateModes([ 34 | 'idle', 35 | 'silent', 36 | 'medium', 37 | 'high' 38 | ]); 39 | 40 | // Humidity limit 41 | this.defineProperty('limit_hum', { 42 | name: 'targetHumidity', 43 | }); 44 | 45 | // Sensor values reported by the device 46 | this.defineProperty('temp_dec', { 47 | name: 'temperature', 48 | mapper: v => v / 10.0 49 | }); 50 | 51 | this.defineProperty('humidity'); 52 | 53 | // Buzzer and beeping 54 | this.defineProperty('buzzer', { 55 | mapper: v => v === 'on' 56 | }); 57 | 58 | this.defineProperty('led_b', { 59 | name: 'ledBrightness', 60 | mapper: v => { 61 | switch(v) { 62 | case 0: 63 | return 'bright'; 64 | case 1: 65 | return 'dim'; 66 | case 2: 67 | return 'off'; 68 | default: 69 | return 'unknown'; 70 | } 71 | } 72 | }); 73 | } 74 | 75 | propertyUpdated(key, value) { 76 | /* 77 | * Emulate that the humidifier has an idle mode. 78 | */ 79 | if(key === 'power') { 80 | if(value) { 81 | const mode = this.property('mode'); 82 | this.updateMode(mode); 83 | } else { 84 | this.updateMode('idle'); 85 | } 86 | } else if(key === 'mode') { 87 | const power = this.property('power'); 88 | if(power) { 89 | this.updateMode(value); 90 | } else { 91 | this.updateMode('idle'); 92 | } 93 | } else if(key === 'targetHumidity') { 94 | this.updateTargetHumidity(value); 95 | } 96 | 97 | super.propertyUpdated(key, value); 98 | } 99 | 100 | changePower(power) { 101 | return this.call('set_power', [ power ? 'on' : 'off' ], { 102 | refresh: [ 'power', 'mode' ], 103 | refreshDelay: 200 104 | }); 105 | } 106 | 107 | /** 108 | * Set the mode of this device. 109 | */ 110 | changeMode(mode) { 111 | let promise; 112 | if(mode === 'idle') { 113 | return this.setPower(false); 114 | } else { 115 | if(this.power()) { 116 | // Already powered on, do nothing 117 | promise = Promise.resolve(); 118 | } else { 119 | // Not powered on, power on 120 | promise = this.setPower(true); 121 | } 122 | } 123 | 124 | return promise.then(() => { 125 | return this.call('set_mode', [ mode ], { 126 | refresh: [ 'power', 'mode' ], 127 | refreshDelay: 200 128 | }) 129 | .then(MiioApi.checkOk) 130 | .catch(err => { 131 | throw err.code === -5001 ? new Error('Mode `' + mode + '` not supported') : err; 132 | }); 133 | }); 134 | } 135 | 136 | changeTargetHumidity(humidity) { 137 | return this.call('set_limit_hum', [ humidity ], { 138 | refresh: [ 'targetHumidity' ] 139 | }).then(MiioApi.checkOk); 140 | } 141 | 142 | /** 143 | * Set the LED brightness to either `bright`, `dim` or `off`. 144 | */ 145 | changeLEDBrightness(level) { 146 | switch(level) { 147 | case 'bright': 148 | level = 0; 149 | break; 150 | case 'dim': 151 | level = 1; 152 | break; 153 | case 'off': 154 | level = 2; 155 | break; 156 | default: 157 | return Promise.reject(new Error('Invalid LED brigthness: ' + level)); 158 | } 159 | return this.call('set_led_b', [ level ], { refresh: [ 'ledBrightness' ] }) 160 | .then(() => null); 161 | } 162 | }; 163 | -------------------------------------------------------------------------------- /lib/devices/eyecare-lamp2.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const MiioApi = require('../device'); 4 | 5 | const { Children } = require('abstract-things'); 6 | const { Light, Dimmable, SwitchablePower } = require('abstract-things/lights'); 7 | const { percentage } = require('abstract-things/values'); 8 | 9 | const MiioDimmable = require('./capabilities/dimmable'); 10 | const MiioPower = require('./capabilities/power'); 11 | 12 | module.exports = class EyecareLamp2 extends Light 13 | .with(MiioPower, MiioDimmable, Children, MiioApi) 14 | { 15 | 16 | constructor(options) { 17 | super(options); 18 | 19 | this.defineProperty('power', { 20 | name: 'power', 21 | mapper: v => v === 'on' 22 | }); 23 | 24 | this.defineProperty('bright', { 25 | name: 'brightness', 26 | mapper: parseInt 27 | }); 28 | 29 | // Get if the 30 | this.defineProperty('notifystatus', { 31 | name: 'notifyStatus', 32 | mapper: v => v === 'on' 33 | }); 34 | 35 | // Power of the secondary light 36 | this.defineProperty('ambstatus', { 37 | name: 'ambientPower', 38 | mapper: v => v === 'on' 39 | }); 40 | 41 | // Brightness of the secondary light 42 | this.defineProperty('ambvalue', { 43 | name: 'ambientBrightness', 44 | mapper: parseInt 45 | }); 46 | 47 | // If the main light is on 48 | this.defineProperty('eyecare', { 49 | name: 'eyeCare', 50 | mapper: v => v === 'on' 51 | }); 52 | 53 | // The current user-defined scene 54 | this.defineProperty('scene_num', { 55 | name: 'userScene', 56 | mapper: v => { 57 | switch (v) { 58 | case 1: 59 | return 'study'; 60 | case 2: 61 | return 'reading'; 62 | case 3: 63 | return 'phone'; 64 | } 65 | } 66 | }); 67 | 68 | this.setModes([ 'study', 'reading', 'phone' ]); 69 | 70 | this.defineProperty('bls', { 71 | name: 'bls', 72 | mapper: v => v === 'on' 73 | }); 74 | 75 | this.defineProperty('dvalue', { 76 | name: 'dvalue', 77 | mapper: parseInt 78 | }); 79 | 80 | this.ambient = new AmbientLight(this); 81 | this.addChild(this.ambient); 82 | } 83 | 84 | changePower(power) { 85 | return this.call('set_power', [power ? 'on' : 'off'], { 86 | refresh: true 87 | }); 88 | } 89 | 90 | changeBrightness(brightness) { 91 | return this.call('set_bright', [ brightness ], { 92 | refresh: true 93 | }).then(MiioApi.checkOk); 94 | } 95 | 96 | /** 97 | * Is eyeCare active? 98 | */ 99 | eyeCare() { 100 | return Promise.resolve(this.property('eyeCare')); 101 | } 102 | 103 | /** 104 | * Enable the eyeCare 105 | */ 106 | setEyeCare(enable) { 107 | return this.call('set_eyecare', [enable ? 'on' : 'off'], { 108 | refresh: true 109 | }) 110 | .then(() => null); 111 | } 112 | 113 | /** 114 | * Set the current eyeCare mode 115 | * 116 | changeMode(mode) { 117 | 118 | switch (mode) { 119 | case 'study': 120 | mode = 1; 121 | break; 122 | case 'reading': 123 | mode = 2; 124 | break; 125 | case 'phone': 126 | mode = 3; 127 | break; 128 | default: 129 | return Promise.reject(new Error('Invalid EyeCare Mode: ' + mode)); 130 | } 131 | return this.call('set_user_scene', [mode], { 132 | refresh: true 133 | }); 134 | } 135 | */ 136 | 137 | setAmbientPower(power) { 138 | return this.call.send('enable_amb', [ power ? 'on' : 'off' ]) 139 | .then(MiioApi.checkOk); 140 | } 141 | 142 | setAmbientBrightness(brightness) { 143 | brightness = percentage(brightness, { min: 0, max: 100 }); 144 | 145 | return this.call.send('set_amb_bright', [ brightness ]) 146 | .then(MiioApi.checkOk); 147 | } 148 | 149 | propertyUpdated(key, value) { 150 | super.propertyUpdated(key, value); 151 | 152 | switch(key) { 153 | case 'ambientPower': 154 | this.ambient.updatePower(value); 155 | break; 156 | case 'ambientBrightness': 157 | this.ambient.updateBrightness(value); 158 | break; 159 | } 160 | } 161 | }; 162 | 163 | class AmbientLight extends Light.with(Dimmable, SwitchablePower) { 164 | 165 | constructor(parent) { 166 | super(); 167 | 168 | this.parent = parent; 169 | this.id = parent.id + ':ambient'; 170 | } 171 | 172 | changePower(power) { 173 | return this.parent.setAmbientPower(power); 174 | } 175 | 176 | changeBrightness(brightness) { 177 | return this.parent.setAmbientBrightness(brightness); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /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/devices/air-purifier.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { AirPurifier } = require('abstract-things/climate'); 4 | const MiioApi = require('../device'); 5 | 6 | const Power = require('./capabilities/power'); 7 | const Mode = require('./capabilities/mode'); 8 | const SwitchableLED = require('./capabilities/switchable-led'); 9 | const LEDBrightness = require('./capabilities/changeable-led-brightness'); 10 | const Buzzer = require('./capabilities/buzzer'); 11 | const { Temperature, Humidity, AQI } = require('./capabilities/sensor'); 12 | 13 | /** 14 | * Abstraction over a Mi Air Purifier. 15 | * 16 | * Air Purifiers have a mode that indicates if is on or not. Changing the mode 17 | * to `idle` will power off the device, all other modes will power on the 18 | * device. 19 | */ 20 | module.exports = class extends AirPurifier 21 | .with(MiioApi, Power, Mode, Temperature, Humidity, AQI, 22 | SwitchableLED, LEDBrightness, Buzzer) 23 | { 24 | 25 | static get type() { 26 | return 'miio:air-purifier'; 27 | } 28 | 29 | constructor(options) { 30 | super(options); 31 | 32 | // Define the power property 33 | this.defineProperty('power', v => v === 'on'); 34 | 35 | // Set the mode property and supported modes 36 | this.defineProperty('mode'); 37 | this.updateModes([ 38 | 'idle', 39 | 40 | 'auto', 41 | 'silent', 42 | 'favorite' 43 | ]); 44 | 45 | // Sensor value for Temperature capability 46 | this.defineProperty('temp_dec', { 47 | name: 'temperature', 48 | mapper: v => v / 10.0 49 | }); 50 | 51 | // Sensor value for RelativeHumidity capability 52 | this.defineProperty('humidity'); 53 | 54 | // Sensor value used for AQI (PM2.5) capability 55 | this.defineProperty('aqi'); 56 | 57 | // The favorite level 58 | this.defineProperty('favorite_level', { 59 | name: 'favoriteLevel' 60 | }); 61 | 62 | // Info about usage 63 | this.defineProperty('filter1_life', { 64 | name: 'filterLifeRemaining' 65 | }); 66 | this.defineProperty('f1_hour_used', { 67 | name: 'filterHoursUsed' 68 | }); 69 | this.defineProperty('use_time', { 70 | name: 'useTime' 71 | }); 72 | 73 | // State for SwitchableLED capability 74 | this.defineProperty('led', { 75 | mapper: v => v === 'on' 76 | }); 77 | 78 | this.defineProperty('led_b', { 79 | name: 'ledBrightness', 80 | mapper: v => { 81 | switch(v) { 82 | case 0: 83 | return 'bright'; 84 | case 1: 85 | return 'dim'; 86 | case 2: 87 | return 'off'; 88 | default: 89 | return 'unknown'; 90 | } 91 | } 92 | }); 93 | 94 | // Buzzer and beeping 95 | this.defineProperty('buzzer', { 96 | mapper: v => v === 'on' 97 | }); 98 | } 99 | 100 | changePower(power) { 101 | return this.call('set_power', [ power ? 'on' : 'off' ], { 102 | refresh: [ 'power', 'mode' ], 103 | refreshDelay: 200 104 | }); 105 | } 106 | 107 | /** 108 | * Perform a mode change as requested by `mode(string)` or 109 | * `setMode(string)`. 110 | */ 111 | changeMode(mode) { 112 | return this.call('set_mode', [ mode ], { 113 | refresh: [ 'power', 'mode' ], 114 | refreshDelay: 200 115 | }) 116 | .then(MiioApi.checkOk) 117 | .catch(err => { 118 | throw err.code === -5001 ? new Error('Mode `' + mode + '` not supported') : err; 119 | }); 120 | } 121 | 122 | /** 123 | * Get the favorite level used when the mode is `favorite`. Between 0 and 16. 124 | */ 125 | favoriteLevel(level=undefined) { 126 | if(typeof level === 'undefined') { 127 | return Promise.resolve(this.property('favoriteLevel')); 128 | } 129 | 130 | return this.setFavoriteLevel(level); 131 | } 132 | 133 | /** 134 | * Set the favorite level used when the mode is `favorite`, should be 135 | * between 0 and 16. 136 | */ 137 | setFavoriteLevel(level) { 138 | return this.call('set_level_favorite', [ level ]) 139 | .then(() => null); 140 | } 141 | 142 | /** 143 | * Set the LED brightness to either `bright`, `dim` or `off`. 144 | */ 145 | changeLEDBrightness(level) { 146 | switch(level) { 147 | case 'bright': 148 | level = 0; 149 | break; 150 | case 'dim': 151 | level = 1; 152 | break; 153 | case 'off': 154 | level = 2; 155 | break; 156 | default: 157 | return Promise.reject(new Error('Invalid LED brigthness: ' + level)); 158 | } 159 | return this.call('set_led_b', [ level ], { refresh: [ 'ledBrightness' ] }) 160 | .then(() => null); 161 | } 162 | }; 163 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/devices/light.md: -------------------------------------------------------------------------------- 1 | # Lights 2 | 3 | * **Devices**: Mi Yeelight Lamps, Philiphs Bulb, Philiphs Eye Care lamp 4 | * **Model identifiers**: `yeelink.light.lamp1`, `yeelink.light.mono1`, `yeelink.light.color1`, `yeelink.light.strip1`, `philips.light.sread1`, `philips.light.bulb` 5 | 6 | Lights are turned into devices of type [light][light] and by default support 7 | [power switching][switchable-power] and [dimming][dimmable]. Depending on the 8 | model the light might [support color][colorable] and [fading][fading]. 9 | 10 | ## Examples 11 | 12 | ### Check if device is a light 13 | 14 | ```javascript 15 | if(device.matches('type:light')) { 16 | /* 17 | * This device is a light. 18 | */ 19 | } 20 | ``` 21 | 22 | ### Check if powered on 23 | 24 | ```javascript 25 | // Get if the light is on 26 | device.power() 27 | .then(isOn => console.log('Light on:', isOn)) 28 | .catch(...); 29 | 30 | // Using async/await 31 | console.log('Light on:', await device.power()); 32 | ``` 33 | 34 | ### Power on light 35 | 36 | ```javascript 37 | // Switch the light on 38 | device.setPower(true) 39 | .then(...) 40 | .catch(...) 41 | 42 | // Switch on via async/await 43 | await device.power(true); 44 | ``` 45 | 46 | ### Read current brightness 47 | 48 | ```javascript 49 | // Read the current brightness 50 | device.brightness() 51 | .then(b => console.log('Brightness:', b)) 52 | .catch(...); 53 | 54 | // Using async/await 55 | console.log('Brightness:', await device.brightness()); 56 | ``` 57 | 58 | ### Change the brightness 59 | 60 | ```javascript 61 | // Set the brightness 62 | device.setBrightness(50) 63 | .then(...) 64 | .catch(...); 65 | 66 | // Using async/await 67 | await device.setBrightness(50); 68 | 69 | // Increase the brightness by 20% 70 | await device.increaseBrightness(20); 71 | 72 | // Decrease the brightness by 20% 73 | await device.decreaseBrightness(20); 74 | ``` 75 | 76 | ### Check if color is supported 77 | 78 | ```javascript 79 | if(device.matches('cap:colorable')) { 80 | // Light supports setting color 81 | } 82 | 83 | if(device.matches('cap:colorable', 'cap:color:full')) { 84 | // Light supports setting full range of colors 85 | } 86 | 87 | if(device.matches('cap:colorable', 'cap:color:temperature')) { 88 | // Light supports color temperatures 89 | } 90 | ``` 91 | 92 | ## API 93 | 94 | ### Power - [`cap:power`][power] and [`cap:switchable-power`][switchable-power] 95 | 96 | * `device.power()` - get if the light is turned on 97 | * `device.power(boolean)` - switch the light on or off 98 | * `device.setPower(boolean)` - switch the light on or off 99 | * `device.on('powerChanged', isOn => ...)` - listen for power changes 100 | 101 | ### Brightness - [`cap:brightness`][brightness] and [`cap:dimmable`][dimmable] 102 | 103 | * `device.brightness()` - get the brightness of the light 104 | * `device.brightness(percentage, duration)` - set the brightness of the light 105 | * `device.setBrightness(percentage, duration)` - set the brightness of the light 106 | * `device.increaseBrightness(percentage, duration)` - increase the brightness of the light 107 | * `device.decreaseBrightness(percentage, duration)` - decrease the brightness of the light 108 | * `device.on('brightnessChanged', bri => ...)` - listen for changes in brightness 109 | 110 | ### Color - [`cap:colorable`][colorable] 111 | 112 | * `device.color()` - get the current color of the light 113 | * `device.color(color, duration)` - update the color of the light, color can be hex-values, Kelvin-values, see capability docs for details 114 | * `device.on('colorChanged', color => ...)` - listen for changes to color 115 | 116 | ### Fading - [`cap:fading`][fading] 117 | 118 | Supported by Yeelights. Indicates that the `duration` parameter works. 119 | 120 | ## Models 121 | 122 | ### Philiphs Eye Care Lamp - `philips.light.sread1` 123 | 124 | The Eye Care lamp provides a child device to control the ambient light. This 125 | ambient light implements [power switching][switchable-power] and 126 | [dimming][dimmable]: 127 | 128 | ```javascript 129 | const ambientLight = light.child('ambient'); 130 | 131 | const isOn = await ambientLight.power(); 132 | ``` 133 | 134 | [light]: http://abstract-things.readthedocs.io/en/latest/lights/index.html 135 | [sensor]: http://abstract-things.readthedocs.io/en/latest/sensors/index.html 136 | [power]: http://abstract-things.readthedocs.io/en/latest/common/power.html 137 | [switchable-power]: http://abstract-things.readthedocs.io/en/latest/common/switchable-power.html 138 | [brightness]: http://abstract-things.readthedocs.io/en/latest/lights/brightness.html 139 | [dimmable]: http://abstract-things.readthedocs.io/en/latest/lights/dimmable.html 140 | [colorable]: http://abstract-things.readthedocs.io/en/latest/lights/colorable.html 141 | [fading]: http://abstract-things.readthedocs.io/en/latest/lights/fading.html 142 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcomed in this repository, not only code changes but also 4 | opening issues, helping with questions and otherwise giving feedback. 5 | 6 | Please note that this project has a code of conduct, please read it before 7 | jumping in and contributing to this project. 8 | 9 | ## Working with the code 10 | 11 | This project doesn't have a super-strict code-style, contributions are expected 12 | to look about the same as existing code. Tabs are used for indentation except 13 | for Markdown and a few JSON-files. Install an [Editorconfig](http://editorconfig.org/) 14 | plugin in your editor to simplify white-space handling. 15 | 16 | Before submitting a pull request, run `npm test` to run a few basic linting 17 | tests. If you have an ESLint-plugin in your editor it should run automatically 18 | while you work. 19 | 20 | ## Issues and helping out with questions 21 | 22 | This project is used both directly by developers and indirectly by end-users 23 | via other projects. When opening an issue try to include as much details about 24 | how the library is used, such as if its being used via a third-party project 25 | and the version being used. 26 | 27 | If you have know-how about the API and library please feel free to answer any 28 | questions you can. If you have the same issue as someone else, try adding a few 29 | more details if you can. 30 | 31 | ## Code of Conduct 32 | 33 | ### Our Pledge 34 | 35 | In the interest of fostering an open and welcoming environment, we as 36 | contributors and maintainers pledge to making participation in our project and 37 | our community a harassment-free experience for everyone, regardless of age, body 38 | size, disability, ethnicity, gender identity and expression, level of experience, 39 | nationality, personal appearance, race, religion, or sexual identity and 40 | orientation. 41 | 42 | ### Our Standards 43 | 44 | Examples of behavior that contributes to creating a positive environment 45 | include: 46 | 47 | * Using welcoming and inclusive language 48 | * Being respectful of differing viewpoints and experiences 49 | * Gracefully accepting constructive criticism 50 | * Focusing on what is best for the community 51 | * Showing empathy towards other community members 52 | 53 | Examples of unacceptable behavior by participants include: 54 | 55 | * The use of sexualized language or imagery and unwelcome sexual attention or 56 | advances 57 | * Trolling, insulting/derogatory comments, and personal or political attacks 58 | * Public or private harassment 59 | * Publishing others' private information, such as a physical or electronic 60 | address, without explicit permission 61 | * Other conduct which could reasonably be considered inappropriate in a 62 | professional setting 63 | 64 | ### Our Responsibilities 65 | 66 | Project maintainers are responsible for clarifying the standards of acceptable 67 | behavior and are expected to take appropriate and fair corrective action in 68 | response to any instances of unacceptable behavior. 69 | 70 | Project maintainers have the right and responsibility to remove, edit, or 71 | reject comments, commits, code, wiki edits, issues, and other contributions 72 | that are not aligned to this Code of Conduct, or to ban temporarily or 73 | permanently any contributor for other behaviors that they deem inappropriate, 74 | threatening, offensive, or harmful. 75 | 76 | ### Scope 77 | 78 | This Code of Conduct applies both within project spaces and in public spaces 79 | when an individual is representing the project or its community. Examples of 80 | representing a project or community include using an official project e-mail 81 | address, posting via an official social media account, or acting as an appointed 82 | representative at an online or offline event. Representation of a project may be 83 | further defined and clarified by project maintainers. 84 | 85 | ### Enforcement 86 | 87 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 88 | reported by contacting the main developer at a@holstenson.se. All 89 | complaints will be reviewed and investigated and will result in a response that 90 | is deemed necessary and appropriate to the circumstances. The project team is 91 | obligated to maintain confidentiality with regard to the reporter of an incident. 92 | Further details of specific enforcement policies may be posted separately. 93 | 94 | Project maintainers who do not follow or enforce the Code of Conduct in good 95 | faith may face temporary or permanent repercussions as determined by other 96 | members of the project's leadership. 97 | 98 | ### Attribution 99 | 100 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 101 | available at [http://contributor-covenant.org/version/1/4][version] 102 | 103 | [homepage]: http://contributor-covenant.org 104 | [version]: http://contributor-covenant.org/version/1/4/ 105 | -------------------------------------------------------------------------------- /docs/devices/humidifier.md: -------------------------------------------------------------------------------- 1 | # Humidifier 2 | 3 | * **Devices**: Mi Humidifier 4 | * **Model identifiers**: `zhimi.humidifier.v1` 5 | 6 | Humidifiers are turned into devices of type [humidifer][humidifier] 7 | and by default support [power switching][switchable-power] and 8 | [setting their mode][switchable-mode]. The Mi Humidifier are also 9 | [sensors][sensor] and can report the [temperature][temp] and 10 | [relative humidity][humidity] of where they are placed. 11 | 12 | ## Examples 13 | 14 | ### Check if device is a humidifier 15 | 16 | ```javascript 17 | if(device.matches('type:humidifier')) { 18 | /* 19 | * This device is a humidifier. 20 | */ 21 | } 22 | ``` 23 | 24 | ### Check if powered on 25 | 26 | ```javascript 27 | // Get if the humidifier is on 28 | device.power() 29 | .then(isOn => console.log('Humidifier on:', isOn)) 30 | .catch(...); 31 | 32 | // Using async/await 33 | console.log('Humidifier on:', await device.power()); 34 | ``` 35 | 36 | ### Power on device 37 | 38 | ```javascript 39 | // Switch the humidifier on 40 | device.setPower(true) 41 | .then(...) 42 | .catch(...) 43 | 44 | // Switch on via async/await 45 | await device.power(true); 46 | ``` 47 | 48 | ### Read temperature 49 | 50 | ```javascript 51 | // Read the temperature 52 | device.temperature() 53 | .then(temp => console.log('Temperature:', temp.celsius)) 54 | .catch(...); 55 | 56 | // Using async/await 57 | const temp = await device.temperature(); 58 | console.log('Temperature:', temp.celsius); 59 | ``` 60 | 61 | ### Read humidity 62 | 63 | ```javascript 64 | // Read the relative humidity 65 | device.relativeHumidity() 66 | .then(rh => console.log('Relative humidity:', rh)) 67 | .catch(...); 68 | 69 | // Using async/await 70 | const rh = await device.relativeHumidity(); 71 | console.log('Relative humidity:', rh); 72 | ``` 73 | 74 | ## API 75 | 76 | ### Power - [`cap:power`][power] and [`cap:switchable-power`][switchable-power] 77 | 78 | * `device.power()` - get if the humidifier is currently active 79 | * `device.power(boolean)` - switch the humidifer is on, returns a promise 80 | * `device.setPower(boolean)` - change the power state of the device, returns a promise 81 | * `device.on('powerChanged', isOn => ...)` - listen for power changes 82 | 83 | ### Mode - [`cap:mode`][mode] and [`cap:switchable-mode`][switchable-mode] 84 | 85 | The air purifiers have different modes that controls their speed. 86 | 87 | * `device.mode()` - get the current mode 88 | * `device.mode(string)` - set the current mode of the device, returns a promise 89 | * `device.setMode(string)` - set the current mode of the device, returns a promise 90 | * `device.modes()` - read-only array indicating the modes supports by the device 91 | * `device.on('modeChanged', mode => ...)` - listen for changes to the current mode 92 | 93 | The modes supported change between different models, but most devices support: 94 | 95 | * `idle`, turn the device off 96 | * `silent`, silent mode, lowest speed 97 | * `medium`, medium speed 98 | * `hight`, high speed 99 | 100 | ### Sensor - [`type:sensor`][sensor] 101 | 102 | * `device.temperature()` - get the current temperature, see [`cap:temperature`][temp] for details 103 | * `device.on('temperature', temp => ...)` - listen to changes to the read temperature 104 | * `device.relativeHumidity()` - get the current relative humidity, see [`cap:relative-humidity`][humidity] for details 105 | * `device.on('relativeHumidityChanged', rh => ...)` - listen to changes to the relative humidity 106 | 107 | ### Target humidity - [`cap:target-humidity`][target-humidity] and [`cap:adjustable-target-humidity`][adjustable-target-humidity] 108 | 109 | * `device.targetHumidity()` - get the current target humidity 110 | * `device.targetHumidity(target)` - set the target humidity 111 | * `device.on('targetHumidityChanged', th => ...)` - listen to changes to the target humidity 112 | 113 | ### Buzzer settings - `cap:miio:buzzer` 114 | 115 | * `device.buzzer()` - boolean indicating if the buzzer (beep) is active 116 | * `device.buzzer(boolean)` - switch the buzzer on or off 117 | * `device.setBuzzer(boolean)` - switch the buzzer on or off 118 | 119 | ### LED brightness - `cap:miio:led-brightness` 120 | 121 | Change the brightness of the LED on the device. 122 | 123 | * `device.ledBrightness()` - the LED brightness, `bright`, `dim` or `off` 124 | * `device.ledBrightness(string)` - set the brightness of the LED 125 | 126 | [air-purifier]: http://abstract-things.readthedocs.io/en/latest/climate/air-purifiers.html 127 | [sensor]: http://abstract-things.readthedocs.io/en/latest/sensors/index.html 128 | [power]: http://abstract-things.readthedocs.io/en/latest/common/power.html 129 | [switchable-power]: http://abstract-things.readthedocs.io/en/latest/common/switchable-power.html 130 | [mode]: http://abstract-things.readthedocs.io/en/latest/common/mode.html 131 | [switchable-mode]: http://abstract-things.readthedocs.io/en/latest/common/switchable-mode.html 132 | [pm2.5]: http://abstract-things.readthedocs.io/en/latest/sensors/pm2.5.html 133 | [temp]: http://abstract-things.readthedocs.io/en/latest/sensors/temperature.html 134 | [humidity]: http://abstract-things.readthedocs.io/en/latest/sensors/relative-humidity.html 135 | [target-humidity]: http://abstract-things.readthedocs.io/en/latest/climate/target-humidity.html 136 | [adjustable-target-humidity]: http://abstract-things.readthedocs.io/en/latest/climate/adjustable-target-humidity.html 137 | -------------------------------------------------------------------------------- /docs/management.md: -------------------------------------------------------------------------------- 1 | # Device management 2 | 3 | The `miio` command line utility supports many device operations, including 4 | discovering and configuring devices. It is also the primary tool used for 5 | managing access to devices by storing tokens of devices. 6 | 7 | ## Install the command line tool 8 | 9 | You can install the command line tool with: 10 | 11 | `npm install -g miio` 12 | 13 | ## Discovering devices on current network 14 | 15 | `miio discover` 16 | 17 | This will list devices that are connected to the same network as your computer. 18 | Let it run for a while so it has a chance to reach all devices, as it might 19 | take a minute or two for all devices to answer. 20 | 21 | The commands outputs each device on this format: 22 | 23 | ``` 24 | Device ID: 48765421 25 | Model info: zhimi.airpurifier.m1 26 | Address: 192.168.100.9 27 | Token: token-as-hex-here via auto-token 28 | Support: At least basic 29 | ``` 30 | 31 | The information output is: 32 | 33 | * __Device ID__ - the unique identifier of the device, does not change if the device is reset. 34 | * __Model ID__ - the model id if it could be determined, this indicates what type of device it is 35 | * __Address__ - the IP that the device has on the network 36 | * __Token__ - the token of the device or ??? if it could not be automatically determined 37 | 38 | ### Storing tokens of all discovered devices 39 | 40 | As future firmware updates might start hiding the token of a device it is a good 41 | idea to sync the tokens to the local token storage. Use the flag `--sync` to 42 | enable this: 43 | 44 | `miio discover --sync` 45 | 46 | ## Changing the WiFi settings of a device 47 | 48 | `miio configure id-or-address --ssid network-ssid --passwd network-password` 49 | 50 | Options: 51 | 52 | * `--ssid` - the required SSID of the 2.4 GHz WiFi network that the device should connect to 53 | * `--passwd` - the password of the WiFi network 54 | 55 | _Warning:_ This command does not verify that the device can actually connect to 56 | the network. If it can not it will be stuck and will need to be reset. 57 | 58 | ## Configuring a new device 59 | 60 | If you have gotten a new device the `--configure` flag can also be used to 61 | configure this new device without using the Mi Home app. 62 | 63 | 1. The Mi Home device will create a wireless network, connect your computer to this network. Your device model will be included in the name such as: `zhimi-airpurifier-m1_miapdf91`. 64 | 2. Run `miio discover` to make sure you can see your device. Make a note of the address (IP) or id. 65 | 3. Configure the WiFi: `miio id-or-address configure --ssid ssid-of-network --passwd password-of-network`. See above for details about the flags. 66 | 4. After the device has been configured the token is saved locally and made available to on your current machine. If you don't need the token locally you can also now copy the token and transfer it to where it is needed. 67 | 5. Reconnect to your regular network. 68 | 6. _Optional:_ Run `miio discover` again to make sure the device actually connected to the network. 69 | 70 | If the device does not show up on your main network the problem is probably one of: 71 | 72 | * Wrong SSID - check that the SSID you gave the `--configure` command is correct. 73 | * Wrong password - check that the password is correct for the WiFi network. 74 | * Not a 2.4 GHz network - make sure to use a 2.4 GHz network as 5 GHz networks are not supported. 75 | 76 | You will need to reset the device to try another connection. 77 | 78 | ## Resetting a device 79 | 80 | There is currently no way to do this via the client, see your manual for how to 81 | do it with your device. It usually involves pressing one or two buttons for 82 | 5 to 10 seconds. The buttons can either be visible or hidden behind a pinhole. 83 | 84 | ## Getting the token of a device 85 | 86 | Some Mi Home devices hide their token and require a reset and reconfiguration 87 | before the device can be used. If you do not use the Mi Home app, do a 88 | reset and the follow the section _Configuring a new device_ found above. 89 | 90 | ### Getting the token when using the Mi Home app 91 | 92 | 1. Reset your Mi Home device. This will reset the token and remove the device from the Mi Home app - you will need to readd it later. Check the manual of your device to find out how to perform a reset. 93 | 2. The Mi Home device will create a wireless network, connect your computer to this network. Your device model will be included in the name such as: `zhimi-airpurifier-m1_miapddd8`. 94 | 3. Run `miio discover --sync` in a terminal window. 95 | 4. a) If you are using the device on the current machine the token has been saved and is now available locally.
b) If you need the token somewhere else copy the token as displayed, add it wherever you need to and store a copy somewhere. 96 | 5. Press Ctrl+C to stop the discovery. 97 | 6. Reconnect to your regular wireless network. 98 | 7. Readd the device in the Mi Home app so that it has access to your regular wireless network. 99 | 100 | ## Setting the token of a device 101 | 102 | If you want to update the token of a device use the `tokens update` command: 103 | 104 | `miio tokens update id-or-address --token token-as-hex` 105 | 106 | This is mostly used when you configured the device on another computer but want 107 | to use another computer to have access to the device. Make sure to run the 108 | `miio` command as the correct user as tokens are stored tied to the current 109 | user. 110 | -------------------------------------------------------------------------------- /cli/commands/inspect.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chalk = require('chalk'); 4 | const log = require('../log'); 5 | const deviceFinder = require('../device-finder'); 6 | 7 | const GROUPS = [ 8 | { name: 'Power', tags: [ 'cap:power', 'cap:switchable-power' ] }, 9 | { name: 'Mode', tags: [ 'cap:mode', 'cap:switchable-mode' ] }, 10 | { name: 'Sensor', tags: [ 11 | 'type:sensor', 12 | 'cap:temperature', 13 | 'cap:relativeHumidity', 14 | 'cap:pressure', 15 | 'cap:pm2.5', 16 | 'cap:illuminance', 17 | 'cap:contact', 18 | 'cap:motion', 19 | ] }, 20 | { name: 'Brightness', tags: [ 'cap:brightness', 'cap:dimmable' ] }, 21 | { name: 'Color', tags: [ 'cap:colorable' ] }, 22 | { name: 'LED', tags: [ 'cap:miio:switchable-led', 'cap:miio:led-brightness' ] }, 23 | { name: 'Buzzer', tags: [ 'cap:miio:buzzer' ] }, 24 | { name: 'Children', tags: [ 'cap:children' ] }, 25 | { name: 'miIO', tags: [ 'type:miio' ]} 26 | ]; 27 | 28 | exports.command = 'inspect '; 29 | exports.description = 'Inspect a device'; 30 | exports.builder = { 31 | }; 32 | 33 | exports.handler = function(argv) { 34 | let target = argv.idOrIp; 35 | log.info('Attempting to inspect', target); 36 | 37 | let foundDevice = false; 38 | let pending = 0; 39 | const browser = deviceFinder({ 40 | instances: true, 41 | filter: target 42 | }); 43 | browser.on('available', device => { 44 | pending++; 45 | 46 | // TODO: Error handling 47 | const mgmt = device.management; 48 | mgmt.info() 49 | .then(info => { 50 | log.plain(); 51 | log.device(device, true); 52 | 53 | if(! mgmt.parent) { 54 | log.plain(chalk.bold('Firmware version:'), info.fw_ver); 55 | log.plain(chalk.bold('Hardware version:'), info.hw_ver); 56 | if(info.mcu_fw_ver) { 57 | log.plain(chalk.bold('MCU firmware version:'), info.mcu_fw_ver); 58 | } 59 | log.plain(); 60 | 61 | if(info.ap) { 62 | log.plain(chalk.bold('WiFi:'), info.ap.ssid, chalk.dim('(' + info.ap.bssid + ')'), chalk.bold('RSSI:'), info.ap.rssi); 63 | } else { 64 | log.plain(chalk.bold('WiFi:'), 'Not Connected'); 65 | } 66 | if(info.wifi_fw_ver) { 67 | log.plain(chalk.bold('WiFi firmware version:'), info.wifi_fw_ver); 68 | } 69 | log.plain(); 70 | 71 | if(info.ot) { 72 | let type; 73 | switch(info.ot) { 74 | case 'otu': 75 | type = 'UDP'; 76 | break; 77 | case 'ott': 78 | type = 'TCP'; 79 | break; 80 | default: 81 | type = 'Unknown (' + info.ot + ')'; 82 | } 83 | log.plain(chalk.bold('Remote access (Mi Home App):'), type); 84 | } else { 85 | log.plain(chalk.bold('Remote access (Mi Home App):'), 'Maybe'); 86 | } 87 | log.plain(); 88 | } 89 | 90 | const props = device.properties; 91 | const keys = Object.keys(props); 92 | if(keys.length > 0) { 93 | log.plain(chalk.bold('Properties:')); 94 | for(const key of keys) { 95 | log.plain(' -', key + ':', props[key]); 96 | } 97 | log.plain(); 98 | } 99 | 100 | if(mgmt.parent) { 101 | log.plain(chalk.bold('Parent:')); 102 | log.group(() => { 103 | log.device(mgmt.parent); 104 | }); 105 | } 106 | 107 | log.plain(chalk.bold('Actions:')); 108 | log.group(() => { 109 | const actions = device.metadata.actions; 110 | const handled = new Set(); 111 | for(const group of GROUPS) { 112 | let seenTags = new Set(); 113 | let actionsToPrint = []; 114 | 115 | /* 116 | * Go through all actions and collect those that 117 | * belong to this group. 118 | */ 119 | for(const name of Object.keys(actions)) { 120 | if(handled.has(name)) continue; 121 | const action = actions[name]; 122 | 123 | for(const t of group.tags) { 124 | if(action.tags.indexOf(t) >= 0) { 125 | seenTags.add(t); 126 | actionsToPrint.push(action); 127 | break; 128 | } 129 | } 130 | } 131 | 132 | if(actionsToPrint.length > 0) { 133 | log.plain(chalk.bold(group.name), '-', Array.from(seenTags).join(', ')); 134 | 135 | for(const action of actionsToPrint) { 136 | printAction(action); 137 | handled.add(action.name); 138 | } 139 | 140 | log.plain(); 141 | } 142 | } 143 | 144 | let isFirst = true; 145 | for(const name of Object.keys(actions)) { 146 | if(handled.has(name)) continue; 147 | 148 | if(isFirst) { 149 | log.plain(chalk.bold('Other actions')); 150 | isFirst = false; 151 | } 152 | 153 | printAction(actions[name]); 154 | } 155 | }); 156 | }) 157 | .catch(err => { 158 | log.error('Could inspect device. Error was:', err.message); 159 | }) 160 | .then(() => { 161 | pending--; 162 | process.exit(0); // eslint-disable-line 163 | }); 164 | }); 165 | 166 | const doneHandler = () => { 167 | if(pending === 0) { 168 | if(! foundDevice) { 169 | log.warn('Could not find device'); 170 | } 171 | process.exit(0); // eslint-disable-line 172 | } 173 | }; 174 | setTimeout(doneHandler, 5000); 175 | browser.on('done', doneHandler); 176 | }; 177 | 178 | function printAction(action) { 179 | log.group(() => { 180 | let args = action.name; 181 | for(const arg of action.arguments) { 182 | args += ' '; 183 | 184 | if(arg.optional) { 185 | args += '['; 186 | } 187 | 188 | args += arg.type; 189 | 190 | if(arg.optional) { 191 | args += ']'; 192 | } 193 | } 194 | 195 | log.plain(args); 196 | }); 197 | } 198 | -------------------------------------------------------------------------------- /docs/devices/air-purifier.md: -------------------------------------------------------------------------------- 1 | # Air Purifier 2 | 3 | * **Devices**: Mi Air Purifier 1 & 2, Pro 4 | * **Model identifiers**: `zhimi.airpurifier.m1`, `zhimi.airpurifier.v1`, `zhimi.airpurifier.v2`, `zhimi.airpurifier.v3`, `zhimi.airpurifier.v6` 5 | 6 | Air purifiers are turned into devices of type [air-purifier][air-purifier] 7 | and by default support [power switching][switchable-power] and [setting their mode][switchable-mode]. The Mi Air 8 | Purifiers are also [sensors][sensor] and can report the [PM2.5 (air quality index)][pm2.5], 9 | [temperature][temp] and [relative humidity][humidity] where they are placed. 10 | 11 | ## Examples 12 | 13 | ### Check if device is an air purifier 14 | 15 | ```javascript 16 | if(device.matches('type:air-purifier')) { 17 | /* 18 | * This device is an air purifier. 19 | */ 20 | } 21 | ``` 22 | 23 | ### Check if powered on 24 | 25 | ```javascript 26 | // Get if the air purifier is on 27 | device.power() 28 | .then(isOn => console.log('Air purifier on:', isOn)) 29 | .catch(...); 30 | 31 | // Using async/await 32 | console.log('Air purifier on:', await device.power()); 33 | ``` 34 | 35 | ### Power on device 36 | 37 | ```javascript 38 | // Switch the air purifier on 39 | device.setPower(true) 40 | .then(...) 41 | .catch(...) 42 | 43 | // Switch on via async/await 44 | await device.power(true); 45 | ``` 46 | 47 | ### Read temperature 48 | 49 | ```javascript 50 | // Read the temperature 51 | device.temperature() 52 | .then(temp => console.log('Temperature:', temp.celsius)) 53 | .catch(...); 54 | 55 | // Using async/await 56 | const temp = await device.temperature(); 57 | console.log('Temperature:', temp.celsius); 58 | ``` 59 | 60 | ### Read humidity 61 | 62 | ```javascript 63 | // Read the relative humidity 64 | device.relativeHumidity() 65 | .then(rh => console.log('Relative humidity:', rh)) 66 | .catch(...); 67 | 68 | // Using async/await 69 | const rh = await device.relativeHumidity(); 70 | console.log('Relative humidity:', rh); 71 | ``` 72 | 73 | ## API 74 | 75 | ### Power - [`cap:power`][power] and [`cap:switchable-power`][switchable-power] 76 | 77 | * `device.power()` - get if the air purifier is currently active 78 | * `device.power(boolean)` - switch the air purifier on, returns a promise 79 | * `device.setPower(boolean)` - change the power state of the device, returns a promise 80 | * `device.on(power, isOn => ...)` - listen for power changes 81 | 82 | ### Mode - [`cap:mode`][mode] and [`cap:switchable-mode`][switchable-mode] 83 | 84 | The air purifiers have different modes that controls their speed. 85 | 86 | * `device.mode()` - get the current mode 87 | * `device.mode(string)` - set the current mode of the device, returns a promise 88 | * `device.setMode(string)` - set the current mode of the device, returns a promise 89 | * `device.modes()` - read-only array indicating the modes supports by the device 90 | * `device.on('modeChanged', mode => ...)` - listen for changes to the current mode 91 | 92 | The modes supported change between different models, but most devices support: 93 | 94 | * `idle`, turn the device off 95 | * `auto`, set the device to automatic mode where it controls the speed itself 96 | * `silent`, lowest speed, for silent operation or night time 97 | * `favorite`, favorite level 98 | 99 | ### Sensor - [`type:sensor`][sensor] 100 | 101 | * `device.temperature()` - get the current temperature, see [`cap:temperature`][temp] for details 102 | * `device.on('temperature', temp => ...)` - listen to changes to the read temperature 103 | * `device.relativeHumidity()` - get the current relative humidity, see [`cap:relative-humidity`][humidity] for details 104 | * `device.on('relativeHumidityChanged', rh => ...)` - listen to changes to the relative humidity 105 | * `device.pm2_5` - get the current PM2.5 (Air Quality Index), see [`cap:pm2.5`][pm2.5] for details 106 | * `device.on('pm2.5Changed', pm2_5 => ...)` - listen to changes to the PM2.5 value 107 | 108 | ### Buzzer settings - `cap:miio:buzzer` 109 | 110 | * `device.buzzer()` - boolean indicating if the buzzer (beep) is active 111 | * `device.buzzer(boolean)` - switch the buzzer on or off 112 | * `device.setBuzzer(boolean)` - switch the buzzer on or off 113 | 114 | ### LED settings - `cap:miio:switchable-led` 115 | 116 | Turn the LED indicator on the device on or off. 117 | 118 | * `device.led()` - if the LED is on or off 119 | * `device.led(boolean)` - switch the LED on or off 120 | 121 | ### LED brightness - `cap:miio:led-brightness` 122 | 123 | Change the brightness of the LED on the device. 124 | 125 | * `device.ledBrightness()` - the LED brightness, `bright`, `dim` or `off` 126 | * `device.ledBrightness(string)` - set the brightness of the LED 127 | 128 | ### Other 129 | 130 | * `device.favoriteLevel()` - get the speed the device should run at when mode is `favorite`. Between 0 and 16. 131 | * `device.favoriteLevel(level)` - set the speed the device should run at when mode is `favorite`. Between 0 and 16. 132 | * `device.setFavoriteLevel(number)` - set the speed for mode `favorite` 133 | 134 | [air-purifier]: http://abstract-things.readthedocs.io/en/latest/climate/air-purifiers.html 135 | [sensor]: http://abstract-things.readthedocs.io/en/latest/sensors/index.html 136 | [power]: http://abstract-things.readthedocs.io/en/latest/common/power.html 137 | [switchable-power]: http://abstract-things.readthedocs.io/en/latest/common/switchable-power.html 138 | [mode]: http://abstract-things.readthedocs.io/en/latest/common/mode.html 139 | [switchable-mode]: http://abstract-things.readthedocs.io/en/latest/common/switchable-mode.html 140 | [pm2.5]: http://abstract-things.readthedocs.io/en/latest/sensors/pm2.5.html 141 | [temp]: http://abstract-things.readthedocs.io/en/latest/sensors/temperature.html 142 | [humidity]: http://abstract-things.readthedocs.io/en/latest/sensors/relative-humidity.html 143 | -------------------------------------------------------------------------------- /lib/devices/gateway/subdevice.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const util = require('util'); 4 | const isDeepEqual = require('deep-equal'); 5 | const { Thing } = require('abstract-things'); 6 | 7 | const IDENTITY_MAPPER = v => v; 8 | 9 | class SubDeviceManagement { 10 | constructor(device, parent) { 11 | this._device = device; 12 | this.parent = parent; 13 | } 14 | 15 | get token() { 16 | return null; 17 | } 18 | 19 | get model() { 20 | return this._device.miioModel; 21 | } 22 | 23 | info() { 24 | const device = this._device; 25 | return Promise.resolve({ 26 | id: device.id, 27 | model: device.model 28 | }); 29 | } 30 | } 31 | 32 | module.exports = Thing.type(Parent => class SubDevice extends Parent { 33 | static get types() { 34 | return [ 'miio', 'miio:subdevice' ]; 35 | } 36 | 37 | constructor(parent, info) { 38 | super(); 39 | 40 | this.miioModel = info.model; 41 | this.id = info.id; 42 | 43 | // Store the internal id by removing the `miio:` prefix 44 | this.internalId = info.id.substring(5); 45 | 46 | this._properties = {}; 47 | this._propertiesToMonitor = []; 48 | this._propertyDefinitions = {}; 49 | 50 | this._parent = parent; 51 | 52 | // Bind the _report function for use with developer API 53 | this._report = this._report.bind(this); 54 | 55 | this.management = new SubDeviceManagement(this, parent); 56 | } 57 | 58 | hasCapability(name) { 59 | return this.capabilities.indexOf(name) >= 0; 60 | } 61 | 62 | initCallback() { 63 | return super.initCallback() 64 | .then(() => this._parent.devApi.on('properties:' + this.internalId, this._report)) 65 | .then(() => this.loadProperties()); 66 | } 67 | 68 | destroyCallback() { 69 | return super.destroyCallback() 70 | .then(() => this._parent.devApi.removeListener('properties:' + this.internalId, this._report)); 71 | } 72 | 73 | _report(data) { 74 | this._propertiesToMonitor.forEach(key => { 75 | const def = this._propertyDefinitions[key]; 76 | let name = key; 77 | let value = data[key]; 78 | if(typeof value === 'undefined') return; 79 | 80 | if(def) { 81 | name = def.name || name; 82 | value = def.mapper(value); 83 | } 84 | 85 | this.setProperty(name, value); 86 | }); 87 | 88 | if(this._currentRead) { 89 | this._currentRead.resolve(); 90 | this._currentRead = null; 91 | } 92 | } 93 | 94 | get properties() { 95 | return Object.assign({}, this._properties); 96 | } 97 | 98 | property(key) { 99 | return this._properties[key]; 100 | } 101 | 102 | /** 103 | * Define a property and how the value should be mapped. All defined 104 | * properties are monitored if #monitor() is called. 105 | */ 106 | defineProperty(name, def) { 107 | if(! def || typeof def.poll === 'undefined' || def.poll) { 108 | this._propertiesToMonitor.push(name); 109 | } 110 | 111 | if(typeof def === 'function') { 112 | def = { 113 | mapper: def 114 | }; 115 | } else if(typeof def === 'undefined') { 116 | def = { 117 | mapper: IDENTITY_MAPPER 118 | }; 119 | } 120 | 121 | if(! def.mapper) { 122 | def.mapper = IDENTITY_MAPPER; 123 | } 124 | 125 | this._propertyDefinitions[name] = def; 126 | } 127 | 128 | setProperty(key, value) { 129 | const oldValue = this._properties[key]; 130 | 131 | if(! isDeepEqual(oldValue, value)) { 132 | this._properties[key] = value; 133 | this.debug('Property', key, 'changed from', oldValue, 'to', value); 134 | 135 | this.propertyUpdated(key, value, oldValue); 136 | } 137 | } 138 | 139 | propertyUpdated(key, value, oldValue) { 140 | } 141 | 142 | getProperties(props=[]) { 143 | const result = {}; 144 | props.forEach(key => { 145 | result[key] = this._properties[key]; 146 | }); 147 | return result; 148 | } 149 | 150 | /** 151 | * Stub for loadProperties to match full device. 152 | */ 153 | loadProperties(props) { 154 | if(this._propertiesToMonitor.length === 0) Promise.resolve(); 155 | 156 | if(this._currentRead) { 157 | return this._currentRead.promise; 158 | } 159 | 160 | // Store information about the current read promise and how to resolve it 161 | this._currentRead = {}; 162 | this._currentRead.promise = new Promise((resolve, reject) => { 163 | // Request the read 164 | this._parent.devApi.read(this.internalId); 165 | 166 | // Store the resolve to be used in _report 167 | this._currentRead.resolve = () => { 168 | resolve(this.getProperties(props)); 169 | }; 170 | 171 | /* 172 | * Fallback behavior, use a regular call if properties have not 173 | * been resolved in a second. 174 | */ 175 | setTimeout(() => { 176 | this.debug('Read via DEV timed out, using fallback API'); 177 | this._parent.call('get_device_prop_exp', [ [ 'lumi.' + this.internalId, ...this._propertiesToMonitor ]]) 178 | .then(result => { 179 | for(let i=0; i { 193 | this._currentRead = null; 194 | reject(err); 195 | }); 196 | }, 1000); 197 | }); 198 | 199 | return this._currentRead.promise; 200 | } 201 | 202 | /** 203 | * Call a method for this sub device. 204 | */ 205 | call(method, args, options) { 206 | if(! options) { 207 | options = {}; 208 | } 209 | 210 | options.sid = this.internalId; 211 | return this._parent.call(method, args, options); 212 | } 213 | 214 | [util.inspect.custom](depth, options) { 215 | if(depth === 0) { 216 | return options.stylize('MiioDevice', 'special') 217 | + '[' + this.miioModel + ']'; 218 | } 219 | 220 | return options.stylize('MiioDevice', 'special') 221 | + ' {\n' 222 | + ' model=' + this.miioModel + ',\n' 223 | + ' types=' + Array.from(this.metadata.types).join(', ') + ',\n' 224 | + ' capabilities=' + Array.from(this.metadata.capabilities).join(', ') 225 | + '\n}'; 226 | } 227 | }); 228 | -------------------------------------------------------------------------------- /lib/devices/gateway.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing, Children } = require('abstract-things'); 4 | const { ChildSyncer } = require('abstract-things/children'); 5 | 6 | const MiioApi = require('../device'); 7 | const { Illuminance } = require('./capabilities/sensor'); 8 | 9 | const DeveloperApi = require('./gateway/developer-api'); 10 | const CHARS = '0123456789abcdefghijklmnopqrstuvwxyz'; 11 | 12 | const LightMixin = require('./gateway/light-mixin'); 13 | const SubDevice = require('./gateway/subdevice'); 14 | const types = require('./gateway/subdevices'); 15 | 16 | /** 17 | * Generate a key for use with the local developer API. This does not generate 18 | * a secure key, but the Mi Home app does not seem to do that either. 19 | */ 20 | function generateKey() { 21 | let result = ''; 22 | for(let i=0; i<16; i++) { 23 | let idx = Math.floor(Math.random() * CHARS.length); 24 | result += CHARS[idx]; 25 | } 26 | return result; 27 | } 28 | 29 | /** 30 | * Abstraction over a Mi Smart Home Gateway. 31 | * 32 | * This device is very different from most other miIO-devices in that it has 33 | * devices on its own. There seems to be a way to talk to these devices by 34 | * setting `sid` in the requests, which means we can poll them. The problem 35 | * is that certain devices also send events, but these seem to only be 36 | * accessible over a local developer connection. 37 | * 38 | * So to work around this we activate the local developer connection and use 39 | * that together with the regular calls for changing things on the gateway. 40 | */ 41 | const Gateway = Thing.type(Parent => class Gateway extends Parent.with(MiioApi, Children) { 42 | static get type() { 43 | return 'miio:gateway'; 44 | } 45 | 46 | static get SubDevice() { return SubDevice; } 47 | 48 | static registerSubDevice(id, deviceClass) { 49 | types[id] = deviceClass; 50 | } 51 | 52 | static availableAPI(builder) { 53 | builder.action('addDevice') 54 | .done(); 55 | 56 | builder.action('stopAddDevice') 57 | .done(); 58 | 59 | builder.action('removeDevice') 60 | .done(); 61 | } 62 | 63 | constructor(options) { 64 | super(options); 65 | 66 | this.defineProperty('illumination', { 67 | name: 'illuminance' 68 | }); 69 | 70 | this.defineProperty('rgb', { 71 | handler: (result, rgba) => { 72 | result['rgb'] = { 73 | red: 0xff & (rgba >> 16), 74 | green: 0xff & (rgba >> 8), 75 | blue: 0xff & rgba 76 | }; 77 | result['brightness'] = Math.round(0xff & (rgba >> 24)); 78 | } 79 | }); 80 | 81 | this.extraChildren = []; 82 | 83 | // Monitor every minute right now 84 | this._monitorInterval = 60 * 1000; 85 | 86 | this.syncer = new ChildSyncer(this, (def, current) => { 87 | // Shortcut to detect extra child 88 | if(def.metadata && def.id) return def; 89 | 90 | if(! def.online) return; 91 | if(current) return current; 92 | 93 | const type = types[def.type] || SubDevice; 94 | const device = new type(this, { 95 | id: def.id, 96 | model: 'lumi.generic.' + def.type 97 | }); 98 | 99 | return device; 100 | }); 101 | } 102 | 103 | _report(properties) { 104 | Object.keys(properties).forEach(key => { 105 | this.setRawProperty(key, properties[key]); 106 | }); 107 | } 108 | 109 | initCallback() { 110 | return super.initCallback() 111 | .then(() => this._findDeveloperKey()) 112 | .then(() => this._updateDeviceList()) 113 | .then(() => { 114 | // Update devices once every half an hour 115 | this._deviceListTimer = setInterval(this._updateDeviceList.bind(this), 30 * 60 * 1000); 116 | }); 117 | } 118 | 119 | destroyCallback() { 120 | return super.destroyCallback() 121 | .then(() => { 122 | 123 | if(this.devApi) { 124 | this.devApi.destroy(); 125 | } 126 | 127 | clearInterval(this._deviceListTimer); 128 | }); 129 | } 130 | 131 | addExtraChild(child) { 132 | this.extraChildren.push(child); 133 | } 134 | 135 | _updateDeviceList() { 136 | function stripLumiFromId(id) { 137 | if(id.indexOf('lumi.') === 0) { 138 | return id.substring(5); 139 | } 140 | return id; 141 | } 142 | 143 | return this.call('get_device_prop', [ 'lumi.0', 'device_list' ]) 144 | .then(list => { 145 | const defs = [ ...this.extraChildren ]; 146 | for(let i=0; i { 175 | let key = result[0]; 176 | if(key && key.length === 16) { 177 | // This is a valid key, store it 178 | return key; 179 | } 180 | 181 | key = generateKey(); 182 | return this.call('set_lumi_dpf_aes_key', [ key ]); 183 | }) 184 | .then(key => this._setDeveloperKey(key)); 185 | } 186 | 187 | /** 188 | * Set the developer key and start listenting for events from the gateway. 189 | */ 190 | _setDeveloperKey(key) { 191 | this._developerKey = key; 192 | 193 | // If we are already connected to the Developer API skip init 194 | if(this.devApi) return Promise.resolve(); 195 | 196 | return new Promise((resolve, reject) => { 197 | this.devApi = new DeveloperApi(this, this.management.address); 198 | 199 | this.devApi.on('propertiesChanged', e => { 200 | if(e.id === '0') { 201 | // Data for the local gateway 202 | this._report(e.data); 203 | } 204 | }); 205 | this.devApi.on('ready', resolve); 206 | 207 | setTimeout(reject, 10000); 208 | }); 209 | } 210 | 211 | /** 212 | * Start the process of adding a device. Allows Zigbee devices to join for 213 | * 30 seconds. 214 | */ 215 | addDevice() { 216 | return this.call('start_zigbee_join', [ 30 ]) 217 | .then(MiioApi.checkOk) 218 | .then(() => setTimeout(this._updateDeviceList.bind(this), 30000)) 219 | .then(() => undefined); 220 | } 221 | 222 | /** 223 | * Stop allowing devices to join. 224 | */ 225 | stopAddDevice() { 226 | return this.call('start_zigbee_join', [ 0 ]) 227 | .then(MiioApi.checkOk) 228 | .then(() => undefined); 229 | } 230 | 231 | /** 232 | * Remove a device from the gateway using the identifier of the device. 233 | */ 234 | removeDevice(id) { 235 | return this.call('remove_device', [ id ]) 236 | .then(MiioApi.checkOk) 237 | .then(this._updateDeviceList.bind(this)) 238 | .then(() => undefined); 239 | } 240 | 241 | }); 242 | 243 | module.exports.Basic = Gateway; 244 | module.exports.WithLightAndSensor = Gateway.with(LightMixin, Illuminance); 245 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/devices/vacuum.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { ChargingState, AutonomousCharging } = require('abstract-things'); 4 | const { 5 | Vacuum, AdjustableFanSpeed, AutonomousCleaning, SpotCleaning 6 | } = require('abstract-things/climate'); 7 | 8 | const MiioApi = require('../device'); 9 | const BatteryLevel = require('./capabilities/battery-level'); 10 | 11 | function checkResult(r) { 12 | //console.log(r) 13 | // {"result":0,"id":17} = Firmware 3.3.9_003095 (Gen1) 14 | // {"result":["ok"],"id":11} = Firmware 3.3.9_003194 (Gen1), 3.3.9_001168 (Gen2) 15 | if( r !== 0 && r[0] !== 'ok' ) { 16 | throw new Error('Could not complete call to device'); 17 | } 18 | } 19 | 20 | /** 21 | * Implementation of the interface used by the Mi Robot Vacuum. This device 22 | * doesn't use properties via get_prop but instead has a get_status. 23 | */ 24 | module.exports = class extends Vacuum.with( 25 | MiioApi, BatteryLevel, AutonomousCharging, AutonomousCleaning, 26 | SpotCleaning, AdjustableFanSpeed, ChargingState 27 | ) { 28 | static get type() { 29 | return 'miio:vacuum'; 30 | } 31 | 32 | constructor(options) { 33 | super(options); 34 | 35 | this.defineProperty('error_code', { 36 | name: 'error', 37 | mapper: e => { 38 | switch(e) { 39 | case 0: 40 | return null; 41 | default: 42 | return { 43 | code: e, 44 | message: 'Unknown error ' + e 45 | }; 46 | } 47 | 48 | // TODO: Find a list of error codes and map them correctly 49 | } 50 | }); 51 | 52 | this.defineProperty('state', s => { 53 | switch(s) { 54 | case 1: 55 | return 'initiating'; 56 | case 2: 57 | return 'charger-offline'; 58 | case 3: 59 | return 'waiting'; 60 | case 5: 61 | return 'cleaning'; 62 | case 6: 63 | return 'returning'; 64 | case 8: 65 | return 'charging'; 66 | case 9: 67 | return 'charging-error'; 68 | case 10: 69 | return 'paused'; 70 | case 11: 71 | return 'spot-cleaning'; 72 | case 12: 73 | return 'error'; 74 | case 13: 75 | return 'shutting-down'; 76 | case 14: 77 | return 'updating'; 78 | case 15: 79 | return 'docking'; 80 | case 17: 81 | return 'zone-cleaning'; 82 | case 100: 83 | return 'full'; 84 | } 85 | return 'unknown-' + s; 86 | }); 87 | 88 | // Define the batteryLevel property for monitoring battery 89 | this.defineProperty('battery', { 90 | name: 'batteryLevel' 91 | }); 92 | 93 | this.defineProperty('clean_time', { 94 | name: 'cleanTime', 95 | }); 96 | this.defineProperty('clean_area', { 97 | name: 'cleanArea', 98 | mapper: v => v / 1000000 99 | }); 100 | this.defineProperty('fan_power', { 101 | name: 'fanSpeed' 102 | }); 103 | this.defineProperty('in_cleaning'); 104 | 105 | // Consumable status - times for brushes and filters 106 | this.defineProperty('main_brush_work_time', { 107 | name: 'mainBrushWorkTime' 108 | }); 109 | this.defineProperty('side_brush_work_time', { 110 | name: 'sideBrushWorkTime' 111 | }); 112 | this.defineProperty('filter_work_time', { 113 | name: 'filterWorkTime' 114 | }); 115 | this.defineProperty('sensor_dirty_time', { 116 | name: 'sensorDirtyTime' 117 | }); 118 | 119 | this._monitorInterval = 60000; 120 | } 121 | 122 | propertyUpdated(key, value, oldValue) { 123 | if(key === 'state') { 124 | // Update charging state 125 | this.updateCharging(value === 'charging'); 126 | 127 | switch(value) { 128 | case 'cleaning': 129 | case 'spot-cleaning': 130 | case 'zone-cleaning': 131 | // The vacuum is cleaning 132 | this.updateCleaning(true); 133 | break; 134 | case 'paused': 135 | // Cleaning has been paused, do nothing special 136 | break; 137 | case 'error': 138 | // An error has occurred, rely on error mapping 139 | this.updateError(this.property('error')); 140 | break; 141 | case 'charging-error': 142 | // Charging error, trigger an error 143 | this.updateError({ 144 | code: 'charging-error', 145 | message: 'Error during charging' 146 | }); 147 | break; 148 | case 'charger-offline': 149 | // Charger is offline, trigger an error 150 | this.updateError({ 151 | code: 'charger-offline', 152 | message: 'Charger is offline' 153 | }); 154 | break; 155 | default: 156 | // The vacuum is not cleaning 157 | this.updateCleaning(false); 158 | break; 159 | } 160 | } else if(key === 'fanSpeed') { 161 | this.updateFanSpeed(value); 162 | } 163 | 164 | super.propertyUpdated(key, value, oldValue); 165 | } 166 | 167 | /** 168 | * Start a cleaning session. 169 | */ 170 | activateCleaning() { 171 | return this.call('app_start', [], { 172 | refresh: [ 'state' ], 173 | refreshDelay: 1000 174 | }) 175 | .then(checkResult); 176 | } 177 | 178 | /** 179 | * Pause the current cleaning session. 180 | */ 181 | pause() { 182 | return this.call('app_pause', [], { 183 | refresh: [ 'state '] 184 | }) 185 | .then(checkResult); 186 | } 187 | 188 | /** 189 | * Stop the current cleaning session. 190 | */ 191 | deactivateCleaning() { 192 | return this.call('app_stop', [], { 193 | refresh: [ 'state' ], 194 | refreshDelay: 1000 195 | }) 196 | .then(checkResult); 197 | } 198 | 199 | /** 200 | * Stop the current cleaning session and return to charge. 201 | */ 202 | activateCharging() { 203 | return this.call('app_stop', []) 204 | .then(checkResult) 205 | .then(() => this.call('app_charge', [], { 206 | refresh: [ 'state' ], 207 | refreshDelay: 1000 208 | })) 209 | .then(checkResult); 210 | } 211 | 212 | /** 213 | * Start cleaning the current spot. 214 | */ 215 | activateSpotClean() { 216 | return this.call('app_spot', [], { 217 | refresh: [ 'state' ] 218 | }) 219 | .then(checkResult); 220 | } 221 | 222 | /** 223 | * Set the power of the fan. Usually 38, 60 or 77. 224 | */ 225 | changeFanSpeed(speed) { 226 | return this.call('set_custom_mode', [ speed ], { 227 | refresh: [ 'fanSpeed' ] 228 | }) 229 | .then(checkResult); 230 | } 231 | 232 | /** 233 | * Activate the find function, will make the device give off a sound. 234 | */ 235 | find() { 236 | return this.call('find_me', ['']) 237 | .then(() => null); 238 | } 239 | 240 | /** 241 | * Get information about the cleaning history of the device. Contains 242 | * information about the number of times it has been started and 243 | * the days it has been run. 244 | */ 245 | getHistory() { 246 | return this.call('get_clean_summary') 247 | .then(result => { 248 | return { 249 | count: result[2], 250 | days: result[3].map(ts => new Date(ts * 1000)) 251 | }; 252 | }); 253 | } 254 | 255 | /** 256 | * Get history for the specified day. The day should be fetched from 257 | * `getHistory`. 258 | */ 259 | getHistoryForDay(day) { 260 | let record = day; 261 | if(record instanceof Date) { 262 | record = Math.floor(record.getTime() / 1000); 263 | } 264 | return this.call('get_clean_record', [ record ]) 265 | .then(result => ({ 266 | day: day, 267 | history: result.map(data => ({ 268 | // Start and end times 269 | start: new Date(data[0] * 1000), 270 | end: new Date(data[1] * 1000), 271 | 272 | // How long it took in seconds 273 | duration: data[2], 274 | 275 | // Area in m2 276 | area: data[3] / 1000000, 277 | 278 | // If it was a complete run 279 | complete: data[5] === 1 280 | })) 281 | })); 282 | } 283 | 284 | loadProperties(props) { 285 | // We override loadProperties to use get_status and get_consumables 286 | props = props.map(key => this._reversePropertyDefinitions[key] || key); 287 | 288 | return Promise.all([ 289 | this.call('get_status'), 290 | this.call('get_consumable') 291 | ]).then(result => { 292 | const status = result[0][0]; 293 | const consumables = result[1][0]; 294 | 295 | const mapped = {}; 296 | props.forEach(prop => { 297 | let value = status[prop]; 298 | if(typeof value === 'undefined') { 299 | value = consumables[prop]; 300 | } 301 | this._pushProperty(mapped, prop, value); 302 | }); 303 | return mapped; 304 | }); 305 | } 306 | }; 307 | -------------------------------------------------------------------------------- /lib/devices/yeelight.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Thing, Nameable } = require('abstract-things'); 4 | const { Light, Fading, Colorable, ColorTemperature, ColorFull } = require('abstract-things/lights'); 5 | const { color } = require('abstract-things/values'); 6 | const MiioApi = require('../device'); 7 | 8 | const Power = require('./capabilities/power'); 9 | const Dimmable = require('./capabilities/dimmable'); 10 | 11 | const DEFAULT_EFFECT = 'smooth'; 12 | const DEFAULT_DURATION = 500; 13 | 14 | const Yeelight = Thing.type(Parent => class Yeelight extends Parent 15 | .with(Light, Fading, MiioApi, Power, Dimmable, Nameable) 16 | { 17 | static get type() { 18 | return 'miio:yeelight'; 19 | } 20 | 21 | constructor(options) { 22 | super(options); 23 | 24 | this.defineProperty('power', { 25 | name: 'power', 26 | mapper: v => v === 'on' 27 | }); 28 | 29 | this.defineProperty('bright', { 30 | name: 'brightness', 31 | mapper: parseInt 32 | }); 33 | 34 | // Used for scheduling turning off after a while 35 | this.defineProperty('delayoff', { 36 | name: 'offDelay', 37 | mapper: parseInt 38 | }); 39 | 40 | // Query for the color mode 41 | this.defineProperty('color_mode', { 42 | name: 'colorMode', 43 | mapper: v => { 44 | v = parseInt(v); 45 | switch(v) { 46 | case 1: 47 | return 'rgb'; 48 | case 2: 49 | return 'colorTemperature'; 50 | case 3: 51 | return 'hsv'; 52 | } 53 | } 54 | }); 55 | 56 | // Read the name of the light 57 | this.defineProperty('name'); 58 | 59 | /* 60 | * Set maximum fade time to 30s - seems to work well, longer values 61 | * sometimes cause jumps on certain models. 62 | */ 63 | this.updateMaxChangeTime('30s'); 64 | } 65 | 66 | propertyUpdated(key, value) { 67 | if(key === 'name') { 68 | this.metadata.name = value; 69 | } 70 | 71 | super.propertyUpdated(key, value); 72 | } 73 | 74 | changePower(power) { 75 | // TODO: Support for duration 76 | return this.call( 77 | 'set_power', 78 | Yeelight.withEffect(power ? 'on' : 'off'), 79 | { 80 | refresh: [ 'power'] 81 | } 82 | ).then(MiioApi.checkOk); 83 | } 84 | 85 | /** 86 | * Make the current state the default state, meaning the light will restore 87 | * to this state after power has been cut. 88 | */ 89 | setDefault() { 90 | return this.call('set_default') 91 | .then(MiioApi.checkOk); 92 | } 93 | 94 | changeBrightness(brightness, options) { 95 | if(brightness <= 0) { 96 | return this.changePower(false); 97 | } else { 98 | let promise; 99 | if(options.powerOn && this.power() === false) { 100 | // Currently not turned on 101 | promise = this.changePower(true); 102 | } else { 103 | promise = Promise.resolve(); 104 | } 105 | 106 | return promise.then(() => 107 | this.call( 108 | 'set_bright', 109 | Yeelight.withEffect(brightness, options.duration), { 110 | refresh: [ 'brightness' ] 111 | }) 112 | ).then(MiioApi.checkOk); 113 | } 114 | } 115 | 116 | changeName(name) { 117 | return this.call('set_name', [ name ]) 118 | .then(MiioApi.checkOk) 119 | .then(() => this.metadata.name = name); 120 | } 121 | 122 | /** 123 | * Helper method to combine an argument with effect information. 124 | */ 125 | static withEffect(arg, duration) { 126 | const result = Array.isArray(arg) ? arg : [ arg ]; 127 | 128 | if(duration) { 129 | if(duration.ms > 0) { 130 | result.push(DEFAULT_EFFECT); 131 | result.push(duration.ms); 132 | } else { 133 | result.push('sudden'); 134 | result.push(0); 135 | } 136 | } else { 137 | result.push(DEFAULT_EFFECT); 138 | result.push(DEFAULT_DURATION); 139 | } 140 | 141 | return result; 142 | } 143 | }); 144 | 145 | module.exports.Yeelight = Yeelight; 146 | 147 | module.exports.ColorTemperature = Thing.mixin(Parent => class extends Parent 148 | .with(MiioApi, Colorable, ColorTemperature) 149 | { 150 | 151 | constructor(...args) { 152 | super(...args); 153 | 154 | // Color temperature 155 | this.defineProperty('ct', { 156 | name: 'colorTemperature', 157 | mapper: parseInt 158 | }); 159 | 160 | // TODO: Do all Yeelights use the same color range? 161 | this.updateColorTemperatureRange(2700, 6500); 162 | } 163 | 164 | propertyUpdated(key, value) { 165 | if(key === 'colorTemperature') { 166 | this.updateColor(color.temperature(value)); 167 | } 168 | 169 | super.propertyUpdated(key, value); 170 | } 171 | 172 | changeColor(color, options) { 173 | const range = this.colorTemperatureRange; 174 | const temperature = Math.min(Math.max(color.temperature.kelvins, range.min), range.max); 175 | 176 | return this.call('set_ct_abx', Yeelight.withEffect(temperature, options.duration), { 177 | refresh: [ 'colorTemperature' ] 178 | }).then(MiioApi.checkOk); 179 | } 180 | }); 181 | 182 | module.exports.ColorFull = Thing.mixin(Parent => class extends Parent 183 | .with(MiioApi, Colorable, ColorTemperature, ColorFull) 184 | { 185 | 186 | constructor(...args) { 187 | super(...args); 188 | 189 | // Color temperature 190 | this.defineProperty('ct', { 191 | name: 'colorTemperature', 192 | mapper: parseInt 193 | }); 194 | 195 | this.defineProperty('rgb', { 196 | name: 'colorRGB', 197 | mapper: rgb => { 198 | rgb = parseInt(rgb); 199 | 200 | return { 201 | red: (rgb >> 16) & 0xff, 202 | green: (rgb >> 8) & 0xff, 203 | blue: rgb & 0xff 204 | }; 205 | } 206 | }); 207 | 208 | this.defineProperty('hue', { 209 | name: 'colorHue', 210 | mapper: parseInt 211 | }); 212 | 213 | this.defineProperty('sat', { 214 | name: 'colorSaturation', 215 | mapper: parseInt 216 | }); 217 | 218 | this.metadata.addCapabilities('color:temperature', 'color:full'); 219 | 220 | // TODO: Do all Yeelights use the same color range? 221 | this.updateColorTemperatureRange(2700, 6500); 222 | } 223 | 224 | propertyUpdated(key, value) { 225 | if(key === 'colorTemperature' || key === 'colorMode' || key === 'colorRGB' || key === 'colorHue' || key === 'colorSaturation' || key === 'brightness') { 226 | let currentColor = this.color(); 227 | switch(this.property('colorMode')) { 228 | case 'colorTemperature': 229 | // Currently using color temperature mode, parse as temperature 230 | currentColor = color.temperature(this.property('colorTemperature')); 231 | break; 232 | case 'rgb': { 233 | // Using RGB, parse if we have gotten the RGB value 234 | let rgb = this.property('colorRGB'); 235 | if(rgb) { 236 | currentColor = color.rgb(rgb.red, rgb.green, rgb.blue); 237 | } 238 | break; 239 | } 240 | case 'hsv': { 241 | // Using HSV, parse the hue, saturation and get the brightness to set color 242 | let hue = this.property('colorHue'); 243 | let saturation = this.property('colorSaturation'); 244 | 245 | if(typeof hue !== 'undefined' && typeof saturation !== 'undefined') { 246 | currentColor = color.hsv(hue, saturation, this.property('brightness')); 247 | } 248 | } 249 | } 250 | 251 | this.updateColor(currentColor); 252 | } 253 | 254 | super.propertyUpdated(key, value); 255 | } 256 | 257 | changeColor(color, options) { 258 | if(color.is('temperature')) { 259 | // The user has request a color via temperature 260 | 261 | const range = this.colorTemperatureRange; 262 | const temperature = Math.min(Math.max(color.temperature.kelvins, range.min), range.max); 263 | 264 | return this.call('set_ct_abx', Yeelight.withEffect(temperature, options.duration), { 265 | refresh: [ 'colorTemperature', 'colorMode' ] 266 | }).then(MiioApi.checkOk); 267 | } else if(color.is('hsl')) { 268 | /* 269 | * User has requested hue and saturation 270 | */ 271 | return this.call('set_hsv', Yeelight.withEffect([ color.hue, color.saturation ], options.duration), { 272 | refresh: [ 'colorHue', 'colorSaturation', 'colorMode' ] 273 | }).then(MiioApi.checkOk); 274 | } else { 275 | /* 276 | * Fallback to applying via RGB. 277 | */ 278 | color = color.rgb; 279 | 280 | const rgb = color.red * 65536 + color.green * 256 + color.blue; 281 | return this.call('set_rgb', Yeelight.withEffect(rgb, options.duration), { 282 | refresh: [ 'colorRGB', 'colorMode' ] 283 | }).then(MiioApi.checkOk); 284 | } 285 | } 286 | }); 287 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # miIO Device Library 2 | 3 | [![npm version](https://badge.fury.io/js/miio.svg)](https://badge.fury.io/js/miio) 4 | [![Dependencies](https://david-dm.org/aholstenson/miio.svg)](https://david-dm.org/aholstenson/miio) 5 | 6 | Control Mi Home devices that implement the miIO protocol, such as the 7 | Mi Air Purifier, Mi Robot Vacuum and Mi Smart Socket. These devices are commonly 8 | part of what Xiaomi calls the Mi Ecosystem which is branded as MiJia. 9 | 10 | `miio` is [MIT-licensed](LICENSE.md) and requires at least Node 6.6.0. As 11 | the API is promise-based Node 8 is recommended which provides support `async` 12 | and `await` that greatly simplifies asynchronous handling. 13 | 14 | **Note:** Since 0.15.0 this library has been rewritten to use [abstract-things](https://github.com/tinkerhub/abstract-things) 15 | as its base. The API of devices will have changed, and some bugs are to be 16 | expected. Testing and feedback on the new API is welcome, please open issues 17 | as needed. 18 | 19 | ## Devices types 20 | 21 | The intent of this library is to support all miIO-compatible devices and to 22 | provide an easy to use API for them. The library maps specific device models to 23 | generic device types with well defined capabilities to simplify interacting with 24 | them. 25 | 26 | Currently supported devices are: 27 | 28 | * Air Purifiers (1, 2 and Pro) 29 | * Mi Humidifier 30 | * Mi Smart Socket Plug and Power Strips 31 | * Mi Robot Vacuum (V1 and V2) 32 | * Mi Smart Home Gateway (Aqara) and accessories - switches, sensors, etc 33 | * Philips Light Bulb and Eyecare Lamp 34 | * Yeelights (White Bulb, Color Bulb, Desk Lamp and Strip) 35 | 36 | See [documentation for devices](docs/devices/README.md) for information about 37 | the types, their API and supported device models. You can also check 38 | [Missing devices](docs/missing-devices.md) if you want to know what you can do 39 | to help this library with support for your device. 40 | 41 | ## Installation 42 | 43 | To install into your project: 44 | 45 | ``` 46 | npm install miio 47 | ``` 48 | 49 | To install globally for access to the command line tool: 50 | 51 | ``` 52 | npm install -g miio 53 | ``` 54 | 55 | ## Usage 56 | 57 | ```javascript 58 | const miio = require('miio'); 59 | ``` 60 | 61 | Resolve a handle to the device: 62 | 63 | ```javascript 64 | // Resolve a device, resolving the token automatically or from storage 65 | miio.device({ address: '192.168.100.8' }) 66 | .then(device => console.log('Connected to', device)) 67 | .catch(err => handleErrorHere); 68 | 69 | // Resolve a device, specifying the token (see below for how to get the token) 70 | miio.device({ address: '192.168.100.8', token: 'token-as-hex' }) 71 | .then(device => console.log('Connected to', device)) 72 | .catch(err => handleErrorHere); 73 | ``` 74 | 75 | Call methods to interact with the device: 76 | 77 | ```javascript 78 | // Switch the power of the device 79 | device.togglePower() 80 | .then(on => console.log('Power is now', on)) 81 | .catch(err => handleErrorHere); 82 | 83 | // Using async/await 84 | await device.togglePower(); 85 | ``` 86 | 87 | Listen to events such as property changes and actions: 88 | 89 | ```javascript 90 | // Listen for power changes 91 | device.on('power', power => console.log('Power changed to', power)); 92 | 93 | // The device is available for event handlers 94 | const handler = ({ action }, device) => console.log('Action', action, 'performed on', device); 95 | device1.on('action', handler); 96 | device2.on('action', handler); 97 | ``` 98 | 99 | Capabilities and types are used to hint about what a device can do: 100 | 101 | ```javascript 102 | if(device.matches('cap:temperature')) { 103 | console.log(await device.temperature()); 104 | } 105 | 106 | if(device.matches('cap:switchable-power')) { 107 | device.setPower(false) 108 | .then(console.log) 109 | .catch(console.error); 110 | } 111 | ``` 112 | 113 | If you are done with the device call `destroy` to stop all network traffic: 114 | 115 | ```javascript 116 | device.destroy(); 117 | ``` 118 | 119 | ## Tokens and device management 120 | 121 | A few miIO devices send back their token during a handshake and can be used 122 | without figuring out the token. Most devices hide their token, such as 123 | Yeelights and the Mi Robot Vacuum. 124 | 125 | There is a command line tool named `miio` that helps with finding and storing 126 | tokens. See [Device management](docs/management.md) for details 127 | and common use cases. 128 | 129 | ## Discovering devices 130 | 131 | Use `miio.devices()` to look for and connect to devices on the local network. 132 | This method of discovery will tell you directly if a device reveals its token 133 | and can be auto-connected to. If you do not want to automatically connect to 134 | devices you can use `miio.browse()` instead. 135 | 136 | Example using `miio.devices()`: 137 | 138 | ```javascript 139 | const devices = miio.devices({ 140 | cacheTime: 300 // 5 minutes. Default is 1800 seconds (30 minutes) 141 | }); 142 | 143 | devices.on('available', device => { 144 | if(device.matches('placeholder')) { 145 | // This device is either missing a token or could not be connected to 146 | } else { 147 | // Do something useful with device 148 | } 149 | }); 150 | 151 | devices.on('unavailable', device => { 152 | // Device is no longer available and is destroyed 153 | }); 154 | ``` 155 | 156 | `miio.devices()` supports these options: 157 | 158 | * `cacheTime`, the maximum amount of seconds a device can be unreachable before it becomes unavailable. Default: `1800` 159 | * `filter`, function used to filter what devices are connected to. Default: `reg => true` 160 | * `skipSubDevices`, if sub devices on Aqara gateways should be skipped. Default: `false` 161 | * `useTokenStorage`, if tokens should be fetched from storage (see device management). Default: `true` 162 | * `tokens`, object with manual mapping between ids and tokens (advanced, use [Device management](docs/management.md) if possible) 163 | 164 | See [Advanced API](docs/advanced-api.md) for details about `miio.browse()`. 165 | 166 | ## Device API 167 | 168 | Check [documentation for devices](docs/devices/README.md) for details about 169 | the API for supported devices. Detailed documentation of the core API is 170 | available in the section [Using things in the abstract-things documentation](http://abstract-things.readthedocs.io/en/latest/using-things.html). 171 | 172 | ## Library versioning and API stability 173 | 174 | This library uses [semantic versioning](http://semver.org/) with an exception 175 | being that the API for devices is based on their type and capabilities and not 176 | their model. 177 | 178 | This means that a device can have methods removed if its type or capabilities 179 | change, which can happen if a better implementation is made available for the 180 | model. When working with the library implement checks against type and 181 | capabilities for future compatibility within the same major version of `miio`. 182 | 183 | Capabilities can be considered stable across major versions, if a device 184 | supports `power` no minor or patch version will introduce `power-mega` and 185 | replace `power`. If new functionality is needed the new capability will be 186 | added along side the older one. 187 | 188 | ## Reporting issues 189 | 190 | [Reporting issues](docs/reporting-issues.md) contains information that is 191 | useful for making any issue you want to report easier to fix. 192 | 193 | ## Debugging 194 | 195 | The library uses [debug](https://github.com/visionmedia/debug) with two 196 | namespaces, `miio` is used for packet details and network discovery and devices 197 | use the `thing:miio` namespace. These are controlled via the `DEBUG` 198 | environment flag. The flag can be set while running the miio command or any 199 | NodeJS script: 200 | 201 | Show debug info about devices during discovery: 202 | 203 | ``` 204 | $ DEBUG=thing\* miio discover 205 | ``` 206 | 207 | To activate both namespaces set `DEBUG` to both: 208 | 209 | ``` 210 | $ DEBUG=miio\*,thing\* miio discover 211 | ``` 212 | 213 | ## Protocol documentation 214 | 215 | This library is based on the documentation provided by OpenMiHome. See https://github.com/OpenMiHome/mihome-binary-protocol for details. For details 216 | about how to figure out the commands for new devices look at the 217 | [documentation for protocol and commands](docs/protocol.md). 218 | -------------------------------------------------------------------------------- /docs/devices/README.md: -------------------------------------------------------------------------------- 1 | # Device types and capabilities 2 | 3 | To make it easier to work with different devices this library normalizes 4 | different models into types. These device types have their own API to match 5 | what the actual device can actually do. In addition each device also has a 6 | set of capabilities, that are used to flag that a device can do something 7 | extra on top of its type API. 8 | 9 | ## Types 10 | 11 | Name | Description | Devices 12 | ----------------------|------------------------------------------------------------|--------------------- 13 | [Power strips](power-strip.md) | Power strip with one or more power channels. | Mi Smart Power Strip 14 | [Power plugs](power-plug.md) | Switchable power plug with one or more power channels | Mi Smart Socket Plug, Aqara Plug 15 | [Power outlets](power-outlet.md) | Wall mounted outlet | None yet 16 | [Wall switches](wall-switch.md) | Wall mounted switch with one or more individual power channels. | Aqara Light Control 17 | [Controllers](controller.md) | Devices that are primarily used to control something else. | Aqara Button, Aqara Cube, Aqara Light Switch 18 | [Gateways](gateway.md) | Mi Smart Home Gateway that pulls in sub devices of the Aqara type | Mi Smart Home Gateway 2, Mi Smart Home Gateway 3 19 | [Air purifiers](air-purifier.md) | Air purifiers and air filtering devices. | Mi Air Purifier, Mi Air Purifier 2 and Mi Air Purifier Pro 20 | [Humidifiers](humidifier.md) | Humidifier. | Mi Humidifier 21 | [Vacuum](vacuum.md) | Robot vacuums. | Mi Robot Vacuum 22 | [Lights](light.md) | For any type of lights. | Mi Yeelights 23 | 24 | ## Models 25 | 26 | The tables below indicates how well different devices are supported. The support 27 | column can be one of the following: 28 | 29 | * ❓ Unknown - support for this device is unknown, you can help test it if you have access to it 30 | * ❌ None - this device is not a miIO-device or has some quirk making it unusable 31 | * ⚠️ Generic - this device is supported via the generic API but does not have a high-level API 32 | * ⚠️ Untested - this device has an implementation but needs testing with a real device 33 | * ✅ Basic - the basic functionality of the device is implemented, but more advanced features are missing 34 | * ✅ Good - most of the functionality is available including some more advanced features such as settings 35 | * ✅ Excellent - as close to complete support as possible 36 | 37 | If your device: 38 | 39 | * Is not in this list, it might still be a miIO-device and at least have generic support. See [Missing devices](../missing-devices.md) for information about how to find out. 40 | * Needs a manual token and the table says it should not, something has probably changed in the firmware, please open an issue so the table can be adjusted. 41 | * Is marked as Untested you can help by testing the implementation is this library and opening an issue with information about the result. 42 | 43 | ### Models by name 44 | 45 | Name | Type | Auto-token | Support | Note 46 | ------------------------------|---------------|------------|-------------|-------- 47 | Mi Air Purifier 1 | Air purifier | Yes | ⚠️ Untested | 48 | Mi Air Purifier 2 | Air purifier | Yes | ✅ Good | 49 | Mi Air Purifier Pro | Air purifier | Yes | ✅ Basic | Some of the new features and sensors are not supported. 50 | Mi Flora | - | - | ❌ None | Communicates using Bluetooth. 51 | Mi Lunar Smart Sleep Sensor | Generic | Yes | ⚠️ Generic | 52 | Mi Robot Vacuum | Vacuum | No | ✅ Basic | DND, timers and mapping features are not supported. 53 | Mi Smart Socket Plug | Power plug | Yes | ✅ Good | 54 | Mi Smart Socket Plug 2 | Power plug | Yes | ✅ Good | 55 | Mi Smart Home Gateway 1 | - | Yes | ⚠️ Generic | API used to access sub devices not supported. 56 | Mi Smart Home Gateway 2 | Gateway | Yes | ✅ Basic | Light, sound and music features not supported. 57 | Mi Smart Home Gateway 3 | Gateway | Yes | ✅ Basic | Light, sound and music features not supported. 58 | Mi Smart Home Cube | Controller | Yes | ✅ Excellent | Aqara device via Smart Home Gateway 59 | Mi Smart Home Light Switch | Controller | Yes | ⚠️ Untested | Aqara device via Smart Home Gateway. 60 | Mi Smart Home Light Control | Wall switch | Yes | ⚠️ Untested | Aqara device via Smart Home Gateway. Controls power to one or two lights. 61 | Mi Smart Home Temperature and Humidity Sensor | Sensor | Yes | Excellent | Aqara device via Smart Home Gateway. 62 | Mi Smart Home Wireless Switch | Controller | Yes | ✅ Excellent | Aqara device via Smart Home Gateway. 63 | Mi Smart Home Door / Window Sensor | Sensor | Yes | ⚠️ Untested | Aqara device via Smart Home Gateway. 64 | Mi Smart Home Occupancy Sensor | Sensor | Yes | ⚠️ Untested | Aqara device via Smart Home Gateway. 65 | Mi Smart Home Aqara Plug | Power plug | Yes | ⚠️ Untested | Aqara device via Smart Home Gateway. 66 | Mi Smart Home Smoke Sensor | - | Yes | ❓ Unknown | Aqara device - unknown support 67 | Mi Smart Home Gas Sensor | - | Yes | ❓ Unknown | Aqara device - unknown support 68 | Mi Smart Home Outlet | - | Yes | ❓ Unknown | Aqara device - unknown support 69 | Mi Smart Power Strip 1 | Power strip | Unknown | ✅ Basic | Setting power and mode is untested. 70 | Mi Smart Power Strip 2 | Power strip | Unknown | ✅ Basic | Setting power and mode is untested. 71 | Mi Rice Cooker | - | Unknown | ❓ Unknown | 72 | Mi Humidifier | Humidifier | Yes | ✅ Good | 73 | Mi Smart Fan | Generic | Unknown | ⚠️ Generic | 74 | Mi Air Quality Monitor (PM2.5)| Sensor | Unknown | ✅ Good | 75 | Yeelight Desk Lamp | Light | No | ✅ Good | 76 | Yeelight Color Bulb | Light | No | ✅ Good | 77 | Yeelight White Bulb | Light | No | ✅ Good | 78 | Yeelight LED Strip | Light | No | ⚠️ Untested | 79 | Yeelight Ceiling Lamp | - | - | ❓ Unknown | 80 | Yeelight Bedside Lamp | - | - | ❓ Unknown | 81 | Mi Washing Machine | - | - | ❓ Unknown | 82 | Mi IR Remote | - | - | ❓ Unknown | 83 | 84 | ### Models by identifier 85 | 86 | __Note:__ This table does not include Aqara (Smart Home Gateway) devices as their model identifier is set based on the type of the device. 87 | 88 | Id | Type | Auto-token | Support | Note 89 | --------------------------|-------------------|------------|--------------|------ 90 | `zhimi.airpurifier.m1` | Air Purifier | Yes | ✅ Good | 91 | `zhimi.airpurifier.v1` | Air Purifier` | Yes | ✅ Good | 92 | `zhimi.airpurifier.v2` | Air Purifier | Yes | ✅ Good | 93 | `zhimi.airpurifier.v3` | Air Purifier | Unknown | ⚠️ Untested | 94 | `zhimi.airpurifier.v4` | - | Unknown | ⚠️ Generic | Testing needed to check compatibility. 95 | `zhimi.airpurifier.v5` | - | Unknown | ⚠️ Generic | Testing needed to check compatibility. 96 | `zhimi.airpurifier.v6` | Air Purifier | Yes | ✅ Basic | 97 | `zhimi.humidifier.v1` | Humidifier | Unknown | ⚠️ Untested | 98 | `chuangmi.plug.m1` | Power plug | Yes | ✅ Good | 99 | `chuangmi.plug.v1` | Power plug | Yes | ✅ Good | 100 | `chuangmi.plug.v2` | Power plug | Yes | ✅ Good | 101 | `qmi.powerstrip.v1` | Power strip | Yes | ⚠️ Untested | 102 | `zimi.powerstrip.v2` | Power strip | Yes | ⚠️ Untested | 103 | `rockrobo.vaccum.v1` | Vacuum | No | ✅ Basic | DND, timers and mapping features are not supported. 104 | `rockrobo.vaccum.s5` | Vacuum | No | ✅ Basic | DND, timers and mapping features are not supported. 105 | `lumi.gateway.v1` | Generic | Yes | ⚠️ Generic | API used to access sub devices not supported. 106 | `lumi.gateway.v2` | Gateway | Yes | ✅ Basic | 107 | `lumi.gateway.v3` | Gateway | Yes | ✅ Basic | 108 | `yeelink.light.lamp1` | Light | No | ✅ Good | 109 | `yeelink.light.mono1` | Light | No | ✅ Good | 110 | `yeelink.light.color1` | Light | No | ✅ Good | 111 | `yeelink.light.strip1` | Light | No | ⚠️ Untested | Support added, verification with real device needed. 112 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------