From 90a6019f928f81e1eff6e700c169297e3a36ea43 Mon Sep 17 00:00:00 2001 From: Eugene Abramov Date: Thu, 10 Feb 2022 22:53:42 +0300 Subject: [PATCH] Add gateway as a device --- admin/tab_m.js | 16 +++++++-- lib/gateway3.js | 85 +++++++++++++++++++++++++++++------------------ lib/lumi.js | 33 +++++++++++++++++- lib/stateClass.js | 50 ++++++++++++++++++++++++++++ lib/utils.js | 34 ++++++++++++++++--- main.js | 51 ++++++++++++++-------------- 6 files changed, 203 insertions(+), 66 deletions(-) diff --git a/admin/tab_m.js b/admin/tab_m.js index 5fbd762..6d8a306 100644 --- a/admin/tab_m.js +++ b/admin/tab_m.js @@ -156,7 +156,19 @@ async function getDevices(ids/* Array */) { await new Promise(resolve => { sendTo(namespace, 'GetDevices', {ids}, function (msg) { if (isArray(msg)) { - const _devices = msg.reduce((acc, d) => Object.assign(acc, {[d.id]: d}), {}); + const _devices = msg + .sort((a, b) => { + if (a.type == 'gateway') { + return -1; + } else if (b.type == 'gateway') { + return 1; + } else { + const aInt = a.mac.match(/[\da-f]{2}/g).reduce((acc, c) => acc + parseInt(c, 16), 0); + const bInt = b.mac.match(/[\da-f]{2}/g).reduce((acc, c) => acc + parseInt(c, 16), 0); + return bInt - aInt; + } + }) + .reduce((acc, d) => Object.assign(acc, {[d.id]: d}), {}); devices = Object.assign(devices, _devices); } @@ -169,7 +181,7 @@ function showDevices() { const html = Object.values(devices).map(device => { const {id, mac, type, did, model, name, fwVer, friendlyName, stateVal, stateCommon} = device; - if (type == 'gateway') return ''; + // if (type == 'gateway') return ''; let img_src = ''; diff --git a/lib/gateway3.js b/lib/gateway3.js index 86e42ec..8cfffc9 100644 --- a/lib/gateway3.js +++ b/lib/gateway3.js @@ -23,7 +23,7 @@ const { /* */ const {isObject, isArray} = require('./tools'); -const {reverseMac, sleep} = require('./utils'); +const {sleep, reverseMac, decodeMiioJson} = require('./utils'); const {SQLite} = require('./utilsdb'); const {Gateway3Helper} = require('./helpers'); const Lumi = require('./lumi'); @@ -350,13 +350,13 @@ class Gateway3 extends XiaomiDevice { let raw = await this.#shell.readFile('/data/zigbee/coordinator.info'); let device = JSON.parse(raw); - const {name, spec} = Lumi.getDevice('lumi.gateway.mgl03'); + const {name, spec, model} = Lumi.getDevice('lumi.gateway.mgl03'); devices.push({ 'did': await this.#shell.getDid(), 'mac': device['mac'], 'type': 'gateway', - 'model': 'lumi.gateway.mgl03', + model, name, 'fwVer': this.#fw_version, 'wlan_mac': await this.#shell.getWlanMac(), @@ -489,17 +489,20 @@ class Gateway3 extends XiaomiDevice { stat: new XiaomiDeviceStat(resetCnt) }); + /* */ if (type == 'gateway') { + this.mac = mac; this.did = did; this.topic = `gw/${String(mac).substr(2).toUpperCase()}/`; - } else { - findOrCreateDevice({ - mac, - model, - spec: filteredSpec.map(el => el[2]).filter(el => el != undefined), - init: init || {} - }); } + + /* Call callback */ + findOrCreateDevice({ + mac, + model, + spec: filteredSpec.map(el => el[2]).filter(el => el != undefined), + init: init || {} + }); } } @@ -636,6 +639,45 @@ class Gateway3 extends XiaomiDevice { cb(mac, payload); } + processMessageLogMiio(message, cb) { + const decodeMessage = decodeMiioJson(message, 'event.gw.heartbeat'); + + if (decodeMessage.length > 0) { + const messageParams = decodeMessage[0]['params'][0]; + + const {spec: deviceSpec} = this.devices[this.did]; + const statesKeyVal = deviceSpec + .reduce((pv, [, state]) => Object.assign({}, pv, state.decode(messageParams)), {}); + const payload = deviceSpec + .map(([, state]) => [state, statesKeyVal[state.stateName]]); + + cb(this.mac, payload); + } + } + + /* */ + processMessageReceived(message, cb) { + const {eui64} = message; + const did = `lumi.${eui64.replace(/\b0x0+/g, '').toLowerCase()}`; + const device = this.devices[did]; + + const payload = []; + + if (device != undefined && device.stat != undefined) { + device.stat.update(message); + + const [, statState] = (device.spec.find(([, state]) => state.stateName == 'messages_stat') || []); + + if (statState != undefined) { + const stat = Object.assign({}, device.stat.object, {did}); + + payload.push([statState, statState.normalizeLeft(stat)]); + } + + cb(device.mac, payload); + } + } + /* */ sendMessage(id, /* object */states, cb) { const device = Object.values(this.devices).find(el => el.mac == `0x${id}`); @@ -669,29 +711,6 @@ class Gateway3 extends XiaomiDevice { } } - /* */ - processMessageReceived(message, cb) { - const {eui64} = message; - const did = `lumi.${eui64.replace(/\b0x0+/g, '').toLowerCase()}`; - const device = this.devices[did]; - - const payload = []; - - if (device != undefined && device.stat != undefined) { - device.stat.update(message); - - const [, statState] = (device.spec.find(([, state]) => state.stateName == 'messages_stat') || []); - - if (statState != undefined) { - const stat = Object.assign({}, device.stat.object, {did}); - - payload.push([statState, statState.normalizeLeft(stat)]); - } - - cb(device.mac, payload); - } - } - /* Check is gateway3 port open or not */ async _checkPort(port) { return Gateway3Helper.checkPort(port, this.#localip); diff --git a/lib/lumi.js b/lib/lumi.js index 2caabbb..6ba50c5 100644 --- a/lib/lumi.js +++ b/lib/lumi.js @@ -14,6 +14,7 @@ const { ColorTemperature, Conductivity, Contact, + LoadAvg, CurtainLevel, CurtainMotor, Formaldehyde, @@ -31,7 +32,9 @@ const { Power, Pressure, Remaining, + Rssi, RunState, + RunTime, Switch, Temperature, TiltAngle, @@ -88,6 +91,9 @@ module.exports = class Zigbee { { 'lumi.gateway.mgl03': ['Xiaomi', 'Gateway 3', 'ZNDMWG03LM'], 'spec': [ + [undefined, undefined, LoadAvg, [], [LoadAvgParser]], + [undefined, undefined, Rssi, [], [RssiParser]], + [undefined, undefined, RunTime, [], [RunTimeParser]], // ['8.0.2012', undefined, 'power_tx'], // ['8.0.2024', undefined, 'channel'], // ['8.0.2081', undefined, 'pairing_stop'], @@ -745,7 +751,13 @@ module.exports = class Zigbee { normalizeRight(val) { return super.normalizeRight(val); } - + + /** + * Decode payload + * + * @param {*} keyVal Array of arrays ([[lumi, val]]) or Object ({}) + * @returns {stateName: stateValue, ...} + */ decode(/* [[lumi, val]] */keyVal) { let payload; @@ -933,6 +945,17 @@ function HumidityParser(model) { return val => val; } +function LoadAvgParser() { + return val => { + const [a, b, c] = val['load_avg'].split('|'); + return [ + Number(a).toFixed(2), + Number(b).toFixed(2), + Number(c).toFixed(2) + ]; + }; +} + function PowerParser(model) { return val => { if (typeof val === 'number') @@ -949,6 +972,10 @@ function PressureParser(model) { return val => val; } +function RssiParser() { + return val => val['rssi'] * -1; +} + function RunStateParser(model) { return val => { // # https://github.com/AlexxIT/XiaomiGateway3/issues/139 @@ -961,6 +988,10 @@ function RunStateParser(model) { }; } +function RunTimeParser() { + return val => Math.floor(Number(val['run_time']) / 60); +} + function SwitchParser(model) { return val => ([1, 0][['on', 'off'].indexOf(val)] || val); } diff --git a/lib/stateClass.js b/lib/stateClass.js index 413cc12..89f655d 100644 --- a/lib/stateClass.js +++ b/lib/stateClass.js @@ -539,6 +539,28 @@ module.exports = { this._update(...args); } }, + LoadAvg: class extends StateClass { + stateName = 'load_avg'; + stateCommon = { + name: 'Load average', + role: 'state', + type: 'string', + write: false + }; + + /* */ + constructor(...args) { + super(); + this._update(...args); + } + + /* */ + setter(dev, cb, context, timers) { + const [, val] = context[this.stateName] || []; + + cb(JSON.stringify(val).replace(/[\[\]\"]/g, '').replace(/,/g, ', ')); + } + }, LoadPower: class extends StateClass { stateName = 'load_power'; stateCommon = { @@ -730,6 +752,20 @@ module.exports = { this._update(...args); } }, + Rssi: class extends StateClass { + stateName = 'rssi'; + stateCommon = { + name: 'RSSI', + role: 'value', + unit: 'dBm' + }; + + /* */ + constructor(...args) { + super(); + this._update(...args); + } + }, RunState: class extends StateClass { stateName = 'run_state'; stateCommon = { @@ -746,6 +782,20 @@ module.exports = { this._update(...args); } }, + RunTime: class extends StateClass { + stateName = 'run_time'; + stateCommon = { + name: 'Run time', + role: 'value', + unit: 'minutes' + }; + + /* */ + constructor(...args) { + super(); + this._update(...args); + } + }, Switch: class extends StateClass { stateName = 'switch'; stateCommon = { diff --git a/lib/utils.js b/lib/utils.js index 7458d34..9442e92 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,9 +1,5 @@ const crypto = require('crypto'); -function reverseMac(mac) { - return mac.match(/[\da-f]{2}/g).reverse().join(''); -} - async function sleep(ms) { return new Promise(resolve => { const id = crypto.randomBytes(8).toString('hex'); @@ -15,7 +11,35 @@ async function sleep(ms) { }); } +function reverseMac(mac) { + return mac.match(/[\da-f]{2}/g).reverse().join(''); +} + +function decodeMiioJson(raw, search) { + const RE_JSON1 = /msg:(.+) length:([0-9]+) bytes/g; + const RE_JSON2 = /\{.+\}/g; + + if (raw.includes(search) == false) + return []; + + let m = RE_JSON1.exec(raw); + + if (m != undefined) { + raw = String(m[1]).substring(0, Number(m[2])); + } else { + m = RE_JSON2.exec(raw); + raw = m[0]; + } + + const items = raw.replace('}{', '}\n{').split('\n'); + return items + .filter(el => el.includes(search)) + .map(el => JSON.parse(el)); +} + +/* */ module.exports = { + sleep, reverseMac, - sleep + decodeMiioJson }; \ No newline at end of file diff --git a/main.js b/main.js index 8a4e571..f4babfc 100644 --- a/main.js +++ b/main.js @@ -351,31 +351,32 @@ class XiaomiGateway3 extends utils.Adapter { } /* MQTT on 'message' event callback */ - async _onMqttMessage(topic, msg) { - if (String(msg).match(/^\{.+\}$/gm) != undefined) { - try { - const msgObject = JSON.parse(msg); - - /* */ - if (topic.match(/^zigbee\/send$/gm)) { - this.logger.debug(`(_LUMI_) ${topic} ${msg}`); - this.gateway3.processMessageLumi(msgObject, this._cbProcessMessage.bind(this)); - } else if (topic.match(/^log\/ble$/gm)) { - this.logger.debug(`(_BLE_) ${topic} ${msg}`); - this.gateway3.processMessageBle(msgObject, this._cbProcessMessage.bind(this)); - } else if (topic.match(/^log\/miio$/gm)) { - // TODO: or not TODO: - } else if (topic.match(/\/heartbeat$/gm)) { - //TODO: or not TODO: - // Gateway heartbeats (don't handle for now) - } else if (topic.match(/\/(MessageReceived|devicestatechange)$/gm)) { - this.gateway3.processMessageReceived(msgObject, this._cbProcessMessage.bind(this)); - } else if (topic.match(/^zigbee\/recv$/gm)) { - this.logger.debug(`(_LUMI_) ${topic} ${msg}`); - } - } catch (e) { - this.logger.error(e.stack); + async _onMqttMessage(topic, message) { + const RE_OBJECT = /^\{.+\}$/gm; + + try { + if (String(message).match(RE_OBJECT) != undefined) + var messageJSON = JSON.parse(message); + /* */ + if (topic.match(/^zigbee\/send$/gm)) { + this.logger.debug(`(_LUMI_) ${topic} ${message}`); + this.gateway3.processMessageLumi(messageJSON, this._cbProcessMessage.bind(this)); + } else if (topic.match(/^log\/ble$/gm)) { + this.logger.debug(`(_BLE_) ${topic} ${message}`); + this.gateway3.processMessageBle(messageJSON, this._cbProcessMessage.bind(this)); + } else if (topic.match(/^log\/miio$/gm)) { + // this.logger.debug(`${topic} ${message}`); //TODO: + this.gateway3.processMessageLogMiio(message, this._cbProcessMessage.bind(this)); + } else if (topic.match(/\/heartbeat$/gm)) { + //TODO: or not TODO: + // Gateway heartbeats (don't handle for now) + } else if (topic.match(/\/(MessageReceived|devicestatechange)$/gm)) { + this.gateway3.processMessageReceived(messageJSON, this._cbProcessMessage.bind(this)); + } else if (topic.match(/^zigbee\/recv$/gm)) { + this.logger.debug(`(_LUMI_) ${topic} ${message}`); } + } catch (e) { + this.logger.error(e.stack); } } @@ -384,7 +385,7 @@ class XiaomiGateway3 extends utils.Adapter { const id = String(mac).substr(2); const states = await this.getStatesAsync(`${id}*`); - /* Construct key-value object from current states */ + /* Construct key-value object from current states */ const deviceStateVal = Object.keys(states) .reduce((obj, s) => { const [sn,] = s.split('.').splice(-1);