diff --git a/.gitignore b/.gitignore index f872e90..ac753a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ -*.iml +.DS_Store +.idea +dist/ /node_modules .log package-lock.json diff --git a/README.md b/README.md index c8f5371..677ef75 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # node-red-contrib-mi-devices This module contains the following nodes to provide easy integration of the Xiaomi devices into node-red. -This module is a fork of [Harald Rietman module, node-red-contrib-xiaomi-devices](https://github.com/hrietman/node-red-contrib-xiaomi-devices) The following devices are currently supported: @@ -15,9 +14,9 @@ The following devices are currently supported: * Power plug (zigbee) * Power plug (wifi) * Yeelight White (mono) -* Yeelight RGB +* Yeelight RGB (color) -## Preperation +## Preparation To interact with the gateway, you need to enable the developer mode, aka LAN mode in the gateway (see below). @@ -30,6 +29,12 @@ Make sure to check his page for compatible devices. npm install node-red-contrib-mi-devices ``` +### Migrating from v1.X.X + +:warning: When I fully rewrote the code, it has been a need to move to other nodes types. So, there is no backward compatibility between 1.X.X and 2.X.X version (thsi is why a v2 has been released..). That also means that you will have to redo all the configurations add replace previous nodes to new ones, sorry for that. + +Last thing, before upgrading to v2, you should remove the previous version, to prevent node-red warn about missing nodes (or delete the `.config.json` file in your userDir, but you might also loose your credentials). + ## Usage From the Xiaomi configurator screen add your different devices by selecting the type of device and a readable description. The readable discription is used on the different edit screen of the nodes to easily select the device you associate to the node. @@ -89,11 +94,9 @@ The lightning icon should be underline un yellow. ## Sources -* [Harald Rietman node-red module](https://github.com/hrietman/node-red-contrib-xiaomi-devices) * [Domoticz Instructions](https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)) * [louisZl Gateway Local API](https://github.com/louisZL/lumi-gateway-local-api) * [Domoticz Gateway Code](https://github.com/domoticz/domoticz/blob/development/hardware/XiaomiGateway.cpp) -* [Node-red UDP nodes](https://github.com/node-red/node-red/blob/master/nodes/core/io/32-udp.js) * [Yeelight specs](http://www.yeelight.com/download/Yeelight_Inter-Operation_Spec.pdf) ## Credits diff --git a/node-red-contrib-xiaomi-actions/icons/mi-bulb.png b/icons/actions/mi-bulb.png similarity index 100% rename from node-red-contrib-xiaomi-actions/icons/mi-bulb.png rename to icons/actions/mi-bulb.png diff --git a/node-red-contrib-xiaomi-actions/icons/mi-click.png b/icons/actions/mi-click.png similarity index 100% rename from node-red-contrib-xiaomi-actions/icons/mi-click.png rename to icons/actions/mi-click.png diff --git a/node-red-contrib-xiaomi-actions/icons/mi-double-click.png b/icons/actions/mi-double-click.png similarity index 100% rename from node-red-contrib-xiaomi-actions/icons/mi-double-click.png rename to icons/actions/mi-double-click.png diff --git a/node-red-contrib-xiaomi-actions/icons/mi-list.png b/icons/actions/mi-list.png similarity index 100% rename from node-red-contrib-xiaomi-actions/icons/mi-list.png rename to icons/actions/mi-list.png diff --git a/node-red-contrib-xiaomi-actions/icons/mi-mute.png b/icons/actions/mi-mute.png similarity index 100% rename from node-red-contrib-xiaomi-actions/icons/mi-mute.png rename to icons/actions/mi-mute.png diff --git a/node-red-contrib-xiaomi-actions/icons/mi-off.png b/icons/actions/mi-off.png similarity index 100% rename from node-red-contrib-xiaomi-actions/icons/mi-off.png rename to icons/actions/mi-off.png diff --git a/node-red-contrib-xiaomi-actions/icons/mi-on.png b/icons/actions/mi-on.png similarity index 100% rename from node-red-contrib-xiaomi-actions/icons/mi-on.png rename to icons/actions/mi-on.png diff --git a/node-red-contrib-xiaomi-actions/icons/mi-read.png b/icons/actions/mi-read.png similarity index 100% rename from node-red-contrib-xiaomi-actions/icons/mi-read.png rename to icons/actions/mi-read.png diff --git a/node-red-contrib-xiaomi-actions/icons/mi-sound.png b/icons/actions/mi-sound.png similarity index 100% rename from node-red-contrib-xiaomi-actions/icons/mi-sound.png rename to icons/actions/mi-sound.png diff --git a/node-red-contrib-xiaomi-actions/icons/mi-toggle.png b/icons/actions/mi-toggle.png similarity index 100% rename from node-red-contrib-xiaomi-actions/icons/mi-toggle.png rename to icons/actions/mi-toggle.png diff --git a/icons/devices/door-icon.png b/icons/gateway-subdevices/door-icon.png similarity index 100% rename from icons/devices/door-icon.png rename to icons/gateway-subdevices/door-icon.png diff --git a/icons/devices/mi-all.png b/icons/gateway-subdevices/mi-all.png similarity index 100% rename from icons/devices/mi-all.png rename to icons/gateway-subdevices/mi-all.png diff --git a/icons/devices/mi-switch.png b/icons/gateway-subdevices/mi-switch.png similarity index 100% rename from icons/devices/mi-switch.png rename to icons/gateway-subdevices/mi-switch.png diff --git a/icons/devices/motion-icon.png b/icons/gateway-subdevices/motion-icon.png similarity index 100% rename from icons/devices/motion-icon.png rename to icons/gateway-subdevices/motion-icon.png diff --git a/icons/devices/outlet-icon.png b/icons/gateway-subdevices/outlet-icon.png similarity index 100% rename from icons/devices/outlet-icon.png rename to icons/gateway-subdevices/outlet-icon.png diff --git a/icons/devices/thermometer-icon.png b/icons/gateway-subdevices/thermometer-icon.png similarity index 100% rename from icons/devices/thermometer-icon.png rename to icons/gateway-subdevices/thermometer-icon.png diff --git a/icons/plug-wifi/outlet-wifi-icon.png b/icons/plug-wifi/outlet-wifi-icon.png new file mode 100644 index 0000000..4da9524 Binary files /dev/null and b/icons/plug-wifi/outlet-wifi-icon.png differ diff --git a/node-red-contrib-xiaomi-actions/xiaomi-actions.html b/node-red-contrib-xiaomi-actions/xiaomi-actions.html index 01740a7..f535e44 100644 --- a/node-red-contrib-xiaomi-actions/xiaomi-actions.html +++ b/node-red-contrib-xiaomi-actions/xiaomi-actions.html @@ -1,179 +1,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/node-red-contrib-xiaomi-gateway/xiaomi-gateway.js b/node-red-contrib-xiaomi-gateway/xiaomi-gateway.js deleted file mode 100644 index acc56b9..0000000 --- a/node-red-contrib-xiaomi-gateway/xiaomi-gateway.js +++ /dev/null @@ -1,229 +0,0 @@ -const dgram = require('dgram'); // Given by udp node -const miDevicesUtils = require('../src/utils'); - -// UDP node copy/paste... - -module.exports = (RED) => { - var udpInputPortsInUse = {}; - - function XiaomiGatewayNode(config) { - RED.nodes.createNode(this, config); - this.gateway = RED.nodes.getNode(config.gateway); - - this.status({fill:"red", shape:"ring", text: "offline"}); - - if (this.gateway) { - this.on('input', (msg) => { - var payload = msg.payload; - - // Input from gateway - if(payload.sid) { - if (payload.sid == this.gateway.sid) { - if(payload.data.rgb) { - var decomposed = miDevicesUtils.computeColor(payload.data.rgb); - payload.data.brightness = decomposed.brightness; - payload.data.color = decomposed.color; - } - this.send([msg]); - } - } - // Prepare for request - else { - msg.gateway = this.gateway; - msg.sid = this.gateway.sid; - this.send(msg); - } - }); - } - } - RED.nodes.registerType("xiaomi-gateway", XiaomiGatewayNode); - - // The Input Node - function GatewayIn(n) { - RED.nodes.createNode(this,n); - this.gatewayNodeId = n.gateway; - this.gateway = RED.nodes.getNode(n.gateway); - this.group = "224.0.0.50"; - this.port = 9898; - this.iface = null; - this.addr = n.ip; - this.ipv = this.ip && this.ip.indexOf(":") >= 0 ? "udp6" : "udp4"; - - this.status({fill:"red", shape:"ring", text: "offline"}); - - var opts = {type:this.ipv, reuseAddr:true}; - if (process.version.indexOf("v0.10") === 0) { opts = this.ipv; } - var server; - - if (!udpInputPortsInUse.hasOwnProperty(this.port)) { - server = dgram.createSocket(opts); // default to udp4 - udpInputPortsInUse[this.port] = server; - } - else { - this.warn(RED._("udp.errors.alreadyused",this.port)); - server = udpInputPortsInUse[this.port]; // re-use existing - } - - if (process.version.indexOf("v0.10") === 0) { opts = this.ipv; } - - server.on("error", (err) => { - if ((err.code == "EACCES") && (this.port < 1024)) { - this.error(RED._("udp.errors.access-error")); - } else { - this.error(RED._("udp.errors.error",{error:err.code})); - } - server.close(); - }); - - server.on('message', (message, remote) => { - var msg; - if(remote.address == this.addr) { - var msg = message.toString('utf8'); - var jsonMsg = JSON.parse(msg); - if(jsonMsg.data) { - jsonMsg.data = JSON.parse(jsonMsg.data) || jsonMsg.data; - if(jsonMsg.data.voltage) { - jsonMsg.data.batteryLevel = miDevicesUtils.computeBatteryLevel(jsonMsg.data.voltage); - } - } - msg = { payload: jsonMsg }; - if(this.gateway && jsonMsg.data.ip && jsonMsg.data.ip === this.gateway.ip) { - if(jsonMsg.token) { - this.gateway.lastToken = jsonMsg.token; - if(!this.gateway.sid) { - this.gateway.sid = jsonMsg.sid; - } - } - RED.nodes.eachNode((tmpNode) => { - if(tmpNode.type.indexOf("xiaomi-gateway") === 0 && tmpNode.gateway == this.gatewayNodeId) { - let tmpNodeInst = RED.nodes.getNode(tmpNode.id); - if(tmpNode.type === "xiaomi-gateway out" && !this.gateway.lastToken) { - tmpNodeInst.status({fill:"yellow", shape:"ring", text: "waiting input"}); - } - tmpNodeInst.status({fill:"blue", shape:"dot", text: "online"}); - } - }); - } - this.send(msg); - } - }); - - server.on('listening', () => { - var address = server.address(); - this.log(RED._("udp.status.listener-at",{host:address.address,port:address.port})); - server.setBroadcast(true); - try { - server.setMulticastTTL(128); - server.addMembership(this.group,this.iface); - this.log(RED._("udp.status.mc-group",{group:this.group})); - } catch (e) { - if (e.errno == "EINVAL") { - this.error(RED._("udp.errors.bad-mcaddress")); - } else if (e.errno == "ENODEV") { - this.error(RED._("udp.errors.interface")); - } else { - this.error(RED._("udp.errors.error",{error:e.errno})); - } - } - }); - - this.on("close", () => { - if (udpInputPortsInUse.hasOwnProperty(this.port)) { - delete udpInputPortsInUse[this.port]; - } - try { - server.close(); - this.log(RED._("udp.status.listener-stopped")); - } catch (err) { - //this.error(err); - } - }); - - try { server.bind(this.port, this.iface); } - catch(e) { } // Don't worry if already bound - } - RED.httpAdmin.get('/udp-ports/:id', RED.auth.needsPermission('udp-ports.read'), (req,res) => { - res.json(Object.keys(udpInputPortsInUse)); - }); - RED.nodes.registerType("xiaomi-gateway in",GatewayIn); - - - // The Output Node - function GatewayOut(n) { - RED.nodes.createNode(this,n); - this.port = 9898; - this.outport = 9898; - this.iface = null; - this.addr = n.ip; - this.ipv = this.ip && this.ip.indexOf(":") >= 0 ? "udp6" : "udp4"; - this.multicast = false; - - this.gatewayNodeId = n.gateway; - this.gateway = RED.nodes.getNode(n.gateway); - - this.status({fill:"red", shape:"ring", text: "offline"}); - - var opts = {type:this.ipv, reuseAddr:true}; - if (process.version.indexOf("v0.10") === 0) { opts = this.ipv; } - - var sock; - if (udpInputPortsInUse[this.outport]) { - sock = udpInputPortsInUse[this.outport]; - } - else { - sock = dgram.createSocket(opts); // default to udp4 - sock.on("error", (err) => { - // Any async error will also get reported in the sock.send call. - // This handler is needed to ensure the error marked as handled to - // prevent it going to the global error handler and shutting node-red - // down. - }); - udpInputPortsInUse[this.outport] = sock; - } - - if (!udpInputPortsInUse[this.outport]) { - sock.bind(this.outport); - this.log(RED._("udp.status.ready",{outport:this.outport,host:this.addr,port:this.port})); - } else { - this.log(RED._("udp.status.ready-nolocal",{host:this.addr,port:this.port})); - } - - this.on("input", (msg) => { - if (msg.hasOwnProperty("payload")) { - var add = this.addr || msg.ip || ""; - var por = this.port || msg.port || 0; - if (add === "") { - this.warn(RED._("udp.errors.ip-notset")); - } else if (por === 0) { - this.warn(RED._("udp.errors.port-notset")); - } else if (isNaN(por) || (por < 1) || (por > 65535)) { - this.warn(RED._("udp.errors.port-invalid")); - } else { - if(msg.payload.cmd === "write" && !msg.payload.data.key && this.gateway && this.gateway.sid && this.gateway.key && this.gateway.lastToken) { - msg.payload.data.key = miDevicesUtils.getGatewayKey(this.gateway.key, this.gateway.lastToken); - } - var message = Buffer.from(JSON.stringify(msg.payload)); - sock.send(message, 0, message.length, por, add, (err, bytes) => { - if (err) { - this.error("udp : "+err,msg); - } - message = null; - }); - } - } - }); - - this.on("close", () => { - if (udpInputPortsInUse.hasOwnProperty(this.outport)) { - delete udpInputPortsInUse[this.outport]; - } - try { - sock.close(); - this.log(RED._("udp.status.output-stopped")); - } catch (err) { - //this.error(err); - } - }); - } - RED.nodes.registerType("xiaomi-gateway out", GatewayOut); -} diff --git a/node-red-contrib-xiaomi-ht/icons/thermometer-icon.png b/node-red-contrib-xiaomi-ht/icons/thermometer-icon.png deleted file mode 100644 index b25be7d..0000000 Binary files a/node-red-contrib-xiaomi-ht/icons/thermometer-icon.png and /dev/null differ diff --git a/node-red-contrib-xiaomi-ht/xiaomi-ht.html b/node-red-contrib-xiaomi-ht/xiaomi-ht.html deleted file mode 100644 index 2bb277c..0000000 --- a/node-red-contrib-xiaomi-ht/xiaomi-ht.html +++ /dev/null @@ -1,122 +0,0 @@ - - - - - diff --git a/node-red-contrib-xiaomi-ht/xiaomi-ht.js b/node-red-contrib-xiaomi-ht/xiaomi-ht.js deleted file mode 100644 index 94914d1..0000000 --- a/node-red-contrib-xiaomi-ht/xiaomi-ht.js +++ /dev/null @@ -1,41 +0,0 @@ -const miDevicesUtils = require('../src/utils'); - -module.exports = (RED) => { - // sensor_ht, weather.v1 - - function XiaomiHtNode(config) { - RED.nodes.createNode(this, config); - this.gateway = RED.nodes.getNode(config.gateway); - this.sid = config.sid; - - this.status({fill:"grey", shape:"ring", text:"battery - na"}); - - if (this.gateway) { - this.on('input', (msg) => { - let payload = msg.payload; - - // Input from gateway - if (payload.sid) { - if (payload.sid == this.sid) { - miDevicesUtils.setStatus(this, payload.data); - ["temperature", "humidity", "pressure"].forEach((dataType) => { - if(payload.data[dataType]) { - payload.data[dataType] = parseInt(payload.data[dataType])/100; - } - }); - } - else { - msg = null; - } - } - // Prepare for request - else { - miDevicesUtils.prepareForGatewayRequest(this, msg); - } - this.send(msg); - }); - } - } - - RED.nodes.registerType("xiaomi-ht", XiaomiHtNode); -}; diff --git a/node-red-contrib-xiaomi-magnet/icons/door-icon.png b/node-red-contrib-xiaomi-magnet/icons/door-icon.png deleted file mode 100644 index f33e892..0000000 Binary files a/node-red-contrib-xiaomi-magnet/icons/door-icon.png and /dev/null differ diff --git a/node-red-contrib-xiaomi-magnet/xiaomi-magnet.html b/node-red-contrib-xiaomi-magnet/xiaomi-magnet.html deleted file mode 100644 index 8d86a61..0000000 --- a/node-red-contrib-xiaomi-magnet/xiaomi-magnet.html +++ /dev/null @@ -1,120 +0,0 @@ - - - - - diff --git a/node-red-contrib-xiaomi-magnet/xiaomi-magnet.js b/node-red-contrib-xiaomi-magnet/xiaomi-magnet.js deleted file mode 100644 index 93f6237..0000000 --- a/node-red-contrib-xiaomi-magnet/xiaomi-magnet.js +++ /dev/null @@ -1,9 +0,0 @@ -const miDevicesUtils = require('../src/utils'); - -module.exports = (RED) => { - // magnet, sensor_magnet.aq2 - function XiaomiMagnetNode(config) { - miDevicesUtils.defaultNode(RED, config, this); - } - RED.nodes.registerType("xiaomi-magnet", XiaomiMagnetNode); -}; diff --git a/node-red-contrib-xiaomi-motion/icons/motion-icon.png b/node-red-contrib-xiaomi-motion/icons/motion-icon.png deleted file mode 100644 index 86c7e68..0000000 Binary files a/node-red-contrib-xiaomi-motion/icons/motion-icon.png and /dev/null differ diff --git a/node-red-contrib-xiaomi-motion/xiaomi-motion.js b/node-red-contrib-xiaomi-motion/xiaomi-motion.js deleted file mode 100644 index d88aedf..0000000 --- a/node-red-contrib-xiaomi-motion/xiaomi-motion.js +++ /dev/null @@ -1,9 +0,0 @@ -const miDevicesUtils = require('../src/utils'); - -module.exports = (RED) => { - // motion - function XiaomiMotionNode(config) { - miDevicesUtils.defaultNode(RED, config, this); - } - RED.nodes.registerType("xiaomi-motion", XiaomiMotionNode); -}; diff --git a/node-red-contrib-xiaomi-switch/icons/mi-switch.png b/node-red-contrib-xiaomi-switch/icons/mi-switch.png deleted file mode 100644 index 8ee44b2..0000000 Binary files a/node-red-contrib-xiaomi-switch/icons/mi-switch.png and /dev/null differ diff --git a/node-red-contrib-xiaomi-switch/xiaomi-switch.js b/node-red-contrib-xiaomi-switch/xiaomi-switch.js deleted file mode 100644 index 7360fe6..0000000 --- a/node-red-contrib-xiaomi-switch/xiaomi-switch.js +++ /dev/null @@ -1,9 +0,0 @@ -const miDevicesUtils = require('../src/utils'); - -module.exports = (RED) => { - // switch, sensor_switch.aq2 - function XiaomiSwitchNode(config) { - miDevicesUtils.defaultNode(RED, config, this); - } - RED.nodes.registerType("xiaomi-switch", XiaomiSwitchNode); -}; diff --git a/package.json b/package.json index d2dc66b..76e1774 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,28 @@ { "name": "node-red-contrib-mi-devices", - "version": "1.1.0", - "description": "A set of nodes to control some of the popular Xiaomi sensors which are connected to the Xiaomi Gateway, and the Gateway itself.", + "version": "2.0.0", + "description": "A set of nodes to control some of the popular Xiaomi sensors which are connected to the Xiaomi Gateway, the Gateway itself and Yeelights.", "repository": { "type": "git", "url": "git+ssh://git@github.com:pierrecle/node-red-contrib-mi-devices.git" }, + "publishConfig": { + "access": "public" + }, + "config": { + "nodes_prefix": "mi-devices" + }, "scripts": { "clean": "rimraf dist", "build": "npm run clean && npm run build:ts && npm run build:ejs && npm run build:icons", "build:ts": "tsc --allowUnreachableCode -p .", - "build:ejs": "npm run build:ejs:indexes && npm run build:ejs:devices", - "build:ejs:indexes": "ejs-cli --base-dir src/ --options \"{\\\"NODES_PREFIX\\\": \\\"mi-devices\\\"}\" \"**/index.ejs\" --out dist/", - "build:ejs:devices": "ejs-cli --base-dir src/ --options \"{\\\"NODES_PREFIX\\\": \\\"mi-devices\\\"}\" \"nodes/devices/*.ejs\" --out dist/", - "build:icons": "npm run build:icons:gateway && npm run build:icons:devices && npm run build:icons:actions && npm run build:icons:yeelight", + "build:ejs": "ejs-cli --base-dir src/ --options \"{\\\"NODES_PREFIX\\\": \\\"${npm_package_config_nodes_prefix}\\\"}\" \"**/index.ejs\" --out dist/", + "build:icons": "npm run build:icons:gateway && npm run build:icons:gateway-subdevices && npm run build:icons:actions && npm run build:icons:yeelight", "build:icons:gateway": "cp -pr icons/gateway dist/nodes/gateway/icons", - "build:icons:devices": "cp -pr icons/devices dist/nodes/devices/icons", + "build:icons:gateway-subdevices": "cp -pr icons/gateway-subdevices dist/nodes/gateway-subdevices/icons", "build:icons:actions": "cp -pr icons/actions dist/nodes/actions/icons", - "build:icons:yeelight": "cp -pr icons/yeelight dist/nodes/yeelight/icons" + "build:icons:yeelight": "cp -pr icons/yeelight dist/nodes/yeelight/icons", + "build:icons:plug-wifi": "cp -pr icons/plug-wifi dist/nodes/plug-wifi/icons" }, "license": "MIT", "keywords": [ @@ -28,16 +33,11 @@ ], "node-red": { "nodes": { - "xiaomi-ht": "dist/nodes/devices/Sensor.js", - "xiaomi-magnet": "dist/nodes/devices/Magnet.js", - "xiaomi-motion": "dist/nodes/devices/Motion.js", - "xiaomi-switch": "dist/nodes/devices/Switch.js", - "xiaomi-socket": "node-red-contrib-xiaomi-socket/xiaomi-socket.js", - "xiaomi-socket-wifi": "node-red-contrib-xiaomi-socket-wifi/xiaomi-socket-wifi.js", - "xiaomi-all": "dist/nodes/devices/All.js", - "xiaomi-gateway": "dist/nodes/gateway/index.js", - "xiaomi-actions": "node-red-contrib-xiaomi-actions/xiaomi-actions.js", - "xiaomi-yeelight": "dist/nodes/yeelight/index.js" + "mi-devices-gateway": "dist/nodes/gateway/index.js", + "mi-devices-gateway-subdevices": "dist/nodes/gateway-subdevices/index.js", + "mi-devices-plug-wifi": "dist/nodes/plug-wifi/index.js", + "mi-devices-yeelight": "dist/nodes/yeelight/index.js", + "mi-devices-actions": "dist/nodes/actions/index.js" } }, "author": "Pierre CLEMENT", @@ -47,7 +47,7 @@ "dependencies": { "cryptojs": "^2.5.3", "lumi-aqara": "^1.4.0", - "miio": "^0.14.1", + "miio": "^0.15.2", "yeelight-wifi": "^2.3.0" }, "engines": { diff --git a/src/devices/Gateway.ts b/src/devices/Gateway.ts new file mode 100644 index 0000000..3f656c1 --- /dev/null +++ b/src/devices/Gateway.ts @@ -0,0 +1,90 @@ +import * as events from 'events'; +import * as crypto from 'crypto'; + +import {GatewayServer} from "./GatewayServer"; +import {GatewayMessage, GatewaySubdevice, Magnet, Motion, Switch, Weather} from "./"; +import * as MessageData from "./GatewayMessageData"; + +export class Gateway extends events.EventEmitter { + static iv: Buffer = Buffer.from([0x17, 0x99, 0x6d, 0x09, 0x3d, 0x28, 0xdd, 0xb3, 0xba, 0x69, 0x5a, 0x2e, 0x6f, 0x58, 0x56, 0x2e]); + protected lastToken: string; + protected password: string; + private _subdevices: { [sid: string]: GatewaySubdevice } = {}; + + constructor(public sid: string, public ip: string) { + super(); + } + + get key(): string { + var cipher = crypto.createCipheriv('aes-128-cbc', this.password, Gateway.iv); + var key = cipher.update(this.lastToken, "ascii", "hex"); + cipher.final('hex'); + + return key; + } + + handleMessage(msg: GatewayMessage) { + if (msg.data) { + if (msg.model === "gateway" && msg.sid === this.sid && msg.token) { + this.lastToken = msg.token; + } + } + + if (msg.isGetIdListAck()) { + ( msg.data).forEach((sid) => { + this.sendRead({cmd: "read", sid: sid}); + }); + } + + if (msg.isReadAck() || msg.isReport()) { + if (!this._subdevices[msg.sid]) { + for (let SubDeviceClass of [Magnet, Motion, Switch, Weather]) { + if (SubDeviceClass.acceptedModels.indexOf(msg.model) >= 0) { + this._subdevices[msg.sid] = new SubDeviceClass(msg.sid, msg.model); + this._subdevices[msg.sid].on('values-updated', (sid: string) => { + this.emit("subdevice-values-updated", sid); + }); + this.emit("subdevice-found", msg.sid); + } + } + } + if (this._subdevices[msg.sid]) { + this._subdevices[msg.sid].handleMessage(msg); + } + } + } + + getSubdevice(sid: string): GatewaySubdevice { + return this._subdevices[sid] || null; + } + + hasSubdevice(sid: string): boolean { + return !!this._subdevices[sid]; + } + + setLight() { + + } + + playSound() { + + } + + sendRead(message: any) { + message.sid = message.sid || this.sid; + GatewayServer.getInstance().sendToGateway(this.sid, message); + } + + get subdevices(): { [sid: string]: GatewaySubdevice } { + return this._subdevices; + } + + toJSON() { + return { + sid: this.sid, + ip: this.ip, + key: this.password, + subdevices: this.subdevices + }; + } +} \ No newline at end of file diff --git a/src/devices/GatewayMessage.ts b/src/devices/GatewayMessage.ts new file mode 100644 index 0000000..8e2931e --- /dev/null +++ b/src/devices/GatewayMessage.ts @@ -0,0 +1,58 @@ +import * as MessageData from './GatewayMessageData'; + +export interface GatewayRawMessage { + cmd: string; + sid: string; + short_id: string | number; + model: string; + port?: string; + ip?: string; + token?: string; + data?: string; +} + +export class GatewayMessage { + cmd: string; + sid: string; + short_id: string | number; + model: string; + ip?: string; + token?: string; + port?: number; + data?: MessageData.GatewayMessageHeartbeatData + | MessageData.GatewayMessageGetIdListData + | MessageData.GatewayMessageReadAckMagnetData + | MessageData.GatewayMessageReadAckReportWeatherData + | MessageData.GatewayMessageDefaultSubdeviceData + | any; + + constructor(raw: GatewayRawMessage) { + Object.assign(this, raw); + if (raw.port) { + this.port = parseInt(raw.port); + } + if (raw.data) { + this.data = JSON.parse(raw.data) || raw.data; + } + } + + isHeartbeat(): boolean { + return this.cmd === "heartbeat"; + } + + isIam(): boolean { + return this.cmd === "iam"; + } + + isGetIdListAck(): boolean { + return this.cmd === "get_id_list_ack"; + } + + isReadAck(): boolean { + return this.cmd === "read_ack"; + } + + isReport(): boolean { + return this.cmd === "report"; + } +} \ No newline at end of file diff --git a/src/devices/GatewayMessageData.ts b/src/devices/GatewayMessageData.ts new file mode 100644 index 0000000..81d6441 --- /dev/null +++ b/src/devices/GatewayMessageData.ts @@ -0,0 +1,20 @@ +export interface GatewayMessageHeartbeatData { + ip: string; +} + +export interface GatewayMessageGetIdListData extends Array { +} + +export interface GatewayMessageDefaultSubdeviceData { + voltage: number; +} + +export interface GatewayMessageReadAckMagnetData extends GatewayMessageDefaultSubdeviceData { + status: string; +} + +export interface GatewayMessageReadAckReportWeatherData extends GatewayMessageDefaultSubdeviceData { + temperature?: string; + humidity?: string; + pressure?: string; +} \ No newline at end of file diff --git a/src/devices/GatewayServer.ts b/src/devices/GatewayServer.ts new file mode 100644 index 0000000..8f02871 --- /dev/null +++ b/src/devices/GatewayServer.ts @@ -0,0 +1,165 @@ +import * as events from 'events'; +import * as dgram from "dgram"; +import {Gateway} from "./Gateway"; +import Timer = NodeJS.Timer; +import {GatewayMessage} from "./GatewayMessage"; + +export class GatewayServer extends events.EventEmitter { + static MULTICAST_ADDRESS = '224.0.0.50'; + static MULTICAST_PORT = 4321; + static SERVER_PORT = 9898; + + private static instance: GatewayServer; + + private server: dgram.Socket; + private _gateways: { [sid: string]: Gateway } = {}; + private _gatewaysPing: { [sid: string]: Timer } = {}; + + static getInstance() { + if (!this.instance) { + this.instance = new GatewayServer(); + } + return this.instance; + } + + discover(ipv: number = 4) { + if (this.server) { + return; + } + + this.server = dgram.createSocket( { + type: `udp${ipv}`, + reuseAddr: true + }); + + this.server.on('listening', () => { + var address = this.server.address(); + //this.log(RED._("udp.status.listener-at",{host:address.address,port:address.port})); + this.server.setBroadcast(true); + try { + this.server.setMulticastTTL(128); + this.server.addMembership(GatewayServer.MULTICAST_ADDRESS, null); + } catch (e) { + /*if (e.errno == "EINVAL") { + this.error(RED._("udp.errors.bad-mcaddress")); + } else if (e.errno == "ENODEV") { + this.error(RED._("udp.errors.interface")); + } else { + this.error(RED._("udp.errors.error",{error:e.errno})); + }*/ + } + }); + + this.server.on("error", (err) => { + /*if ((err.code == "EACCES") && (this.port < 1024)) { + this.error(RED._("udp.errors.access-error")); + } else { + this.error(RED._("udp.errors.error",{error:err.code})); + }*/ + this.server.close(); + delete this.server; + }); + + this.server.on('message', (message, remote) => { + let msg = new GatewayMessage(JSON.parse(message.toString('utf8'))); + //console.log(msg); + let gatewaySid = null; + if ((msg.isHeartbeat() || msg.isIam()) && msg.model === "gateway") { + if (!this._gateways[msg.sid]) { + this._gateways[msg.sid] = new Gateway(msg.sid, remote.address); + this.sendToGateway(msg.sid, {cmd: "get_id_list"}); + this.emit("gateway-online", msg.sid); + } + else { + // Any IP update? + this._gateways[msg.sid].ip = remote.address; + } + if (this._gatewaysPing[msg.sid]) { + clearTimeout(this._gatewaysPing[msg.sid]); + delete this._gatewaysPing[msg.sid]; + } + + // Consider the gateway as unreachable after 2 heartbeats missed (1 heartbeat every 10s) + this._gatewaysPing[msg.sid] = setTimeout(() => { + this.emit("gateway-offline", msg.sid); + delete this._gateways[msg.sid]; + }, 25 * 1000); + + gatewaySid = msg.sid; + } + if (!gatewaySid) { + gatewaySid = Object.keys(this._gateways).filter((gatewaySid) => this._gateways[gatewaySid].ip === remote.address)[0]; + } + + gatewaySid && this._gateways[gatewaySid] && this._gateways[gatewaySid].handleMessage(msg); + + /*if(remote.address == this.addr) { + var msg = message.toString('utf8'); + var jsonMsg = JSON.parse(msg); + if(jsonMsg.data) { + jsonMsg.data = JSON.parse(jsonMsg.data) || jsonMsg.data; + if(jsonMsg.data.voltage) { + jsonMsg.data.batteryLevel = miDevicesUtils.computeBatteryLevel(jsonMsg.data.voltage); + } + } + msg = { payload: jsonMsg }; + if(this.gateway && jsonMsg.data.ip && jsonMsg.data.ip === this.gateway.ip) { + if(jsonMsg.token) { + this.gateway.lastToken = jsonMsg.token; + if(!this.gateway.sid) { + this.gateway.sid = jsonMsg.sid; + } + } + RED.nodes.eachNode((tmpNode) => { + if(tmpNode.type.indexOf("xiaomi-gateway") === 0 && tmpNode.gateway == this.gatewayNodeId) { + let tmpNodeInst = RED.nodes.getNode(tmpNode.id); + if(tmpNode.type === "xiaomi-gateway out" && !this.gateway.lastToken) { + tmpNodeInst.status({fill:"yellow", shape:"ring", text: "waiting input"}); + } + tmpNodeInst.status({fill:"blue", shape:"dot", text: "online"}); + } + }); + } + this.send(msg); + }*/ + }); + + return new Promise((resolve, reject) => { + try { + this.server.bind(GatewayServer.SERVER_PORT, null); + let msg = Buffer.from(JSON.stringify({cmd: "whois"})); + this.server.send(msg, 0, msg.length, GatewayServer.MULTICAST_PORT, GatewayServer.MULTICAST_ADDRESS); + resolve(this.server); + } + catch (e) { + reject(); + } + }); + } + + stop() { + if (this.server) { + this.server.close(); + delete this.server; + } + } + + getGateway(sid: string): Gateway { + return this._gateways[sid] || null; + } + + hasGateway(sid: string): boolean { + return !!this._gateways[sid]; + } + + get gateways(): { [sid: string]: Gateway } { + return this._gateways; + } + + sendToGateway(sid: string, message: any) { + if (this.server && this._gateways[sid]) { + let msg = Buffer.from(JSON.stringify(message)); + this.server.send(msg, 0, msg.length, GatewayServer.SERVER_PORT, this._gateways[sid].ip); + } + } +} \ No newline at end of file diff --git a/src/devices/GatewaySubdevice.ts b/src/devices/GatewaySubdevice.ts new file mode 100644 index 0000000..ca3cd17 --- /dev/null +++ b/src/devices/GatewaySubdevice.ts @@ -0,0 +1,45 @@ +import * as events from 'events'; + +import {GatewayMessage} from "./GatewayMessage"; +import {GatewayMessageDefaultSubdeviceData} from "./GatewayMessageData"; + +export abstract class GatewaySubdevice extends events.EventEmitter { + public voltage: number; + public message: GatewayMessage; + + constructor(public sid: string, public model: string) { + super(); + } + + get batteryLevel(): number { + /* + When full, CR2032 batteries are between 3 and 3.4V + http://farnell.com/datasheets/1496885.pdf + */ + return this.voltage ? Math.min(Math.round((this.voltage - 2200) / 10), 100) : -1; + } + + handleMessage(msg: GatewayMessage): void { + this.voltage = ( msg.data).voltage; + this.message = msg; + } + + static get acceptedModels(): string[] { + return []; + }; + + abstract get internalModel(): string; + + toJSON() { + let json:any = {}; + for(let prop of Object.keys(this)) { + json[prop] = this[prop]; + } + delete json._events; + delete json._eventsCount; + delete json._maxListeners; + json.batteryLevel = this.batteryLevel; + json.internalModel = this.internalModel; + return json; + } +} \ No newline at end of file diff --git a/src/devices/Magnet.ts b/src/devices/Magnet.ts new file mode 100644 index 0000000..99f396a --- /dev/null +++ b/src/devices/Magnet.ts @@ -0,0 +1,39 @@ +import {GatewaySubdevice} from "./GatewaySubdevice"; +import {GatewayMessage} from "./GatewayMessage"; +import {GatewayMessageReadAckMagnetData} from "./GatewayMessageData"; + +export class Magnet extends GatewaySubdevice { + status: string; + + static get acceptedModels(): string[] { + return ['magnet', 'sensor_magnet.aq2']; + } + + get internalModel(): string { + return 'mi.magnet'; + } + + isClosed(): boolean { + return this.status === "close"; + } + + isOpened(): boolean { + return this.status === "open"; + } + + isUnkownState(): boolean { + return this.status === "unkown"; + } + + handleMessage(msg: GatewayMessage) { + super.handleMessage(msg); + if (msg.isReadAck() || msg.isReport()) { + let data = msg.data; + // mintime + if (this.status !== data.status) { + this.status = data.status; + this.emit('values-updated', this.sid); + } + } + } +} \ No newline at end of file diff --git a/src/devices/Motion.ts b/src/devices/Motion.ts new file mode 100644 index 0000000..305e55c --- /dev/null +++ b/src/devices/Motion.ts @@ -0,0 +1,17 @@ +import {GatewaySubdevice} from "./GatewaySubdevice"; +import {GatewayMessage} from "./GatewayMessage"; + +export class Motion extends GatewaySubdevice { + + static get acceptedModels():string[] { + return ['motion']; + } + + get internalModel(): string { + return 'mi.motion'; + } + + handleMessage(msg: GatewayMessage) { + super.handleMessage(msg); + } +} \ No newline at end of file diff --git a/src/devices/Switch.ts b/src/devices/Switch.ts new file mode 100644 index 0000000..6580152 --- /dev/null +++ b/src/devices/Switch.ts @@ -0,0 +1,17 @@ +import {GatewaySubdevice} from "./GatewaySubdevice"; +import {GatewayMessage} from "./GatewayMessage"; + +export class Switch extends GatewaySubdevice { + + static get acceptedModels():string[] { + return ['switch', 'sensor_switch.aq2']; + } + + get internalModel(): string { + return 'mi.switch'; + } + + handleMessage(msg: GatewayMessage) { + super.handleMessage(msg); + } +} \ No newline at end of file diff --git a/src/devices/Weather.ts b/src/devices/Weather.ts new file mode 100644 index 0000000..cc95369 --- /dev/null +++ b/src/devices/Weather.ts @@ -0,0 +1,49 @@ +import {GatewaySubdevice} from "./GatewaySubdevice"; +import {GatewayMessage} from "./GatewayMessage"; +import {GatewayMessageReadAckReportWeatherData} from "./GatewayMessageData"; + +export class Weather extends GatewaySubdevice { + temperature: number; + humidity: number; + /** + * Pressure in Pascals + */ + pressure: number; + + static get acceptedModels():string[] { + return ['sensor_ht', 'weather.v1']; + } + + get internalModel(): string { + return 'mi.weather'; + } + + get temperatureInDegrees(): number { + return this.temperature / 100; + } + + get humidityInPercent(): number { + return this.humidity / 100; + } + + get pressureInBar(): number { + return this.pressure / 100000; + } + + get pressureInhPa(): number { + return this.pressure / 100; + } + + handleMessage(msg: GatewayMessage) { + super.handleMessage(msg); + if (msg.isReadAck() || msg.isReport()) { + let data = msg.data; + ['temperature', 'humidity', 'pressure'].forEach((dataType: string) => { + if (data[dataType]) { + this[dataType] = parseInt(data[dataType]); + } + }); + this.emit('values-updated', this.sid); + } + } +} \ No newline at end of file diff --git a/src/devices/index.ts b/src/devices/index.ts new file mode 100644 index 0000000..4c93553 --- /dev/null +++ b/src/devices/index.ts @@ -0,0 +1,8 @@ +export * from './Gateway'; +export * from './GatewayMessage'; +export * from './GatewayServer'; +export * from './GatewaySubdevice'; +export * from './Magnet'; +export * from './Motion'; +export * from './Switch'; +export * from './Weather'; \ No newline at end of file diff --git a/src/nodes/actions/Action.ejs b/src/nodes/actions/Action.ejs new file mode 100644 index 0000000..cbdbb3f --- /dev/null +++ b/src/nodes/actions/Action.ejs @@ -0,0 +1,39 @@ + + + + + \ No newline at end of file diff --git a/src/nodes/actions/GatewayLight.ejs b/src/nodes/actions/GatewayLight.ejs new file mode 100644 index 0000000..02887ec --- /dev/null +++ b/src/nodes/actions/GatewayLight.ejs @@ -0,0 +1,79 @@ + + + + + + \ No newline at end of file diff --git a/src/nodes/actions/GatewayLight.ts b/src/nodes/actions/GatewayLight.ts new file mode 100644 index 0000000..27794fb --- /dev/null +++ b/src/nodes/actions/GatewayLight.ts @@ -0,0 +1,34 @@ +import { Red, NodeProperties } from "node-red"; +import { Constants } from "../constants"; + +export default (RED:Red) => { + class GatewayLight { + public color:string; + public brightness:number; + + constructor(props:NodeProperties) { + RED.nodes.createNode( this, props); + ( this).setListeners(); + } + + protected setListeners() { + ( this).on('input', (msg) => { + let color = msg.color || this.color; + let brightness = msg.brightness || this.brightness; + if(msg.sid) { + msg.payload = { + cmd: "write", + data: { rgb: 123, sid: msg.sid } + }; + } + else { + msg.payload = { + brightness: brightness + }; + } + ( this).send(msg); + }); + } + } + RED.nodes.registerType(`${Constants.NODES_PREFIX}-actions gateway_light`, GatewayLight); +}; \ No newline at end of file diff --git a/src/nodes/actions/ReadAction.ts b/src/nodes/actions/ReadAction.ts new file mode 100644 index 0000000..25cfb17 --- /dev/null +++ b/src/nodes/actions/ReadAction.ts @@ -0,0 +1,22 @@ +import { Red, NodeProperties, NodeStatus } from "node-red"; +import { Constants } from "../constants"; + +export default (RED:Red, type:string) => { + class ReadAction { + constructor(props:NodeProperties) { + RED.nodes.createNode( this, props); + + ( this).on('input', (msg) => { + if(msg.sid) { + msg.payload = { + cmd: ( this).type.replace(`${Constants.NODES_PREFIX}-actions `, ''), + sid: msg.sid + }; + ( this).send(msg); + } + }); + } + } + + RED.nodes.registerType(`${Constants.NODES_PREFIX}-actions ${type}`, ReadAction); +}; \ No newline at end of file diff --git a/src/nodes/actions/WriteAction.ts b/src/nodes/actions/WriteAction.ts new file mode 100644 index 0000000..2654adc --- /dev/null +++ b/src/nodes/actions/WriteAction.ts @@ -0,0 +1,24 @@ +import { Red, NodeProperties, NodeStatus } from "node-red"; +import { Constants } from "../constants"; + +export default (RED:Red, type:string) => { + class WriteAction { + constructor(props:NodeProperties) { + RED.nodes.createNode( this, props); + + ( this).on('input', (msg) => { + if(msg.sid) { + msg.payload = { + cmd: "write", + data: { + status: ( this).type.replace(`${Constants.NODES_PREFIX}-actions `, ''), + sid: msg.sid + } + }; + ( this).send(msg); + } + }); + } + } + RED.nodes.registerType(`${Constants.NODES_PREFIX}-actions ${type}`, WriteAction); +}; \ No newline at end of file diff --git a/src/nodes/actions/index.ejs b/src/nodes/actions/index.ejs new file mode 100644 index 0000000..0206057 --- /dev/null +++ b/src/nodes/actions/index.ejs @@ -0,0 +1,33 @@ +<%# ---------------------------------- read ---------------------------------- %> +<%- include('./Action', { + type: "read", + label: "read", + icon: "mi-read", + docTitle: "Ask the gateway to read the report of the input device." +}) %> + +<%# ---------------------------------- get_id_list ---------------------------------- %> +<%- include('./Action', { + type: "get_id_list", + label: "get id list", + icon: "mi-list", + docTitle: "Ask the gateway to the list of devices ids." +}) %> + +<%# ---------------------------------- click ---------------------------------- %> +<%- include('./Action', { + type: "click", + label: "click", + icon: "mi-click", + docTitle: "Virtual single click for switch." +}) %> + +<%# ---------------------------------- double_click ---------------------------------- %> +<%- include('./Action', { + type: "double_click", + label: "double click", + icon: "double-click", + docTitle: "Virtual double click for switch." +}) %> + +<%- include('./GatewayLight', {}) %> \ No newline at end of file diff --git a/src/nodes/actions/index.ts b/src/nodes/actions/index.ts new file mode 100644 index 0000000..e578158 --- /dev/null +++ b/src/nodes/actions/index.ts @@ -0,0 +1,17 @@ +import { Red, NodeProperties } from "node-red"; +import * as LumiAqara from 'lumi-aqara'; + +import {default as ReadAction} from './ReadAction'; +import {default as WriteAction} from './WriteAction'; +import {default as GatewayLight} from './GatewayLight'; + +export = (RED:Red) => { + GatewayLight(RED); + ["read", "get_id_list"].forEach((action) => { + ReadAction(RED, action); + }); + + ["click", "double_click"].forEach((action) => { + WriteAction(RED, action); + }); +}; \ No newline at end of file diff --git a/src/nodes/constants.ts b/src/nodes/constants.ts index 51b56bb..b335331 100644 --- a/src/nodes/constants.ts +++ b/src/nodes/constants.ts @@ -1,3 +1,6 @@ export class Constants { - static readonly NODES_PREFIX = "mi-devices"; + static get NODES_PREFIX(){ + let packageJson = require(`${__dirname}/../../package`); + return packageJson.config.nodes_prefix; + }; } \ No newline at end of file diff --git a/src/nodes/gateway-subdevices/All.ejs b/src/nodes/gateway-subdevices/All.ejs new file mode 100644 index 0000000..837b61a --- /dev/null +++ b/src/nodes/gateway-subdevices/All.ejs @@ -0,0 +1,124 @@ + + + + + diff --git a/src/nodes/gateway-subdevices/All.ts b/src/nodes/gateway-subdevices/All.ts new file mode 100644 index 0000000..e3020c8 --- /dev/null +++ b/src/nodes/gateway-subdevices/All.ts @@ -0,0 +1,60 @@ +import { Red, NodeProperties } from "node-red"; +import { Constants } from "../constants"; + +export default (RED:Red) => { + class All { + protected gateway: any; + protected onlyModels: string[]; + protected excludedSids: string[]; + + static getOnlyModelsValue(input) { + var cleanOnlyModels = []; + input.forEach((value) => { + cleanOnlyModels = cleanOnlyModels.concat(value.split(',')); + }); + return cleanOnlyModels; + } + + constructor(props:NodeProperties) { + RED.nodes.createNode( this, props); + this.gateway = RED.nodes.getNode(( props).gateway); + this.onlyModels = All.getOnlyModelsValue(( props).onlyModels || []); + this.excludedSids = ( props).excludedSids; + } + + protected setMessageListener() { + ( this).on('input', (msg) => { + if (this.gateway) { + // Filter input + if(msg.payload && msg.payload.model && msg.payload.sid) { + if(!this.isDeviceValid(msg.payload)) { + msg = null; + } + } + // Prepare for request + else { + msg.payload = this.gateway.deviceList.filter((device) => this.isDeviceValid(device)); + } + ( this).send(msg); + } + }); + } + + isDeviceValid(device) { + if((!this.onlyModels || this.onlyModels.length == 0) && (!this.excludedSids || this.excludedSids.length == 0)) { + return true; + } + // Is excluded + if((this.excludedSids && this.excludedSids.length != 0) && this.excludedSids.indexOf(device.sid) >= 0) { + return false; + } + if((this.onlyModels && this.onlyModels.length != 0) && this.onlyModels.indexOf(device.model) >= 0) { + return true; + } + + return false; + } + } + + RED.nodes.registerType(`${Constants.NODES_PREFIX}-all`, All); +}; \ No newline at end of file diff --git a/node-red-contrib-xiaomi-switch/xiaomi-switch.html b/src/nodes/gateway-subdevices/GatewaySubdevice.ejs similarity index 74% rename from node-red-contrib-xiaomi-switch/xiaomi-switch.html rename to src/nodes/gateway-subdevices/GatewaySubdevice.ejs index 97f79d9..bbb7d4f 100644 --- a/node-red-contrib-xiaomi-switch/xiaomi-switch.html +++ b/src/nodes/gateway-subdevices/GatewaySubdevice.ejs @@ -1,18 +1,18 @@ - - +

Sample payload after incoming incoming message:

+

<%- incomingSample %>
+ Where <%= (!!locals.incomingDetails)?incomingDetails + ',':'' %> batteryLevel is a computed percentage of remaining battery. +

+ \ No newline at end of file diff --git a/src/nodes/gateway-subdevices/GatewaySubdevice.ts b/src/nodes/gateway-subdevices/GatewaySubdevice.ts new file mode 100644 index 0000000..62fd00a --- /dev/null +++ b/src/nodes/gateway-subdevices/GatewaySubdevice.ts @@ -0,0 +1,49 @@ +import { Red, NodeProperties, NodeStatus } from "node-red"; +import { Constants } from "../constants"; + +export default (RED:Red, type:string) => { + class GatewayDevice { + protected gateway: any; + protected sid: string; + + constructor(props:NodeProperties) { + RED.nodes.createNode( this, props); + this.gateway = RED.nodes.getNode(( props).gateway); + + ( this).status({fill:"grey", shape:"ring", text:"battery - na"}); + + if (this.gateway) { + ( this).on('input', (msg) => { + let payload = msg.payload; + + // Input from gateway + if (payload.sid) { + if (payload.sid == this.sid) { + let batteryLevel = payload.getBatteryPercentage(); + var status:NodeStatus = { + fill: "green", shape: "dot", + text: "battery - " + batteryLevel + "%" + }; + + if (batteryLevel < 10) { + status.fill = "red"; + } else if (batteryLevel < 45) { + status.fill = "yellow"; + } + ( this).status(status); + ( this).send([msg]); + } + } + // Prepare for request + else { + msg.sid = this.sid; + msg.gateway = this.gateway; + ( this).send(msg); + } + }); + } + } + } + + RED.nodes.registerType(`${Constants.NODES_PREFIX}-${type}`, GatewayDevice); +}; \ No newline at end of file diff --git a/node-red-contrib-xiaomi-motion/xiaomi-motion.html b/src/nodes/gateway-subdevices/Plug.ejs similarity index 55% rename from node-red-contrib-xiaomi-motion/xiaomi-motion.html rename to src/nodes/gateway-subdevices/Plug.ejs index 3721091..5d08fc4 100644 --- a/node-red-contrib-xiaomi-motion/xiaomi-motion.html +++ b/src/nodes/gateway-subdevices/Plug.ejs @@ -1,21 +1,21 @@ - - diff --git a/src/nodes/gateway-subdevices/Plug.ts b/src/nodes/gateway-subdevices/Plug.ts new file mode 100644 index 0000000..93a4367 --- /dev/null +++ b/src/nodes/gateway-subdevices/Plug.ts @@ -0,0 +1,40 @@ +import { Red, NodeProperties } from "node-red"; +import { Constants } from "../constants"; + +export default (RED:Red) => { + class Plug { + protected gateway: any; + protected sid: string; + + constructor(props:NodeProperties) { + RED.nodes.createNode( this, props); + this.gateway = RED.nodes.getNode(( props).gateway); + + ( this).status({fill:"grey", shape:"ring", text:"status"}); + } + + protected setListener() { + if (this.gateway) { + ( this).on('input', (msg) => { + var payload = msg.payload; + if(payload.sid) { + if (payload.sid == this.sid) { + if (payload.data.status && payload.data.status == "on") { + ( this).status({fill:"green", shape:"dot", text:"on"}); + } else if (payload.data.status && payload.data.status == "off") { + ( this).status({fill:"red", shape:"dot", text:"off"}); + } + ( this).send(msg); + } + } + // Prepare for request + else { + ( this).send(msg); + } + }); + } + } + } + + RED.nodes.registerType(`${Constants.NODES_PREFIX}-plug`, Plug); +} \ No newline at end of file diff --git a/src/nodes/gateway-subdevices/index.ejs b/src/nodes/gateway-subdevices/index.ejs new file mode 100644 index 0000000..73e206d --- /dev/null +++ b/src/nodes/gateway-subdevices/index.ejs @@ -0,0 +1,85 @@ +<%- include('./All', {}); %> +<%- include('./Plug', {}); %> + +<%# ---------------------------------- Magnet ---------------------------------- %> +<%- include('./GatewaySubdevice', { + type: "magnet", + label: "contact", + icon: "door-icon", + filterType: "magnet", + docTitle: "The Xiaomi contact", + incomingSample: `{ + cmd: "read_ack" + model: "sensor_magnet.aq2" + sid: "158d000112fb5d" + short_id: 50301 + data: { + voltage: 3015, + status: "close", + batteryLevel: 23 + } +}`, + incomingDetails: `status can be "open" or "close"` +}) %> + +<%# ---------------------------------- Motion ---------------------------------- %> +<%- include('./GatewaySubdevice', { + type: "motion", + label: "motion", + icon: "motion-icon", + filterType: "motion", + docTitle: "The Xiaomi body motion", + incomingSample: `{ + cmd: "read_ack" + model: "motion" + sid: "158d00015ef56c" + short_id: 21672 + data: { + voltage: 3035, + status: "motion", + batteryLevel: 45 + } +}` +}) %> + +<%# ---------------------------------- Sensor HT ---------------------------------- %> +<%- include('./GatewaySubdevice', { + type: "ht", + label: "sensor HT", + icon: "thermometer-icon", + filterType: "sensor_ht", + docTitle: "The Xiaomi Humidity & Temperature", + incomingSample: `{ + cmd: "read_ack" + model: "weather.v1" + sid: "158d00010b7f1b" + short_id: 8451 + data: { + voltage:3005, + temperature:23.25, + humidity:56.99, + pressure:981.26, + batteryLevel: 34 + } +}`, + incomingDetails: `humidy is in percents, pressure in kPa` +}) %> + +<%# ---------------------------------- Switch ---------------------------------- %> +<%- include('./GatewaySubdevice', { + type: "switch", + label: "switch", + icon: "mi-switch", + filterType: "switch", + docTitle: "The Xiaomi Switch", + incomingSample: `{ + cmd: "report" + model: "switch" + sid: "158d000128b124" + short_id: 56773 + data: { + status: "click", + batteryLevel: 23 + } +}` +}) %> \ No newline at end of file diff --git a/src/nodes/gateway-subdevices/index.ts b/src/nodes/gateway-subdevices/index.ts new file mode 100644 index 0000000..7d7441c --- /dev/null +++ b/src/nodes/gateway-subdevices/index.ts @@ -0,0 +1,14 @@ +import { Red, NodeProperties } from "node-red"; +import * as LumiAqara from 'lumi-aqara'; + +import {default as All} from "./All"; +import {default as Plug} from "./Plug"; +import {default as GatewaySubdevice} from "./GatewaySubdevice"; + +export = (RED:Red) => { + All(RED); + Plug(RED); + ["magnet", "motion", "sensor", "switch"].forEach((subdeviceType) => { + GatewaySubdevice(RED, subdeviceType); + }); +}; \ No newline at end of file diff --git a/src/nodes/gateway/GatewayConfigurator.ejs b/src/nodes/gateway/GatewayConfigurator.ejs index 54ee938..74ab316 100644 --- a/src/nodes/gateway/GatewayConfigurator.ejs +++ b/src/nodes/gateway/GatewayConfigurator.ejs @@ -3,64 +3,105 @@ category: 'config', defaults: { name: {value: ""}, - ip: {value: ""}, sid: {value: ""}, + key: { value: "" }, deviceList: {value:{}} }, - credentials: { - key: { type: "text" } - }, paletteLabel: "gateway configurator", label: function () { return this.name || "gateway configurator"; }, oneditprepare: function() { - RED.settings.miDevicesGatewayConfiguratorDiscoveredGateways.forEach(function(gateway, index) { + var foundGateways = RED.settings.miDevicesGatewayConfiguratorDiscoveredGateways; + Object.keys(foundGateways).forEach(function(sid) { + var gateway = foundGateways[sid]; $('#discovered-gateways').append(''); }); var node = this; - function addSubdevice(device) { - var devicesConfig = { - "sensor": {value:"sensor", label:"sensor ht", icon:"icons/node-red-contrib-mi-devices/thermometer-icon.png"}, - "magnet": {value:"magnet", label:"magnet", icon:"icons/node-red-contrib-mi-devices/door-icon.png"}, - "motion": {value:"motion", label:"motion", icon:"icons/node-red-contrib-mi-devices/motion-icon.png"}, - "switch": {value:"switch", label:"switch", icon:"icons/node-red-contrib-mi-devices/mi-switch.png"}, - "plug": {value:"plug", label:"plug zigbee", icon:"icons/node-red-contrib-mi-devices/outlet-icon.png"} - }; + var devicesConfig = { + "mi.weather": {label:"weather", icon:"icons/node-red-contrib-mi-devices/thermometer-icon.png"}, + "mi.magnet": {label:"magnet", icon:"icons/node-red-contrib-mi-devices/door-icon.png"}, + "mi.motion": {label:"motion", icon:"icons/node-red-contrib-mi-devices/motion-icon.png"}, + "mi.switch": {label:"switch", icon:"icons/node-red-contrib-mi-devices/mi-switch.png"}, + "mi.plug": {label:"plug zigbee", icon:"icons/node-red-contrib-mi-devices/outlet-icon.png"} + }; - var row = $('
', {class: "form-row"}).appendTo($('#input-subdevices')); - $('', {value: device.sid, type: "hidden", name: "sid"}).appendTo(row); - $('', {value: device.type, type: "hidden", name: "type"}).appendTo(row); - $('
-
- - -
- - + +
-

Note: use ip or sid - sid is better.

Devices

-
+
+
    +
    \ No newline at end of file diff --git a/src/nodes/gateway/GatewayOut.ts b/src/nodes/gateway/GatewayOut.ts index 1748987..85ef5f8 100644 --- a/src/nodes/gateway/GatewayOut.ts +++ b/src/nodes/gateway/GatewayOut.ts @@ -1,47 +1,44 @@ import { Red, NodeProperties } from "node-red"; -import { LumiAqara } from "../../../typings/index"; import { Constants } from "../constants"; +import {Gateway} from "../../devices/Gateway"; export interface IGatewayOutNode extends Node { gatewayConf:any; - gateway: LumiAqara.Gateway; - - setGateway(gateway:LumiAqara.Gateway); + gateway: Gateway; } export default (RED:Red) => { class GatewayOut { protected gatewayConf: any; - protected gateway: LumiAqara.Gateway; - constructor(props:NodeProperties) { + constructor(props: NodeProperties) { RED.nodes.createNode( this, props); - this.gatewayConf= RED.nodes.getNode(( props).gateway); - ( this).status({fill:"red", shape:"ring", text: "offline"}); + this.gatewayConf = RED.nodes.getNode(( props).gateway); - this.setMessageListener(); + (this).status({fill: "red", shape: "ring", text: "offline"}); + + if (this.gatewayConf.gateway) { + (this).status({fill: "blue", shape: "dot", text: "online"}); + } + + this.gatewayConf.on('gateway-online', () => { + (this).status({fill: "blue", shape: "dot", text: "online"}); + }); + + this.gatewayConf.on('gateway-offline', () => { + (this).status({fill: "red", shape: "ring", text: "offline"}); + }); } protected setMessageListener() { - ( this).on("input", (msg) => { + /*( this).on("input", (msg) => { if (msg.hasOwnProperty("payload") && this.gateway) { if(msg.payload.cmd === "write" && !msg.payload.data.key && this.gateway && this.gateway.sid && this.gateway._key) { msg.payload.data.key = this.gateway._key; } this.gateway._sendUnicast(JSON.stringify(msg.payload)); } - }); - } - - setGateway(gateway) { - this.gateway = gateway; - this.gateway.setPassword(this.gatewayConf.password); - ( this).status({fill:"blue", shape:"dot", text: "online"}); - - this.gateway.on('offline', () => { - this.gateway = null; - ( this).status({fill:"red", shape:"ring", text: "offline"}); - }); + });*/ } } diff --git a/src/nodes/gateway/Searcher.ts b/src/nodes/gateway/Searcher.ts deleted file mode 100644 index 7cde419..0000000 --- a/src/nodes/gateway/Searcher.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Red } from "node-red"; -import * as LumiAqara from 'lumi-aqara'; - -import { Constants } from "../constants"; -import { IGatewayConfiguratorNode } from "./GatewayConfigurator"; - -export class Searcher { - static _gateways:LumiAqara.Gateway[] = []; - - static discover(RED:Red) { - new Promise(() => { - const aqara = new LumiAqara(); - - aqara.on('gateway', (gateway:LumiAqara.Gateway) => { - let frontGateway = { - sid: gateway.sid, - ip: gateway.ip, - subdevices: [] - }; - this._gateways.push(frontGateway); - gateway.on('subdevice', (device:LumiAqara.SubDevice) => { - frontGateway.subdevices.push({ - sid: device.getSid(), - type: device.getType() - }); - }); - RED.nodes.eachNode((tmpNode) => { - if(tmpNode.type.indexOf(`${Constants.NODES_PREFIX}-gateway configurator`) === 0) { - let tmpNodeInst = RED.nodes.getNode(tmpNode.id); - if(tmpNodeInst && (tmpNodeInst.ip === gateway.ip || tmpNodeInst.sid === gateway.sid)) { - tmpNodeInst.gateway = gateway; - } - } - }); - }); - }); - } - - static get gateways():LumiAqara.Gateway { - return this._gateways; - } -} \ No newline at end of file diff --git a/src/nodes/gateway/index.ts b/src/nodes/gateway/index.ts index 3aaedb5..06000ef 100644 --- a/src/nodes/gateway/index.ts +++ b/src/nodes/gateway/index.ts @@ -1,14 +1,14 @@ import { Red, NodeProperties } from "node-red"; import * as LumiAqara from 'lumi-aqara'; -import { Searcher } from "./Searcher"; +import { GatewayServer } from "../../devices/GatewayServer"; import {default as GatewayConfigurator} from "./GatewayConfigurator"; import {default as Gateway} from "./Gateway"; import {default as GatewayIn} from "./GatewayIn"; import {default as GatewayOut} from "./GatewayOut"; export = (RED:Red) => { - Searcher.discover(RED); + GatewayServer.getInstance().discover(); GatewayConfigurator(RED); Gateway(RED); diff --git a/src/nodes/plug-wifi/index.ejs b/src/nodes/plug-wifi/index.ejs new file mode 100644 index 0000000..3066cd1 --- /dev/null +++ b/src/nodes/plug-wifi/index.ejs @@ -0,0 +1,67 @@ + + + + + diff --git a/src/nodes/plug-wifi/index.ts b/src/nodes/plug-wifi/index.ts new file mode 100644 index 0000000..be0918b --- /dev/null +++ b/src/nodes/plug-wifi/index.ts @@ -0,0 +1,151 @@ +import { Constants } from "../constants"; + +const miio = require("miio"); + +export = (RED) => { + var connectionState = "timeout"; + var retryTimer; + var delayedStatusMsgTimer; + + + function XiaomiPlugWifiNode(config) { + RED.nodes.createNode(this, config); + this.ip = config.ip; + this.plug = null; + + this.status({fill: "yellow", shape: "dot", text: "connecting"}); + + miio.device({address: this.ip}) + .then((plug) => { + this.plug = plug; + this.status({fill:"green", shape:"dot", text:"connected"}); + connectionState = "connected"; + delayedStatusMsgUpdate(); + + this.plug.on('propertyChanged', (e) => { + if (e.property === "power") { + if (e.value['0']) { + setState("on"); + } else { + setState("off"); + } + } + }); + watchdog(); + }) + .catch((error) => { + connectionState = "reconnecting"; + watchdog(); + }) + + this.on('input', (msg) => { + var payload = msg.payload; + if (connectionState === "connected") { + if (payload == 'on') { + this.plug.setPower(true); + } + + if (payload == 'off') { + this.plug.setPower(false); + } + } + }); + + this.on('close', (done) => { + if (retryTimer) { + clearTimeout(retryTimer); + } + if (delayedStatusMsgTimer) { + clearTimeout(delayedStatusMsgTimer); + } + if (this.plug) { + this.plug.destroy(); + } + done(); + }); + + var setState = (state) => { + if (this.plug) { + let status = { + payload: { + id: this.plug.id, + type: this.plug.type, + model: this.plug.model, + capabilities: this.plug.capabilities, + address: this.plug.address, + port: this.plug.port, + power: this.plug.power(), + state: state + } + }; + this.send(status); + } + }; + + var delayedStatusMsgUpdate = () => { + delayedStatusMsgTimer = setTimeout(() => { + if (this.plug.power()['0']) { + setState("on"); + } else { + setState("off"); + } + }, 1500); + }; + + var discoverDevice = () => { + miio.device({address: this.ip}) + .then((plug) => { + if (this.plug == null) { + this.plug = plug; + this.plug.on('propertyChanged', (e) => { + if (e.property === "power") { + if (e.value['0']) { + setState("on"); + } else { + setState("off"); + } + } + }); + } + if (connectionState === "reconnecting") { + this.status({fill:"green", shape:"dot", text:"connected"}); + connectionState = "connected"; + delayedStatusMsgUpdate(); + } + }) + .catch((error) => { + connectionState = "reconnecting"; + if (this.plug) { + this.plug.destroy(); + this.plug = null; + } + }) + }; + + var watchdog = () => { + var node = this; + function retryTimer() { + discoverDevice(); + if (connectionState === "reconnecting") { + node.status({fill: "red", shape: "dot", text: "reconnecting"}); + } + setTimeout(retryTimer, 30000); + } + setTimeout(retryTimer, 30000); + } + } + + process.on('unhandledRejection', function(reason, p) { + // console.log("Possibly Unhandled Rejection at: Promise ", p, " reason: ", reason); + var message = reason + ""; + if (message.indexOf("Call to device timed out") >= 0) { + if (this.plug) { + console.log("Issue with miio package; discard plug and reconnect."); + this.plug.destroy(); + this.plug = null; + } + } + }); + + RED.nodes.registerType(`${Constants.NODES_PREFIX}-wifi-plug`, XiaomiPlugWifiNode); +} diff --git a/src/nodes/yeelight/YeelightConfigurator.ejs b/src/nodes/yeelight/YeelightConfigurator.ejs index d395d3d..0b22dcb 100644 --- a/src/nodes/yeelight/YeelightConfigurator.ejs +++ b/src/nodes/yeelight/YeelightConfigurator.ejs @@ -7,7 +7,7 @@ sid: {value: ""} }, label: function () { - return this.name || "yeelight configurator"; + return this.name || "yeelight conf"; }, oneditprepare: function() { RED.settings.miDevicesYeelightConfiguratorDiscoveredBulbs.forEach(function(bulb, index) {