├── .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 | [](https://sonarcloud.io/summary/new_code?id=tbowmo_node-red-small-timer)
3 | 
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 |
--------------------------------------------------------------------------------