diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..4540487 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,21 @@ +*.bak +*.json +*.log +*.md +*.sh +*.swo +*.swp +*.txt +.DS_Store +._* + +/.git +/node_modules + +/LICENSE +/README.md + +/test + +/buffer-test-data.js +/device.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..471c9c8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,215 @@ +# Automatically handle line endings for text files +# Leave all files detected as binary untouched +# This handles all files NOT found below +* text=auto + + +# These files are binary and should be left untouched +# (binary is a macro for -text -diff) +*.3gp binary +*.3gpp binary +*.7z binary +*.ai binary +*.as binary +*.asf binary +*.asx binary +*.bmp binary +*.db binary +*.eot binary +*.eps binary +*.exe binary +*.fla binary +*.flv binary +*.gif binary +*.gz binary +*.ico binary +*.jar binary +*.jng binary +*.jp2 binary +*.jpeg binary +*.jpg binary +*.jpx binary +*.jxr binary +*.kar binary +*.m4a binary +*.m4v binary +*.mid binary +*.midi binary +*.mng binary +*.mov binary +*.mp3 binary +*.mp4 binary +*.mpeg binary +*.mpg binary +*.node binary +*.o binary +*.ogg binary +*.ogv binary +*.otf binary +*.p binary +*.pdf binary +*.phar binary +*.pkl binary +*.png binary +*.psb binary +*.psd binary +*.pyc binary +*.pyd binary +*.pyo binary +*.ra binary +*.rar binary +*.svgz binary +*.swc binary +*.swf binary +*.tar binary +*.tif binary +*.tiff binary +*.ttf binary +*.wbmp binary +*.webm binary +*.webp binary +*.woff binary +*.woff2 binary +*.xz binary +*.zip binary + +# crlf = Windows +# lf = Linux/Unix/macOS 10+ + +# These files are text and should be normalized (Convert crlf => lf) +*.bat text eol=crlf +*.bnf text +*.bowerrc text +*.bsd text +*.c text +*.cc text +*.cnf text +*.coffee text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*.conf text +*.config text +*.css text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*.csv text +*.d text +*.def text +*.df text +*.dockerignore text +*.dot text +*.dts text +*.ejs text +*.el text +*.engine text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 diff=php +*.gnu text +*.gyp text +*.gypi text +*.h text +*.haml text +*.handlebars text +*.hbs text +*.hbt text +*.htm text +*.html text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 diff=html +*.in text +*.inc text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 diff=php +*.info text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*.ini text +*.install text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 diff=php +*.jade text +*.js text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*.json text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*.jst text +*.jsx text +*.latte text +*.less text +*.log text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*.lock text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*.ls text +*.map text +*.markdown text +*.md text +*.mdown text +*.mdtxt text +*.mdwn text +*.mit text +*.mk text +*.mkd text +*.mkdn text +*.module text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 diff=php +*.mount text +*.mustache text +*.njk text +*.npmignore text +*.od text +*.opts text +*.patch text +*.php text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 diff=php +*.phtml text +*.pl text +*.po text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*.pxd text +*.py text +*.py3 text +*.pyw text +*.pyx text +*.rb text +*.sass text +*.scm text +*.script text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*.scss text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*.sh text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 diff=php +*.sql text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*.styl text +*.svg text +*.tag text +*.tagx text +*.tap text +*.targ text +*.test text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 diff=php +*.theme text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 diff=php +*.tld text +*.tmpl text +*.tpl text +*.trc text +*.ts text +*.tsx text +*.twig text +*.txt text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*.xhtml text +*.xml text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*.yaml text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*.yml text eol=lf whitespace=blank-at-eol,-blank-at-eof,-space-before-tab,tab-in-indent,tabwidth=2 +*COPYRIGHT* text +*README* text +.babelrc text +.browserslistrc text +.csslintrc text +.editorconfig text +.eslintignore text +.eslintrc text +.gitattributes text +.gitconfig text +.gitignore text +.gitmodules text +.htaccess text +.htmlhintrc text +.jscsrc text +.jshintignore text +.jshintrc text +.slugignore text +.stylelintrc text +AUTHORS text +CHANGELOG text +CHANGES text +CONTRIBUTING text +COPYING text +Dockerfile text +INSTALL text +LICENSE text +Makefile text +NEWS text +Procfile text +TODO text +browserslist text +copyright text +license text +makefile text +readme text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e6d032 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +*.bak +*.log +*.swo +*.swp +._* +.DS_Store + +/*.json + +/json.bak/ +/log/ +/node_modules/ +/test/ diff --git a/README.md b/README.md index 0a6fa6e..07fe1ae 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,65 @@ # iris2mqtt + Lowe's Iris Gen1 to MQTT/Home Assistant adapter application + +* Uses MQTT autodiscovery with Home Assistant +* Works with: + * Alarm keypads + * Key presses are transmitted as HA events via the HA event API + * Door sensors + * Contact sensor state + * Temperature + * Battery level/voltage + * RSSI + * Motion sensors + * Motion sensor state + * Temperature + * Battery level/voltage + * RSSI + * Power plugs + * On/off state (and control) + * RSSI +* Add your sensor/switch/plug Zigbee remote64 identifiers and names as object keys +under .nodeNames as shown in the config example below +* Also, yes, this needs more documentation + +## Example config.json + +```json +{ + "api": { + "port": 1376 + }, + "homeassistant": { + "host": "https://ha.localdomain", + "ignoreCert": false, + "port": 8123, + "token": "TOKEN" + }, + "mqtt": { + "clientId": "iris2mqtt", + "server": "mqtt.localdomain" + }, + "nodeNames": { + "000d6f00024c2cc9": "Garage motion sensor", + "000d6f000258985d": "Kitchen back yard entry door sensor", + "000d6f00028f3f4d": "Living room A sensor", + "000d6f0003b314f2": "Front room A sensor", + "000d6f0003bbf44d": "Stairwell motion sensor", + "000d6f0003bc4ffb": "Living room motion sensor", + "000d6f0003bc5844": "Front room motion sensor", + "000d6f0003bc59cd": "Kitchen motion sensor" + }, + "temperatureOffset": { + "000d6f00024c2cc9": -2, + "000d6f000258985d": -2, + "000d6f00028f3f4d": -1.7798788888799895, + "000d6f0003b314f2": -2 + }, + "xbee": { + "apiMode": 2, + "baudRate": 115200, + "port": "/dev/ttyUSB0" + } +} +``` diff --git a/api-cmds.sh b/api-cmds.sh new file mode 100755 index 0000000..6242d9e --- /dev/null +++ b/api-cmds.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +SCRIPT="$(realpath "${0}")" +SCRIPT_PATH="$(dirname "${SCRIPT}")" + +API_JS="${SCRIPT_PATH}/api.js" + +grep -Eo "app\..*.\('.*.'" "${API_JS}" | grep -Ev '\.all\(' | sed -e 's/app\.//g' -e 's/get(/GET\ \ /g' -e 's/post(/POST\ /g' diff --git a/api.js b/api.js new file mode 100644 index 0000000..8859ae2 --- /dev/null +++ b/api.js @@ -0,0 +1,183 @@ +const express = require('express'); +const app = express(); +const server = require('http').Server(app); + +// body-parser to handle POSTed JSON +const body_parser = require('body-parser'); +app.use(body_parser.json()); + + +async function init() { + log.lib('Initializing'); + + app.all('*', (req, res, next) => { + log.lib('[' + req.method + '] ' + req.originalUrl, { body : req.body }); + res.set('Content-Type', 'application/json'); + next(); + }); + + + app.get('/config', (req, res) => { res.send(config); }); + app.get('/status', (req, res) => { res.send(status); }); + + app.post('/config', async (req, res) => { + if (req.headers['content-type'] !== 'application/json') { + res.send({ error : 'invalid content-type' }); + return; + } + + config = req.body; + await json.writeConfig(); + await res.send(config); + }); + + + app.post('/xbee/readAddresses', async (req, res) => { + const xbeeReturn = await xbee.readAddresses(); + await res.send(xbeeReturn); + }); + + app.post('/xbee/sendATCommand', async (req, res) => { + const xbeeReturn = await xbee.sendATCommand(req.body.command, req.body.commandParameter); + await res.send(xbeeReturn); + }); + + app.post('/xbee/sendMessage', async (req, res) => { + const xbeeReturn = await xbee.sendMessage(req.body.messageName, req.body.params, req.body.remote64); + await res.send(xbeeReturn); + }); + + + app.post('/xbee/sendTxExplicit', async (req, res) => { + const xbeeReturn = await xbee.sendTxExplicit(req.body.message, req.body.remote64, req.body.remote16); + await res.send(xbeeReturn); + }); + + app.post('/xbee/writeFrameObject', async (req, res) => { + const xbeeReturn = await xbee.writeFrameObject(req.body.frameObject); + await res.send(xbeeReturn); + }); + + + app.post('/xbee/generateActiveEndpointRequest', (req, res) => { + const xbeeReturn = xbee.generateActiveEndpointRequest(req.body.params); + res.send(xbeeReturn); + }); + + app.post('/xbee/generateMatchDescriptorRequest', (req, res) => { + const xbeeReturn = xbee.generateMatchDescriptorRequest(req.body.params); + res.send(xbeeReturn); + }); + + app.post('/xbee/generateMatchDescriptorResponse', (req, res) => { + const xbeeReturn = xbee.generateMatchDescriptorResponse(req.body.params); + res.send(xbeeReturn); + }); + + app.post('/xbee/generateMessage', (req, res) => { + const xbeeReturn = xbee.generateMessage(req.body.messageName, req.body.params); + res.send(xbeeReturn); + }); + + app.post('/xbee/generateModeChangeRequest', (req, res) => { + const xbeeReturn = xbee.generateModeChangeRequest(req.body.params); + res.send(xbeeReturn); + }); + + app.post('/xbee/generateSecurityInit', (req, res) => { + const xbeeReturn = xbee.generateSecurityInit(); + res.send(xbeeReturn); + }); + + app.post('/xbee/generateSwitchStateRequest', (req, res) => { + const xbeeReturn = xbee.generateSwitchStateRequest(req.body.params); + res.send(xbeeReturn); + }); + + app.post('/xbee/generateVersionInfoRequest', (req, res) => { + const xbeeReturn = xbee.generateVersionInfoRequest(); + res.send(xbeeReturn); + }); + + + app.post('/xbee/parseButtonPress', (req, res) => { + const xbeeReturn = xbee.parseButtonPress(req.body.data); + res.send(xbeeReturn); + }); + + app.post('/xbee/parseFrame', (req, res) => { + const xbeeReturn = xbee.parseFrame(req.body.frame); + res.send(xbeeReturn); + }); + + app.post('/xbee/parsePowerConsumption', (req, res) => { + const xbeeReturn = xbee.parsePowerConsumption(req.body.data); + res.send(xbeeReturn); + }); + + app.post('/xbee/parseActivePower', (req, res) => { + const xbeeReturn = xbee.parseActivePower(req.body.data); + res.send(xbeeReturn); + }); + + app.post('/xbee/parsePowerUnknown', (req, res) => { + const xbeeReturn = xbee.parsePowerUnknown(req.body.data); + res.send(xbeeReturn); + }); + + app.post('/xbee/parseRangeInfoUpdate', (req, res) => { + const xbeeReturn = xbee.parseRangeInfoUpdate(req.body.data); + res.send(xbeeReturn); + }); + + app.post('/xbee/parseSecurityState', (req, res) => { + const xbeeReturn = xbee.parseSecurityState(req.body.data); + res.send(xbeeReturn); + }); + + app.post('/xbee/parseStatusUpdate', (req, res) => { + const xbeeReturn = xbee.parseStatusUpdate(req.body.data); + res.send(xbeeReturn); + }); + + app.post('/xbee/parseSwitchStateRequest', (req, res) => { + const xbeeReturn = xbee.parseSwitchStateRequest(req.body.data); + res.send(xbeeReturn); + }); + + app.post('/xbee/parseSwitchStateUpdate', (req, res) => { + const xbeeReturn = xbee.parseSwitchStateUpdate(req.body.data); + res.send(xbeeReturn); + }); + + app.post('/xbee/parseTamperState', (req, res) => { + const xbeeReturn = xbee.parseTamperState(req.body.data); + res.send(xbeeReturn); + }); + + app.post('/xbee/parseVersionInfoUpdate', (req, res) => { + const xbeeReturn = xbee.parseVersionInfoUpdate(req.body.data); + res.send(xbeeReturn); + }); + + + log.lib('Initialized'); + + await new Promise(resolve => server.listen(config.api.port, resolve)); + + log.lib('Express listening on port ' + config.api.port); +} + +async function term() { + log.lib('Terminating'); + + await server.close(); + + log.lib('Terminated'); +} + + +module.exports = { + init, + term, +}; diff --git a/bitmask.js b/bitmask.js new file mode 100644 index 0000000..8a7d8bd --- /dev/null +++ b/bitmask.js @@ -0,0 +1,171 @@ +// All 9 bitmasks in hex and dec +const b = [ 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x00 ]; +const bit = [ 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x00 ]; +const dec = [ 1, 2, 4, 8, 16, 32, 64, 128, 0 ]; + +// Test number for all bitmasks and return object +// Example output as JSON with 0x89/137 as input +// { +// "data": { +// "dec": 137, +// "hex": "0x89", +// "set": 3, +// "unset": 6 +// }, +// "array": { +// "bits": [ 1, 2, 4, 8, 16, 32, 64, 128, 0 ], +// "bits_h": [ "0x01", "0x02", "0x04", "0x08", "0x10", "0x20", "0x40", "0x80", "0x00" ], +// "mask": [ true, false, false, true, false, false, false, true, false ], +// }, +// "bits": { +// "bit0": 1, +// "bit1": 2, +// "bit2": 4, +// "bit3": 8, +// "bit4": 16, +// "bit5": 32, +// "bit6": 64, +// "bit7": 128, +// "bit8": 0 +// }, +// "bits_h": { +// "bit0": "0x01", +// "bit1": "0x02", +// "bit2": "0x04", +// "bit3": "0x08", +// "bit4": "0x10", +// "bit5": "0x20", +// "bit6": "0x40", +// "bit7": "0x80", +// "bit8": "0x00" +// }, +// "mask": { +// "bit0": true, +// "bit1": false, +// "bit2": false, +// "bit3": true, +// "bit4": false, +// "bit5": false, +// "bit6": false, +// "bit7": true, +// "bit8": false +// } +// } +function check(num) { + // Bounce if it's over a single-bit bitmask (over 0xFF/255) + if (num > 0xFF) return false; + if (typeof num === 'undefined' || num === null || num === '') return false; + + // Init return object + const object = { + data : { + dec : num, + hex : hex.i2s(num), + set : 0, + unset : 0, + }, + array : { + bits : [], + mask : [], + }, + bits : {}, + mask : {}, + }; + + // Init loop counter + let count = 0; + + // Loop bits and push results into return array + bitmask.bit.forEach((bit) => { + let result; + + // Alter logic if it's the fake "9th" bitmask + // Test if number completely equals the bit + if (bit === bitmask.bit[8]) { + result = num === bit; + } + else { // Test number for bit + result = bitmask.test(num, bit); + } + + // Adjust total counters accordingly + object.data.set += result; + object.data.unset += !result; + + // Add data to array output + object.array.bits.push(bit); + object.array.mask.push(result); + + // Add data to object output + object.bits['b' + count] = bit; + object.mask['b' + count] = result; + + object.bits['bit' + count] = bit; + object.mask['bit' + count] = result; + + // Increment counter + count++; + }); + + return object; +} + +// Create a complete bitmask by setting multiple bits +function create(object) { + let mask = 0x00; + + // Init loop counter + let count = 0; + + // Loop bits and set bits based on input object + bitmask.bit.forEach((bit) => { + // Skip fake 9th bitmask + if (count === 8) return; + + if (object['b' + count] || object['bit' + count]) mask = bitmask.set(mask, bit); + + // Increment counter + count++; + }); + + return mask; +} + +// Set a bit in a bitmask +function set(num, bit) { + num |= bit; + return num; +} + +// Test number for bitmask +function test(num, bit) { + return num & bit && true || false; +} + +// Toggle a bit in a bitmask +function toggle(num, bit) { + num ^= bit; + return num; +} + +// Unset a bit in a bitmask +function unset(num, bit) { + num &= ~bit; + return num; +} + + +module.exports = { + // Variables + b, + bit, + dec, + + // Functions + check, + create, + set, + test, + toggle, + unset, +}; diff --git a/config-default.js b/config-default.js new file mode 100644 index 0000000..0d33f09 --- /dev/null +++ b/config-default.js @@ -0,0 +1,32 @@ +const config_object = { + api : { + port : 1376, + }, + + homeassistant : { + host : null, + port : 8123, + token : null, + ignoreCert : false, + }, + + mqtt : { + clientId : 'iris2mqtt', + server : 'mqtt, + }, + + nodeNames : { + }, + + temperatureOffset : { + }, + + xbee : { + apiMode : 2, + baudRate : 115200, + port : null, + }, +}; + + +module.exports = config_object; diff --git a/hass.js b/hass.js new file mode 100644 index 0000000..dc8d0b9 --- /dev/null +++ b/hass.js @@ -0,0 +1,308 @@ +// https://www.npmjs.com/package/homeassistant + +let homeAssistant; + + +async function fireKeypadEvent(keypadData) { + const eventName = 'keypad_entry'; + + try { + log.lib('Firing Home Assistant event', eventName); + + const eventFireReturn = await homeAssistant.events.fire(eventName, keypadData); + + log.lib('Fired Home Assistant event', eventName); + return eventFireReturn; + } + catch (homeAssistantEventFireError) { + log.error('fireKeypadEvent() homeAssistantEventFireError', homeAssistantEventFireError); + return false; + } +} + + +async function getHassData() { + const hassData = { + config : null, + discoveryInfo : null, + status : null, + }; + + try { + hassData.config = await homeAssistant.config(); + } + catch (configError) { + console.dir({ configError }); + } + + try { + hassData.discoveryInfo = await homeAssistant.discoveryInfo(); + } + catch (discoveryInfoError) { + console.dir({ discoveryInfoError }); + } + + try { + hassData.status = await homeAssistant.status(); + } + catch (statusError) { + console.dir({ statusError }); + } + + + return hassData; +} + + +async function getState(entityId) { + try { + const entityIdParts = entityId.split('.'); + + const domain = entityIdParts[0]; + const entity = entityIdParts[1]; + + log.lib(`Getting state for ${domain}.${entity}`); + + const state = homeAssistant.states.get(domain, entity); + + return state; + } + catch (homeAssistantGetStateError) { + log.error('getState() homeAssistantGetStateError', homeAssistantGetStateError); + return false; + } +} + + +function generateDeviceRegistryEntry(remote64, entryType) { + const deviceStatus = status.xbee.nodes64[remote64]; + + const entryTypeData = { + uniqueId : { + deviceName : deviceStatus.name.toLowerCase().replace(/\s+/g, '').replace('\'', ''), + nodeName : entryType.toLowerCase().replace(/\s+/g, '').replace('\'', ''), + }, + + name : '', + deviceClass : '', + icon : '', + unitOfMeasurement : '', + }; + + + let entityType = 'sensor'; + + switch (entryType) { + case 'activePower' : { + entryTypeData.name = 'active power'; + entryTypeData.deviceClass = 'power'; + entryTypeData.unitOfMeasurement = 'W'; + break; + } + + case 'batteryLevel' : { + entryTypeData.name = 'battery level'; + entryTypeData.deviceClass = 'battery'; + entryTypeData.unitOfMeasurement = '%'; + break; + } + + case 'batteryVoltage' : { + entryTypeData.name = 'battery voltage'; + entryTypeData.deviceClass = 'voltage'; + entryTypeData.unitOfMeasurement = 'V'; + break; + } + + case 'buttonState' : { + entityType = 'binary_sensor'; + entryTypeData.name = 'button'; + break; + } + + case 'contactState' : { + entityType = 'binary_sensor'; + entryTypeData.name = 'door'; + entryTypeData.deviceClass = 'door'; + break; + } + + case 'motionState' : { + entityType = 'binary_sensor'; + entryTypeData.name = 'motion'; + entryTypeData.deviceClass = 'motion'; + break; + } + + case 'motionValue' : { + entryTypeData.name = 'motion value'; + entryTypeData.icon = 'mdi:motion-sensor'; + entryTypeData.unitOfMeasurement = '%'; + break; + } + + case 'rssi' : { + entryTypeData.name = entryType.toUpperCase(); + entryTypeData.deviceClass = 'signal_strength'; + entryTypeData.unitOfMeasurement = 'dBm'; + break; + } + + case 'switchState' : { + entityType = 'switch'; + entryTypeData.name = 'switch'; + entryTypeData.icon = 'mdi:power-socket-us'; + break; + } + + case 'temperature' : { + entryTypeData.name = entryType; + entryTypeData.deviceClass = entryType; + entryTypeData.unitOfMeasurement = '°C'; + break; + } + } // switch (entryType) + + + const deviceRegistryEntry = { + unique_id : `${remote64}-${entryTypeData.uniqueId.deviceName}-${entryTypeData.uniqueId.nodeName}`, + name : `${deviceStatus.name} ${entryTypeData.name}`, + + availability_topic : `tele/${remote64}/LWT`, + state_topic : `stat/${remote64}/${entryType}`, + + icon : entryTypeData.icon, + + unit_of_measurement : entryTypeData.unitOfMeasurement, + + device_class : entryTypeData.deviceClass, + state_class : 'measurement', + + device : { + identifiers : remote64, + // connections : [ + // [ 'remote64', status.xbee.self.addr64 ], + // ], + + manufacturer : deviceStatus.make, + model : deviceStatus.model, + name : deviceStatus.name, + sw_version : deviceStatus.buildDate, + }, + }; + + if (entryType === 'switchState') { + deviceRegistryEntry.command_topic = `cmnd/${remote64}/${entryType}`; + } + + + if (deviceRegistryEntry.name.includes('button button')) { + deviceRegistryEntry.name = deviceRegistryEntry.name.replace('button button', 'button'); + } + + if (deviceRegistryEntry.name.includes('motion sensor motion')) { + deviceRegistryEntry.name = deviceRegistryEntry.name.replace('motion sensor motion', 'motion'); + } + + if (deviceRegistryEntry.name.includes('door sensor door')) { + deviceRegistryEntry.name = deviceRegistryEntry.name.replace('door sensor door', 'door'); + } + + if (deviceRegistryEntry.device_class === '') delete deviceRegistryEntry.device_class; + if (deviceRegistryEntry.icon === '') delete deviceRegistryEntry.icon; + if (deviceRegistryEntry.unit_of_measurement === '') delete deviceRegistryEntry.unit_of_measurement; + + if (entityType !== 'sensor') delete deviceRegistryEntry.state_class; + + mqtt.pub(`homeassistant/${entityType}/${deviceRegistryEntry.unique_id}/config`, deviceRegistryEntry, true); + + return deviceRegistryEntry; +} // generateDeviceRegistryEntry(remote64, entryType) + +async function updateDeviceRegistry() { + for await (const remote64 of Object.keys(status.xbee.nodes64)) { + const device = status.xbee.nodes64[remote64]; + + generateDeviceRegistryEntry(remote64, 'rssi'); + + switch (device.model) { + case 'Button Device' : + case 'Contact Sensor Device' : + case 'PIR Device' : { + generateDeviceRegistryEntry(remote64, 'batteryLevel'); + generateDeviceRegistryEntry(remote64, 'batteryVoltage'); + generateDeviceRegistryEntry(remote64, 'temperature'); + } + } + + switch (device.model) { + case 'Button Device' : { + generateDeviceRegistryEntry(remote64, 'buttonState'); + break; + } + + case 'Contact Sensor Device' : { + generateDeviceRegistryEntry(remote64, 'contactState'); + break; + } + + case 'KeyPad Device' : { + break; + } + + case 'Keyfob Device' : { + break; + } + + case 'PIR Device' : { + generateDeviceRegistryEntry(remote64, 'motionValue'); + generateDeviceRegistryEntry(remote64, 'motionState'); + break; + } + + case 'SmartPlug2.5' : { + generateDeviceRegistryEntry(remote64, 'activePower'); + generateDeviceRegistryEntry(remote64, 'switchState'); + break; + } + } + } // for await (const remote64 of Object.keys(status.xbee.nodes64)) +} // async updateDeviceRegistry() + + +async function init() { + log.lib('Initializing'); + + try { + log.lib('Connecting to Home Assistant'); + homeAssistant = new (require('homeassistant'))(config.homeassistant); + log.lib('Connected to Home Assistant'); + } + catch (homeAssistantConnectError) { + log.error('init() homeAssistantConnectError', homeAssistantConnectError); + return false; + } + + log.lib('Initialized'); +} // async init() + + +async function term() { + log.lib('Terminating'); + + log.lib('Terminated'); +} // async term() + + +module.exports = { + fireKeypadEvent, + getHassData, + getState, + + generateDeviceRegistryEntry, + updateDeviceRegistry, + + // Start/stop functions + init, + term, +}; diff --git a/hex.js b/hex.js new file mode 100644 index 0000000..bf7fedf --- /dev/null +++ b/hex.js @@ -0,0 +1,63 @@ +// ASCII to hex for IKE/MID message +function a2h(data) { + const array = []; + + for (let n = 0, l = data.length; n < l; n++) { + array.push(data.charCodeAt(n)); + } + + return array; +} + +// Convert hex to ASCII +function h2a(data) { + data = data.toString(); + let str = ''; + + for (let i = 0; i < data.length; i += 2) { str += String.fromCharCode(parseInt(data.substr(i, 2), 16)); } + + return str; +} + +// Convert hex to string +function h2s(data) { + data = Buffer.from(data); + + // IKE text, BMBT/MID/GT menu text + if (data[0] === 0x21) data = data.slice(4); // MID menu + if (data[0] === 0x23) data = data.slice(4); + if (data[0] === 0x24) data = data.slice(3); + + // IKE text suffix + if (data[data.length - 1] === 0x04) data = data.slice(0, -1); + + // Format + data = data.toString(); + data = data.replace(/�/g, '°'); + data = data.replace(/ {2}/g, ' '); + + data = data.trim(); + return data; +} + +// Convert integer to hex string +// Useful for CANBUS ARBIDs, etc +// +// hex.i2s(191) => '0xBF' +function i2s(data, prefix = true, length = 2) { + if (typeof data === 'undefined' || data === null || data === '') return false; + + let hexstr = data.toString(16).toUpperCase().padStart(length, '0'); + if (prefix === true) hexstr = '0x' + hexstr; + return hexstr; +} + + +module.exports = { + // Functions + a2h : (data) => a2h(data), + h2a : (data) => h2a(data), + h2s : (data) => h2s(data), + + i2s : (data, prefix, length) => i2s(data, prefix, length), +}; diff --git a/index.js b/index.js new file mode 100644 index 0000000..506fb31 --- /dev/null +++ b/index.js @@ -0,0 +1,101 @@ +/* eslint no-console : 0 */ +/* eslint no-global-assign : 0 */ + +process.title = 'hgbg-mqtt'; + +terminating = false; + +if (typeof process.env.NODE_ENV !== 'undefined' && process.env.NODE_ENV !== null && process.env.NODE_ENV !== '') { + appEnv = process.env.NODE_ENV; +} +else { + appEnv = 'development'; +} + + +// hgbg libraries +api = require('./api'); +bitmask = require('./bitmask'); +hass = require('./hass'); +hex = require('./hex'); +json = require('./json'); +log = require('./log-output'); +mqtt = require('./mqtt'); +num = require('./num'); +xbee = require('./xbee'); + +update = new (require('./update'))(); + + +// Global init +async function init() { + log.msg('Initializing'); + + // console.dir(process.argv, { depth : null, showHidden : true }); + + // Configure term event listeners + process.on('SIGTERM', async () => { + console.log(''); + log.msg('Caught SIGTERM :: terminating = ' + terminating); + + if (terminating === true) return; + await term(); + }); + + process.on('SIGINT', async () => { + console.log(''); + log.msg('Caught SIGINT :: terminating = ' + terminating); + + if (terminating === true) return; + await term(); + }); + + + // Read JSON config and status files + await json.read(); + + // Start Home Assistant interface + await hass.init(); + + // Start MQTT client + await mqtt.init(); + + // Start XBee interface + await xbee.init(); + + // Start Express API server + await api.init(); + + // Update Home Assistant device registry + // hass.updateDeviceRegistry(); + + log.msg('Initialized'); +} // async init() + +// Global term +async function term() { + terminating = true; + + log.msg('Terminating'); + + // Terminate Express API server + await api.term(); + + // Terminate XBee interface + await xbee.term(); + + // Terminate MQTT client + await mqtt.term(); + + // Terminate Home Assistant interface + await hass.term(); + + // Write JSON config and status files, and clear save interval + await json.write('term'); + + log.msg('Terminated'); +} // async term() + + +// FASTEN SEATBELTS +(async () => { await init(); })(); diff --git a/json.js b/json.js new file mode 100644 index 0000000..d71fc43 --- /dev/null +++ b/json.js @@ -0,0 +1,132 @@ +const write_options = { spaces : '\t' }; + +const defaults = require('defaults-deep'); +const jsonfile = require('jsonfile'); + +const file_config = './config.json'; +const file_status = './status.json'; + +const config_default = require('./config-default'); +const status_default = require('./status-default'); + +let saveInterval; + + +// Read config+status +async function read(skipInterval = false) { + // Read JSON config+status files + await readConfig(); + await readStatus(); + + if (skipInterval === true) return; + + log.lib('Set 5 minute data save interval'); + saveInterval = setInterval(write, (5 * 60 * 1000)); +} + +// Write config+status +async function write(param = null) { + // Write JSON config+status files + await writeConfig(); + await writeStatus(); + + if (param === 'term') clearInterval(saveInterval); +} + + +// Read config JSON +async function readConfig() { + let config_data; + + try { + config_data = await jsonfile.readFileSync(file_config); + } + catch (error) { + log.lib('Failed reading config, applying default config'); + // log.error(error); + + config = config_default; + await writeConfig(); + return false; + } + + // Lay the default values on top of the read object, in case new values were added + config = await defaults(config_data, config_default); + + log.lib('Read config'); +} + +// Read status JSON +async function readStatus() { + let status_data; + + try { + status_data = await jsonfile.readFileSync(file_status); + } + catch (error) { + log.lib('Failed reading status, applying default status'); + // log.error(error); + + status = status_default; + await writeStatus(); + return false; + } + + // Lay the default values on top of the read object, in case new values were added + status = await defaults(status_data, status_default); + + log.lib('Read status'); +} + + +// Write config JSON +async function writeConfig() { + // Don't write if empty + if (typeof config.mqtt === 'undefined') { + log.lib('Failed writing config, config object empty'); + return; + } + + try { + await jsonfile.writeFileSync(file_config, config, write_options); + } + catch (error) { + log.lib('Failed writing config'); + log.error(error); + return false; + } + + log.lib('Wrote config'); +} + +// Write status JSON +async function writeStatus() { + // Don't write if empty + if (typeof status.xbee === 'undefined') { + log.lib('Failed writing status, status object empty'); + return; + } + + try { + await jsonfile.writeFileSync(file_status, status, write_options); + } + catch (error) { + log.lib('Failed writing status'); + log.error(error); + return false; + } + + log.lib('Wrote status'); +} + + +module.exports = { + readConfig, + writeConfig, + + readStatus, + writeStatus, + + read, + write, +}; diff --git a/log-output.js b/log-output.js new file mode 100644 index 0000000..ea431d3 --- /dev/null +++ b/log-output.js @@ -0,0 +1,243 @@ +/* eslint no-console: 0 */ + + +const align = require('multipad'); +const caller = require('callers-path'); +const path = require('path'); +const trucolor = require('trucolor'); + + +// 24bit color chalk-style palette +const chalk = (0, trucolor.chalkish)((0, trucolor.palette)({}, { + black : 'rgb:48,48,48', + blue : 'rgb:51,152,219', + cyan : 'rgb:0,200,200', + green : 'rgb:47,223,100', + gray : 'rgb:144,144,144', + orange : 'rgb:255,153,50', + pink : 'rgb:178,0,140', + purple : 'rgb:114,83,178', + red : 'rgb:231,76,60', + white : 'rgb:224,224,224', + yellow : 'rgb:255,204,50', + lightgray : 'rgb:175,175,175', + + boldblack : 'bold rgb:48,48,48', + boldblue : 'bold rgb:51,152,219', + boldcyan : 'bold rgb:0,200,200', + boldgreen : 'bold rgb:47,223,100', + boldgray : 'bold rgb:144,144,144', + boldorange : 'bold rgb:255,153,50', + boldpink : 'bold rgb:178,0,140', + boldpurple : 'bold rgb:114,83,178', + boldred : 'bold rgb:231,76,60', + boldwhite : 'bold rgb:224,224,224', + boldyellow : 'bold rgb:255,204,50', + + italicblack : 'italic rgb:48,48,48', + italicblue : 'italic rgb:51,152,219', + italiccyan : 'italic rgb:0,200,200', + italicgreen : 'italic rgb:47,223,100', + italicgray : 'italic rgb:144,144,144', + italicorange : 'italic rgb:255,153,50', + italicpink : 'italic rgb:178,0,140', + italicpurple : 'italic rgb:114,83,178', + italicred : 'italic rgb:231,76,60', + italicwhite : 'italic rgb:224,224,224', + italicyellow : 'italic rgb:255,204,50', +})); + +const padding = { + src : 6, + topic : 9, +}; + + +function center(string, width) { + return align.center(string, width, ' '); +} + +// Colorize data source string by name +function colorizeSource(sourceName) { + switch (sourceName.trim()) { + case 'api' : sourceName = chalk.green(sourceName); break; + case 'index' : sourceName = chalk.cyan(sourceName); break; + case 'mqtt' : sourceName = chalk.purple(sourceName); break; + case 'xbee' : sourceName = chalk.orange(sourceName); break; + default : sourceName = chalk.yellow(sourceName); + } + + return sourceName; +} + +function colorize(string) { + string = string.toString(); + + string = string.replace('Attempting', chalk.yellow('Attempting')); + string = string.replace('Connecting', chalk.yellow('Connecting')); + string = string.replace('Initializing', chalk.yellow('Initializing')); + string = string.replace('Reset', chalk.yellow('Reset')); + string = string.replace('Shutting down', chalk.yellow('Shutting down')); + string = string.replace('Starting', chalk.yellow('Starting')); + string = string.replace('Stopping', chalk.yellow('Stopping')); + string = string.replace('Terminating', chalk.yellow('Terminating')); + + string = string.replace('Disconnected', chalk.red('Disconnected')); + string = string.replace('Error', chalk.red('Error')); + string = string.replace('SIGINT', chalk.red('SIGINT')); + string = string.replace('SIGTERM', chalk.red('SIGTERM')); + string = string.replace('Shut down', chalk.red('Shut down')); + string = string.replace('Stopped', chalk.red('Stopped')); + string = string.replace('Terminated', chalk.red('Terminated')); + string = string.replace('Unset', chalk.red('Unset')); + string = string.replace(' closed', chalk.red(' closed')); + string = string.replace(' disconnected', chalk.red(' disconnected')); + string = string.replace('error', chalk.red('error')); + string = string.replace('false', chalk.red('false')); + + string = string.replace('Connected ', chalk.green('Connected ')); + string = string.replace('Reconnected ', chalk.green('Reconnected ')); + string = string.replace('reconnected ', chalk.green('reconnected ')); + string = string.replace('Initialized', chalk.green('Initialized')); + string = string.replace('Listening ', chalk.green('Listening ')); + string = string.replace('Loaded ', chalk.green('Loaded ')); + string = string.replace('Read ', chalk.green('Read ')); + string = string.replace('Set ', chalk.green('Set ')); + string = string.replace('Started', chalk.green('Started')); + string = string.replace('Wrote', chalk.green('Wrote')); + string = string.replace(' connected', chalk.green(' connected')); + string = string.replace(' opened', chalk.green(' opened')); + string = string.replace('true', chalk.green('true')); + + return string; +} + + +// Formatted output for when a value changes +function change(data) { + let dataNew = data.valueNew; + let dataSrc = path.parse(caller()).name; + let dataTopic = 'CHANGE'; + let dataValue = data.keyFull; + + // Pad strings + dataSrc = center(dataSrc, padding.src); + dataTopic = center(dataTopic, padding.topic); + + // Catch nulls + if (typeof data.valueNew === 'undefined' || data.valueNew === null) data.valueNew = 'null'; + + // Colorize strings + dataSrc = chalk.blue(dataSrc); + dataTopic = chalk.cyan(dataTopic); + dataValue = chalk.boldblue(dataValue); + + // Replace and colorize true/false + let dataNewFormat; + switch (typeof dataNew) { + case 'boolean' : { + dataNew = dataNew.toString().replace('true', chalk.green('true')).replace('false', chalk.red('false')); + dataNewFormat = '%s'; + break; + } + + case 'string' : { + dataNew = chalk.cyan(dataNew); + dataNewFormat = '\'%s\''; + break; + } + + default : { + dataNewFormat = '%o'; + } + } + + // Output formatted string + console.log('[%s] [%s] %s: ' + dataNewFormat, dataSrc, dataTopic, dataValue, dataNew); +} // change(data) + +function error(dataMsg, ...rest) { + let dataSrc = path.parse(caller()).name; + let dataTopic = 'ERROR'; + + // Pad strings + dataSrc = center(dataSrc, padding.src); + dataTopic = center(dataTopic, padding.topic); + + // Colorize strings + dataSrc = chalk.red(dataSrc); + dataTopic = chalk.red(dataTopic); + + // Only colorize log message if it is a string + let dataMsgFormat = '%o'; + if (typeof dataMsg === 'string') { + dataMsgFormat = '%s'; + } + + // Output formatted string + console.log('[%s] [%s] ' + dataMsgFormat, dataSrc, dataTopic, dataMsg, ...rest); +} // error(data) + +function lib(dataMsg, ...rest) { + if (appEnv === 'production') return; + + let dataSrc = path.parse(caller()).name; + let dataTopic = 'LIBRARY'; + + // Pad strings + dataSrc = center(dataSrc, padding.src); + dataTopic = center(dataTopic, padding.topic); + + // Colorize strings + dataSrc = colorizeSource(dataSrc); + dataTopic = chalk.gray(dataTopic); + + + // Only colorize log message if it is a string + let dataMsgFormat = '%o'; + if (typeof dataMsg === 'string') { + dataMsg = chalk.lightgray(dataMsg); + dataMsg = colorize(dataMsg); + dataMsgFormat = '%s'; + } + + // Output formatted string + console.log('[%s] [%s] ' + dataMsgFormat, dataSrc, dataTopic, dataMsg, ...rest); +} // lib(data) + +function msg(dataMsg, ...rest) { + if (appEnv === 'production') return; + + let dataSrc = path.parse(caller()).name; + let dataTopic = 'MESSAGE'; + + // Pad strings + dataSrc = center(dataSrc, padding.src); + dataTopic = center(dataTopic, padding.topic); + + // Colorize strings + dataSrc = colorizeSource(dataSrc); + dataTopic = chalk.pink(dataTopic); + + // Only colorize log message if it is a string + let dataMsgFormat = '%o'; + if (typeof dataMsg === 'string') { + dataMsg = chalk.italicpink(dataMsg); + dataMsg = colorize(dataMsg); + dataMsgFormat = '%s'; + } + + // Output formatted string + console.log('[%s] [%s] ' + dataMsgFormat, dataSrc, dataTopic, dataMsg, ...rest); +} // msg(data) + + +module.exports = { + // 24bit color chalk-style palette + chalk, + + change, + error, + lib, + msg, +}; diff --git a/mqtt.js b/mqtt.js new file mode 100644 index 0000000..61e7a38 --- /dev/null +++ b/mqtt.js @@ -0,0 +1,218 @@ +/* eslint no-console : 0 */ + +const mqtt = require('async-mqtt'); + +let client; + + +// Data sender +async function pub(topic, data = null, retain = false) { + // We're bout to get outta here + if (terminating === true) { + log.lib(`[PUB ] Topic: '${topic}', terminating = true, bailing`); + return; + } + + // Client not here yet - wait a sec + if (typeof client === 'undefined' || client === null || typeof client.connected === 'undefined' || client.connected !== true) { + log.lib(`[PUB ] Topic: '${topic}', not connected to server yet, waiting 5s and trying again`); + + return setTimeout(async () => { + await pub(topic, data, retain); + }, 5000); + } + + // if (topic.includes('homeassistant')) { + // log.lib('[PUB ]', { topic, data }); + // } + + // Format data blob + switch (typeof data) { + case 'boolean' : { + switch (Number(data)) { + case 0 : data = 'OFF'; break; + case 1 : data = 'ON'; break; + default : data = 'UNKNOWN'; + } + break; + } + + case 'number' : data = data.toString(); break; + case 'object' : data = JSON.stringify(data); break; + } + + const publishOptions = { + dup : false, + qos : 0, + retain, + }; + + // Turns out, this is kind of spammy + // log.msg('pub()', { topic, data, retain }); + try { + // Publish message + await client.publish(topic, data, publishOptions); + } + catch (clientPublishError) { + log.error('pub(): clientPublishError', clientPublishError); + log.error('pub(): clientPublishArgs', { topic, data, publishOptions }); + } +} // async pub(topic, data = null, retain = false) + + +function router(topic, message) { + if (topic === 'homeassistant/status') { + return hass.updateDeviceRegistry(); + } + + message = message.toString(); + + const topicData = topic.split('/'); + + // Bounce if invalid message + if (typeof topicData[0] === 'undefined') return; + if (typeof topicData[1] === 'undefined') return; + if (typeof topicData[2] === 'undefined') return; + + // log.lib('router()', { topic, message }); + + switch (topicData[0]) { + case 'cmnd' : return processCmnd(topic, message); + case 'stat' : return processStat(topic, message); + } +} // router(topic, message) + + +// 'cmnd' data handler +async function processCmnd(topic, message) { + message = message.toString(); + + const topicData = topic.split('/'); + + // Bounce if invalid message + if (typeof topicData[0] === 'undefined') return; + if (typeof topicData[1] === 'undefined') return; + if (typeof topicData[2] === 'undefined') return; + + log.lib('processCmnd()', { topic, message }); + + switch (topicData[2]) { + case 'switchState' : { + // await xbee.sendMessage('switchStateRequest', { switchState : 'check' }, topicData[1]); + await xbee.sendMessage('switchStateRequest', { switchState : message }, topicData[1]); + break; + } + } +} // async processCmnd(topic, message) + +// 'stat' data handler +function processStat(topic, message) { + message = message.toString(); + + const topicData = topic.split('/'); + + // Bounce if invalid message + if (typeof topicData[0] === 'undefined') return; + if (typeof topicData[1] === 'undefined') return; + if (typeof topicData[2] === 'undefined') return; + + log.lib('[STAT] topic: \'' + topic + '\', message: ' + message); +} // processStat(topic, message) + + +async function init() { + log.lib('Initializing'); + + try { + client = await mqtt.connectAsync('mqtt://' + config.mqtt.server, { + clientId : config.mqtt.clientId, + }); + + mqtt.clientConnected = true; + } + catch (clientConnectError) { + log.error('init(): clientConnectError', clientConnectError); + process.exit(1); + } + + + log.lib('Connected to server'); + // cmnd - prefix to issue commands; ask for status + // stat - reports back status or configuration message + + + client.on('connect', () => { + log.lib('event: \'connect\''); + }); + + client.on('reconnect', () => { + log.lib('event: \'reconnect\''); + }); + + client.on('close', () => { + log.lib('event: \'close\''); + }); + + client.on('message', router); + + + // Subscribe to 'cmnd' messages + await client.subscribe('cmnd/+/#'); + await client.subscribe('homeassistant/status'); + + + // action_topic : "stat/tstat-02/ACTION" + // + // aux_command_topic : "cmnd/tstat-02/AUX" + // aux_state_topic : "stat/tstat-02/AUX" + // + // availability_topic : "tele/tstat-02/LWT" + // + // current_temperature_topic : "stat/tstat-02/UPSTAIRS" + // + // fan_mode_command_topic : "cmnd/tstat-02/FAN" + // fan_mode_state_topic : "stat/tstat-02/FAN" + // + // mode_command_topic : "cmnd/tstat-02/MODE" + // mode_state_topic : "stat/tstat-02/MODE" + // + // temperature_command_topic : "cmnd/tstat-02/TARGET" + // temperature_state_topic : "stat/tstat-02/TARGET" + + + await pub('tele/' + config.mqtt.clientId + '/LWT', 'online', true); + + log.lib('Initialized'); +} // async init() + +async function term() { + log.lib('Terminating'); + + await pub('tele/' + config.mqtt.clientId + '/LWT', 'offline', true); + + if (typeof client !== 'undefined') { + if (typeof client.end === 'function') { + try { + log.lib('Ending client'); + await client.end(); + + mqtt.clientConnected = false; + log.lib('Ended client'); + } + catch (clientEndError) { + log.error('term(): clientEndError', clientEndError); + } + } + } + + log.lib('Terminated'); +} // async term() + + +module.exports = { + clientConnected : false, + + init, + term, + pub, +}; diff --git a/num.js b/num.js new file mode 100644 index 0000000..74a5349 --- /dev/null +++ b/num.js @@ -0,0 +1,33 @@ +// Math.ceil to specified decimal places (2nd argument, default 2) +function ceil2(value, places = 2) { + const multiplier = Number((1).toString().padEnd((places + 1), 0)); + return Math.ceil(value * multiplier + Number.EPSILON) / multiplier; +} + +// Math.floor to specified decimal places (2nd argument, default 2) +function floor2(value, places = 2) { + const multiplier = Number((1).toString().padEnd((places + 1), 0)); + return Math.floor(value * multiplier + Number.EPSILON) / multiplier; +} + +// Math.round to specified decimal places (2nd argument, default 2) +function round2(value, places = 2) { + const multiplier = Number((1).toString().padEnd((places + 1), 0)); + return Math.round(value * multiplier + Number.EPSILON) / multiplier; +} + +// For use with ADC sensor stats (5V sensors) +function ok2minmax(value) { + if (value < 0.9) return false; + if (value > 4.6) return false; + return true; +} + + +module.exports = { + ceil2 : (value, places = 2) => ceil2(value, places), + floor2 : (value, places = 2) => floor2(value, places), + round2 : (value, places = 2) => round2(value, places), + + ok2minmax : (value) => ok2minmax(value), +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d91e26 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "author": "kdm ", + "bugs": { + "url": "https://github.com/kmalinich/iris2mqtt/issues" + }, + "dependencies": { + "async-mqtt": "^2.6.1", + "body-parser": "^1.19.0", + "callers-path": "^1.0.5", + "defaults-deep": "^0.2.4", + "express": "^4.17.1", + "homeassistant": "^0.2.0", + "jsonfile": "^6.1.0", + "multipad": "^1.1.1", + "object-path": "^0.11.8", + "rfdc": "^1.3.0", + "serialport": "^9.2.5", + "trucolor": "^2.0.4", + "xbee-api": "^0.6.0" + }, + "description": "Lowe's Iris Gen1 to MQTT/Home Assistant adapter application", + "devDependencies": { + "eslint": "^8.1.0", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-standard": "^4.1.0" + }, + "homepage": "https://github.com/kmalinich/iris2mqtt#readme", + "keywords": [ + "hgbg", + "mqtt" + ], + "license": "UNLICENSED", + "main": "index.js", + "name": "iris2mqtt", + "optionalDependencies": {}, + "repository": { + "type": "git", + "url": "git+https://github.com/kmalinich/iris2mqtt.git" + }, + "version": "0.0.2" +} diff --git a/pkg/systemd/iris2mqtt.service b/pkg/systemd/iris2mqtt.service new file mode 100644 index 0000000..7133584 --- /dev/null +++ b/pkg/systemd/iris2mqtt.service @@ -0,0 +1,35 @@ +[Unit] +Description = Lowe's Iris Gen1 to MQTT/Home Assistant adapter application + +After = homeassistant.service mosquitto.service +Wants = homeassistant.service mosquitto.service + +StartLimitIntervalSec = 10s +StartLimitBurst = 10 + + +[Service] +SyslogIdentifier = iris2mqtt + +Type = simple + +User = iot +Group = iot + +WorkingDirectory = /usr/local/lib/iris2mqtt + +Environment = NODE_ENV=production + +ExecStart = /usr/bin/env node --title=iris2mqtt --throw-deprecation --trace-uncaught --trace-deprecation --trace-warnings /usr/local/lib/iris2mqtt/index.js + +TimeoutStopSec = 30 + +Restart = on-failure +RestartSec = 3 + + +[Install] +WantedBy = multi-user.target + + +# vim: set filetype=systemd ts=2 sw=2 tw=0 noet : diff --git a/pkg/udev/99-usb-xbee.rules b/pkg/udev/99-usb-xbee.rules new file mode 100644 index 0000000..e108fc7 --- /dev/null +++ b/pkg/udev/99-usb-xbee.rules @@ -0,0 +1 @@ +SUBSYSTEMS=="usb", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6015", GROUP="homeassistant", MODE="0660", SYMLINK+="xbee" diff --git a/status-default.js b/status-default.js new file mode 100644 index 0000000..f7845b4 --- /dev/null +++ b/status-default.js @@ -0,0 +1,14 @@ +const status_object = { + xbee : { + nodes64 : { + }, + + self : { + addr16 : null, + addr64 : null, + }, + }, +}; + + +module.exports = status_object; diff --git a/update.js b/update.js new file mode 100644 index 0000000..cc76e66 --- /dev/null +++ b/update.js @@ -0,0 +1,44 @@ +const object_path = require('object-path'); + +// WIP +// const statusTransform = { +// engine : { +// rpm : (input) => { +// return Math.round(input); +// }, +// }, +// }; + + +class update { + // update.config('system.host_data.refresh_interval', 15000, false); + config(key, valueNew, quiet) { + const valueOld = object_path.get(config, key); + + if (valueNew === valueOld) return false; + + const keyFull = 'config.' + key; + if (quiet !== true) log.change({ keyFull, valueNew }); + + object_path.set(config, key, valueNew); + + return true; + } + + // update.status('engine.rpm', 1235, false); + status(key, valueNew, quiet = false) { + const valueOld = object_path.get(status, key); + + if (valueNew === valueOld) return false; + + object_path.set(status, key, valueNew); + + const keyFull = 'status.' + key; + if (quiet !== true) log.change({ keyFull, valueNew }); + + return true; + } +} + + +module.exports = update; diff --git a/xbee.js b/xbee.js new file mode 100644 index 0000000..6021134 --- /dev/null +++ b/xbee.js @@ -0,0 +1,2021 @@ +const SerialPort = require('serialport'); +const xbee_api = require('xbee-api'); +const clone = require('rfdc')(); + +const motionTimeoutSec = 180; + +const batteryMinVoltage = 2.0; +const batteryMaxVoltage = 3.28; + + +let serialport; +let xbeeAPI; + +const addr64List = [ null, null ]; + + +// Zigbee addressing +// const BROADCAST_LONG = [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF ]; +// const BROADCAST_SHORT = [ 0xFF, 0xFE ]; + + +// Zigbee profile IDs +const PROFILE_ID_ALERTME = 'c216'; // AlertMe device profile +const PROFILE_ID_HA = '0104'; // HA device profile +const PROFILE_ID_LL = 'c05e'; // Light link Profile +const PROFILE_ID_ZDP = '0000'; // Zigbee device profile + + +// Zigbee endpoints +const ENDPOINT_ALERTME = '02'; // AlertMe/Iris endpoint +const ENDPOINT_ZDO = '00'; // Zigbee device objects endpoint + + +const endpointList = [ ENDPOINT_ZDO, ENDPOINT_ALERTME ]; + + +// ZDP status +const ZDP_STATUS = { + OK : 0x00, + INVALID : 0x80, + NOT_FOUND : 0x81, +}; + + +// Cluster IDs +const CLUSTER_ID = { + // AlertMe cluster IDs + // http://www.desert-home.com/2015/06/hacking-into-iris-door-sensor-part-4.html + AM : { + ATTRIBUTE : '00c0', // Attribute + BUTTON : '00f3', // Button/keyfob + DISCOVERY : '00f6', // Device discovery + POWER : '00ef', // Power information + SECURITY : '0500', // Security + STATUS : '00f0', // Device status + SWITCH : '00ee', // SmartPlug switch + TAMPER : '00f2', // Device tamper + }, // AM + + // ZDO cluster IDs + // http://ftp1.digi.com/support/images/APP_NOTE_XBee_Zigbee_Device_Profile.pdf + ZDO : { + ACTIVE_ENDPOINT : { + REQUEST : '0005', // Active endpoint request + RESPONSE : '8005', // Active endpoint response + }, + + END_DEVICE_ANNOUNCE : '0013', // End device announce + + MANAGEMENT_ROUTING : { + REQUEST : '0032', // Management routing request + RESPONSE : '8032', // Management routing response + }, + + MATCH_DESCRIPTOR : { + REQUEST : '0006', // Match descriptor request + RESPONSE : '8006', // Match descriptor response + }, + + NETWORK_ADDRESS : { + REQUEST : '0000', // Network address (16-bit) request + RESPONSE : '8000', // Network address (16-bit) response + }, + + PERMIT_JOINING : { + REQUEST : '0036', // Permit joining request + RESPONSE : '8036', // Permit joining response + }, + + SIMPLE_DESC_REQ : '0004', // Simple descriptor request + }, // ZDO +}; // CLUSTER_ID + +// Cluster commands +const CLUSTER_CMD = { + // AlertMe + AM : { + // Security IasZoneCluster commands cluster 0x500 = 1280 + SEC_ENROLL_REQ : '01', + SEC_STATUS_CHANGE : '00', // Security event (sensors) + + // AmGeneralCluster commands cluster [ 0x00, 0xF0 ] : 240 + LIFESIGN_CMD : 'fb', // LIFESIGN_CMD : 251 + RTC_CMD_REQ : '80', // REQUEST_RTC_CMD : 128 + SET_MODE_CMD : 'fa', // SET_MODE_CMD : 250 + SET_RTC_CMD : '00', // SET_RTC_CMD : 0 + STOP_POLLING_CMD : 'fd', // STOP_POLLING_CMD : 253 + + // AmPowerCtrlCluster commands cluster [ 0x00, 0xEE ] : 238 + STATE_REQ : '01', // CMD_SET_OPERATING_MODE : 1 // State request (SmartPlug) + STATE_CHANGE : '02', // CMD_SET_RELAY_STATE : 2 // State change request (SmartPlug) + STATE_REPORT_REQ : '03', // CMD_REQUEST_REPORT : 3 + STATE_RESP : '80', // CMD_STATUS_REPORT : 128 // switch status update + + // AmPowerMonCluster commands cluster [ 0x00, 0xEF ] : 239 + POWER_SET_REPT_PARAMS : '00', // CMD_SET_REPT_PARAMS : 0 + POWER_REQUEST_REPORT : '03', // CMD_REQUEST_REPORT : 3 + POWER_SET_REPORT_RATE : '04', // CMD_SET_REPORT_RATE : 4 + POWER_DEMAND : '81', // CMD_POWER_REPORT : 129 // Power demand update + POWER_CONSUMPTION : '82', // CMD_ENERGY_REPORT : 130 // Power consumption & uptime update + + PWD_BATCH_POWER_REPORT : '84', // CMD_BATCH_POWER_REPORT : 132 + PWD_BATCH_ENERGY_REPORT : '85', // CMD_BATCH_ENERGY_REPORT : 133 + PWD_POWER_ENERGY_REPORT : '86', // CMD_POWER_ENERGY_REPORT : 134 + PWD_BATCH_POWER_ENERGY_REPORT : '87', // CMD_BATCH_POWER_ENERGY_REPORT : 135 + + POWER_UNKNOWN : '86', // Unknown British Gas power meter update + + // AmMaintenanceCluster commands cluster [ 0x00, 0xF6 ] : 246 + MAINT_HELLO_REQ : 'fc', // HELLO_WORLD_REQ : 252 + MAINT_HELLO_RESP : 'fe', // HELLO_WORLD_RESP : 254 + + MAINT_RANGE_TEST_REQ : 'fd', // RANGE_TEST_SEND_CMD : 253 + MAINT_RANGE_TEST_RESP : 'fd', // RANGE_TEST_RECV_CMD : 253 + + MODE_REQ : 'fa', // Mode change request + STATUS : 'fb', // Status update + VERSION_REQ : 'fc', // Version information request + RSSI : 'fd', // RSSI range test update + VERSION_RESP : 'fe', // Version information response + }, // AM +}; // CLUSTER_CMD + + +// AlertMe device modes +const DEVICE_MODE = { + IDLE : 0x04, + LOCKED : 0x02, + NORMAL_OPS : 0x00, + OPT_CLEAR_HNF : 0x02, + OPT_NONE : 0x00, + OPT_SET_HNF : 0x01, + QUIESCENT : 0x05, + RANGE_TEST : 0x01, + SEEKING : 0x03, + SILENT : 0x03, + TEST : 0x02, +}; + + +// Utilized by generateMessage() +const messages = { + activeEndpointRequest : { + name : 'Active endpoint request', + frame : { + profileId : PROFILE_ID_ZDP, + clusterId : CLUSTER_ID.ZDO.ACTIVE_ENDPOINT.REQUEST, + sourceEndpoint : ENDPOINT_ZDO, + destinationEndpoint : ENDPOINT_ZDO, + dataGenerate : (params) => generateActiveEndpointRequest(params), + data : [ ], + }, + }, + + matchDescriptorRequest : { + name : 'Match descriptor request', + frame : { + profileId : PROFILE_ID_ZDP, + clusterId : CLUSTER_ID.ZDO.MATCH_DESCRIPTOR.REQUEST, + sourceEndpoint : ENDPOINT_ZDO, + destinationEndpoint : ENDPOINT_ZDO, + dataGenerate : (params) => generateMatchDescriptorRequest(params), + data : [ ], + }, + }, + + matchDescriptorResponse : { + name : 'Match descriptor response', + frame : { + profileId : PROFILE_ID_ZDP, + clusterId : CLUSTER_ID.ZDO.MATCH_DESCRIPTOR.RESPONSE, + sourceEndpoint : ENDPOINT_ZDO, + destinationEndpoint : ENDPOINT_ZDO, + dataGenerate : (params) => generateMatchDescriptorResponse(params), + data : [ ], + }, + }, + + + modeChangeRequest : { + name : 'Mode change request', + frame : { + profileId : PROFILE_ID_ALERTME, + clusterId : CLUSTER_ID.AM.STATUS, + sourceEndpoint : ENDPOINT_ALERTME, + destinationEndpoint : ENDPOINT_ALERTME, + dataGenerate : (params) => generateModeChangeRequest(params), + data : [ ], + }, + }, + + permitJoinRequest : { + name : 'Management permit join request', + frame : { + profileId : PROFILE_ID_ZDP, + clusterId : CLUSTER_ID.ZDO.PERMIT_JOINING.REQUEST, + sourceEndpoint : ENDPOINT_ZDO, + destinationEndpoint : ENDPOINT_ZDO, + data : [ 0xFF, 0x00 ], + }, + }, + + routingTableRequest : { + name : 'Management routing table request', + frame : { + profileId : PROFILE_ID_ZDP, + clusterId : CLUSTER_ID.ZDO.MANAGEMENT_ROUTING.REQUEST, + sourceEndpoint : ENDPOINT_ZDO, + destinationEndpoint : ENDPOINT_ZDO, + data : [ 0x12, 0x01 ], + }, + }, + + securityInit : { + name : 'Security initialization', + frame : { + profileId : PROFILE_ID_ALERTME, + clusterId : CLUSTER_ID.AM.SECURITY, + sourceEndpoint : ENDPOINT_ALERTME, + destinationEndpoint : ENDPOINT_ALERTME, + dataGenerate : () => generateSecurityInit(), + data : [ ], + }, + }, + + switchStateRequest : { + name : 'Switch state request', + frame : { + profileId : PROFILE_ID_ALERTME, + clusterId : CLUSTER_ID.AM.SWITCH, + sourceEndpoint : ENDPOINT_ZDO, + destinationEndpoint : ENDPOINT_ALERTME, + dataGenerate : (params) => generateSwitchStateRequest(params), + data : [ ], + }, + }, + + versionInfoRequest : { + name : 'Version info request', + frame : { + profileId : PROFILE_ID_ALERTME, + clusterId : CLUSTER_ID.AM.DISCOVERY, + sourceEndpoint : ENDPOINT_ZDO, + destinationEndpoint : ENDPOINT_ALERTME, + dataGenerate : (params) => generateVersionInfoRequest(params), + data : [ ], + }, + }, +}; // messages + + +// Issue AT commands to find addresses of connected XBee device +async function readAddresses() { + await sendATCommand('MY'); + await sendATCommand('SH'); + await sendATCommand('SL'); +} // async readAddresses() + + +function generateActiveEndpointRequest(params) { + // The active endpoint request needs the short address of the device in the payload + // + // It needs to be little endian (backwards) + // + // The first byte in the payload is simply a number to identify the message + // The response will have the same number in it + + // Field name Size Description + // ---------- ---- ----------- + // Sequence 1 Frame sequence + // Network address 2 16-bit address of a device in the network whose active endpoint list being requested + + // :param params: + // :return: Message data + + // Example: [ 0xAA, 0x9F, 0x88 ] + + // TODO + const netAddr = [ Buffer.from(params.remote16, 'hex')[0], Buffer.from(params.remote16, 'hex')[1] ]; + + const data = [ params.zdoSequenceNumber ].concat(netAddr); + + log.msg('generateActiveEndpointRequest()', { params, netAddr, data }); + + return data; +} // generateActiveEndpointRequest(params) + +function generateMatchDescriptorRequest(params) { + log.msg('generateMatchDescriptorRequest()', params); + + // params: + // - inClusters + // - outClusters + // - profileId + // - remote16 + // - zdoSequenceNumber + + // Broadcast or unicast transmission used to discover the device(s) that supports a specified profile ID and/or clusters + + // Field name Size Description + // ---------- ---- ----------- + // Sequence 1 Frame sequence + // Network address 2 16-bit address of a device in the network whose power descriptor is being requested + // Profile ID 2 Profile ID to be matched at the destination + // Number of input clusters 1 The number of input clusters in the in cluster list for matching. Set to 0 if no clusters supplied + // Input cluster list 2* List of input cluster IDs to be used for matching + // Number of output clusters 1 The number of output clusters in the output cluster list for matching. Set to 0 if no clusters supplied + // Output cluster list 2* List of output cluster IDs to be used for matching + // * Number of Input Clusters + + // Example: [ 0x01, 0xFD, 0xFF, 0x16, 0xC2, 0x00, 0x01, 0xF0, 0x00 ] + + // :param params: + // :return: Message data + const netAddr = [ Buffer.from(params.remote16, 'hex')[0], Buffer.from(params.remote16, 'hex')[1] ]; // 0xFD, 0xFF + const profileId = [ Buffer.from(params.profileId, 'hex')[1], Buffer.from(params.profileId, 'hex')[0] ]; // 0x16, 0xC2 PROFILE_ID_ALERTME (reversed) + + // TODO: Finish this off! At the moment this does not support multiple clusters, it just supports one! + + const data = [ params.zdoSequenceNumber ].concat(netAddr).concat(profileId).concat(params.inClusters.length).concat(params.inClusters).concat(params.outClusters.length).concat(params.outClusters[1]).concat(params.outClusters[0]); + return data; +} // generateMatchDescriptorRequest(params) + +function generateMatchDescriptorResponse(params) { + // If a descriptor match is found on the device, this response contains a list of endpoints that support the request criteria + // + // Field Name Size Description + // ---------- ---- ----------- + // Sequence 1 Frame sequence + // Status 1 Response status + // Network Address 2 Indicates the 16-bit address of the responding device + // Length 1 The number of endpoints on the remote device that match the request criteria + // Match List Variable List of endpoints on the remote that match the request criteria + // + // Example: [ 0x01, 0x00, 0x00, 0xE1, 0x02, 0x00, 0x02 ] + // + // :param params: + // :return: Message data + + // params: + // - remote16 + // - zdoSequenceNumber + + const responseStatus = ZDP_STATUS.OK; // 0x00 + + // TODO + const netAddr = [ Buffer.from(params.remote16, 'hex')[0], Buffer.from(params.remote16, 'hex')[1] ]; + const matchList = [ Buffer.from(endpointList[0], 'hex')[0], Buffer.from(endpointList[1], 'hex')[0] ]; + + // const data = [ params.zdoSequenceNumber ].concat(responseStatus).concat(netAddr).concat(matchList.length).concat(matchList); + const data = [ params.zdoSequenceNumber, responseStatus, 0x00, 0x00, 0x01, 0x02 ]; + + log.msg('generateMatchDescriptorResponse()', { + params, + responseStatus, + netAddr, + matchList, + data, + }); + + return data; +} // generateMatchDescriptorResponse(params) + + +function generateModeChangeRequest(params = null) { + // Available modes: + // idle + // locked + // normal + // rangeTest + // silent + + // Field name Size Description + // ---------- ---- ----------- + // Preamble 2 Unknown preamble TBC + // Cluster command 1 Cluster command - mode change request ([ 0xFA ]) + // Mode 2 Requested mode (1: Normal, 257: Range Test, 513: Locked, 769: Silent) + + // :param params: Object of requested mode + // :return: Message data + const preamble = [ 0x11, 0x00 ]; + // const preamble = [ 0x19, 0x41 ]; + const clusterCmd = Buffer.from(CLUSTER_CMD.AM.MODE_REQ, 'hex')[0]; // TODO: Flip this around so there's one Buffer.from() in parseMessage + + // Default normal if no mode + let mode = 'normal'; + let payload = DEVICE_MODE.NORMAL_OPS; + + if (typeof params === 'object' && typeof params.mode === 'string') { + mode = params.mode; + } + + switch (mode) { + case 'idle' : payload = DEVICE_MODE.IDLE; break; + case 'locked' : payload = DEVICE_MODE.LOCKED; break; + case 'normal' : payload = DEVICE_MODE.NORMAL_OPS; break; + case 'rangeTest' : payload = DEVICE_MODE.RANGE_TEST; break; + case 'silent' : payload = DEVICE_MODE.SILENT; break; + + default : { + log.error('generateModeChangeRequest(): invalid mode', mode); + return []; + } + } + + const data = preamble.concat(clusterCmd).concat([ payload, 0x01 ]); + + log.msg('generateModeChangeRequest()', { params, data }); + + return data; +} // generateModeChangeRequest(params) + +function generateSecurityInit() { + // Keeps security devices joined? + + // Field name Size Description + // ---------- ---- ----------- + // Preamble 2 Unknown preamble TBC ([ 0x11, 0x80 ]) + // Cluster command 1 Cluster command - Security event ([ 0x00 ]) + // Unknown 2 ??? ([ 0x00, 0x05 ]) + + // :param params: Object (none required) + // :return: Message data + const preamble = [ 0x11, 0x80 ]; + const clusterCmd = Buffer.from(CLUSTER_CMD.AM.SEC_STATUS_CHANGE, 'hex')[0]; // TODO: Flip this around so there's one Buffer.from() in parseMessage + const payload = [ 0x00, 0x05 ]; + + const data = preamble.concat(clusterCmd).concat(payload); + + log.msg('generateSecurityInit()', { data }); + + return data; +} // generateSecurityInit() + +function generateSwitchStateRequest(params = { switchState : 'check' }) { + log.msg('generateSwitchStateRequest()', { params }); + + // This message is sent FROM the Hub TO the SmartPlug requesting state change + + // Field name Size Description + // ---------- ---- ----------- + // Preamble 2 Unknown preamble TBC + // Cluster command 1 Cluster command - Change state (SmartPlug) ([ 0x01 ] / [ 0x02 ]) + // Requested relay state 2* [ 0x01 ] = Check Only, [ 0x01, 0x01 ] = On, [ 0x00, 0x01 ] = Off + // * Size = 1 if check only + + // :param params: Object of switch relay state + // :return: Message data + const preamble = [ 0x11, 0x00 ]; + + let clusterCmd; + let payload; + + if (typeof params.switchState === 'undefined') params.switchState = 'check'; + if (params.switchState === null) params.switchState = 'check'; + if (params.switchState === '') params.switchState = 'check'; + + switch (params.switchState.toString().toLowerCase()) { + case '1' : + case 'active' : + case 'activate' : + case 'on' : + case 'poweron' : + case 'switchon' : + case 'true' : + params.switchState = 1; + + clusterCmd = CLUSTER_CMD.AM.STATE_CHANGE; + payload = [ 0x01, 0x01 ]; // On + break; + + case '0' : + case 'inactive' : + case 'deactivate' : + case 'off' : + case 'poweroff' : + case 'switchoff' : + case 'false' : + params.switchState = 0; + + clusterCmd = CLUSTER_CMD.AM.STATE_CHANGE; + payload = [ 0x00, 0x01 ]; // Off + break; + + default : + // Check only + params.switchState = 'check'; + + clusterCmd = CLUSTER_CMD.AM.STATE_REQ; + payload = [ 0x01 ]; + } + + clusterCmd = Buffer.from(clusterCmd, 'hex')[0]; // TODO: Flip this around so there's one Buffer.from() in parseMessage + + const data = preamble.concat(clusterCmd).concat(payload); + + log.msg('generateSwitchStateRequest()', { data }); + + return data; +} // generateSwitchStateRequest(params) + +function generateVersionInfoRequest() { + // This message is sent FROM the Hub TO the SmartPlug requesting version information + + // Field name Size Description + // ---------- ---- ----------- + // Preamble 2 Unknown preamble TBC + // Cluster command 1 Cluster command - version information request ([ 0xFC ]) + + // :return: Message data + const preamble = [ 0x11, 0x00 ]; + const clusterCmd = Buffer.from(CLUSTER_CMD.AM.VERSION_REQ, 'hex')[0]; // TODO: Flip this around so there's one Buffer.from() in parseMessage + + const data = preamble.concat(clusterCmd); + return data; +} // generateVersionInfoRequest() + + +function parseActiveEndpointResponse(data, remote64, nodeName) { + const activeEndpointResponseState = {}; + + log.msg('parseActiveEndpointResponse()', { remote64, nodeName, data, activeEndpointResponseState, error : 'none' }); + + return activeEndpointResponseState; +} // parseActiveEndpointResponse(data, remote64, nodeName) + +function parseATCommandResponse(frame) { + switch (frame.command) { + case 'MY' : update.status('xbee.self.addr16', frame.commandData.toString('hex')); break; + + case 'SH' : addr64List[0] = frame.commandData.toString('hex'); break; + case 'SL' : addr64List[1] = frame.commandData.toString('hex'); break; + } // switch (frame.command) + + if (typeof addr64List[0] === 'string' && typeof addr64List[1] === 'string') { + update.status('xbee.self.addr64', addr64List[0] + addr64List[1]); + } +} // parseATCommandResponse(frame) + +// Messages labeled "attribute" in arcus, sent from Keypad device +function parseAttribute(data) { + // constants alertme.KeyPad { + // const u8 DEVICE_TYPE = 0x1C; + // + // const u8 ATTR_STATE = 0x20; + // const u8 ATTR_PIN = 0x21; + // const u8 ATTR_ACTION_KEY_PRESS = 0x22; + // const u8 ATTR_ACTION_KEY_RELEASE = 0x23; + // const u8 ATTR_HUB_POLL_RATE = 0x24; + // const u8 ATTR_SOUNDS_MASK = 0x25; + // const u8 ATTR_SOUND_ID = 0x26; + // const u8 ATTR_CUSTOM_SOUND = 0x27; + // const u8 ATTR_UNSUCCESSFUL_STATE_CHANGE = 0x27; + // + // const u8 KEYPAD_STATE_UNKNOWN = 0x00; + // const u8 KEYPAD_STATE_HOME = 0x01; + // const u8 KEYPAD_STATE_ARMED = 0x02; + // const u8 KEYPAD_STATE_NIGHT = 0x03; + // const u8 KEYPAD_STATE_PANIC = 0x04; + // const u8 KEYPAD_STATE_ARMING = 0x05; + // const u8 KEYPAD_STATE_ALARMING = 0x06; + // const u8 KEYPAD_STATE_NIGHT_ARMING = 0x07; + // const u8 KEYPAD_STATE_NIGHT_ALARMING = 0x08; + // + // const u8 KEYPAD_STATE_LOCKED_MASK = 0x80; + // + // const u8 ACTION_KEY_POUND = 0x23; // '#' + // const u8 ACTION_KEY_HOME = 0x48; // 'H' + // const u8 ACTION_KEY_AWAY = 0x41; // 'A' + // const u8 ACTION_KEY_NIGHT = 0x4E; // 'N' + // const u8 ACTION_KEY_PANIC = 0x50; // 'P' + // + // const u8 SOUND_CUSTOM = 0x00; + // const u8 SOUND_KEYCLICK = 0x01; + // const u8 SOUND_LOSTHUB = 0x02; + // const u8 SOUND_ARMING = 0x03; + // const u8 SOUND_ARMED = 0x04; + // const u8 SOUND_HOME = 0x05; + // const u8 SOUND_NIGHT = 0x06; + // const u8 SOUND_ALARM = 0x07; + // const u8 SOUND_PANIC = 0x08; + // const u8 SOUND_BADPIN = 0x09; + // const u8 SOUND_OPENDOOR = 0x0A; + // const u8 SOUND_LOCKED = 0x0B; + // } + + + // PIN length seems to be limited to 15 digits in hardware + // + // Examples from logs + // + // Periodic messages + // + // + // + + // Pressing 'OFF' button + // + // + // + + // Pressing 'ON' button + // + // + + // Pressing 'PARTIAL' button + // + // + + // Pressing 'PANIC' button + // + // + + + // Entering 5 4 3 2 1 in succession + // + // + // Entering 5 6 0 8 5 in succession + // + // + // Entering 1 2 3 1 2 3 in succession + // + // + // Entering 16 or more digits on keypad + // + // + // Entering 20 digits on keypad (entered 51340722145134072214) + // [ xbee ] [ MESSAGE ] parseAttribute() { + // data: , + // attributeData: { + // pinLength: 15, + // pinBuffer: , + // pinString: '072214513400000' + // } + // } + + + let attributeName = 'unknown'; + const attributeData = {}; + + switch (data[2]) { + case 0x0A : { + switch (data[3]) { + case 0x21 : { // ATTR_PIN + attributeName = 'pinEntry'; + + + let pinLength = data[6]; + if (pinLength > 15) pinLength = 15; + + const pinBuffer = data.slice(7, (7 + pinLength)); + const pinString = pinBuffer.toString(); + + // attributeData[attributeName] = {}; + // attributeData[attributeName].pinLength = pinLength; + // attributeData[attributeName].pinBuffer = pinBuffer.toJSON().data; + // attributeData[attributeName].pinString = pinString; + attributeData[attributeName] = pinString; + break; + } + + case 0x22 : { // ATTR_ACTION_KEY_PRESS + attributeName = 'actionKeyPress'; + + let keyName = 'unknown'; + switch (data[7]) { + case 0x2A : keyName = '*'; break; + case 0x23 : keyName = '#'; break; + case 0x48 : keyName = 'on'; break; // ACTION_KEY_HOME = 0x48; // 'H' + case 0x41 : keyName = 'off'; break; // ACTION_KEY_AWAY = 0x41; // 'A' + case 0x4E : keyName = 'partial'; break; // ACTION_KEY_NIGHT = 0x4E; // 'N' + case 0x50 : keyName = 'panic'; break; // ACTION_KEY_PANIC = 0x50; // 'P' + } + + attributeData[attributeName] = keyName; + break; + } + + case 0x23 : { // ATTR_ACTION_KEY_RELEASE + attributeName = 'actionKeyRelease'; + + let keyName = 'unknown'; + switch (data[7]) { + case 0x2A : keyName = '*'; break; + case 0x23 : keyName = '#'; break; + case 0x48 : keyName = 'on'; break; // ACTION_KEY_HOME = 0x48; // 'H' + case 0x41 : keyName = 'off'; break; // ACTION_KEY_AWAY = 0x41; // 'A' + case 0x4E : keyName = 'partial'; break; // ACTION_KEY_NIGHT = 0x4E; // 'N' + case 0x50 : keyName = 'panic'; break; // ACTION_KEY_PANIC = 0x50; // 'P' + } + + attributeData[attributeName] = keyName; + break; + } + + // ATTR_HUB_POLL_RATE = 0x24; + // ATTR_SOUNDS_MASK = 0x25; + // ATTR_SOUND_ID = 0x26; + // ATTR_CUSTOM_SOUND = 0x27; + // ATTR_UNSUCCESSFUL_STATE_CHANGE = 0x27; + } + + hass.fireKeypadEvent(attributeData); + break; + } + } + + + log.msg('parseAttribute()', { data, attributeData }); + + return attributeData; +} // parseAttribute(data) + +function parseButtonPress(data) { + log.msg('parseButtonPress()', data); + + // Process message, parse for button press status + + // Field name Size Description + // ---------- ---- ----------- + // Preamble 1 Unknown preamble TBC ([ 0x09 ]) + // Cluster command 1 Cluster command - Security event ([ 0x00 ]) + // Button state 1 Button state ([ 0x01 ] = On, [ 0x00 ] = Off) + // Unknown 1 ??? ([ 0x00 ]) + // Unknown 1 ??? ([ 0x01 ], [ 0x02 ]) + // Counter 2 Counter (milliseconds) ([ 0xBF, 0xC3, 0x12, 0xCA ]) + // Unknown 2 ??? ([ 0x00, 0x00 ]) + + // Examples: + // [ 0x09, 0x00, 0x00, 0x00, 0x02, 0xBF, 0xC3, 0x00, 0x00 ] { state : 0, counter : 50111 } + // [ 0x09, 0x00, 0x01, 0x00, 0x01, 0x12, 0xCA, 0x00, 0x00 ] { state : 1, counter : 51730 } + + const attributes = { + buttonState : Boolean(data[2]), + // TODO + // counter : struct.unpack(' + // data: + // data: + // + // Oddball: + // data: ( 0.39) + // data: (43.14) + + // const stateBits = bitmask.check(securityState.securityStateId); + // const tamperState = stateBits.mask.bit2; + // securityState.triggerState = stateBits.mask.bit0; + + // The security states are in byte [3] and is a bitfield: + // bit 0 is the magnetic reed switch state + // bit 3 is the tamper switch state + + securityState.securityStateId = data[3]; + + for (let i = 0; i < data.length; i++) { + securityState['securityStateValue' + i] = data[i]; + } + + if (typeof status.xbee.nodes64[remote64].model === 'undefined' || status.xbee.nodes64[remote64].model === null) { + log.msg('parseSecurityState()', { remote64, nodeName, data, securityState, error : 'Missing model name' }); + return securityState; + } + + if (data[0] !== 0x09) { + log.msg('parseSecurityState()', { remote64, nodeName, data, securityState, error : 'data[0] !== 0x09' }); + return securityState; + } + + + switch (status.xbee.nodes64[remote64].model) { + case 'Button Device' : { + // When a contact sensor broadcasts a state change for the reed switch, data[1] is 0x6E + const stateBits = bitmask.check(data[3]); + securityState.tamperState = Boolean(!stateBits.mask.bit2); + break; + } + + case 'Contact Sensor Device' : { + // When a contact sensor broadcasts a state change for the reed switch, data[1] is 0x6E + const stateBits = bitmask.check(data[3]); + securityState.contactState = Boolean(stateBits.mask.bit0); + securityState.tamperState = Boolean(!stateBits.mask.bit2); + break; + } + + case 'Keyfob Device' : { + const stateBits = bitmask.check(data[3]); + securityState.tamperState = Boolean(!stateBits.mask.bit2); + break; + } + + case 'PIR Device' : { + // TODO + // For whatever reason, there are these two invalid values that come through + // They are somehow correlated to the tamperState set in parseStatusUpdate() + if (data[1] === 0x01 || data[1] === 0x6E) { + if (remote64 === '000d6f0003bc6b0c') { + log.msg('parseSecurityState()', { + remote64, + error : 'data[1] === 0x01 || data[1] === 0x6E', + nodeName, + data, + motionValueWouldBe : parseFloat(((data[1] / 255) * 100).toFixed(2)), + }); + } + + break; + } + + securityState.motionValue = parseFloat(((data[1] / 255) * 100).toFixed(2)); + securityState.motionState = Boolean(securityState.motionValue > 0); + break; + } + + case 'SmartPlug2.5' : { + break; + } + } + + // if (remote64 === '000d6f0003bc6b0c') { + // log.msg('parseSecurityState()', { remote64, error : 'none', nodeName, data, securityState }); + // } + + return securityState; +} // parseSecurityState(data, remote64, nodeName) + +function parseStatusUpdate(data, remote64, nodeName = null) { + // Process message, parse for status update + + // Field name Size Description + // ---------- ---- ----------- + // Preamble 2 Unknown preamble TBC ([ 0x09, 0x89 ]) + // Cluster command 1 Cluster command - status update ([ 0xFB ]) + // + // Type 1 0x1B clamp, 0x1C switch, 0x1D key fob, 0x1E, 0x1F door + // + // Counter 4 Counter ([ 0x0D, 0xB2, 0x00, 0x00 ]) + // TempCelsius 2 Temperature (Celsius) ([ 0xF0, 0x0B ]) + // Unknown 6 ??? ([ 'na', 0xD3, 0xFF, 0x03, 0x00 ]) + + // Examples: + // 0x09, 0x89, 0xFB, 0x1D, 0x0D, 0xB2, 0x00, 0x00, 0xF0, 0x0B, 'na', 0xD3, 0xFF, 0x03, 0x00 { temperature: 30.56, Counter: 13019 } + // + // 0x09, 0x0D, 0xFB, 0x1F < 0xF1, 0x08, 0x02 / 0x10 0x02, 0xCF, 0xFF, 0x01, 0x00 { temperature : 41.43, triggerState : 0, tamperState : 1 } + + + // message alertme.AMGeneral.Lifesign { + // const u8 LIFESIGN_HAS_VOLTAGE = 0x01; + // const u8 LIFESIGN_HAS_TEMPERATURE = 0x02; + // const u8 LIFESIGN_HAS_SWITCH_STATUS = 0x04; + // const u8 LIFESIGN_HAS_LQI = 0x08; + // const u8 LIFESIGN_HAS_RSSI = 0x10; + // + // const u8 SWITCH_MASK_TAMPER_BUTTON = 0x02; + // const u8 SWITCH_MASK_MAIN_SENSOR = 0x01; + // + // const u8 SWITCH_STATE_TAMPER_BUTTON = 0x02; + // const u8 SWITCH_STATE_MAIN_SENSOR = 0x01; + // + // u8 statusFlags; + // + // u32 msTimer; + // + // i16 batteryVoltage; + // i16 temperature; + // + // i8 rssi; + // + // u8 lqi; + // u8 switchMask; + // u8 switchState; + // } + + const statusState = {}; + const deviceType = data[3]; + + for (let i = 0; i < data.length; i++) { + statusState['statusStateValue' + i] = data[i]; + } + + + switch (deviceType) { + case 0x1B : statusState.statusSource = 'power clamp'; break; + case 0x1C : statusState.statusSource = 'power switch'; break; + case 0x1D : statusState.statusSource = 'key fob'; break; + case 0x1E : statusState.statusSource = 'door sensor1E'; break; + case 0x1F : statusState.statusSource = 'door sensor1F'; + } + + + // TODO + const supportBits = bitmask.check(data[14]); + const stateBits = bitmask.check(data[0]); + + switch (statusState.statusSource) { + case 'key fob' : + case 'power clamp' : + case 'power switch' : { + break; + } + + case 'door sensor1E' : + case 'door sensor1F' : { + // statusState.tamperState = Boolean(stateBits.mask.bit1); + statusState.triggerState = Boolean(stateBits.mask.bit0); + break; + } + + default : { + log.error(`Unrecognized ${hex.i2s(deviceType)} device status from ${nodeName} (${remote64}) with length ${data.length}`, data); + } + } + + // statusState.counter = struct.unpack(' 100) batteryLevel = 100; + + statusState.batteryLevel = num.round2(batteryLevel, 2); + } + } + + if (supportFlags.temperature === true) { + const temperature = parseFloat((data.readInt16LE(10) * 0.0625).toString()); + + if (temperature !== 0) { + switch (typeof config.temperatureOffset[remote64] === 'number') { + case false : { + statusState.temperature = temperature; + break; + } + + case true : { + // Offset temperature by configurable amount (in deg F) + statusState.temperature = num.round2((temperature + config.temperatureOffset[remote64]), 3); + } + } + } + } + + if (supportFlags.lqi === true) { + statusState.lqi = num.round2((data.readUInt8(13) / 255) * 100); + } + + statusState.rssi = data.readInt8(12); + + // log.msg(`Additional ${statusState.statusSource} (${hex.i2s(deviceType)}) status data from ${nodeName} (${remote64}) with length ${data.length}`, data, supportFlags); + + + if (remote64 === '000d6f0003bc6b0c') { + log.msg('parseStatusUpdate()', { nodeName, data, statusState, error : 'none' }); + } + + return statusState; +} // parseStatusUpdate(data, remote64, nodeName) + +function parseSwitchStateRequest(data) { + log.msg('parseSwitchStateRequest()', data); + + // Process message, parse for switch relay state change request + // This message is sent FROM the Hub TO the SmartPlug requesting state change + + // Field name Size Description + // ---------- ---- ----------- + // Preamble 2 Unknown preamble TBC + // Cluster command 1 Cluster command - change state (SmartPlug) ([ 0x02 ]) + // Requested relay state 2 [ 0x01, 0x01 ] = On, [ 0x00, 0x01 ] = Off + + // Parse switch state request + return { switchState : Boolean(data[3]) }; +} // parseSwitchStateRequest(data) + +function parseSwitchStateUpdate(data) { + log.msg('parseSwitchStateUpdate()', data); + + // Process message, parse for switch status + // This message is sent TO the hub, FROM the SmartPlug, advertising state change + + // Field name Size Description + // ---------- ---- ----------- + // Preamble 2 Unknown preamble TBC + // Cluster command 1 Cluster command - Switch status Update ([ 0x80 ]) + // Relay state 2 [ 0x07, 0x01 ] = On, [ 0x06, 0x00 ] = Off + + // Examples: + // + // + + const attributes = { + switchState : Boolean(data[4]), + // TODO + // values : struct.unpack('< 2x b b b', data), + }; + + return attributes; +} // parseSwitchStateUpdate(data) + +function parseTamperState(data) { + log.msg('parseTamperState()', data); + + // Process message, parse for tamper switch state change + + // Field name Size Description + // ---------- ---- ----------- + // Preamble 1 Unknown preamble TBC ([ 0x09 ]) + // Cluster command 1 Cluster command - Security event ([ 0x00 ]) + // Unknown 1 ??? ([ 0x00 ], [ 0x01 ]) + // Tamper state 1 Tamper state ([ 0x01 = closed, 0x02 = open) + // Counter 2 Counter (milliseconds) ([ 0xE8, 0xA6 ]) + // Unknown 2 ??? ([ 0x00, 0x00 ]) + + // Examples: + // [ 0x09, 0x00, 0x00, 0x02, 0xE8, 0xA6, 0x00, 0x00 ] { counter : 42728, tamperState : 1 } + // [ 0x09, 0x00, 0x01, 0x01, 0xAB, 0x00, 0x00 ] { counter : 43819, tamperState : 0 } + + const stateBits = bitmask.check(data[3]); + + const attributes = { + tamperState : Boolean(stateBits.mask.bit1), + // TODO + // counter : struct.unpack('> { + // type : 145, + // typeName : 'Zigbee Explicit Rx Indicator (AO=1) (0x91)' + // remote64 : '000d6f000354cbad', + // remote16 : '478e', + // sourceEndpoint : '02', + // destinationEndpoint : '02', + // clusterId : '00ee', + // profileId : 'c216', + // receiveOptions : 1, + // data : , + // } + + // log000d6f000354a654.msg('parseFrame type', frame.type); + + // type -> profileId -> clusterId -> clusterCmd + + + // Get updated attributes + let frameType = null; + let frameProfile = null; + let frameCluster = null; + let frameClusterCmd = null; + + let attributes; + let clusterCmd; + + + switch (frame.type) { + case 0x88 : { // AT command response + frameType = 'AT command response to command ' + frame.command; + + switch (frame.command) { + case 'MY' : update.status('xbee.self.addr16', frame.commandData.toString('hex')); break; + + case 'SH' : addr64List[0] = frame.commandData.toString('hex'); break; + case 'SL' : addr64List[1] = frame.commandData.toString('hex'); break; + } // switch (frame.command) + + if (typeof addr64List[0] === 'string' && typeof addr64List[1] === 'string') { + update.status('xbee.self.addr64', addr64List[0] + addr64List[1]); + } + + break; + } + + case 0x8B: { // Zigbee transmit status + // frameType = 'Zigbee transmit status'; + frameType = 'TX status'; + break; + } + + case 0x91 : { // Zigbee explicit RX indicator (AO=1) + // Zigbee device profileId + // const params = { + // remote16 : frame.remote16, + // zdoSequenceNumber : frame.data[0], + // }; + + // frameType = 'Zigbee explicit RX indicator'; + frameType = 'ExplRX'; + + switch (frame.profileId) { + case PROFILE_ID_ALERTME : { + frameProfile = 'AlertMe'; + + clusterCmd = Buffer.from([ frame.data[2] ]).toString('hex'); + + switch (frame.clusterId) { + case CLUSTER_ID.AM.ATTRIBUTE : { + frameCluster = 'attribute'; + + attributes = parseAttribute(frame.data); + break; + } // CLUSTER_ID.AM.ATTRIBUTE + + case CLUSTER_ID.AM.BUTTON : { + frameCluster = 'button'; + + attributes = parseButtonPress(frame.data); + break; + } // CLUSTER_ID.AM.BUTTON + + case CLUSTER_ID.AM.DISCOVERY : { + frameCluster = 'discovery'; + + switch (clusterCmd) { + case CLUSTER_CMD.AM.RSSI : { + frameClusterCmd = 'range info update'; + attributes = parseRangeInfoUpdate(frame.data); + + // sendMessage('modeChangeRequest', { mode : 'normal' }, frame.remote64); + break; + } // CLUSTER_CMD.AM.RSSI + + case CLUSTER_CMD.AM.VERSION_REQ : { + frameClusterCmd = 'version info request'; + + // sendMessage('versionInfoUpdate', { remote16 : frame.remote16, zdoSequenceNumber }, frame.remote64); + break; + } // CLUSTER_CMD.AM.VERSION_REQ + + case CLUSTER_CMD.AM.VERSION_RESP : { + frameClusterCmd = 'version info update'; + attributes = parseVersionInfoUpdate(frame.data); + break; + } // CLUSTER_CMD.AM.VERSION_RESP + + default : { + frameClusterCmd = 'unknown (' + clusterCmd + ')'; + } // CLUSTER_CMD_AM default + } // switch (clusterCmd) + + break; + } // CLUSTER_ID.AM.DISCOVERY + + case CLUSTER_ID.AM.POWER : { + frameCluster = 'power'; + + switch (clusterCmd) { + case CLUSTER_CMD.AM.POWER_CONSUMPTION : { + frameClusterCmd = 'consumption & uptime update'; + attributes = parsePowerConsumption(frame.data); + + sendMessage('switchStateRequest', { switchState : 'check' }, frame.remote64); + break; + } // CLUSTER_CMD.AM.POWER_CONSUMPTION + + case CLUSTER_CMD.AM.POWER_DEMAND : { + frameClusterCmd = 'demand'; + attributes = parseActivePower(frame.data); + break; + } // CLUSTER_CMD.AM.POWER_DEMAND + + case CLUSTER_CMD.AM.POWER_UNKNOWN : { + frameClusterCmd = 'unknown'; + attributes = parsePowerUnknown(frame.data); + break; + } // CLUSTER_CMD.AM.POWER_UNKNOWN + + default : { + frameClusterCmd = 'unknown (' + clusterCmd + ')'; + } // CLUSTER_CMD.AM.POWER default + } // switch (clusterCmd) + break; + } // CLUSTER_ID.AM.POWER + + case CLUSTER_ID.AM.SECURITY : { + frameCluster = 'security'; + + // When the device first connects, it comes up in a state that + // needs initialization, this command seems to take care of that + // So, look at the value of the data and send the command + + // Buffer to compare to to detect + const bufferCompare = Buffer.from([ 0x15, 0x00, 0x39, 0x10 ]); + + if (bufferCompare.compare(frame.data, 3, 6, 0, 3) === 0) { + sendMessage('securityInit', null, frame.remote64); + } + + attributes = parseSecurityState(frame.data, frame.remote64, nodeName); + break; + } // CLUSTER_ID.AM.SECURITY + + case CLUSTER_ID.AM.STATUS : { + frameCluster = 'status'; + + switch (clusterCmd) { + case CLUSTER_CMD.AM.MODE_REQ : { + frameClusterCmd = 'mode change request'; + // attributes = parseModeChangeRequest(frame.data); + break; + } // CLUSTER_CMD.AM.MODE_REQ + + case CLUSTER_CMD.AM.STATUS : { + frameClusterCmd = 'update'; + attributes = parseStatusUpdate(frame.data, frame.remote64, nodeName); + break; + } // CLUSTER_CMD.AM.STATUS + + default : { + frameClusterCmd = 'unknown (' + clusterCmd + ')'; + } + } // switch (clusterCmd) + break; + } // CLUSTER_ID.AM.STATUS + + case CLUSTER_ID.AM.SWITCH : { + frameCluster = 'switch'; + + switch (clusterCmd) { + case CLUSTER_CMD.AM.STATE_CHANGE : { + frameClusterCmd = 'state change'; + + // ON : 0x11 0x00 0x02 0x01 0x01 + // OFF : 0x11 0x00 0x02 0x00 0x01 + // attributes = parseSwitchStateRequest(frame.data); + + // TODO + // sendMessage('switchStateUpdate', { remote16 : frame.remote16, zdoSequenceNumber }, frame.remote64); + break; + } // CLUSTER_CMD.AM.STATE_CHANGE + + case CLUSTER_CMD.AM.STATE_REQ : { + frameClusterCmd = 'state request'; + + // 0x11 0x00 0x01 0x01 + // sendMessage('switchStateUpdate', { remote16 : frame.remote16, zdoSequenceNumber }, frame.remote64); + break; + } // CLUSTER_CMD.AM.STATE_REQ + + case CLUSTER_CMD.AM.STATE_RESP : { + frameClusterCmd = 'state update'; + attributes = parseSwitchStateUpdate(frame.data); + break; + } // CLUSTER_CMD.AM.STATE_RESP + + default : { + frameClusterCmd = `unknown (${clusterCmd})`; + } + } // switch (clusterCmd) + break; + } // CLUSTER_ID.AM.SWITCH + + case CLUSTER_ID.AM.TAMPER : { + frameCluster = 'tamper'; + attributes = parseTamperState(frame.data); + break; + } // CLUSTER_ID.AM.TAMPER + + default : { + frameCluster = `unknown (${frame.clusterId})`; + } // CLUSTER_ID default + } // switch (frame.clusterId) + + break; + } // PROFILE_ID_ALERTME + + case PROFILE_ID_HA : { + frameProfile = 'HA'; + break; + } // PROFILE_ID_HA + + case PROFILE_ID_LL : { + frameProfile = 'LL'; + break; + } // PROFILE_ID_HA + + case PROFILE_ID_ZDP : { + frameProfile = 'Zigbee'; + + const zdoSequenceNumber = frame.data[0]; + + switch (frame.clusterId) { + case CLUSTER_ID.ZDO.ACTIVE_ENDPOINT.REQUEST : { // 0x0005 + frameCluster = 'active endpoint request'; + break; + } + + case CLUSTER_ID.ZDO.ACTIVE_ENDPOINT.RESPONSE : { // 0x8005 + frameCluster = 'active endpoint response'; + attributes = parseActiveEndpointResponse(frame.data, frame.remote64, nodeName); + + // This message tells us what the device can do, but it isn't constructed correctly to match what the switch can do according to the spec + // This is another message that gets it's response after we receive the match descriptor request (below) + + // A couple of messages are sent to cause the switch to join with the controller at a network level and to cause it to regard this controller as valid + // The device has to receive these two messages to stay joined + setTimeout(() => { + sendMessage('modeChangeRequest', { mode : 'normal' }, frame.remote64); + }, (5 * 1000)); + + setTimeout(() => { + sendMessage('versionInfoRequest', null, frame.remote64); + }, (7 * 1000)); + break; + } + + case CLUSTER_ID.ZDO.NETWORK_ADDRESS.REQUEST : { + frameCluster = 'network (16-bit) address request'; + break; + } + + case CLUSTER_ID.ZDO.NETWORK_ADDRESS.RESPONSE : { + frameCluster = 'network (16-bit) address response'; + break; + } + + case CLUSTER_ID.ZDO.MANAGEMENT_ROUTING.REQUEST : { + frameCluster = 'management routing table request'; + break; + } + + case CLUSTER_ID.ZDO.MANAGEMENT_ROUTING.RESPONSE : { + frameCluster = 'management routing response'; + break; + } + + case CLUSTER_ID.ZDO.SIMPLE_DESC_REQ : { + frameCluster = 'simple descriptor request'; + break; + } + + case CLUSTER_ID.ZDO.MATCH_DESCRIPTOR.REQUEST : { // 0x0006 + frameCluster = 'match descriptor request'; + log.lib(frameCluster, frame.data); + + // Send the match descriptor response + setTimeout(() => { + sendMessage('matchDescriptorResponse', { remote16 : frame.remote16, zdoSequenceNumber }, frame.remote64); + }, (1 * 1000)); + + // This will tell me the address of the new thing, so we're going to send an Active Endpoint Request + setTimeout(() => { + sendMessage('activeEndpointRequest', { remote16 : frame.remote16, zdoSequenceNumber }, frame.remote64); + }, (3 * 1000)); + break; + } + + case CLUSTER_ID.ZDO.MATCH_DESCRIPTOR.RESPONSE : { + frameCluster = 'match descriptor response'; + break; + } + + case CLUSTER_ID.ZDO.END_DEVICE_ANNOUNCE : { // 0x0013 + frameCluster = 'device announce message'; + break; + } + + case CLUSTER_ID.ZDO.PERMIT_JOINING.RESPONSE : { + frameCluster = 'permit joining response'; + break; + } + + default : { + frameCluster = `unknown (${frame.clusterId})`; + } // CLUSTER_ID default + } // switch (frame.clusterId) + + break; + } // PROFILE_ID_ZDP + + default : { + frameProfile = `unknown (${frame.profileId})`; + } + } // switch (frame.profileId) + break; + } + + default : { + frameType = `unknown (${frame.type})`; + } + } // switch (frame.type) + + + let logString = 'Received '; + + logString += `from ${frame.remote64}/`; + logString += frame.remote16; + logString += ' '; + + if (nodeName !== null) logString += `(${nodeName}) `; + + if (typeof frame.sourceEndpoint !== 'undefined' && frame.sourceEndpoint !== null) logString = logString.trim() + ' ' + frame.sourceEndpoint; + if (typeof frame.destinationEndpoint !== 'undefined' && frame.destinationEndpoint !== null) logString += '/' + frame.destinationEndpoint; + + if ((typeof frame.sourceEndpoint !== 'undefined' && frame.sourceEndpoint !== null) || (typeof frame.destinationEndpoint !== 'undefined' && frame.destinationEndpoint !== null)) { + logString += ' '; + } + + if (frameType !== null) logString = `${logString.trim()} ${frameType}`; + + if (frameProfile !== null) logString += ' > ' + frameProfile; + if (frameCluster !== null) logString += ' > ' + frameCluster; + if (frameClusterCmd !== null) logString += ' > ' + frameClusterCmd; + + + log.lib(logString); + + if (typeof attributes !== 'object' || frame.remote64 === status.xbee.self.addr64) { + return { attributes }; + } + + + Object.keys(attributes).forEach(attribute => { + // log.msg('parseFrame() attribute', attribute, attributes[attribute]); + + // const mqttPublish = update.status(`xbee.nodes64.${frame.remote64}.${attribute}`, attributes[attribute]); + // if (mqttPublish !== true) return; + + let updateQuiet = false; + // This is kind of wasteful + if (attribute.includes('statusStateValue')) updateQuiet = true; + + update.status(`xbee.nodes64.${frame.remote64}.${attribute}`, attributes[attribute], updateQuiet); + + const attributeLastSeenOld = new Date(status.xbee.nodes64[frame.remote64][`${attribute}LastSeen`]); + const attributeLastSeenNew = new Date(); + + const diffLastSeen = (attributeLastSeenNew.getTime() - attributeLastSeenOld.getTime()) / 1000; + + let skipUpdate = false; + + // activePower + // buttonState + // contactState + // motionState + // motionValue + // switchState + // temperature + switch (attribute) { + case 'motionState' : + // log.msg('attributeLastSeen()', frame.remote64, attribute, diffLastSeen); + + // console.log({ r64 : frame.remote64, diffLastSeen, attr : attributes[attribute] }); + if (diffLastSeen < motionTimeoutSec && attributes[attribute] === false) { + skipUpdate = true; + } + break; + } + + if (skipUpdate === true) return; + + // Update last attribute message timestamp + update.status(`xbee.nodes64.${frame.remote64}.${attribute}LastSeen`, new Date(), true); + + const mqttTopicPath = mqttTopicPrefix + '/' + attribute; + mqtt.pub(mqttTopicPath, attributes[attribute], true); + }); + + return { attributes }; +} // parseFrame(frame) + + +async function writeFrameObject(frameObject) { + if (xbee.portOpen !== true) return; + + const frameData = xbeeAPI.buildFrame(frameObject); + + try { + await new Promise((resolve, reject) => serialport.write(frameData, resolve, reject)); + await new Promise((resolve, reject) => serialport.drain(resolve, reject)); + + log.lib('writeFrameObject() wrote', frameData); + + return true; + } + catch (serialportWriteError) { + log.error('writeFrameObject() serialportWriteError', serialportWriteError); + return false; + } +} // async writeFrameObject(frameObject) + +function generateMessage(messageName, params = null) { + if (xbee.portOpen !== true) return; + + // Make a deep copy of the message + const message = clone(messages[messageName]); + + // If 'message.frame.data' is an anonymous function, then call it and replace with the return value + if (typeof message.frame.dataGenerate === 'function') { + message.frame.data = message.frame.dataGenerate(params); + delete message.frame.dataGenerate; + } + + // log.msg('generateMessage()', { messageName, params, message }); + + // Return processed message + return message.frame; +} // generateMessage(messageName, params) + +async function sendATCommand(command, commandParameter = []) { + if (xbee.portOpen !== true) return; + + const frameObject = { + type : xbee_api.constants.FRAME_TYPE.AT_COMMAND, // 0x08 + + command, + commandParameter, + }; + + await writeFrameObject(frameObject); +} // sendATCommand(command, commandParameter) + +async function sendMessage(messageName, params = null, remote64) { + if (typeof status.xbee.nodes64[remote64] === 'undefined') { + log.error('Failed to find remote64 address ' + remote64); + return false; + } + + if (typeof status.xbee.nodes64[remote64].remote16 === 'undefined') { + log.error('Failed to find remote16 address corresponding with remote64 address ' + remote64); + return false; + } + + const remote16 = status.xbee.nodes64[remote64].remote16; + + const message = generateMessage(messageName, params); + + // log.msg('sendMessage()', { messageName, params, remote64, remote16 }); + log.msg('sendMessage()', { messageName, remote64 }); + + await sendTxExplicit(message, remote64, remote16); +} // async sendMessage(messageName, params, remote64) + +async function sendTxExplicit(message, remote64, remote16) { + if (xbee.portOpen !== true) return; + + const frameObject = { + type : xbee_api.constants.FRAME_TYPE.EXPLICIT_ADDRESSING_ZIGBEE_COMMAND_FRAME, // 0x11 + destination64 : remote64, + destination16 : remote16, + ...message, + }; + + // log.msg('sendTxExplicit()', frameObject); + log.msg('sendTxExplicit()', frameObject.destination64, frameObject.clusterId + '/' + frameObject.profileId, frameObject.data); + + await writeFrameObject(frameObject); +} // sendTxExplicit(message, remote64, remote16) + + +function checkLastSeenDevices() { + for (const nodeKey of Object.keys(status.xbee.nodes64)) { + const node = status.xbee.nodes64[nodeKey]; + + if (typeof node.lastSeen === 'undefined' || node.lastSeen === null) continue; + if (typeof node.remote64 === 'undefined' || node.remote64 === null) continue; + + const dateOld = new Date(node.lastSeen); + const dateNew = new Date(); + + const diffLastSeen = (dateNew.getTime() - dateOld.getTime()) / 1000; + + // log.msg('checkLastSeenDevices()', node.remote64, diffLastSeen); + + let lwtStatus = 'offline'; + if (diffLastSeen < 600) lwtStatus = 'online'; + + update.status('xbee.nodes64.' + nodeKey + '.lwtStatus', lwtStatus); + mqtt.pub('tele/' + node.remote64 + '/LWT', lwtStatus, true); + } +} // checkLastSeenDevices() + + +async function init() { + log.lib('Initializing'); + + xbeeAPI = new xbee_api.XBeeAPI({ + api_mode : config.xbee.apiMode, + }); + + serialport = new SerialPort(config.xbee.port, { + autoOpen : false, + baudRate : config.xbee.baudRate, + }); + + serialport.pipe(xbeeAPI.parser); + xbeeAPI.builder.pipe(serialport); + + serialport.on('drain', () => { + log.error('event: \'drain\''); + }); + + serialport.on('error', error => { + log.error('serialport event: \'error\', ', error); + }); + + serialport.on('close', () => { + log.lib('event: \'close\''); + }); + + serialport.on('open', () => { + xbee.portOpen = true; + log.lib('event: \'open\''); + + readAddresses(); + }); + + // All frames parsed by the XBee will be emitted here + xbeeAPI.parser.on('data', parseFrame); + + try { + log.lib('Opening serial port'); + await new Promise((resolve, reject) => serialport.open(resolve, reject)); + log.lib('Opened serial port'); + } + catch (serialportOpenError) { + log.error('init() serialportOpenError', serialportOpenError); + return false; + } + + if (xbee.interval.checkLastSeenDevices === null) { + log.msg('init()', 'Set checkLastSeenDevices() interval'); + xbee.interval.checkLastSeenDevices = setInterval(checkLastSeenDevices, 5000); + } + + log.lib('Initialized'); +} // async init() + +async function term() { + log.lib('Terminating'); + + clearInterval(xbee.interval.checkLastSeenDevices); + xbee.interval.checkLastSeenDevices = null; + + try { + log.lib('Closing serial port'); + await new Promise((resolve, reject) => serialport.close(resolve, reject)); + xbee.portOpen = false; + log.lib('Closed serial port'); + } + catch (serialportCloseError) { + log.error('term() serialportCloseError', serialportCloseError); + return false; + } + + log.lib('Terminated'); +} // async term() + + +module.exports = { + interval : { + checkLastSeenDevices : null, + }, + + portOpen : false, + + // Normal functions + generateActiveEndpointRequest, + generateMatchDescriptorRequest, + generateMatchDescriptorResponse, + generateMessage, + generateModeChangeRequest, + generateSecurityInit, + generateSwitchStateRequest, + generateVersionInfoRequest, + + parseActivePower, + parseButtonPress, + parseFrame, + parsePowerConsumption, + parsePowerUnknown, + parseRangeInfoUpdate, + parseSecurityState, + parseStatusUpdate, + parseSwitchStateRequest, + parseSwitchStateUpdate, + parseTamperState, + parseVersionInfoUpdate, + + // Async functions + readAddresses, + sendATCommand, + sendMessage, + sendTxExplicit, + writeFrameObject, + + // Start/stop functions + init, + term, +};