├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── npm-publish.yml │ └── sonarcloud.yml ├── .gitignore ├── .mocharc.js ├── .npmignore ├── .nycrc ├── README.md ├── examples └── example-1.json ├── package.json ├── sonar-project.properties ├── src ├── lib │ ├── decoder │ │ ├── auto-decode.ts │ │ ├── decoder-interface.ts │ │ ├── mysensors-decoder.spec.ts │ │ ├── mysensors-decoder.ts │ │ ├── mysensors-mqtt.spec.ts │ │ ├── mysensors-mqtt.ts │ │ ├── mysensors-serial.spec.ts │ │ └── mysensors-serial.ts │ ├── mysensors-controller.spec.ts │ ├── mysensors-controller.ts │ ├── mysensors-debug.spec.ts │ ├── mysensors-debug.ts │ ├── mysensors-msg.ts │ ├── mysensors-types.ts │ ├── nodered-storage.spec.ts │ ├── nodered-storage.ts │ ├── nullcheck.spec.ts │ ├── nullcheck.ts │ └── storage-interface.ts └── nodes │ ├── common.ts │ ├── controller.html │ ├── controller.ts │ ├── decode.html │ ├── decode.ts │ ├── encapsulate.html │ ├── encapsulate.ts │ ├── encode.html │ ├── encode.ts │ ├── mysdebug.html │ ├── mysdebug.ts │ ├── mysensors-db.html │ └── mysensors-db.ts ├── test └── sinon.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs. 2 | # More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 4 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.xml] 15 | indent_style = space 16 | indent_size = 4 17 | 18 | [*.neon] 19 | indent_style = tab 20 | indent_size = 4 21 | 22 | [*.{yaml,yml}] 23 | indent_style = space 24 | indent_size = 2 25 | 26 | [composer.json] 27 | indent_style = space 28 | indent_size = 4 29 | 30 | [*.md] 31 | trim_trailing_whitespace = false 32 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbowmo/node-red-contrib-mysensors/1e7de26a595962e1a66130c97870945f837387e1/.eslintignore -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es2021: true, 4 | node: true, 5 | }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | ], 10 | overrides: [ 11 | ], 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | sourceType: 'module', 16 | }, 17 | plugins: [ 18 | '@typescript-eslint', 19 | 'import', 20 | 'import-newlines', 21 | ], 22 | root: true, 23 | rules: { 24 | 'import/order': 'off', 25 | 'eol-last': 'error', 26 | 'comma-dangle': [ 27 | 'warn', 28 | 'always-multiline', 29 | ], 30 | indent: [ 31 | 'error', 32 | 4, 33 | ], 34 | 'linebreak-style': [ 35 | 'error', 36 | 'unix', 37 | ], 38 | quotes: [ 39 | 'warn', 40 | 'single', 41 | { avoidEscape: true }, 42 | ], 43 | semi: [ 44 | 'warn', 45 | 'never', 46 | ], 47 | 'max-len': [ 48 | 'error', 49 | { 50 | 'code' : 180, 51 | }, 52 | ], 53 | 'no-console': [ 54 | 'warn', 55 | ], 56 | curly: [ 57 | 'error', 58 | ], 59 | eqeqeq: [ 60 | 'error', 61 | ], 62 | complexity: [ 63 | 'error', 64 | 11, 65 | ], 66 | 'import-newlines/enforce': [ 67 | 'error', 68 | { 69 | items: 2, 70 | 'max-len': 180, 71 | semi: false, 72 | }, 73 | ], 74 | 75 | }, 76 | } 77 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 16 18 | - run: yarn install --immutable 19 | - run: yarn build 20 | - run: yarn coverage 21 | 22 | publish-gpr: 23 | needs: build 24 | runs-on: ubuntu-latest 25 | permissions: 26 | contents: read 27 | packages: write 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: actions/setup-node@v3 31 | with: 32 | node-version: 16 33 | registry-url: https://registry.npmjs.org/ 34 | - run: yarn install --immutable 35 | - run: yarn build 36 | - run: npm publish 37 | env: 38 | NODE_AUTH_TOKEN: ${{secrets.PUBLISH}} 39 | -------------------------------------------------------------------------------- /.github/workflows/sonarcloud.yml: -------------------------------------------------------------------------------- 1 | name: Sonarcloud 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | types: [opened, synchronize, reopened] 8 | jobs: 9 | sonarcloud: 10 | name: SonarCloud 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 16 | - name: Install dependencies 17 | run: yarn 18 | - name: Test and coverage 19 | run: yarn coverage:ci 20 | - name: SonarCloud Scan 21 | uses: SonarSource/sonarcloud-github-action@master 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 24 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 25 | 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | 36 | _lib 37 | _nodes 38 | 39 | dist/**/* 40 | .coverage 41 | .nyc_output 42 | 43 | src/**/*.js 44 | src/**/*.js.map 45 | src/lib/**/*.d.ts 46 | 47 | gh-pages 48 | -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | exit: true, 5 | bail: true, 6 | recursive: true, 7 | } 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | 36 | _lib 37 | _nodes 38 | 39 | typings/**/* 40 | typings/browser.d.ts 41 | # typings/main/** 42 | # typings/main.d.ts 43 | 44 | gh-pages 45 | out/ 46 | data/ 47 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "all": true, 3 | "per-file": true, 4 | "lines": 95, 5 | "functions": 95, 6 | "branches": 95, 7 | "statements": 95, 8 | "cache": false, 9 | "watermarks": { 10 | "lines": [95, 99], 11 | "functions": [95, 99], 12 | "branches": [90, 95], 13 | "statements": [95, 99] 14 | }, 15 | "exclude": [ 16 | "**/*.spec.ts" 17 | ], 18 | "include": [ 19 | "src/lib/**/*.ts" 20 | ], 21 | "extension": [ 22 | ".ts" 23 | ], 24 | "require": [ 25 | "ts-node/register" 26 | ], 27 | "sourceMap": true, 28 | "instrument": true, 29 | "report-dir": "./.coverage" 30 | } 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-red-contrib-mysensors 2 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=tbowmo_node-red-small-timer&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=tbowmo_node-red-small-timer) 3 | ![Sonar cloud test](https://github.com/tbowmo/node-red-small-timer/actions/workflows/sonarcloud.yml/badge.svg) 4 | 5 | A node-RED [mysensors](http://www.mysensors.org) protocol decoder / encoder / wrapper package, including basic controller functionality 6 | Contains a node to decode / encode mysensors serial protocol to / from node-red messages, and a node for adding mysensors specific data like sensor type, nodeid etc. which can then be sent to mysensors network 7 | 8 | ## Install 9 | 10 | Within your local installation of Node-RED run: 11 | 12 | `npm install node-red-contrib-mysensors` 13 | 14 | Once installed, restart your node-red server, and you will have a set of new nodes available in your palette under mysensors: 15 | 16 | ## Node-RED mysdecode 17 | 18 | This decodes the mysensors serial protocol packages or a MQTT topic from a mysensors MQTT gateway, and enriches the Node-RED msg object with the following extra data: 19 | 20 | ``` 21 | msg.payload // Payload data from sensor network 22 | msg.nodeId // node of the origin 23 | msg.childSensorId 24 | msg.messageType 25 | msg.ack 26 | msg.subType 27 | msg.messageTypeStr 28 | msg.subTypeStr 29 | ``` 30 | The last two parameters are text representations for message type and sub type. No other mysensros node is actively using these values. 31 | 32 | see [mysensors API v2.x](http://www.mysensors.org/download/serial_api_20) for more info on the different parts 33 | 34 | The following nodes will be able to use these properties to interact with the messages from the mysensors network 35 | 36 | ## Node-RED mysencode 37 | 38 | This encodes a message into either mysensors serial, or a mysensors mqtt 39 | topic. 40 | 41 | If using MQTT, then set topicRoot on the message, before sending it 42 | into this node, in order to set your own root topic, or set it in the node to set the topicRoot like 43 | 44 | topicRoot/nodeId/childSensorId/ack/subType 45 | 46 | ## Node-RED mysencap 47 | 48 | This will add the message properties mentioned under mysdecenc to the message object of an existing Node-RED message. By sending the output through mysencode, you can create a message that can be sent to your sensor network, or sent to another controller that understands MySensors serial protocol or MQTT topic format. 49 | 50 | If you want to send it to another controller as a serial port format, use socat for creating a dummy serial port (on linux): 51 | 52 | ``` 53 | socat PTY,link=/dev/ttyS80,mode=666,group=dialout,raw PTY,link=/dev/ttyUSB20,mode=666,group=dialout,raw & 54 | ``` 55 | Now use /dev/ttyS80 in a serial port node in node-red, and use /dev/ttyUSB20 in your chosen controller. 56 | 57 | ## Node-RED mysdebug 58 | 59 | This will decode the mysensors serial protocol payload, and enrich it with descriptions of sensor types etc. Meant as a debugging tool. Data will be sent out of the node, and can be used in a debug node, or dumped to disk, for file logging 60 | 61 | ## Node-RED myscontroller 62 | 63 | This node can handle ID assignment to nodes on your network. Will respond with a new ID everytime it sees a request for an ID from a node. 64 | 65 | The node uses node-red context for storage, which is normally in memory only, and is reset on every startup of your node-red instance. You can configure a filesystem context as well in your node-red settings.js file: 66 | 67 | ```js 68 | contextStorage: { 69 | default: "memoryOnly", 70 | memoryOnly: { 71 | module: "memory", 72 | }, 73 | file: { module: 'localfilesystem' } 74 | } 75 | ``` 76 | 77 | In this example node-red defaults to memory (keeping things as is), and in addition it creates a secondary localfile storage (called `file`) which you can then set the myscontroller node to use for persistent data storage. Checkout [node-red documentation on context](https://nodered.org/docs/user-guide/context) 78 | 79 | The data is kept as a object on a single key entry in the context 80 | 81 | The controller keeps track of when it hears the nodes, sketch name / version reported during presentation etc. and will be shown when you look at the configuration page of the node. 82 | 83 | -------------------------------------------------------------------------------- /examples/example-1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "50fab8d88513fff8", 4 | "type": "mysdecode", 5 | "z": "631f2df853fdf880", 6 | "database": "1d1ced330982e64e", 7 | "name": "", 8 | "mqtt": false, 9 | "enrich": true, 10 | "x": 830, 11 | "y": 240, 12 | "wires": [ 13 | [ 14 | "efac5ec036f7597a" 15 | ] 16 | ] 17 | }, 18 | { 19 | "id": "efac5ec036f7597a", 20 | "type": "debug", 21 | "z": "631f2df853fdf880", 22 | "name": "Mysensors decoded from serial", 23 | "active": false, 24 | "tosidebar": true, 25 | "console": false, 26 | "tostatus": false, 27 | "complete": "true", 28 | "targetType": "full", 29 | "statusVal": "", 30 | "statusType": "auto", 31 | "x": 1090, 32 | "y": 240, 33 | "wires": [] 34 | }, 35 | { 36 | "id": "bdd6b94f5022e698", 37 | "type": "myscontroller", 38 | "z": "631f2df853fdf880", 39 | "database": "1d1ced330982e64e", 40 | "name": "", 41 | "handleid": true, 42 | "timeresponse": true, 43 | "timezone": "Europe/Copenhagen", 44 | "measurementsystem": "M", 45 | "mqttroot": "mys-out", 46 | "x": 630, 47 | "y": 380, 48 | "wires": [ 49 | [ 50 | "f1a261912450dc79" 51 | ] 52 | ] 53 | }, 54 | { 55 | "id": "f1a261912450dc79", 56 | "type": "debug", 57 | "z": "631f2df853fdf880", 58 | "name": "Controller return message", 59 | "active": true, 60 | "tosidebar": true, 61 | "console": false, 62 | "tostatus": false, 63 | "complete": "true", 64 | "targetType": "full", 65 | "statusVal": "", 66 | "statusType": "auto", 67 | "x": 850, 68 | "y": 380, 69 | "wires": [] 70 | }, 71 | { 72 | "id": "4b2b0cb473a98e41", 73 | "type": "mysencap", 74 | "z": "631f2df853fdf880", 75 | "name": "", 76 | "nodeid": "5", 77 | "childid": "1", 78 | "subtype": "2", 79 | "internal": 0, 80 | "ack": false, 81 | "msgtype": 1, 82 | "presentation": true, 83 | "presentationtype": "7", 84 | "presentationtext": "test node", 85 | "fullpresentation": true, 86 | "firmwarename": "Firmware 1", 87 | "firmwareversion": "1.1", 88 | "x": 330, 89 | "y": 380, 90 | "wires": [ 91 | [ 92 | "0681c355ace3a6c0", 93 | "e645f9769eb677aa", 94 | "5d9864c80f53b964" 95 | ] 96 | ] 97 | }, 98 | { 99 | "id": "49c241794aa6e101", 100 | "type": "inject", 101 | "z": "631f2df853fdf880", 102 | "name": "", 103 | "props": [ 104 | { 105 | "p": "payload" 106 | }, 107 | { 108 | "p": "topic", 109 | "vt": "str" 110 | } 111 | ], 112 | "repeat": "", 113 | "crontab": "", 114 | "once": false, 115 | "onceDelay": 0.1, 116 | "topic": "", 117 | "payload": "", 118 | "payloadType": "date", 119 | "x": 100, 120 | "y": 380, 121 | "wires": [ 122 | [ 123 | "4b2b0cb473a98e41" 124 | ] 125 | ] 126 | }, 127 | { 128 | "id": "0681c355ace3a6c0", 129 | "type": "mysencode", 130 | "z": "631f2df853fdf880", 131 | "name": "", 132 | "mqtt": false, 133 | "mqtttopic": "", 134 | "x": 610, 135 | "y": 240, 136 | "wires": [ 137 | [ 138 | "50fab8d88513fff8", 139 | "5a92bb1204c7a0ce" 140 | ] 141 | ] 142 | }, 143 | { 144 | "id": "5a92bb1204c7a0ce", 145 | "type": "debug", 146 | "z": "631f2df853fdf880", 147 | "name": "Mysensors encoded serial", 148 | "active": false, 149 | "tosidebar": true, 150 | "console": false, 151 | "tostatus": false, 152 | "complete": "true", 153 | "targetType": "full", 154 | "statusVal": "", 155 | "statusType": "auto", 156 | "x": 850, 157 | "y": 180, 158 | "wires": [] 159 | }, 160 | { 161 | "id": "e645f9769eb677aa", 162 | "type": "debug", 163 | "z": "631f2df853fdf880", 164 | "name": "Encapsulated message", 165 | "active": true, 166 | "tosidebar": true, 167 | "console": false, 168 | "tostatus": false, 169 | "complete": "true", 170 | "targetType": "full", 171 | "statusVal": "", 172 | "statusType": "auto", 173 | "x": 630, 174 | "y": 480, 175 | "wires": [] 176 | }, 177 | { 178 | "id": "b2581e60c920ba9b", 179 | "type": "inject", 180 | "z": "631f2df853fdf880", 181 | "name": "Node time request (MQTT topic)", 182 | "props": [ 183 | { 184 | "p": "payload" 185 | }, 186 | { 187 | "p": "topic", 188 | "vt": "str" 189 | } 190 | ], 191 | "repeat": "", 192 | "crontab": "", 193 | "once": false, 194 | "onceDelay": 0.1, 195 | "topic": "mys-in/5/1/3/0/1", 196 | "payload": "", 197 | "payloadType": "str", 198 | "x": 170, 199 | "y": 240, 200 | "wires": [ 201 | [ 202 | "5d9864c80f53b964" 203 | ] 204 | ] 205 | }, 206 | { 207 | "id": "54d66bda4ce0c269", 208 | "type": "mysdebug", 209 | "z": "631f2df853fdf880", 210 | "name": "", 211 | "x": 630, 212 | "y": 440, 213 | "wires": [ 214 | [ 215 | "ec7aa00387adaa0b" 216 | ] 217 | ] 218 | }, 219 | { 220 | "id": "ec7aa00387adaa0b", 221 | "type": "debug", 222 | "z": "631f2df853fdf880", 223 | "name": "Mysensors debug output", 224 | "active": true, 225 | "tosidebar": true, 226 | "console": false, 227 | "tostatus": false, 228 | "complete": "payload", 229 | "targetType": "msg", 230 | "statusVal": "", 231 | "statusType": "auto", 232 | "x": 850, 233 | "y": 440, 234 | "wires": [] 235 | }, 236 | { 237 | "id": "5d9864c80f53b964", 238 | "type": "junction", 239 | "z": "631f2df853fdf880", 240 | "x": 480, 241 | "y": 340, 242 | "wires": [ 243 | [ 244 | "bdd6b94f5022e698", 245 | "54d66bda4ce0c269" 246 | ] 247 | ] 248 | }, 249 | { 250 | "id": "1d1ced330982e64e", 251 | "type": "mysensorsdb", 252 | "name": "test", 253 | "store": "mysensor-controller", 254 | "contextType": "global" 255 | } 256 | ] 257 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-red-contrib-mysensors", 3 | "author": { 4 | "name": "Thomas Bowman Mørch" 5 | }, 6 | "version": "4.2.0", 7 | "engines": { 8 | "node": ">=16.0.0" 9 | }, 10 | "scripts": { 11 | "postcoverage": "nyc check-coverage --functions 50 --branches 50 --statements 90", 12 | "build": "mkdir -p dist/nodes/ && cp -ar src/nodes/*.html dist/nodes/ && tsc ", 13 | "prepublish": "npm run build", 14 | "mocha": "TZ=Z mocha -r ts-node/register -r source-map-support/register", 15 | "coverage": "TZ=Z nyc --clean --cache false --reporter=text-summary --reporter=html mocha --forbid-only -r ts-node/register -r source-map-support/register 'src/**/*.spec.ts'", 16 | "format": "prettier --write src/**/*.ts", 17 | "lint": "tsc --noEmit && eslint src/**/*.ts", 18 | "coverage:ci": "TZ=Z nyc --clean --cache false --reporter=lcov mocha --forbid-only -r ts-node/register -r source-map-support/register 'src/**/*.spec.ts'" 19 | }, 20 | "husky": { 21 | "hooks": { 22 | "pre-commit": "lint-staged && npm run lint" 23 | } 24 | }, 25 | "lint-staged": { 26 | "{src,e2e,cypress}/**/*.{ts,json,md,scss}": [ 27 | "prettier --write", 28 | "git add" 29 | ] 30 | }, 31 | "bugs": { 32 | "url": "https://github.com/tbowmo/node-red-contrib-mysensors/issues" 33 | }, 34 | "deprecated": false, 35 | "description": "Mysensors related nodes, for decoding / encoding mysensors serial protocol and MQTT topic, and wrapping arbitrary messages into mysensors compatible messages", 36 | "homepage": "https://github.com/tbowmo/node-red-contrib-mysensors", 37 | "keywords": [ 38 | "node-red", 39 | "mysensors", 40 | "decode", 41 | "encode", 42 | "wrap", 43 | "encapsulate", 44 | "debug" 45 | ], 46 | "license": "GPL-2.0", 47 | "main": "index.js", 48 | "node-red": { 49 | "version": ">=3.0", 50 | "nodes": { 51 | "mysdecode": "dist/nodes/decode.js", 52 | "mysencode": "dist/nodes/encode.js", 53 | "mysencap": "dist/nodes/encapsulate.js", 54 | "mysdebug": "dist/nodes/mysdebug.js", 55 | "myscontroler": "dist/nodes/controller.js", 56 | "mysdb": "dist/nodes/mysensors-db.js" 57 | } 58 | }, 59 | "repository": { 60 | "type": "git", 61 | "url": "git+https://github.com/tbowmo/node-red-contrib-mysensors.git" 62 | }, 63 | "dependencies": { 64 | "date-fns": "^2.30.0", 65 | "date-fns-tz": "^2.0.0" 66 | }, 67 | "devDependencies": { 68 | "@types/chai": "^4.3.5", 69 | "@types/mocha": "^10.0.1", 70 | "@types/node": "^20.1.4", 71 | "@types/node-red": "1.3.1", 72 | "@types/node-red-node-test-helper": "^0.2.3", 73 | "@types/sinon": "^10.0.15", 74 | "@typescript-eslint/eslint-plugin": "^5.59.6", 75 | "@typescript-eslint/parser": "^5.59.6", 76 | "eslint-plugin-import": "^2.27.5", 77 | "eslint-plugin-import-newlines": "^1.3.1", 78 | "chai": "^4.3.7", 79 | "eslint": "^8.40.0", 80 | "husky": "^8.0.3", 81 | "lint-staged": "^13.2.2", 82 | "mocha": "^10.2.0", 83 | "node-red": "^3.0.2", 84 | "node-red-node-test-helper": "^0.3.1", 85 | "nyc": "^15.1.0", 86 | "sinon": "^15.0.4", 87 | "source-map-support": "^0.5.21", 88 | "ts-node": "^10.9.1", 89 | "tslint": "^6.1.3", 90 | "typescript": "*" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=tbowmo_node-red-contrib-mysensors 2 | sonar.organization=tbowmogithub 3 | 4 | # This is the name and version displayed in the SonarCloud UI. 5 | #sonar.projectName=node-red-small-timer 6 | #sonar.projectVersion=1.0 7 | 8 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 9 | #sonar.sources=. 10 | 11 | # Encoding of the source code. Default is default system encoding 12 | #sonar.sourceEncoding=UTF-8 13 | sonar.javascript.lcov.reportPaths=./.coverage/lcov.info 14 | -------------------------------------------------------------------------------- /src/lib/decoder/auto-decode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IMysensorsMsg, 3 | INodeMessage, 4 | IStrongMysensorsMsg, 5 | MsgOrigin, 6 | MysensorsCommand, 7 | validateStrongMysensorsMsg, 8 | } from '../mysensors-msg'; 9 | import { MysensorsMqtt } from './mysensors-mqtt'; 10 | import { MysensorsSerial } from './mysensors-serial'; 11 | 12 | export async function AutoDecode( 13 | msg: Readonly 14 | ): Promise | undefined> { 15 | if (validateStrongMysensorsMsg(msg)) { 16 | return { 17 | ...msg, 18 | origin: MsgOrigin.decoded, 19 | }; 20 | } 21 | 22 | if (!msg.topic) { 23 | return new MysensorsSerial().decode(msg as INodeMessage); 24 | } else { 25 | return new MysensorsMqtt().decode(msg as INodeMessage); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/decoder/decoder-interface.ts: -------------------------------------------------------------------------------- 1 | import { INodeMessage, IStrongMysensorsMsg, MysensorsCommand } from '../mysensors-msg'; 2 | 3 | export interface IDecoder { 4 | decode(msg: Readonly): Promise| undefined> 5 | encode(msg: Readonly>): IStrongMysensorsMsg 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/decoder/mysensors-decoder.spec.ts: -------------------------------------------------------------------------------- 1 | import {MysensorsDecoder} from './mysensors-decoder'; 2 | import { IStrongMysensorsMsg, MysensorsCommand } from '../mysensors-msg'; 3 | import { expect } from 'chai'; 4 | import { useSinonSandbox } from '../../../test/sinon'; 5 | 6 | describe('lib/decoder/mysensors-decoder', () => { 7 | const sinon = useSinonSandbox(); 8 | 9 | class dummy extends MysensorsDecoder { 10 | public testEnrich(msg: IStrongMysensorsMsg) { 11 | return this.enrich(msg); 12 | } 13 | } 14 | 15 | it('should descriptions for C_SET message', async () => { 16 | const decoder = new dummy(false); 17 | 18 | const result = await decoder.testEnrich({ 19 | ack: 0, 20 | _msgid: '', 21 | childSensorId: 1, 22 | messageType: 1, 23 | nodeId: 1, 24 | subType: 1, 25 | }); 26 | 27 | expect(result).to.deep.equal({ 28 | _msgid: '', 29 | ack: 0, 30 | childSensorId: 1, 31 | messageType: 1, 32 | messageTypeStr: 'C_SET', 33 | nodeId: 1, 34 | subType: 1, 35 | subTypeStr: 'V_HUM', 36 | }); 37 | }); 38 | 39 | it('should descriptions for C_REQ message', async () => { 40 | const decoder = new dummy(false); 41 | 42 | const result = await decoder.testEnrich({ 43 | ack: 0, 44 | _msgid: '', 45 | childSensorId: 1, 46 | messageType: 2, 47 | nodeId: 1, 48 | subType: 5, 49 | }); 50 | 51 | expect(result).to.deep.equal({ 52 | _msgid: '', 53 | ack: 0, 54 | childSensorId: 1, 55 | messageType: 2, 56 | messageTypeStr: 'C_REQ', 57 | nodeId: 1, 58 | subType: 5, 59 | subTypeStr: 'V_FORECAST', 60 | }); 61 | }); 62 | 63 | it('should return descriptions for STREAM message', async() => { 64 | const decoder = new dummy(false); 65 | 66 | const result = await decoder.testEnrich({ 67 | ack: 0, 68 | _msgid: '', 69 | childSensorId: 1, 70 | messageType: 4, 71 | nodeId: 1, 72 | subType: 1, 73 | }); 74 | 75 | expect(result).to.deep.equal({ 76 | _msgid: '', 77 | ack: 0, 78 | childSensorId: 1, 79 | messageType: 4, 80 | messageTypeStr: 'C_STREAM', 81 | nodeId: 1, 82 | subType: 1, 83 | subTypeStr: 'ST_FIRMWARE_CONFIG_RESPONSE', 84 | }); 85 | 86 | }); 87 | 88 | it('should enrich with database lookup', async() => { 89 | const database = { 90 | getChild: sinon.stub().resolves({sType: 1}), 91 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 92 | } as any; 93 | const decoder = new dummy(true, database); 94 | 95 | const result = await decoder.testEnrich({ 96 | ack: 0, 97 | _msgid: '', 98 | childSensorId: 1, 99 | messageType: 1, 100 | nodeId: 1, 101 | subType: 1, 102 | }); 103 | 104 | expect(result).to.deep.equal({ 105 | _msgid: '', 106 | ack: 0, 107 | childSensorId: 1, 108 | messageType: 1, 109 | messageTypeStr: 'C_SET', 110 | nodeId: 1, 111 | subType: 1, 112 | subTypeStr: 'V_HUM', 113 | sensorTypeStr: 'S_MOTION', 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/lib/decoder/mysensors-decoder.ts: -------------------------------------------------------------------------------- 1 | import { IStorage } from '../storage-interface'; 2 | import { IStrongMysensorsMsg, MysensorsCommand } from '../mysensors-msg'; 3 | import { 4 | mysensor_command, 5 | mysensor_data, 6 | mysensor_internal, 7 | mysensor_sensor, 8 | mysensor_stream, 9 | } from '../mysensors-types'; 10 | import { NullCheck } from '../nullcheck'; 11 | 12 | export abstract class MysensorsDecoder { 13 | protected enrichWithDb: boolean; 14 | 15 | constructor(enrich?: boolean, private database?: IStorage) { 16 | this.enrichWithDb = enrich && !!database || false; 17 | } 18 | 19 | protected async enrich(msg: IStrongMysensorsMsg): Promise> { 20 | const newMsg: IStrongMysensorsMsg = { 21 | ...msg 22 | }; 23 | newMsg.messageTypeStr = mysensor_command[msg.messageType]; 24 | switch (msg.messageType) 25 | { 26 | case mysensor_command.C_INTERNAL: 27 | newMsg.subTypeStr = mysensor_internal[msg.subType]; 28 | break; 29 | case mysensor_command.C_PRESENTATION: 30 | newMsg.subTypeStr = mysensor_sensor[msg.subType]; 31 | break; 32 | case mysensor_command.C_REQ: 33 | case mysensor_command.C_SET: 34 | newMsg.subTypeStr = mysensor_data[msg.subType]; 35 | break; 36 | case mysensor_command.C_STREAM: 37 | newMsg.subTypeStr = mysensor_stream[msg.subType]; 38 | break; 39 | } 40 | if (this.enrichWithDb && this.database) 41 | { 42 | const res = await this.database.getChild(msg.nodeId, msg.childSensorId); 43 | if (NullCheck.isDefinedOrNonNull(res)) { 44 | newMsg.sensorTypeStr = mysensor_sensor[res.sType]; 45 | } 46 | } 47 | 48 | return newMsg; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/decoder/mysensors-mqtt.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import 'mocha'; 3 | import { IMysensorsMsg, INodeMessage, IStrongMysensorsMsg } from '../mysensors-msg'; 4 | import { mysensor_command } from '../mysensors-types'; 5 | import { MysensorsMqtt } from './mysensors-mqtt'; 6 | 7 | describe('MQTT decode / encode', () => { 8 | it('Should create correct decoded output when mqtt topic is received', async () => { 9 | const msg: INodeMessage = { 10 | _msgid: '', 11 | payload: '6', 12 | topic: 'mys-in/1/2/3/0/5', 13 | }; 14 | const expected: IMysensorsMsg = { 15 | _msgid: '', 16 | ack: 0, 17 | childSensorId: 2, 18 | messageType: 3, 19 | nodeId: 1, 20 | payload: '6', 21 | subType: 5, 22 | topicRoot: 'mys-in', 23 | 24 | }; 25 | const out = await new MysensorsMqtt().decode(msg); 26 | expect(out).to.include(expected); 27 | }); 28 | 29 | it('if not mysensors formatted input return undefined', async () => { 30 | const msg: INodeMessage = { 31 | _msgid: '', 32 | payload: '200', 33 | }; 34 | const out = await new MysensorsMqtt().decode(msg); 35 | expect(out).to.equal(undefined); 36 | }); 37 | 38 | it('Encode to mysensors mqtt message', () => { 39 | const msg: IStrongMysensorsMsg = { 40 | _msgid: '', 41 | ack: 0, 42 | childSensorId: 2, 43 | messageType: mysensor_command.C_PRESENTATION, 44 | nodeId: 1, 45 | payload: '100', 46 | subType: 4, 47 | topicRoot: 'mys-out', 48 | }; 49 | const out = new MysensorsMqtt().encode(msg); 50 | expect(out).to.include({topic: 'mys-out/1/2/0/0/4', payload: '100'}); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/lib/decoder/mysensors-mqtt.ts: -------------------------------------------------------------------------------- 1 | import { INodeMessage, IStrongMysensorsMsg, MsgOrigin, MysensorsCommand } from '../mysensors-msg'; 2 | import { IDecoder } from './decoder-interface'; 3 | import { MysensorsDecoder } from './mysensors-decoder'; 4 | 5 | export class MysensorsMqtt extends MysensorsDecoder implements IDecoder { 6 | 7 | public async decode(msg: Readonly): Promise| undefined> { 8 | if (msg.topic) { 9 | const split = msg.topic.toString().split('/'); 10 | if (split.length >= 6) { 11 | const msgOut: IStrongMysensorsMsg = { 12 | ...msg, 13 | topicRoot: split.slice(0, split.length - 5).join('/'), 14 | nodeId: parseInt( split[split.length - 5], 10 ), 15 | childSensorId: parseInt( split[split.length - 4], 10 ), 16 | messageType: parseInt( split[split.length - 3], 10 ), 17 | ack: (split[split.length - 2] === '1') ? 1 : 0, 18 | subType: parseInt( split[split.length - 1], 10 ), 19 | origin: MsgOrigin.mqtt, 20 | }; 21 | return this.enrich(msgOut); 22 | } 23 | } 24 | } 25 | 26 | public encode( 27 | msg: Readonly> 28 | ): IStrongMysensorsMsg { 29 | return { 30 | ...msg, 31 | topic: (msg.topicRoot ? `${msg.topicRoot}/` : '') 32 | + `${msg.nodeId}/${msg.childSensorId}/${msg.messageType}/${msg.ack}/${msg.subType}`, 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/decoder/mysensors-serial.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import 'mocha'; 3 | import { IMysensorsMsg, INodeMessage, IStrongMysensorsMsg } from '../mysensors-msg'; 4 | import { mysensor_command } from '../mysensors-types'; 5 | import { MysensorsSerial } from './mysensors-serial'; 6 | 7 | describe('Serial decode / encode', () => { 8 | let decode: MysensorsSerial; 9 | 10 | beforeEach(() => { 11 | decode = new MysensorsSerial(); 12 | }); 13 | 14 | it('Should create correct decoded output when serial is received', async () => { 15 | const msg: INodeMessage = { 16 | _msgid: 'id', 17 | payload: '1;2;3;0;5;6', 18 | }; 19 | 20 | const expected: IMysensorsMsg = { 21 | _msgid: 'id', 22 | ack: 0, 23 | childSensorId: 2, 24 | messageType: 3, 25 | nodeId: 1, 26 | payload: '6', 27 | subType: 5, 28 | }; 29 | const out = await decode.decode(msg); 30 | expect(out).to.include(expected); 31 | }); 32 | 33 | it('if not mysensors formatted input return undefined', async () => { 34 | const msg: INodeMessage = { 35 | _msgid: 'id', 36 | payload: '200', 37 | }; 38 | const out = await decode.decode(msg); 39 | expect(out).to.eq(undefined); 40 | }); 41 | 42 | it('Encode to mysensors serial message', () => { 43 | const msg: IStrongMysensorsMsg = { 44 | _msgid: 'id', 45 | ack: 0, 46 | childSensorId: 2, 47 | messageType: mysensor_command.C_REQ, 48 | nodeId: 1, 49 | payload: '100', 50 | subType: 4, 51 | }; 52 | const out = decode.encode(msg); 53 | expect(out).to.include({payload: '1;2;2;0;4;100'}); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/lib/decoder/mysensors-serial.ts: -------------------------------------------------------------------------------- 1 | import { INodeMessage, IStrongMysensorsMsg, MsgOrigin, MysensorsCommand } from '../mysensors-msg'; 2 | import { IStorage } from '../storage-interface'; 3 | import { IDecoder } from './decoder-interface'; 4 | import { MysensorsDecoder } from './mysensors-decoder'; 5 | 6 | export class MysensorsSerial extends MysensorsDecoder implements IDecoder { 7 | 8 | constructor(enrich?: boolean, database?: IStorage, private addNewline = false) { 9 | super(enrich, database); 10 | } 11 | 12 | public async decode(msg: Readonly): Promise| undefined> { 13 | let message = msg.payload.toString(); 14 | message = message.replace(/(\r\n|\n|\r)/gm, ''); 15 | const tokens = message.split(';'); 16 | 17 | if (tokens.length === 6) { 18 | const msgOut: IStrongMysensorsMsg = { 19 | ...msg, 20 | nodeId: parseInt(tokens[0], 10), 21 | childSensorId: parseInt(tokens[1], 10), 22 | messageType: parseInt(tokens[2], 10), 23 | ack: tokens[3] === '1' ? 1 : 0, 24 | subType: parseInt(tokens[4], 10), 25 | payload: tokens[5], 26 | origin: MsgOrigin.serial 27 | }; 28 | 29 | return this.enrich(msgOut); 30 | } 31 | } 32 | 33 | public encode( 34 | msg: Readonly> 35 | ): IStrongMysensorsMsg { 36 | // eslint-disable-next-line max-len 37 | const payload = [ 38 | msg.nodeId, 39 | msg.childSensorId, 40 | msg.messageType, 41 | msg.ack, 42 | msg.subType, 43 | msg.payload 44 | ].join(';'); 45 | return { 46 | ...msg, 47 | payload: `${payload}${this.addNewline ? '\n' : ''}` 48 | }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/mysensors-controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { useSinonSandbox } from '../../test/sinon' 3 | import { MysensorsController } from './mysensors-controller' 4 | import { 5 | IMysensorsMsg, 6 | IStrongMysensorsMsg, 7 | MsgOrigin, 8 | } from './mysensors-msg' 9 | import { mysensor_command, mysensor_internal } from './mysensors-types' 10 | 11 | describe('Controller test', () => { 12 | const sinon = useSinonSandbox() 13 | 14 | function setupTest(timeZone = 'CET') { 15 | const storage = { 16 | getFreeNodeId: sinon.stub().resolves(777), 17 | getChild: sinon.stub().resolves(''), 18 | getNodeList: sinon.stub().resolves(''), 19 | child: sinon.stub().resolves(), 20 | childHeard: sinon.stub(), 21 | close: sinon.stub(), 22 | nodeHeard: sinon.stub(), 23 | setBatteryLevel: sinon.stub(), 24 | setParent: sinon.stub(), 25 | sketchName: sinon.stub(), 26 | sketchVersion: sinon.stub(), 27 | } 28 | 29 | return { 30 | storage, 31 | controller: new MysensorsController( 32 | storage, 33 | true, 34 | true, 35 | timeZone, 36 | 'M', 37 | 'mys-out', 38 | false, 39 | ), 40 | } 41 | } 42 | 43 | it('should not respond to an id request if no id can be retrieved', async () => { 44 | const { controller, storage } = setupTest() 45 | storage.getFreeNodeId.resolves(undefined) 46 | const input: IMysensorsMsg = { 47 | _msgid: '', 48 | payload: '', 49 | topic: 'mys-in/255/255/3/0/3', 50 | origin: MsgOrigin.serial, 51 | } 52 | 53 | const result = await controller.messageHandler(input) 54 | 55 | expect(result).to.equal(undefined) 56 | }) 57 | 58 | it('should respond to a mqtt id request with a new id', async () => { 59 | const { controller } = setupTest() 60 | const input: IMysensorsMsg = { 61 | _msgid: '', 62 | payload: '', 63 | topic: 'mys-in/255/255/3/0/3', 64 | } 65 | const expected: IMysensorsMsg = { 66 | _msgid: '', 67 | payload: '777', 68 | subType: mysensor_internal.I_ID_RESPONSE, 69 | topicRoot: 'mys-out', 70 | } 71 | const result = await controller.messageHandler(input) 72 | expect(result).to.include(expected) 73 | }) 74 | 75 | describe('sketch details', () => { 76 | it('should handle sketch name', async () => { 77 | const { controller, storage } = setupTest() 78 | const input: IMysensorsMsg = { 79 | _msgid: '', 80 | payload: '123', 81 | topic: 'mys-in/255/255/3/0/11', 82 | } 83 | 84 | const result = await controller.messageHandler(input) 85 | sinon.assert.called(storage.sketchName) 86 | expect(result).to.equal(undefined) 87 | }) 88 | 89 | it('should handle sketch version', async () => { 90 | const { controller, storage } = setupTest() 91 | const input: IMysensorsMsg = { 92 | _msgid: '', 93 | payload: '123', 94 | topic: 'mys-in/255/255/3/0/12', 95 | } 96 | 97 | const result = await controller.messageHandler(input) 98 | sinon.assert.called(storage.sketchVersion) 99 | expect(result).to.equal(undefined) 100 | }) 101 | }) 102 | 103 | it('should handle battery message', async () => { 104 | const { controller, storage } = setupTest() 105 | const input: IMysensorsMsg = { 106 | _msgid: '', 107 | payload: '123', 108 | topic: 'mys-in/255/255/3/0/0', 109 | } 110 | 111 | const result = await controller.messageHandler(input) 112 | sinon.assert.called(storage.setBatteryLevel) 113 | expect(result).to.equal(undefined) 114 | }) 115 | 116 | describe('parrent node', () => { 117 | 118 | it('should not set parent node if incorrect debug message is received', async () => { 119 | const { controller, storage } = setupTest() 120 | const input: IMysensorsMsg = { 121 | _msgid: '', 122 | payload: 'TSF:MSG:WRITE,1-2-3', 123 | topic: 'mys-in/255/255/3/0/9', 124 | } 125 | 126 | const result = await controller.messageHandler(input) 127 | sinon.assert.notCalled(storage.setParent) 128 | expect(result).to.equal(undefined) 129 | }) 130 | 131 | it('should set parent when correct debug message is received', async () => { 132 | const { controller, storage } = setupTest() 133 | const input: IMysensorsMsg = { 134 | _msgid: '', 135 | payload: 'TSF:MSG:READ,1-2-3', 136 | topic: 'mys-in/255/255/3/0/9', 137 | } 138 | 139 | const result = await controller.messageHandler(input) 140 | sinon.assert.calledWith(storage.setParent, 1, 2) 141 | expect(result).to.equal(undefined) 142 | }) 143 | }) 144 | 145 | it('should handle config request from UART message', async () => { 146 | const { controller } = setupTest() 147 | const expected: IMysensorsMsg = { 148 | _msgid: '', 149 | payload: '255;255;3;0;6;M', 150 | } 151 | const request: IMysensorsMsg = { 152 | payload: '255;255;3;0;6;0', 153 | _msgid: '', 154 | } 155 | 156 | const result = await controller.messageHandler(request) 157 | expect(result).to.include(expected) 158 | }) 159 | 160 | it('should decoded time request CET zone', async () => { 161 | const { controller } = setupTest() 162 | 163 | sinon.clock.setSystemTime(new Date('2023-01-01 00:00Z')) 164 | 165 | const request: IMysensorsMsg = { 166 | _msgid: '', 167 | ack: 0, 168 | childSensorId: 255, 169 | messageType: mysensor_command.C_INTERNAL, 170 | nodeId: 10, 171 | payload: '', 172 | subType: mysensor_internal.I_TIME, 173 | } 174 | 175 | const expected: IStrongMysensorsMsg = { 176 | _msgid: '', 177 | payload: '1672534800', 178 | ack: 0, 179 | childSensorId: 255, 180 | messageType: mysensor_command.C_INTERNAL, 181 | nodeId: 10, 182 | origin: 0, 183 | subType: mysensor_internal.I_TIME, 184 | } 185 | 186 | const result = await controller.messageHandler(request) 187 | expect(result).to.deep.equal(expected) 188 | }) 189 | 190 | it('should decoded time request ZULU zone', async () => { 191 | const { controller } = setupTest('Z') 192 | 193 | sinon.clock.setSystemTime(new Date('2023-01-01 00:00Z')) 194 | 195 | const request: IMysensorsMsg = { 196 | _msgid: '', 197 | ack: 0, 198 | childSensorId: 255, 199 | messageType: mysensor_command.C_INTERNAL, 200 | nodeId: 10, 201 | payload: '', 202 | subType: mysensor_internal.I_TIME, 203 | } 204 | 205 | const expected: IStrongMysensorsMsg = { 206 | _msgid: '', 207 | payload: '1672531200', 208 | ack: 0, 209 | childSensorId: 255, 210 | messageType: mysensor_command.C_INTERNAL, 211 | nodeId: 10, 212 | origin: 0, 213 | subType: mysensor_internal.I_TIME, 214 | } 215 | 216 | const result = await controller.messageHandler(request) 217 | expect(result).to.deep.equal(expected) 218 | }) 219 | 220 | it('should call last heard when message is received', async () => { 221 | const { controller, storage } = setupTest() 222 | 223 | await controller.messageHandler({ 224 | payload: '10;255;3;0;6;0', 225 | _msgid: '', 226 | }) 227 | sinon.assert.called(storage.nodeHeard) 228 | }) 229 | 230 | it('should return undefined without processing if message is not decodable', async () => { 231 | const { controller, storage } = setupTest() 232 | 233 | const result = await controller.messageHandler({payload: 'no-valid', _msgid: ''}) 234 | 235 | expect(result).to.equal(undefined) 236 | 237 | sinon.assert.notCalled(storage.child) 238 | sinon.assert.notCalled(storage.childHeard) 239 | sinon.assert.notCalled(storage.getFreeNodeId) 240 | sinon.assert.notCalled(storage.sketchName) 241 | sinon.assert.notCalled(storage.sketchVersion) 242 | }) 243 | 244 | it('should handle presentation message', async () => { 245 | const {controller, storage} = setupTest() 246 | 247 | const testData: IMysensorsMsg = { 248 | _msgid: '', 249 | payload: '10;255;0;0;0;100', 250 | } 251 | 252 | const result = await controller.messageHandler(testData) 253 | 254 | sinon.assert.calledOnce(storage.child) 255 | expect(result).to.equal(undefined) 256 | }) 257 | }) 258 | -------------------------------------------------------------------------------- /src/lib/mysensors-controller.ts: -------------------------------------------------------------------------------- 1 | import { IStorage } from './storage-interface' 2 | import { AutoDecode } from './decoder/auto-decode' 3 | import { IDecoder } from './decoder/decoder-interface' 4 | import { MysensorsMqtt } from './decoder/mysensors-mqtt' 5 | import { MysensorsSerial } from './decoder/mysensors-serial' 6 | import { 7 | IMysensorsMsg, 8 | IStrongMysensorsMsg, 9 | MsgOrigin, 10 | MysensorsCommand, 11 | } from './mysensors-msg' 12 | import { mysensor_command, mysensor_internal } from './mysensors-types' 13 | import { utcToZonedTime } from 'date-fns-tz' 14 | 15 | export class MysensorsController { 16 | constructor( 17 | private database: IStorage, 18 | private handleIds: boolean, 19 | private timeResponse: boolean, 20 | private timeZone: string, 21 | private measurementSystem: string, 22 | private mqttRoot: string, 23 | private addSerialNewline: boolean, 24 | ) {} 25 | 26 | public async messageHandler( 27 | IncommingMsg: Readonly, 28 | ): Promise { 29 | const msg = await AutoDecode(IncommingMsg) 30 | 31 | if (!msg) { 32 | return 33 | } 34 | 35 | await this.updateHeard(msg) 36 | 37 | if ( 38 | msg.messageType === mysensor_command.C_PRESENTATION 39 | ) { 40 | await this.database.child( 41 | msg.nodeId, 42 | msg.childSensorId, 43 | msg.subType, 44 | msg.payload as string, 45 | ) 46 | } 47 | 48 | if (msg.messageType !== mysensor_command.C_INTERNAL) { 49 | return 50 | } 51 | 52 | switch (msg.subType) { 53 | case mysensor_internal.I_ID_REQUEST: 54 | return this.encode(await this.handleIdRequest(msg)) 55 | case mysensor_internal.I_SKETCH_NAME: 56 | case mysensor_internal.I_SKETCH_VERSION: 57 | await this.handleSketchVersion(msg) 58 | break 59 | case mysensor_internal.I_LOG_MESSAGE: 60 | await this.handleDebug(msg) 61 | break 62 | case mysensor_internal.I_TIME: 63 | return this.encode(await this.handleTimeResponse(msg)) 64 | case mysensor_internal.I_CONFIG: 65 | return this.encode(await this.handleConfig(msg)) 66 | case mysensor_internal.I_BATTERY_LEVEL: 67 | await this.handleBattery(msg) 68 | break 69 | } 70 | } 71 | 72 | private async updateHeard(msg: Readonly>) { 73 | await this.database.nodeHeard(msg.nodeId) 74 | await this.database.childHeard(msg.nodeId, msg.childSensorId) 75 | } 76 | 77 | private async handleConfig( 78 | msg: Readonly>, 79 | ): Promise | undefined> { 80 | if (this.measurementSystem === 'N') { 81 | return 82 | } 83 | 84 | return { 85 | ...msg, 86 | payload: this.measurementSystem, 87 | } 88 | } 89 | 90 | private async handleTimeResponse( 91 | msg: Readonly>, 92 | ): Promise | undefined> { 93 | if (!this.timeResponse || !msg.messageType) { 94 | return 95 | } 96 | 97 | const msgCopy = {...msg} 98 | msgCopy.subType = mysensor_internal.I_TIME 99 | 100 | if (this.timeZone === 'Z') { 101 | msgCopy.payload = Math.trunc(new Date().getTime() / 1000).toString() 102 | } else { 103 | msgCopy.payload = Math.trunc(utcToZonedTime(new Date(), this.timeZone).getTime() / 1000).toString() 104 | } 105 | return msgCopy 106 | 107 | } 108 | 109 | private async handleIdRequest( 110 | msg: Readonly>, 111 | ): Promise | undefined> { 112 | if (!this.handleIds) { 113 | return 114 | } 115 | 116 | const newNodeId = await this.database.getFreeNodeId() 117 | if (!newNodeId) { 118 | return 119 | } 120 | 121 | return { 122 | ...msg, 123 | subType: mysensor_internal.I_ID_RESPONSE, 124 | payload: newNodeId.toString(), 125 | } 126 | } 127 | 128 | private async handleDebug(msg: Readonly>): Promise { 129 | const r = /TSF:MSG:READ,(\d+)-(\d+)-(\d+)/ 130 | const m = r.exec(msg.payload as string) 131 | if (!m) { 132 | return 133 | } 134 | 135 | return this.database.setParent(Number(m[1]), Number(m[2])) 136 | } 137 | 138 | private async handleBattery(msg: Readonly>): Promise { 139 | await this.database.setBatteryLevel(msg.nodeId, Number(msg.payload)) 140 | } 141 | 142 | private async handleSketchVersion(msg: Readonly>): Promise { 143 | if (msg.subType === mysensor_internal.I_SKETCH_VERSION) { 144 | return this.database.sketchVersion(msg.nodeId, msg.payload as string) 145 | } else if (msg.subType === mysensor_internal.I_SKETCH_NAME) { 146 | return this.database.sketchName(msg.nodeId, msg.payload as string) 147 | } 148 | } 149 | 150 | private encode(msg: Readonly> | undefined) { 151 | if (!msg) { 152 | return 153 | } 154 | 155 | let encoder: IDecoder | undefined 156 | 157 | if (msg.origin === MsgOrigin.serial) { 158 | encoder = new MysensorsSerial(undefined, undefined, this.addSerialNewline) 159 | } else if (msg.origin === MsgOrigin.mqtt) { 160 | encoder = new MysensorsMqtt() 161 | } 162 | if (!encoder) { 163 | return msg 164 | } 165 | return encoder.encode({...msg, topicRoot: this.mqttRoot}) 166 | } 167 | 168 | } 169 | -------------------------------------------------------------------------------- /src/lib/mysensors-debug.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import {MysensorsDebugDecode} from './mysensors-debug' 3 | 4 | describe('lib/mysensors-debug', () => { 5 | 6 | it('should decode basic message', () => { 7 | const debug = new MysensorsDebugDecode() 8 | 9 | const result = debug.decode('MCO:BGN:INIT CP=(SIGNING)') 10 | 11 | expect(result).to.equal('Core initialization with capabilities (SIGNING)') 12 | }) 13 | 14 | it('should decode command C_REQ', () => { 15 | const debug = new MysensorsDebugDecode() 16 | 17 | // Fake testvalue, but excercises most of the regex replace methods 18 | const testValue = 'MCO:BGN:BFR {command:2} {type:1:2}' 19 | 20 | const result = debug.decode(testValue) 21 | 22 | expect(result).to.equal('Callback before() C_REQ V_LIGHT') 23 | }) 24 | 25 | it('should decode command C_SET', () => { 26 | const debug = new MysensorsDebugDecode() 27 | 28 | // Fake testvalue, but excercises most of the regex replace methods 29 | const testValue = 'MCO:BGN:BFR {command:1} {type:1:1}' 30 | 31 | const result = debug.decode(testValue) 32 | 33 | expect(result).to.equal('Callback before() C_SET V_HUM') 34 | }) 35 | 36 | it('should decode type C_INTERNAL', () => { 37 | const debug = new MysensorsDebugDecode() 38 | 39 | // Fake testvalue, but excercises most of the regex replace methods 40 | const testValue = 'MCO:BGN:BFR {command:3} {pt:2} {type:3:2}' 41 | 42 | const result = debug.decode(testValue) 43 | 44 | expect(result).to.equal('Callback before() C_INTERNAL P_INT16 I_VERSION') 45 | }) 46 | 47 | it('should decode type C_PRESENTATION', () => { 48 | const debug = new MysensorsDebugDecode() 49 | 50 | // Fake testvalue, but excercises most of the regex replace methods 51 | const testValue = 'MCO:BGN:BFR {command:0} {pt:2} {type:0:2}' 52 | 53 | const result = debug.decode(testValue) 54 | 55 | expect(result).to.equal('Callback before() C_PRESENTATION P_INT16 S_SMOKE') 56 | }) 57 | 58 | it('should decode type C_STREAM', () => { 59 | const debug = new MysensorsDebugDecode() 60 | 61 | // Fake testvalue, but excercises most of the regex replace methods 62 | const testValue = 'MCO:BGN:BFR {command:4} {pt:1} {type:4:2}' 63 | 64 | const result = debug.decode(testValue) 65 | 66 | expect(result).to.equal('Callback before() C_STREAM P_BYTE ST_FIRMWARE_REQUEST') 67 | }) 68 | 69 | 70 | }) 71 | -------------------------------------------------------------------------------- /src/lib/mysensors-debug.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | import { 3 | mysensor_command, 4 | mysensor_data, 5 | mysensor_internal, 6 | mysensor_payload, 7 | mysensor_sensor, 8 | mysensor_stream, 9 | } from './mysensors-types' 10 | 11 | /* tslint:disable:max-line-length */ 12 | 13 | interface IMatch { 14 | re: string | RegExp; 15 | d: string; 16 | } 17 | 18 | export class MysensorsDebugDecode { 19 | private rprefix = '(?:\\d+ )?(?:mysgw: )?(?:Client 0: )?' 20 | private match: IMatch[] = [ 21 | { re: 'MCO:BGN:INIT CP=([^,]+)', d: 'Core initialization with capabilities $1' }, 22 | { re: 'MCO:BGN:INIT (\\w+),CP=([^,]+),VER=(.*)', d: 'Core initialization of $1 , with capabilities $2, library version $3' }, 23 | { re: 'MCO:BGN:BFR', d: 'Callback before()' }, 24 | { re: 'MCO:BGN:STP', d: 'Callback setup()' }, 25 | { re: 'MCO:BGN:INIT OK,TSP=(.*)', d: 'Core initialized, transport status $1 , (1=initialized, 0=not initialized, NA=not available)' }, 26 | { re: 'MCO:BGN:NODE UNLOCKED', d: 'Node successfully unlocked (see signing chapter)' }, 27 | { re: '!MCO:BGN:TSP FAIL', d: 'Transport initialization failed' }, 28 | { re: 'MCO:REG:REQ', d: 'Registration request' }, 29 | { re: 'MCO:REG:NOT NEEDED', d: 'No registration needed (i.e. GW)' }, 30 | { re: '!MCO:SND:NODE NOT REG', d: 'Node is not registered, cannot send message' }, 31 | { re: 'MCO:PIM:NODE REG=(\\d+)', d: 'Registration response received, registration status $1 ' }, 32 | { re: 'MCO:PIM:ROUTE N=(\\d+),R=(\\d+)', d: 'Routing table, messages to node $1 are routed via node $2 ' }, 33 | { re: 'MCO:SLP:MS=(\\d+),SMS=(\\d+),I1=(\\d+),M1=(\\d+),I2=(\\d+),M2=(\\d+)', d: 'Sleep node, duration $1 ms, SmartSleep= $2 , Int1= $3 , Mode1= $4 , Int2= $5 , Mode2= $6 ' }, 34 | { re: 'MCO:SLP:MS=(\\d+)', d: 'Sleep node, duration $1 ms' }, 35 | { re: 'MCO:SLP:TPD', d: 'Sleep node, powerdown transport' }, 36 | { re: 'MCO:SLP:WUP=(-?\\d+)', d: 'Node woke-up, reason/IRQ= $1 (-2=not possible, -1=timer, >=0 IRQ)' }, 37 | { re: '!MCO:SLP:FWUPD', d: 'Sleeping not possible, FW update ongoing' }, 38 | { re: '!MCO:SLP:REP', d: 'Sleeping not possible, repeater feature enabled' }, 39 | { re: '!MCO:SLP:TNR', d: ' Transport not ready, attempt to reconnect until timeout' }, 40 | { re: 'MCO:NLK:NODE LOCKED. UNLOCK: GND PIN (\\d+) AND RESET', d: 'Node locked during booting, see signing documentation for additional information' }, 41 | { re: 'MCO:NLK:TPD', d: 'Powerdown transport' }, 42 | { re: 'TSM:INIT', d: 'Transition to Init state' }, 43 | { re: 'TSM:INIT:STATID=(\\d+)', d: 'Init static node id $1 ' }, 44 | { re: 'TSM:INIT:TSP OK', d: 'Transport device configured and fully operational' }, 45 | { re: 'TSM:INIT:GW MODE', d: 'Node is set up as GW, thus omitting ID and findParent states' }, 46 | { re: '!TSM:INIT:TSP FAIL', d: 'Transport device initialization failed' }, 47 | { re: 'TSM:FPAR', d: 'Transition to Find Parent state' }, 48 | { re: 'TSM:FPAR:STATP=(\\d+)', d: 'Static parent $1 has been set, skip finding parent' }, 49 | { re: 'TSM:FPAR:OK', d: 'Parent node identified' }, 50 | { re: '!TSM:FPAR:NO REPLY', d: 'No potential parents replied to find parent request' }, 51 | { re: '!TSM:FPAR:FAIL', d: 'Finding parent failed' }, 52 | { re: 'TSM:ID', d: 'Transition to Request Id state' }, 53 | { re: 'TSM:ID:OK,ID=(\\d+)', d: 'Node id $1 is valid' }, 54 | { re: 'TSM:ID:REQ', d: 'Request node id from controller' }, 55 | { re: '!TSM:ID:FAIL,ID=(\\d+)', d: 'Id verification failed, $1 is invalid' }, 56 | { re: 'TSM:UPL', d: 'Transition to Check Uplink state' }, 57 | { re: 'TSM:UPL:OK', d: 'Uplink OK, GW returned ping' }, 58 | { re: '!TSM:UPL:FAIL', d: 'Uplink check failed, i.e. GW could not be pinged' }, 59 | { re: 'TSM:READY:NWD REQ', d: 'Send transport network discovery request' }, 60 | { re: 'TSM:READY:SRT', d: 'Save routing table' }, 61 | { re: 'TSM:READY:ID=(\\d+),PAR=(\\d+),DIS=(\\d+)', d: 'Transport ready, node id $1 , parent node id $2 , distance to GW is $3 ' }, 62 | { re: '!TSM:READY:UPL FAIL,SNP', d: 'Too many failed uplink transmissions, search new parent' }, 63 | { re: '!TSM:READY:FAIL,STATP', d: 'Too many failed uplink transmissions, static parent enforced' }, 64 | { re: 'TSM:READY', d: 'Transition to Ready state' }, 65 | { re: 'TSM:FAIL:DIS', d: 'Disable transport' }, 66 | { re: 'TSM:FAIL:CNT=(\\d+)', d: 'Transition to Failure state, consecutive failure counter is $1 ' }, 67 | 68 | { re: 'TSM:FAIL:PDT', d: 'Power-down transport' }, 69 | { re: 'TSM:FAIL:RE-INIT', d: 'Attempt to re-initialize transport' }, 70 | { re: 'TSF:CKU:OK,FCTRL', d: 'Uplink OK, flood control prevents pinging GW in too short intervals' }, 71 | { re: 'TSF:CKU:OK', d: 'Uplink OK' }, 72 | { re: 'TSF:CKU:DGWC,O=(\\d+),N=(\\d+)', d: 'Uplink check revealed changed network topology, old distance $1 , new distance $2 ' }, 73 | { re: 'TSF:CKU:FAIL', d: 'No reply received when checking uplink' }, 74 | { re: 'TSF:SID:OK,ID=(\\d+)', d: 'Node id $1 assigned' }, 75 | { re: '!TSF:SID:FAIL,ID=(\\d+)', d: 'Assigned id $1 is invalid' }, 76 | { re: 'TSF:PNG:SEND,TO=(\\d+)', d: 'Send ping to destination $1 ' }, 77 | { re: 'TSF:WUR:MS=(\\d+)', d: 'Wait until transport ready, timeout $1 ' }, 78 | { re: 'TSF:MSG:ACK REQ', d: 'ACK message requested' }, 79 | { re: 'TSF:MSG:ACK', d: 'ACK message, do not proceed but forward to callback' }, 80 | { re: 'TSF:MSG:FPAR RES,ID=(\\d+),D=(\\d+)', d: 'Response to find parent request received from node $1 with distance $2 to GW' }, 81 | { re: 'TSF:MSG:FPAR PREF FOUND', d: 'Preferred parent found, i.e. parent defined via MY_PARENT_NODE_ID' }, 82 | { re: 'TSF:MSG:FPAR OK,ID=(\\d+),D=(\\d+)', d: 'Find parent response from node $1 is valid, distance $2 to GW' }, 83 | { re: 'TSF:MSG:FPAR INACTIVE', d: 'Find parent response received, but no find parent request active, skip response' }, 84 | { re: 'TSF:MSG:FPAR REQ,ID=(\\d+)', d: 'Find parent request from node $1 ' }, 85 | { re: 'TSF:MSG:PINGED,ID=(\\d+),HP=(\\d+)', d: 'Node pinged by node $1 with $2 hops' }, 86 | { re: 'TSF:MSG:PONG RECV,HP=(\\d+)', d: 'Pinged node replied with $1 hops' }, 87 | { re: 'TSF:MSG:BC', d: 'Broadcast message received' }, 88 | { re: 'TSF:MSG:GWL OK', d: 'Link to GW ok' }, 89 | { re: 'TSF:MSG:FWD BC MSG', d: 'Controlled broadcast message forwarding' }, 90 | { re: 'TSF:MSG:REL MSG', d: 'Relay message' }, 91 | { re: 'TSF:MSG:REL PxNG,HP=(\\d+)', d: 'Relay PING/PONG message, increment hop counter to $1 ' }, 92 | { re: '!TSF:MSG:LEN,(\\d+)!=(\\d+)', d: 'Invalid message length, $1 (actual) != $2 (expected)' }, 93 | { re: '!TSF:MSG:PVER,(\\d+)!=(\\d+)', d: 'Message protocol version mismatch, $1 (actual) != $2 (expected)' }, 94 | { re: '!TSF:MSG:SIGN VERIFY FAIL', d: 'Signing verification failed' }, 95 | { re: '!TSF:MSG:REL MSG,NORP', d: 'Node received a message for relaying, but node is not a repeater, message skipped' }, 96 | { re: '!TSF:MSG:SIGN FAIL', d: 'Signing message failed' }, 97 | { re: '!TSF:MSG:GWL FAIL', d: 'GW uplink failed' }, 98 | { re: '!TSF:MSG:ID TK INVALID', d: 'Token for ID request invalid' }, 99 | { re: 'TSF:SAN:OK', d: 'Sanity check passed' }, 100 | { re: '!TSF:SAN:FAIL', d: 'Sanity check failed, attempt to re-initialize radio' }, 101 | { re: 'TSF:CRT:OK', d: 'Clearing routing table successful' }, 102 | { re: 'TSF:LRT:OK', d: 'Loading routing table successful' }, 103 | { re: 'TSF:SRT:OK', d: 'Saving routing table successful' }, 104 | { re: '!TSF:RTE:FPAR ACTIVE', d: 'Finding parent active, message not sent' }, 105 | { re: '!TSF:RTE:DST (\\d+) UNKNOWN', d: 'Routing for destination $1 unknown, sending message to parent' }, 106 | { re: 'TSF:RRT:ROUTE N=(\\d+),R=(\\d+)', d: 'Routing table, messages to node ( $1 ) are routed via node ( $2 )'}, 107 | { re: '!TSF:SND:TNR', d: 'Transport not ready, message cannot be sent' }, 108 | { re: 'TSF:TDI:TSL', d: 'Set transport to sleep' }, 109 | { re: 'TSF:TDI:TPD', d: 'Power down transport' }, 110 | { re: 'TSF:TRI:TRI', d: 'Reinitialise transport' }, 111 | { re: 'TSF:TRI:TSB', d: 'Set transport to standby' }, 112 | { re: 'TSF:SIR:CMD=(\\d+),VAL=(\\d+)', d: 'Get signal report $1 , value: $2 ' }, 113 | { re: 'TSF:MSG:READ,(\\d+)-(\\d+)-(\\d+),s=(\\d+),c=(\\d+),t=(\\d+),pt=(\\d+),l=(\\d+),sg=(\\d+):(.*)', d: ' Received Message \r Sender : $1\r Last Node : $2\r Destination : $3\r Sensor Id : $4\r Command : {command:$5}\r Message Type : {type:$5:$6}\r Payload Type : {pt:$7}\r Payload Length : $8\r Signing : $9\r Payload : $10' }, 114 | { re: 'TSF:MSG:SEND,(\\d+)-(\\d+)-(\\d+)-(\\d+),s=(\\d+),c=(\\d+),t=(\\d+),pt=(\\d+),l=(\\d+),sg=(\\d+),ft=(\\d+),st=(\\w+):(.*)', d: ' Sent Message \r Sender : $1\r Last Node : $2\r Next Node : $3\r Destination : $4\r Sensor Id : $5\r Command : {command:$6}\r Message Type :{type:$6:$7}\r Payload Type : {pt:$8}\r Payload Length : $9\r Signing : $10\r Failed uplink counter : $11\r Status : $12 (OK=success, NACK=no radio ACK received)\r Payload : $13' }, 115 | { re: '!TSF:MSG:SEND,(\\d+)-(\\d+)-(\\d+)-(\\d+),s=(\\d+),c=(\\d+),t=(\\d+),pt=(\\d+),l=(\\d+),sg=(\\d+),ft=(\\d+),st=(\\w+):(.*)', d: 'Sent Message \r Sender : $1\r Last Node : $2\r Next Node : $3\r Destination : $4\r Sensor Id : $5\r Command : {command:$6}\r Message Type :{type:$6:$7}\r Payload Type : {pt:$8}\r Payload Length : $9\r Signing : $10\r Failed uplink counter : $11\r Status : $12 (OK=success, NACK=no radio ACK received)\r Payload : $13' }, 116 | 117 | // Signing backend 118 | 119 | { re: 'SGN:INI:BND OK', d: 'Backend has initialized ok' }, 120 | { re: '!SGN:INI:BND FAIL', d: 'Backend has not initialized ok' }, 121 | { re: 'SGN:PER:OK', d: 'Personalization data is ok' }, 122 | { re: '!SGN:PER:TAMPERED', d: 'Personalization data has been tampered' }, 123 | { re: 'SGN:PRE:SGN REQ', d: 'Signing required' }, 124 | { re: 'SGN:PRE:SGN REQ,TO=(\\d+)', d: 'Tell node $1 that we require signing' }, 125 | { re: 'SGN:PRE:SGN REQ,FROM=(\\d+)', d: ' Node $1 require signing' }, 126 | { re: 'SGN:PRE:SGN NREQ', d: 'Signing not required' }, 127 | { re: 'SGN:PRE:SGN REQ,TO=(\\d+)', d: 'Tell node $1 that we do not require signing' }, 128 | { re: 'SGN:PRE:SGN NREQ,FROM=(\\d+)', d: 'Node $1 does not require signing' }, 129 | { re: '!SGN:PRE:SGN NREQ,FROM=(\\d+) REJ', d: 'Node $1 does not require signing but used to (requirement remain unchanged)' }, 130 | { re: 'SGN:PRE:WHI REQ', d: 'Whitelisting required' }, 131 | { re: 'SGN:PRE:WHI REQ;TO=(\\d+)', d: 'Tell $1 that we require whitelisting' }, 132 | { re: 'SGN:PRE:WHI REQ,FROM=(\\d+)', d: 'Node $1 require whitelisting' }, 133 | { re: 'SGN:PRE:WHI NREQ', d: ' Whitelisting not required' }, 134 | { re: 'SGN:PRE:WHI NREQ,TO=(\\d+)', d: 'Tell node $1 that we do not require whitelisting' }, 135 | { re: 'SGN:PRE:WHI NREQ,FROM=(\\d+)', d: 'Node $1 does not require whitelisting' }, 136 | { re: '!SGN:PRE:WHI NREQ,FROM=(\\d+) REJ', d: 'Node $1 does not require whitelisting but used to (requirement remain unchanged)' }, 137 | { re: 'SGN:PRE:XMT,TO=(\\d+)', d: 'Presentation data transmitted to node $1 ' }, 138 | { re: '!SGN:PRE:XMT,TO=(\\d+) FAIL', d: 'Presentation data not properly transmitted to node $1 ' }, 139 | { re: 'SGN:PRE:WAIT GW', d: 'Waiting for gateway presentation data' }, 140 | { re: '!SGN:PRE:VER=(\\d+)', d: 'Presentation version $1 is not supported' }, 141 | { re: 'SGN:PRE:NSUP', d: 'Received signing presentation but signing is not supported' }, 142 | { re: 'SGN:PRE:NSUP,TO=(\\d+)', d: 'Informing node $1 that we do not support signing' }, 143 | { re: 'SGN:SGN:NCE REQ,TO=(\\d+)', d: 'Nonce request transmitted to node $1 ' }, 144 | { re: '!SGN:SGN:NCE REQ,TO=(\\d+) FAIL', d: 'Nonce request not properly transmitted to node $1 ' }, 145 | { re: '!SGN:SGN:NCE TMO', d: 'Timeout waiting for nonce' }, 146 | { re: 'SGN:SGN:SGN', d: 'Message signed' }, 147 | { re: '!SGN:SGN:SGN FAIL', d: 'Message failed to be signed' }, 148 | { re: 'SGN:SGN:NREQ=(\\d+)', d: 'Node $1 does not require signed messages' }, 149 | { re: 'SGN:SGN:(\\d+)!=(\\d+) NUS', d: 'Will not sign because $1 is not $2 (repeater)' }, 150 | { re: '!SGN:SGN:STATE', d: 'Security system in a invalid state (personalization data tampered)' }, 151 | { re: '!SGN:VER:NSG', d: 'Message was not signed, but it should have been' }, 152 | { re: '!SGN:VER:FAIL', d: 'Verification failed' }, 153 | { re: 'SGN:VER:OK', d: 'Verification succeeded' }, 154 | { re: 'SGN:VER:LEFT=(\\d+)', d: ' $1 number of failed verifications left in a row before node is locked' }, 155 | { re: '!SGN:VER:STATE', d: 'Security system in a invalid state (personalization data tampered)' }, 156 | { re: 'SGN:SKP:MSG CMD=(\\d+),TYPE=(\\d+)', d: 'Message with command $1 and type $1 does not need to be signed' }, 157 | { re: 'SGN:SKP:ACK CMD=(\\d+),TYPE=(\\d+)', d: 'ACK messages does not need to be signed' }, 158 | { re: 'SGN:NCE:LEFT=(\\d+)', d: ' $1 number of nonce requests between successful verifications left before node is locked' }, 159 | { re: 'SGN:NCE:XMT,TO=(\\d+)', d: 'Nonce data transmitted to node $1 ' }, 160 | { re: '!SGN:NCE:XMT,TO=(\\d+) FAIL', d: 'Nonce data not properly transmitted to node $1 ' }, 161 | { re: '!SGN:NCE:GEN', d: 'Failed to generate nonce' }, 162 | { re: 'SGN:NCE:NSUP (DROPPED)', d: 'Ignored nonce/request for nonce (signing not supported)' }, 163 | { re: 'SGN:NCE:FROM=(\\d+)', d: 'Received nonce from node $1 ' }, 164 | { re: 'SGN:NCE:(\\d+)!=(\\d+) (DROPPED)', d: 'Ignoring nonce as it did not come from the desgination of the message to sign' }, 165 | { re: '!SGN:BND:INIT FAIL', d: 'Failed to initialize signing backend' }, 166 | { re: '!SGN:BND:PWD<8', d: 'Signing password too short' }, 167 | { re: '!SGN:BND:PER', d: 'Backend not personalized' }, 168 | { re: '!SGN:BND:SER', d: 'Could not get device unique serial from backend' }, 169 | { re: '!SGN:BND:TMR', d: 'Backend timed out' }, 170 | { re: '!SGN:BND:SIG,SIZE,(\\d+)>(\\d+)', d: 'Refusing to sign message with length $1 because it is bigger than allowed size $2 ' }, 171 | { re: 'SGN:BND:SIG WHI,ID=(\\d+)', d: 'Salting message with our id $1 ' }, 172 | { re: 'SGN:BND:SIG WHI,SERIAL=(.*)', d: 'Salting message with our serial $1 ' }, 173 | { re: '!SGN:BND:VER ONGOING', d: 'Verification failed, no ongoing session' }, 174 | { re: '!SGN:BND:VER,IDENT=(\\d+)', d: 'Verification failed, identifier $1 is unknown' }, 175 | { re: 'SGN:BND:VER WHI,ID=(\\d+)', d: 'Id $1 found in whitelist' }, 176 | { re: 'SGN:BND:VER WHI,SERIAL=(.*)', d: 'Expecting serial $1 for this sender' }, 177 | { re: '!SGN:BND:VER WHI,ID=(\\d+) MISSING', d: 'Id $1 not found in whitelist' }, 178 | { re: 'SGN:BND:NONCE=(.*)', d: 'Calculating signature using nonce $1 ' }, 179 | { re: 'SGN:BND:HMAC=(.*)', d: 'Calculated signature is $1 ' }, 180 | ] 181 | 182 | constructor() { 183 | for (let i = 0, len = this.match.length; i < len; i++) { 184 | this.match[i].re = new RegExp('^' + this.rprefix + this.match[i].re) 185 | } 186 | } 187 | 188 | public decode(msg: string): string | undefined { 189 | for (const r of this.match) { 190 | if (r.re instanceof RegExp && r.re.test(msg)) { 191 | let outStr = msg.replace(r.re, r.d) 192 | outStr = outStr.replace( 193 | /{command:(\d+)}/g, 194 | (__, m1) => mysensor_command[m1], 195 | ) 196 | outStr = outStr.replace( 197 | /{pt:(\d+)}/g, 198 | (__, m1) => mysensor_payload[m1], 199 | ) 200 | return outStr.replace( 201 | /{type:(\d+):(\d+)}/g, 202 | (__, cmd, type) => { 203 | return this.type(Number(cmd), Number(type)) 204 | }, 205 | ) 206 | } 207 | } 208 | } 209 | 210 | private type(cmd: mysensor_command, type: number): string { 211 | switch (cmd) { 212 | case mysensor_command.C_REQ: 213 | case mysensor_command.C_SET: 214 | return mysensor_data[type] 215 | case mysensor_command.C_INTERNAL: 216 | return mysensor_internal[type] 217 | case mysensor_command.C_PRESENTATION: 218 | return mysensor_sensor[type] 219 | case mysensor_command.C_STREAM: 220 | return mysensor_stream[type] 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/lib/mysensors-msg.ts: -------------------------------------------------------------------------------- 1 | import { NodeMessageInFlow } from 'node-red' 2 | import { 3 | mysensor_command, 4 | mysensor_data, 5 | mysensor_internal, 6 | mysensor_sensor, 7 | mysensor_stream, 8 | } from './mysensors-types' 9 | 10 | export interface INodeMessage extends NodeMessageInFlow { 11 | payload: string 12 | topic?: string 13 | } 14 | 15 | interface InternalMsg< 16 | SubType extends ( mysensor_data | mysensor_internal | mysensor_sensor | mysensor_stream), 17 | Command extends mysensor_command 18 | > extends NodeMessageInFlow { 19 | topicRoot?: string 20 | nodeId: number 21 | childSensorId: number 22 | messageType: Command 23 | messageTypeStr?: string 24 | ack: 0 | 1 25 | subType: SubType 26 | subTypeStr?: string 27 | sensorTypeStr?: string 28 | origin?: MsgOrigin 29 | } 30 | export type IStrongMysensorsMsg = 31 | Command extends mysensor_command.C_INTERNAL ? InternalMsg : 32 | Command extends mysensor_command.C_PRESENTATION ? InternalMsg : 33 | Command extends mysensor_command.C_REQ ? InternalMsg : 34 | Command extends mysensor_command.C_SET ? InternalMsg : 35 | InternalMsg 36 | 37 | export type MysensorsCommand = mysensor_command 38 | 39 | export interface IMysensorsMsg extends NodeMessageInFlow { 40 | topicRoot?: string 41 | nodeId?: number 42 | childSensorId?: number 43 | messageType?: mysensor_command 44 | messageTypeStr?: string 45 | ack?: 0 | 1 46 | subType?: mysensor_data | mysensor_internal | mysensor_sensor | mysensor_stream 47 | subTypeStr?: string 48 | sensorTypeStr?: string 49 | origin?: MsgOrigin 50 | } 51 | 52 | export interface IWeakMysensorsMsg extends NodeMessageInFlow { 53 | nodeId?: number 54 | } 55 | 56 | export enum MsgOrigin { 57 | decoded, 58 | serial, 59 | mqtt, 60 | } 61 | 62 | export function validateStrongMysensorsMsg( 63 | input: IMysensorsMsg | IStrongMysensorsMsg, 64 | ): input is IStrongMysensorsMsg { 65 | return input.nodeId !== undefined 66 | && input.childSensorId !== undefined 67 | && input.messageType !== undefined 68 | && input.subType !== undefined 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/mysensors-types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | export enum mysensor_command { 3 | C_PRESENTATION = 0, // !< Sent by a node when they present attached sensors. This is usually done in presentation() at startup. 4 | C_SET = 1, // !< This message is sent from or to a sensor when a sensor value should be updated. 5 | C_REQ = 2, // !< Requests a variable value (usually from an actuator destined for controller). 6 | C_INTERNAL = 3, // !< Internal MySensors messages (also include common messages provided/generated by the library). 7 | C_STREAM = 4, // !< For firmware and other larger chunks of data that need to be divided into pieces. 8 | } 9 | 10 | export enum mysensor_sensor { 11 | S_DOOR = 0, // !< Door sensor, V_TRIPPED, V_ARMED 12 | S_MOTION = 1, // !< Motion sensor, V_TRIPPED, V_ARMED 13 | S_SMOKE = 2, // !< Smoke sensor, V_TRIPPED, V_ARMED 14 | S_BINARY = 3, // !< Binary light or relay, V_STATUS, V_WATT 15 | S_LIGHT = 3, // !< \deprecated Same as S_BINARY 16 | S_DIMMER = 4, // !< Dimmable light or fan device, V_STATUS (on/off), V_PERCENTAGE (dimmer level 0-100), V_WATT 17 | S_COVER = 5, // !< Blinds or window cover, V_UP, V_DOWN, V_STOP, V_PERCENTAGE (open/close to a percentage) 18 | S_TEMP = 6, // !< Temperature sensor, V_TEMP 19 | S_HUM = 7, // !< Humidity sensor, V_HUM 20 | S_BARO = 8, // !< Barometer sensor, V_PRESSURE, V_FORECAST 21 | S_WIND = 9, // !< Wind sensor, V_WIND, V_GUST 22 | S_RAIN = 10, // !< Rain sensor, V_RAIN, V_RAINRATE 23 | S_UV = 11, // !< Uv sensor, V_UV 24 | S_WEIGHT = 12, // !< Personal scale sensor, V_WEIGHT, V_IMPEDANCE 25 | S_POWER = 13, // !< Power meter, V_WATT, V_KWH, V_VAR, V_VA, V_POWER_FACTOR 26 | S_HEATER = 14, // !< Header device, V_HVAC_SETPOINT_HEAT, V_HVAC_FLOW_STATE, V_TEMP 27 | S_DISTANCE = 15, // !< Distance sensor, V_DISTANCE 28 | S_LIGHT_LEVEL = 16, // !< Light level sensor, V_LIGHT_LEVEL (uncalibrated in percentage), V_LEVEL (light level in lux) 29 | S_ARDUINO_NODE = 17, // !< Used (internally) for presenting a non-repeating Arduino node 30 | S_ARDUINO_REPEATER_NODE = 18, // !< Used (internally) for presenting a repeating Arduino node 31 | S_LOCK = 19, // !< Lock device, V_LOCK_STATUS 32 | S_IR = 20, // !< IR device, V_IR_SEND, V_IR_RECEIVE 33 | S_WATER = 21, // !< Water meter, V_FLOW, V_VOLUME 34 | S_AIR_QUALITY = 22, // !< Air quality sensor, V_LEVEL 35 | S_CUSTOM = 23, // !< Custom sensor 36 | S_DUST = 24, // !< Dust sensor, V_LEVEL 37 | S_SCENE_CONTROLLER = 25, // !< Scene controller device, V_SCENE_ON, V_SCENE_OFF. 38 | S_RGB_LIGHT = 26, // !< RGB light. Send color component data using V_RGB. Also supports V_WATT 39 | S_RGBW_LIGHT = 27, // !< RGB light with an additional White component. Send data using V_RGBW. Also supports V_WATT 40 | S_COLOR_SENSOR = 28, // !< Color sensor, send color information using V_RGB 41 | S_HVAC = 29, // !< Thermostat/HVAC device. V_HVAC_SETPOINT_HEAT, V_HVAC_SETPOINT_COLD, V_HVAC_FLOW_STATE, V_HVAC_FLOW_MODE, V_TEMP 42 | S_MULTIMETER = 30, // !< Multimeter device, V_VOLTAGE, V_CURRENT, V_IMPEDANCE 43 | S_SPRINKLER = 31, // !< Sprinkler, V_STATUS (turn on/off), V_TRIPPED (if fire detecting device) 44 | S_WATER_LEAK = 32, // !< Water leak sensor, V_TRIPPED, V_ARMED 45 | S_SOUND = 33, // !< Sound sensor, V_TRIPPED, V_ARMED, V_LEVEL (sound level in dB) 46 | S_VIBRATION = 34, // !< Vibration sensor, V_TRIPPED, V_ARMED, V_LEVEL (vibration in Hz) 47 | S_MOISTURE = 35, // !< Moisture sensor, V_TRIPPED, V_ARMED, V_LEVEL (water content or moisture in percentage?) 48 | S_INFO = 36, // !< LCD text device / Simple information device on controller, V_TEXT 49 | S_GAS = 37, // !< Gas meter, V_FLOW, V_VOLUME 50 | S_GPS = 38, // !< GPS Sensor, V_POSITION 51 | S_WATER_QUALITY = 39, // !< V_TEMP, V_PH, V_ORP, V_EC, V_STATUS 52 | } 53 | 54 | export enum mysensor_data { 55 | V_TEMP = 0, // !< S_TEMP. Temperature S_TEMP, S_HEATER, S_HVAC 56 | V_HUM = 1, // !< S_HUM. Humidity 57 | V_STATUS = 2, // !< S_BINARY, S_DIMMER, S_SPRINKLER, S_HVAC, S_HEATER. Used for setting/reporting binary (on/off) status. 1=on, 0=off 58 | V_LIGHT = 2, // !< \deprecated Same as V_STATUS 59 | V_PERCENTAGE = 3, // !< S_DIMMER. Used for sending a percentage value 0-100 (%). 60 | V_DIMMER = 3, // !< \deprecated Same as V_PERCENTAGE 61 | V_PRESSURE = 4, // !< S_BARO. Atmospheric Pressure 62 | V_FORECAST = 5, // !< S_BARO. Whether forecast. string of 'stable', 'sunny', 'cloudy', 'unstable', 'thunderstorm' or 'unknown' 63 | V_RAIN = 6, // !< S_RAIN. Amount of rain 64 | V_RAINRATE = 7, // !< S_RAIN. Rate of rain 65 | V_WIND = 8, // !< S_WIND. Wind speed 66 | V_GUST = 9, // !< S_WIND. Gust 67 | V_DIRECTION = 10, // !< S_WIND. Wind direction 0-360 (degrees) 68 | V_UV = 11, // !< S_UV. UV light level 69 | V_WEIGHT = 12, // !< S_WEIGHT. Weight(for scales etc) 70 | V_DISTANCE = 13, // !< S_DISTANCE. Distance 71 | V_IMPEDANCE = 14, // !< S_MULTIMETER, S_WEIGHT. Impedance value 72 | V_ARMED = 15, // !< S_DOOR, S_MOTION, S_SMOKE, S_SPRINKLER. Armed status of a security sensor. 1 = Armed, 0 = Bypassed 73 | V_TRIPPED = 16, // !< S_DOOR, S_MOTION, S_SMOKE, S_SPRINKLER, S_WATER_LEAK, S_SOUND, S_VIBRATION, S_MOISTURE. Tripped status of a security sensor. 1 = Tripped, 0 74 | V_WATT = 17, // !< S_POWER, S_BINARY, S_DIMMER, S_RGB_LIGHT, S_RGBW_LIGHT. Watt value for power meters 75 | V_KWH = 18, // !< S_POWER. Accumulated number of KWH for a power meter 76 | V_SCENE_ON = 19, // !< S_SCENE_CONTROLLER. Turn on a scene 77 | V_SCENE_OFF = 20, // !< S_SCENE_CONTROLLER. Turn of a scene 78 | V_HVAC_FLOW_STATE = 21, // !< S_HEATER, S_HVAC. HVAC flow state ('Off', 'HeatOn', 'CoolOn', or 'AutoChangeOver') 79 | V_HEATER = 21, // !< \deprecated Same as V_HVAC_FLOW_STATE 80 | V_HVAC_SPEED = 22, // !< S_HVAC, S_HEATER. HVAC/Heater fan speed ('Min', 'Normal', 'Max', 'Auto') 81 | V_LIGHT_LEVEL = 23, // !< S_LIGHT_LEVEL. Uncalibrated light level. 0-100%. Use V_LEVEL for light level in lux 82 | V_VAR1 = 24, // !< VAR1 83 | V_VAR2 = 25, // !< VAR2 84 | V_VAR3 = 26, // !< VAR3 85 | V_VAR4 = 27, // !< VAR4 86 | V_VAR5 = 28, // !< VAR5 87 | V_UP = 29, // !< S_COVER. Window covering. Up 88 | V_DOWN = 30, // !< S_COVER. Window covering. Down 89 | V_STOP = 31, // !< S_COVER. Window covering. Stop 90 | V_IR_SEND = 32, // !< S_IR. Send out an IR-command 91 | V_IR_RECEIVE = 33, // !< S_IR. This message contains a received IR-command 92 | V_FLOW = 34, // !< S_WATER. Flow of water (in meter) 93 | V_VOLUME = 35, // !< S_WATER. Water volume 94 | V_LOCK_STATUS = 36, // !< S_LOCK. Set or get lock status. 1=Locked, 0=Unlocked 95 | V_LEVEL = 37, // !< S_DUST, S_AIR_QUALITY, S_SOUND (dB), S_VIBRATION (hz), S_LIGHT_LEVEL (lux) 96 | V_VOLTAGE = 38, // !< S_MULTIMETER 97 | V_CURRENT = 39, // !< S_MULTIMETER 98 | V_RGB = 40, // !< S_RGB_LIGHT, S_COLOR_SENSOR. Sent as ASCII hex: RRGGBB (RR=red, GG=green, BB=blue component) 99 | V_RGBW = 41, // !< S_RGBW_LIGHT. Sent as ASCII hex: RRGGBBWW (WW=white component) 100 | V_ID = 42, // !< Used for sending in sensors hardware ids (i.e. OneWire DS1820b). 101 | V_UNIT_PREFIX = 43, // !< Allows sensors to send in a string representing the unit prefix to be displayed in GUI, not parsed by controller! E.g. cm, m, km, inch. 102 | V_HVAC_SETPOINT_COOL = 44, // !< S_HVAC. HVAC cool setpoint (Integer between 0-100) 103 | V_HVAC_SETPOINT_HEAT = 45, // !< S_HEATER, S_HVAC. HVAC/Heater setpoint (Integer between 0-100) 104 | V_HVAC_FLOW_MODE = 46, // !< S_HVAC. Flow mode for HVAC ('Auto', 'ContinuousOn', 'PeriodicOn') 105 | V_TEXT = 47, // !< S_INFO. Text message to display on LCD or controller device 106 | V_CUSTOM = 48, // !< Custom messages used for controller/inter node specific commands, preferably using S_CUSTOM device type. 107 | V_POSITION = 49, // !< GPS position and altitude. Payload: latitude;longitude;altitude(m). E.g. '55.722526;13.017972;18' 108 | V_IR_RECORD = 50, // !< Record IR codes S_IR for playback 109 | V_PH = 51, // !< S_WATER_QUALITY, water PH 110 | V_ORP = 52, // !< S_WATER_QUALITY, water ORP : redox potential in mV 111 | V_EC = 53, // !< S_WATER_QUALITY, water electric conductivity μS/cm (microSiemens/cm) 112 | V_VAR = 54, // !< S_POWER, Reactive power: volt-ampere reactive (var) 113 | V_VA = 55, // !< S_POWER, Apparent power: volt-ampere (VA) 114 | V_POWER_FACTOR = 56, // !< S_POWER, Ratio of real power to apparent power: floating point value in the range [-1,..,1] 115 | } 116 | 117 | export enum mysensor_internal { 118 | I_BATTERY_LEVEL = 0, // !< Battery level 119 | I_TIME = 1, // !< Time (request/response) 120 | I_VERSION = 2, // !< Version 121 | I_ID_REQUEST = 3, // !< ID request 122 | I_ID_RESPONSE = 4, // !< ID response 123 | I_INCLUSION_MODE = 5, // !< Inclusion mode 124 | I_CONFIG = 6, // !< Config (request/response) 125 | I_FIND_PARENT_REQUEST = 7, // !< Find parent 126 | I_FIND_PARENT_RESPONSE = 8, // !< Find parent response 127 | I_LOG_MESSAGE = 9, // !< Log message 128 | I_CHILDREN = 10, // !< Children 129 | I_SKETCH_NAME = 11, // !< Sketch name 130 | I_SKETCH_VERSION = 12, // !< Sketch version 131 | I_REBOOT = 13, // !< Reboot request 132 | I_GATEWAY_READY = 14, // !< Gateway ready 133 | I_SIGNING_PRESENTATION = 15, // !< Provides signing related preferences (first byte is preference version) 134 | I_NONCE_REQUEST = 16, // !< Request for a nonce 135 | I_NONCE_RESPONSE = 17, // !< Payload is nonce data 136 | I_HEARTBEAT_REQUEST = 18, // !< Heartbeat request 137 | I_PRESENTATION = 19, // !< Presentation message 138 | I_DISCOVER_REQUEST = 20, // !< Discover request 139 | I_DISCOVER_RESPONSE = 21, // !< Discover response 140 | I_HEARTBEAT_RESPONSE = 22, // !< Heartbeat response 141 | I_LOCKED = 23, // !< Node is locked (reason in string-payload) 142 | I_PING = 24, // !< Ping sent to node, payload incremental hop counter 143 | I_PONG = 25, // !< In return to ping, sent back to sender, payload incremental hop counter 144 | I_REGISTRATION_REQUEST = 26, // !< Register request to GW 145 | I_REGISTRATION_RESPONSE = 27, // !< Register response from GW 146 | I_DEBUG = 28, // !< Debug message 147 | I_SIGNAL_REPORT_REQUEST = 29, // !< Device signal strength request 148 | I_SIGNAL_REPORT_REVERSE = 30, // !< Internal 149 | I_SIGNAL_REPORT_RESPONSE = 31, // !< Device signal strength response (RSSI) 150 | I_PRE_SLEEP_NOTIFICATION = 32, // !< Message sent before node is going to sleep 151 | I_POST_SLEEP_NOTIFICATION = 33, // !< Message sent after node woke up (if enabled) 152 | } 153 | 154 | export enum mysensor_stream { 155 | ST_FIRMWARE_CONFIG_REQUEST = 0, // !< Request new FW, payload contains current FW details 156 | ST_FIRMWARE_CONFIG_RESPONSE = 1, // !< New FW details to initiate OTA FW update 157 | ST_FIRMWARE_REQUEST = 2, // !< Request FW block 158 | ST_FIRMWARE_RESPONSE = 3, // !< Response FW block 159 | ST_SOUND = 4, // !< Sound 160 | ST_IMAGE = 5, // !< Image 161 | } 162 | 163 | export enum mysensor_payload { 164 | P_STRING = 0, // !< Payload type is string 165 | P_BYTE = 1, // !< Payload type is byte 166 | P_INT16 = 2, // !< Payload type is INT16 167 | P_UINT16 = 3, // !< Payload type is UINT16 168 | P_LONG32 = 4, // !< Payload type is INT32 169 | P_ULONG32 = 5, // !< Payload type is UINT32 170 | P_CUSTOM = 6, // !< Payload type is binary 171 | P_FLOAT32 = 7, // !< Payload type is float32 172 | } 173 | -------------------------------------------------------------------------------- /src/lib/nodered-storage.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { NodeContextData } from 'node-red' 3 | import { useSinonSandbox } from '../../test/sinon' 4 | import {NoderedStorage} from './nodered-storage' 5 | 6 | describe('lib/nodered-storage', () => { 7 | const sinon = useSinonSandbox() 8 | 9 | function setupStub(storageKey = 'test-key', store = 'test-store') { 10 | const get = sinon.stub().named('get') 11 | const set = sinon.stub().named('set') 12 | const keys = sinon.stub().named('keys') 13 | const context: NodeContextData = { 14 | get, 15 | set, 16 | keys, 17 | } 18 | sinon.clock.setSystemTime(new Date('2023-01-01 01:00Z')) 19 | 20 | return { 21 | get, 22 | set, 23 | keys, 24 | nodeRedStorage: new NoderedStorage(context, storageKey, store), 25 | } 26 | } 27 | 28 | it('should set node heard attribute', async () => { 29 | const {get, set, nodeRedStorage} = setupStub() 30 | 31 | await nodeRedStorage.nodeHeard(1) 32 | 33 | sinon.assert.calledOnce(get) 34 | sinon.assert.calledWith(set, 'test-key', { 35 | '1': { 36 | batteryLevel: -1, 37 | nodeId: 1, 38 | label: '', 39 | lastHeard: new Date('2023-01-01T01:00:00.000Z'), 40 | lastRestart: new Date('2023-01-01T01:00:00.000Z'), 41 | parentId: -1, 42 | sensors: [], 43 | sketchName: '', 44 | sketchVersion: '', 45 | used: 1, 46 | }, 47 | }) 48 | }) 49 | 50 | it('should set sketch name', async () => { 51 | const {set, nodeRedStorage} = setupStub() 52 | 53 | await nodeRedStorage.sketchName(2, 'test') 54 | 55 | sinon.assert.calledWith(set, 'test-key', { 56 | '2': { 57 | batteryLevel: -1, 58 | nodeId: 2, 59 | label: '', 60 | lastHeard: new Date('2023-01-01T01:00:00.000Z'), 61 | lastRestart: new Date('2023-01-01T01:00:00.000Z'), 62 | parentId: -1, 63 | sensors: [], 64 | sketchName: 'test', 65 | sketchVersion: '', 66 | used: 1, 67 | }, 68 | }) 69 | }) 70 | 71 | it('should set sketch version', async () => { 72 | const {get, set, nodeRedStorage} = setupStub() 73 | 74 | get.returns({ 75 | '2': { 76 | batteryLevel: -1, 77 | nodeId: 2, 78 | label: '', 79 | lastHeard: new Date('2023-01-01T01:00:00.000Z'), 80 | lastRestart: new Date('2023-01-01T01:00:00.000Z'), 81 | parentId: -1, 82 | sensors: [], 83 | sketchName: 'not-altered', 84 | sketchVersion: 'not-valid', 85 | used: 1, 86 | }, 87 | }) 88 | await nodeRedStorage.sketchVersion(2, '1.5') 89 | 90 | sinon.assert.calledWith(set, 'test-key', { 91 | '2': { 92 | batteryLevel: -1, 93 | nodeId: 2, 94 | label: '', 95 | lastHeard: new Date('2023-01-01T01:00:00.000Z'), 96 | lastRestart: new Date('2023-01-01T01:00:00.000Z'), 97 | parentId: -1, 98 | sensors: [], 99 | sketchName: 'not-altered', 100 | sketchVersion: '1.5', 101 | used: 1, 102 | }, 103 | }) 104 | }) 105 | 106 | it('should set child as heard', async () => { 107 | const {get, set, nodeRedStorage} = setupStub() 108 | get.returns({ 109 | '2': { 110 | batteryLevel: -1, 111 | nodeId: 2, 112 | label: '', 113 | lastHeard: new Date('1972-01-01T00:00:00.000Z'), 114 | lastRestart: new Date('1972-01-01T00:00:00.000Z'), 115 | parentId: -1, 116 | sensors: [ 117 | { 118 | childId: 5, 119 | description: 'not-altered', 120 | lastHeard: new Date('1969-01-01T00:00:00.000Z'), 121 | nodeId: 2, 122 | sType: 0, 123 | }, 124 | ], 125 | sketchName: 'not-altered', 126 | sketchVersion: 'not-valid', 127 | used: 1, 128 | }, 129 | }) 130 | await nodeRedStorage.childHeard(2, 5) 131 | 132 | sinon.assert.calledWith(set, 'test-key', { 133 | '2': { 134 | batteryLevel: -1, 135 | nodeId: 2, 136 | label: '', 137 | lastHeard: new Date('1972-01-01T00:00:00.000Z'), 138 | lastRestart: new Date('1972-01-01T00:00:00.000Z'), 139 | parentId: -1, 140 | sensors: [ 141 | { 142 | childId: 5, 143 | description: 'not-altered', 144 | lastHeard: new Date('2023-01-01T01:00:00.000Z'), 145 | nodeId: 2, 146 | sType: 0, 147 | }, 148 | ], 149 | sketchName: 'not-altered', 150 | sketchVersion: 'not-valid', 151 | used: 1, 152 | }, 153 | }, 'test-store') 154 | }) 155 | 156 | it('should not set child node if there is no parent', async () => { 157 | const {get, set, nodeRedStorage} = setupStub() 158 | get.returns({ 159 | '2': { 160 | batteryLevel: -1, 161 | nodeId: 2, 162 | label: '', 163 | lastHeard: new Date('1972-01-01T00:00:00.000Z'), 164 | lastRestart: new Date('1972-01-01T00:00:00.000Z'), 165 | parentId: -1, 166 | sensors: [ 167 | { 168 | childId: 5, 169 | description: 'not-altered', 170 | lastHeard: new Date('1969-01-01T00:00:00.000Z'), 171 | nodeId: 2, 172 | sType: 0, 173 | }, 174 | ], 175 | sketchName: 'not-altered', 176 | sketchVersion: 'not-valid', 177 | used: 1, 178 | }, 179 | }) 180 | await nodeRedStorage.childHeard(3, 5) 181 | 182 | sinon.assert.notCalled(set) 183 | }) 184 | 185 | it('should get list of nodes', async () => { 186 | const { get, nodeRedStorage } = setupStub() 187 | 188 | get.resolves({ 189 | '1': { 190 | sketchName: 'heard', 191 | used: true, 192 | }, 193 | '2': { 194 | sketchName: 'unknown', 195 | used: false, 196 | }, 197 | }) 198 | 199 | const result = await nodeRedStorage.getNodeList() 200 | 201 | expect(result).to.deep.equal([ 202 | { 203 | sketchName: 'heard', 204 | used: true, 205 | }, 206 | ]) 207 | }) 208 | 209 | it('should get a free node id to assign to a new node', async () => { 210 | const { get, nodeRedStorage } = setupStub() 211 | 212 | get.resolves({ 213 | '1': { 214 | nodeId: 1, 215 | sketchName: 'heard', 216 | used: true, 217 | }, 218 | '2': { 219 | nodeId: 2, 220 | sketchName: 'unknown', 221 | used: false, 222 | }, 223 | }) 224 | 225 | const result = await nodeRedStorage.getFreeNodeId() 226 | 227 | expect(result).to.equal(2) 228 | }) 229 | 230 | it('should get default child info if none is found', async () => { 231 | const { get, nodeRedStorage } = setupStub() 232 | 233 | get.resolves({ 234 | '1': { 235 | nodeId: 1, 236 | sketchName: 'heard', 237 | used: true, 238 | sensors: [], 239 | }, 240 | }) 241 | 242 | const result = await nodeRedStorage.getChild(1, 2) 243 | 244 | expect(result).to.deep.equal({ 245 | nodeId: 1, 246 | childId: 2, 247 | sType: 0, 248 | description: '', 249 | lastHeard: new Date('2023-01-01T01:00:00.000Z'), 250 | }) 251 | }) 252 | 253 | it('should get child info from context', async () => { 254 | const { get, nodeRedStorage } = setupStub() 255 | 256 | get.resolves({ 257 | '1': { 258 | nodeId: 1, 259 | sketchName: 'heard', 260 | used: true, 261 | sensors: [ 262 | { 263 | childId: 2, 264 | nodeId: 1, 265 | lastHeard: new Date('1985-01-01'), 266 | description: 'known child', 267 | sType: 2, 268 | }, 269 | ], 270 | }, 271 | }) 272 | 273 | const result = await nodeRedStorage.getChild(1, 2) 274 | 275 | expect(result).to.deep.equal({ 276 | nodeId: 1, 277 | childId: 2, 278 | sType: 2, 279 | description: 'known child', 280 | lastHeard: new Date('1985-01-01'), 281 | }) 282 | }) 283 | 284 | it('should set child details', async() => { 285 | const { get, set, nodeRedStorage } = setupStub() 286 | 287 | get.resolves({ 288 | '1': { 289 | nodeId: 1, 290 | sketchName: 'heard', 291 | used: true, 292 | sensors: [], 293 | }, 294 | }) 295 | 296 | const result = await nodeRedStorage.child(1, 2, 5, 'some description') 297 | 298 | expect(result).to.equal(undefined) 299 | sinon.assert.calledWith(set, 'test-key', { 300 | '1': { 301 | nodeId: 1, 302 | sketchName: 'heard', 303 | used: true, 304 | sensors: [ 305 | { 306 | nodeId: 1, 307 | childId: 2, 308 | sType: 5, 309 | description: 'some description', 310 | lastHeard: new Date('2023-01-01T01:00:00.000Z'), 311 | }, 312 | ], 313 | }, 314 | }, 'test-store') 315 | }) 316 | 317 | it('should set parent node', async () => { 318 | const { get, set, nodeRedStorage } = setupStub() 319 | 320 | get.resolves({ 321 | '1': { 322 | nodeId: 1, 323 | sketchName: 'heard', 324 | used: true, 325 | sensors: [], 326 | }, 327 | }) 328 | 329 | const result = await nodeRedStorage.setParent(1, 2) 330 | 331 | expect(result).to.equal(undefined) 332 | sinon.assert.calledWith(set, 'test-key', { 333 | '1': { 334 | nodeId: 1, 335 | sketchName: 'heard', 336 | used: true, 337 | sensors: [], 338 | parentId: 2, 339 | }, 340 | }, 'test-store') 341 | }) 342 | 343 | it('should set batterylevel for node', async () => { 344 | const { get, set, nodeRedStorage } = setupStub() 345 | 346 | get.resolves({ 347 | '1': { 348 | nodeId: 1, 349 | sketchName: 'heard', 350 | used: true, 351 | sensors: [], 352 | }, 353 | }) 354 | 355 | const result = await nodeRedStorage.setBatteryLevel(1, 2) 356 | 357 | expect(result).to.equal(undefined) 358 | sinon.assert.calledWith(set, 'test-key', { 359 | '1': { 360 | nodeId: 1, 361 | sketchName: 'heard', 362 | used: true, 363 | sensors: [], 364 | batteryLevel: 2, 365 | }, 366 | }, 'test-store') 367 | }) 368 | 369 | it('should handle undefined context gracefully', async () => { 370 | const nodeRedStorage = new NoderedStorage(undefined, '') 371 | 372 | const result = await nodeRedStorage.setBatteryLevel(1, 2) 373 | 374 | expect(result).to.equal(undefined) 375 | }) 376 | }) 377 | -------------------------------------------------------------------------------- /src/lib/nodered-storage.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import type { NodeContextData } from 'node-red' 3 | import { 4 | IStorage, 5 | INodeData, 6 | ISensorData, 7 | } from './storage-interface' 8 | 9 | type Nodes = { 10 | [key in number]: INodeData 11 | } 12 | 13 | export class NoderedStorage implements IStorage { 14 | 15 | constructor( 16 | private context: NodeContextData | undefined, 17 | private storageKey: string, 18 | private store = 'default', 19 | ) { } 20 | 21 | private async getNodes(): Promise { 22 | const data = await this.context?.get(this.storageKey, this.store) 23 | 24 | return (data || {}) as Nodes 25 | } 26 | 27 | private async setNode(nodeId: number, data: Partial): Promise { 28 | const nodes = await this.getNodes() 29 | const node = nodes?.[nodeId] || { 30 | batteryLevel: -1, 31 | nodeId: nodeId, 32 | label: '', 33 | lastHeard: new Date(), 34 | lastRestart: new Date(), 35 | parentId: -1, 36 | sensors: [], 37 | sketchName: '', 38 | sketchVersion: '', 39 | used: 1, 40 | } 41 | 42 | nodes[nodeId] = { 43 | ...node, 44 | ...data, 45 | } 46 | 47 | return this.context?.set(this.storageKey, nodes, this.store) 48 | } 49 | 50 | private async setChild(nodeId: number, childId: number, data: Partial) { 51 | const children = (await this.getNodes())?.[nodeId]?.sensors 52 | if (!children) { // We do not have parent node, so do _not_ try to set any child sensor information 53 | return 54 | } 55 | const child = children.find((item) => item.childId === childId) || { 56 | childId, 57 | description: '', 58 | lastHeard: new Date(), 59 | nodeId: nodeId, 60 | sType: 0, 61 | } 62 | 63 | const newSensors = children.filter((item) => item.childId !== childId).concat([{ 64 | ...child, 65 | ...data, 66 | }]) 67 | 68 | this.setNode(nodeId, {sensors: newSensors}) 69 | } 70 | 71 | public async nodeHeard(nodeId: number): Promise { 72 | await this.setNode(nodeId, {lastHeard: new Date()}) 73 | } 74 | 75 | public async sketchName(nodeId: number, name: string): Promise { 76 | await this.setNode(nodeId, { sketchName: name}) 77 | } 78 | 79 | public async sketchVersion(nodeId: number, version: string): Promise { 80 | await this.setNode(nodeId, {sketchVersion: version}) 81 | } 82 | 83 | public async getNodeList(): Promise { 84 | return Object.values(await this.getNodes()).filter((item) => item.used) 85 | } 86 | 87 | public async getFreeNodeId(): Promise { 88 | const freeNode = Object.values(await this.getNodes()).find((item) => !item.used) 89 | return freeNode?.nodeId 90 | } 91 | 92 | public async setParent(nodeId: number, last: number): Promise { 93 | await this.setNode(nodeId, {parentId: last}) 94 | } 95 | 96 | public async setBatteryLevel(nodeId: number, batterylevel: number): Promise { 97 | await this.setNode(nodeId, {batteryLevel: batterylevel}) 98 | } 99 | 100 | 101 | /** child nodes, dummy implementation for now */ 102 | public async getChild(nodeId: number, childId: number): Promise { 103 | const nodes = await this.getNodes() 104 | return nodes[nodeId]?.sensors?.find((item) => item.childId === childId) || { 105 | childId, 106 | nodeId, 107 | description: '', 108 | lastHeard: new Date(), 109 | sType: 0, 110 | } 111 | } 112 | 113 | public async childHeard(nodeId: number, childId: number): Promise { 114 | return this.setChild(nodeId, childId, {lastHeard:new Date()}) 115 | } 116 | 117 | public async child(_nodeId: number, _childId: number, _type: number, _description: string): Promise { 118 | await this.setChild(_nodeId, _childId, {sType: _type, description:_description}) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/lib/nullcheck.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { NullCheck } from './nullcheck' 3 | 4 | describe('lib/nullcheck', () => { 5 | it('should verify that input is undefined or null', () => { 6 | expect(NullCheck.isUndefinedOrNull(undefined)).to.equal(true) 7 | expect(NullCheck.isUndefinedOrNull(null)).to.equal(true) 8 | expect(NullCheck.isUndefinedOrNull('')).to.equal(false) 9 | expect(NullCheck.isUndefinedOrNull(0)).to.equal(false) 10 | }) 11 | 12 | it('should verify that input is defined and not null', () => { 13 | expect(NullCheck.isDefinedOrNonNull(undefined)).to.equal(false) 14 | expect(NullCheck.isDefinedOrNonNull(null)).to.equal(false) 15 | expect(NullCheck.isDefinedOrNonNull('')).to.equal(true) 16 | expect(NullCheck.isDefinedOrNonNull(0)).to.equal(true) 17 | }) 18 | 19 | }) 20 | -------------------------------------------------------------------------------- /src/lib/nullcheck.ts: -------------------------------------------------------------------------------- 1 | export class NullCheck { 2 | public static isDefinedOrNonNull( 3 | subject: T | undefined | null, 4 | ): subject is T { 5 | return subject !== undefined && subject !== null 6 | } 7 | 8 | public static isUndefinedOrNull( 9 | subject: T | undefined | null, 10 | ): subject is undefined | null { 11 | return subject === undefined || subject === null 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/storage-interface.ts: -------------------------------------------------------------------------------- 1 | import { mysensor_sensor } from './mysensors-types' 2 | 3 | export type INodeData = { 4 | nodeId: number 5 | label: string 6 | sketchName: string 7 | sketchVersion: string 8 | lastHeard: Date 9 | parentId: number 10 | used: boolean 11 | sensors: ISensorData[] 12 | lastRestart: Date 13 | batteryLevel: number 14 | } 15 | 16 | export type ISensorData = { 17 | nodeId: number 18 | childId: number 19 | description: string 20 | sType: mysensor_sensor 21 | lastHeard: Date 22 | } 23 | 24 | export interface IStorage { 25 | nodeHeard(nodeId: number): Promise 26 | sketchName(nodeId: number, name: string): Promise 27 | sketchVersion(nodeId: number, version: string): Promise 28 | getNodeList(): Promise 29 | getFreeNodeId(): Promise 30 | setParent(nodeId: number, last: number): Promise 31 | child( 32 | nodeId: number, 33 | childId: number, 34 | type: number, 35 | description: string, 36 | ): Promise 37 | childHeard(nodeId: number, childId: number): Promise 38 | getChild(nodeId: number, childId: number): Promise 39 | setBatteryLevel(nodeId: number, batterylevel: number): Promise 40 | } 41 | -------------------------------------------------------------------------------- /src/nodes/common.ts: -------------------------------------------------------------------------------- 1 | import { Node, NodeDef } from 'node-red' 2 | import { IStorage } from '../lib/storage-interface' 3 | import { IDecoder } from '../lib/decoder/decoder-interface' 4 | import { MysensorsController } from '../lib/mysensors-controller' 5 | import { MysensorsDebugDecode } from '../lib/mysensors-debug' 6 | import { IMysensorsMsg } from '../lib/mysensors-msg' 7 | import { mysensor_sensor } from '../lib/mysensors-types' 8 | 9 | /* Encode */ 10 | export interface IEncodeProperties extends NodeDef { 11 | mqtt: boolean 12 | mqtttopic: string 13 | } 14 | 15 | /* Decode */ 16 | export interface IDecodeProperties extends NodeDef { 17 | mqtt: boolean 18 | enrich: boolean 19 | database?: string 20 | } 21 | export interface IDecodeEncodeConf extends Node { 22 | decoder: IDecoder 23 | database?: IDbConfigNode 24 | enrich: boolean 25 | } 26 | 27 | /* DB */ 28 | export interface IDbConfigNode extends Node { 29 | database: IStorage 30 | contextType: 'flow' | 'global' 31 | contextKey: { 32 | store?: string; 33 | key: string; 34 | } 35 | } 36 | 37 | export interface IDBProperties extends NodeDef { 38 | store: string, 39 | contextType: 'flow' | 'global', 40 | } 41 | 42 | /* Controller */ 43 | export interface IControllerProperties extends NodeDef { 44 | database?: string 45 | handleid?: boolean 46 | timeresponse?: boolean 47 | timezone?: string 48 | measurementsystem?: string 49 | mqttroot?: string 50 | addSerialNewline?: boolean 51 | } 52 | 53 | export interface IControllerConfig extends Node { 54 | controller: MysensorsController 55 | database: IDbConfigNode 56 | handleid: boolean 57 | } 58 | 59 | /* Encapsulate */ 60 | export interface IEncapsulateConfig extends Node { 61 | sensor: IMysensorsMsg 62 | presentation: boolean 63 | presentationtext: string 64 | presentationtype: mysensor_sensor 65 | fullpresentation: boolean 66 | internal: number 67 | firmwarename: string 68 | firmwareversion: string 69 | } 70 | 71 | export interface IEncapsulateProperties extends NodeDef { 72 | nodeid: number 73 | childid: number 74 | subtype: number 75 | internal: number 76 | ack: boolean 77 | msgtype: number 78 | presentation: boolean 79 | presentationtype: number 80 | presentationtext: string 81 | fullpresentation: boolean 82 | firmwarename: string 83 | firmwareversion: string 84 | } 85 | 86 | export interface IDebugConfig extends Node { 87 | mysDbg: MysensorsDebugDecode 88 | } 89 | -------------------------------------------------------------------------------- /src/nodes/controller.html: -------------------------------------------------------------------------------- 1 | 52 | 53 | 698 | 699 | 736 | 737 | -------------------------------------------------------------------------------- /src/nodes/controller.ts: -------------------------------------------------------------------------------- 1 | import { NodeAPI } from 'node-red' 2 | 3 | import { MysensorsController } from '../lib/mysensors-controller' 4 | import { IMysensorsMsg } from '../lib/mysensors-msg' 5 | import { 6 | IControllerConfig, 7 | IControllerProperties, 8 | IDbConfigNode, 9 | } from './common' 10 | 11 | export = (RED: NodeAPI): void => { 12 | RED.nodes.registerType( 13 | 'myscontroller', 14 | function (this: IControllerConfig, props: IControllerProperties): void { 15 | RED.nodes.createNode(this, props) 16 | 17 | if (!props.database) { 18 | return 19 | } 20 | 21 | this.database = RED.nodes.getNode( 22 | props.database, 23 | ) as IDbConfigNode 24 | 25 | this.controller = new MysensorsController( 26 | this.database.database, 27 | props.handleid ?? false, 28 | props.timeresponse ?? true, 29 | props.timezone ?? 'UTC', 30 | props.measurementsystem ?? 'M', 31 | props.mqttroot ?? 'mys-out', 32 | props.addSerialNewline ?? false, 33 | ) 34 | 35 | this.on('input', async (msg: IMysensorsMsg, send, done) => { 36 | try { 37 | const msgOut = await this.controller.messageHandler(msg) 38 | if (msgOut) { 39 | send(msgOut) 40 | } 41 | done() 42 | } catch (err) { 43 | done(err as Error) 44 | } 45 | }) 46 | }, 47 | ) 48 | 49 | RED.httpAdmin.get( 50 | '/mysensornodes/:database', 51 | RED.auth.needsPermission(''), 52 | async (req, res) => { 53 | const dbNode = RED.nodes.getNode( 54 | req.params.database, 55 | ) as IDbConfigNode 56 | if (dbNode.database) { 57 | const x = await dbNode.database.getNodeList() 58 | res.json(JSON.stringify({ data: x })) 59 | } 60 | }, 61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /src/nodes/decode.html: -------------------------------------------------------------------------------- 1 | 38 | 39 | 57 | 58 | 100 | -------------------------------------------------------------------------------- /src/nodes/decode.ts: -------------------------------------------------------------------------------- 1 | import { NodeAPI } from 'node-red' 2 | 3 | import { MysensorsMqtt } from '../lib/decoder/mysensors-mqtt' 4 | import { MysensorsSerial } from '../lib/decoder/mysensors-serial' 5 | import { IMysensorsMsg, INodeMessage } from '../lib/mysensors-msg' 6 | import { 7 | IDbConfigNode, 8 | IDecodeEncodeConf, 9 | IDecodeProperties, 10 | } from './common' 11 | 12 | export = (RED: NodeAPI) => { 13 | RED.nodes.registerType( 14 | 'mysdecode', 15 | function (this: IDecodeEncodeConf, props: IDecodeProperties) { 16 | const config = props as IDecodeProperties 17 | if (props.database) { 18 | this.database = RED.nodes.getNode( 19 | props.database, 20 | ) as IDbConfigNode 21 | } 22 | 23 | this.enrich = props.enrich 24 | 25 | if (config.mqtt) { 26 | this.decoder = new MysensorsMqtt( 27 | props.enrich, 28 | this.database?.database, 29 | ) 30 | } else { 31 | this.decoder = new MysensorsSerial( 32 | props.enrich, 33 | this.database?.database, 34 | ) 35 | } 36 | 37 | RED.nodes.createNode(this, config) 38 | 39 | this.on('input', async (msg: IMysensorsMsg, send, done) => { 40 | const message = await this.decoder.decode(msg as INodeMessage) 41 | if (message) { 42 | send(message) 43 | } 44 | done() 45 | }) 46 | }, 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/nodes/encapsulate.html: -------------------------------------------------------------------------------- 1 | 74 | 109 | 110 | 241 | -------------------------------------------------------------------------------- /src/nodes/encapsulate.ts: -------------------------------------------------------------------------------- 1 | import { NodeAPI } from 'node-red' 2 | 3 | import { IMysensorsMsg } from '../lib/mysensors-msg' 4 | import { 5 | mysensor_data, 6 | mysensor_internal, 7 | mysensor_sensor, 8 | } from '../lib/mysensors-types' 9 | import { IEncapsulateConfig, IEncapsulateProperties } from './common' 10 | 11 | export = (RED: NodeAPI) => { 12 | RED.nodes.registerType( 13 | 'mysencap', 14 | function (this: IEncapsulateConfig, props: IEncapsulateProperties) { 15 | RED.nodes.createNode(this, props) 16 | this.sensor = getSensor(props) 17 | this.presentation = props.presentation || false 18 | this.presentationtext = props.presentationtext || '' 19 | this.presentationtype = props.presentationtype || 0 20 | this.fullpresentation = props.fullpresentation || false 21 | this.internal = props.internal || 0 22 | this.firmwarename = props.firmwarename || '' 23 | this.firmwareversion = props.firmwareversion || '' 24 | 25 | if (this.presentation) { 26 | setTimeout(() => { 27 | const msg = getSensor(props) 28 | msg.ack = 0 29 | if (this.fullpresentation) { 30 | msg.messageType = 3 31 | msg.childSensorId = 255 // Internal messages always send as childi 255 32 | msg.subType = 11 // Sketchname 33 | msg.payload = this.firmwarename 34 | this.send(msg) 35 | 36 | msg.subType = 12 // Sketchname 37 | msg.payload = this.firmwareversion 38 | this.send(msg) 39 | } 40 | msg.messageType = 0 41 | msg.subType = this.presentationtype 42 | msg.payload = this.presentationtext 43 | this.send(msg) 44 | }, 5000) 45 | } 46 | 47 | this.on('input', (msg: IMysensorsMsg, send, done) => { 48 | const msgOut = this.sensor 49 | msgOut.payload = msg.payload 50 | if (this.sensor.messageType === 3) { 51 | msgOut.childSensorId = 255 52 | msgOut.subType = this.internal 53 | } 54 | send(msgOut) 55 | done() 56 | }) 57 | }, 58 | ) 59 | 60 | RED.httpAdmin.get( 61 | '/mysensordefs/:id', 62 | RED.auth.needsPermission(''), 63 | (req, res) => { 64 | const type = req.params.id 65 | 66 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 67 | let mysVal: any 68 | 69 | switch (type) { 70 | case 'subtype': 71 | mysVal = mysensor_data 72 | break 73 | case 'presentation': 74 | mysVal = mysensor_sensor 75 | break 76 | case 'internal': 77 | mysVal = mysensor_internal 78 | break 79 | } 80 | 81 | const kv = Object.keys(mysVal) 82 | .filter((k) => typeof mysVal[k] === 'number') 83 | .reduce((l: Record, k) => { 84 | if (typeof k !== 'number') { 85 | l[k] = mysVal[k] 86 | } 87 | return l 88 | }, {}) 89 | res.json(JSON.stringify(kv)) 90 | }, 91 | ) 92 | } 93 | 94 | function getSensor(config: IEncapsulateProperties): IMysensorsMsg { 95 | const sensor: IMysensorsMsg = { 96 | _msgid: '', 97 | ack: config.ack ? 1 : 0, 98 | childSensorId: Number(config.childid), 99 | messageType: Number(config.msgtype), 100 | nodeId: Number(config.nodeid), 101 | payload: '', 102 | subType: Number(config.subtype), 103 | } 104 | return sensor 105 | } 106 | -------------------------------------------------------------------------------- /src/nodes/encode.html: -------------------------------------------------------------------------------- 1 | 43 | 44 | 58 | 59 | 102 | -------------------------------------------------------------------------------- /src/nodes/encode.ts: -------------------------------------------------------------------------------- 1 | import { NodeAPI } from 'node-red' 2 | 3 | import { MysensorsMqtt } from '../lib/decoder/mysensors-mqtt' 4 | import { MysensorsSerial } from '../lib/decoder/mysensors-serial' 5 | import { IMysensorsMsg, validateStrongMysensorsMsg } from '../lib/mysensors-msg' 6 | import { IDecodeEncodeConf, IEncodeProperties } from './common' 7 | 8 | export = (RED: NodeAPI) => { 9 | RED.nodes.registerType( 10 | 'mysencode', 11 | function (this: IDecodeEncodeConf, props: IEncodeProperties) { 12 | RED.nodes.createNode(this, props) 13 | if (props.mqtt) { 14 | this.decoder = new MysensorsMqtt() 15 | } else { 16 | this.decoder = new MysensorsSerial() 17 | } 18 | this.on('input', (msg: IMysensorsMsg, send, done) => { 19 | if (props.mqtttopic !== '') { 20 | msg.topicRoot = props.mqtttopic 21 | } 22 | if (validateStrongMysensorsMsg(msg)) { 23 | send(this.decoder.encode(msg)) 24 | } 25 | done() 26 | }) 27 | }, 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/nodes/mysdebug.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 23 | 24 | 35 | -------------------------------------------------------------------------------- /src/nodes/mysdebug.ts: -------------------------------------------------------------------------------- 1 | import { NodeDef, NodeAPI } from 'node-red' 2 | 3 | import { AutoDecode } from '../lib/decoder/auto-decode' 4 | import { MysensorsDebugDecode } from '../lib/mysensors-debug' 5 | import { IMysensorsMsg } from '../lib/mysensors-msg' 6 | import { 7 | mysensor_command, 8 | mysensor_data, 9 | mysensor_internal, 10 | mysensor_sensor, 11 | mysensor_stream, 12 | } from '../lib/mysensors-types' 13 | import { IDebugConfig } from './common' 14 | 15 | export = (RED: NodeAPI) => { 16 | RED.nodes.registerType( 17 | 'mysdebug', 18 | function (this: IDebugConfig, config: NodeDef) { 19 | RED.nodes.createNode(this, config) 20 | this.mysDbg = new MysensorsDebugDecode() 21 | 22 | this.on('input', async (incommingMsg: IMysensorsMsg, send, done) => { 23 | 24 | const msg = await AutoDecode(incommingMsg) 25 | 26 | if (!msg) { 27 | done() 28 | return 29 | } 30 | 31 | let msgHeader = '' 32 | let msgSubType: string | undefined 33 | switch (msg.messageType) { 34 | case mysensor_command.C_PRESENTATION: 35 | msgHeader = 'PRESENTATION' 36 | msgSubType = mysensor_sensor[msg.subType] 37 | break 38 | case mysensor_command.C_SET: 39 | msgHeader = 'SET' 40 | msgSubType = mysensor_data[msg.subType] 41 | break 42 | case mysensor_command.C_REQ: 43 | msgHeader = 'REQ' 44 | msgSubType = mysensor_data[msg.subType] 45 | break 46 | case mysensor_command.C_INTERNAL: 47 | if (msg.subType === 9) { // Debug, we try to decode this 48 | send({payload: this.mysDbg.decode(msg.payload as string)}) 49 | } else { 50 | msgHeader = 'INTERNAL' 51 | msgSubType = mysensor_internal[msg.subType] 52 | } 53 | break 54 | case mysensor_command.C_STREAM: 55 | msgHeader = 'STREAM' 56 | msgSubType = mysensor_stream[msg.subType] 57 | break 58 | default: 59 | send({payload: `unsupported msgType ${(msg as {messageType: number}).messageType}`}) 60 | } 61 | 62 | if (msgSubType) { 63 | send({ 64 | // eslint-disable-next-line max-len 65 | payload: `${msgHeader} nodeId:${msg.nodeId} childId:${msg.childSensorId} subType:${msg.subType} payload:${msg.payload}`, 66 | }) 67 | } 68 | done() 69 | }) 70 | }, 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /src/nodes/mysensors-db.html: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | 24 | 48 | -------------------------------------------------------------------------------- /src/nodes/mysensors-db.ts: -------------------------------------------------------------------------------- 1 | import { NodeAPI } from 'node-red' 2 | 3 | import { NoderedStorage } from '../lib/nodered-storage' 4 | import { IDbConfigNode, IDBProperties } from './common' 5 | 6 | export = (RED: NodeAPI) => { 7 | RED.nodes.registerType( 8 | 'mysensorsdb', 9 | function MysensorsDb(this: IDbConfigNode, props: IDBProperties) { 10 | RED.nodes.createNode(this, props) 11 | this.contextType = props.contextType || 'flow' 12 | this.contextKey = RED.util.parseContextStore(props.store) 13 | const myContext = this.context()[this.contextType] 14 | 15 | this.database = new NoderedStorage(myContext, this.contextKey.key, this.contextKey.store ) 16 | }, 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /test/sinon.ts: -------------------------------------------------------------------------------- 1 | import { createSandbox } from 'sinon'; 2 | import type { InstalledClock } from '@sinonjs/fake-timers'; 3 | import * as fakeTimers from '@sinonjs/fake-timers'; 4 | 5 | const clock: InstalledClock = fakeTimers.install(); 6 | 7 | export const useSinonSandbox = () => { 8 | const sandbox = createSandbox(); 9 | 10 | afterEach(() => { 11 | sandbox.restore(); 12 | }); 13 | 14 | return { 15 | ...sandbox, 16 | get clock() { 17 | return clock; 18 | } 19 | }; 20 | }; 21 | 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "target": "es2020", 6 | "noImplicitAny": true, 7 | "removeComments": true, 8 | "preserveConstEnums": true, 9 | "experimentalDecorators": true, 10 | "sourceMap": false, 11 | "declaration": false, 12 | "listFiles": false, 13 | "strict": true, 14 | "outDir": "./dist/", 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "esModuleInterop": true, 18 | }, 19 | "include": [ 20 | "src/**/*.ts" 21 | ], 22 | "exclude": [ 23 | "node_modules/**/*", 24 | "src/**/*.spec.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "max-line-length": { 5 | "options": [120] 6 | }, 7 | "quotemark": [ 8 | true, 9 | "single", 10 | "avoid-escape" 11 | ] 12 | }, 13 | "jsRules": { 14 | "max-line-length": { 15 | "options": [120] 16 | } 17 | } 18 | } 19 | --------------------------------------------------------------------------------