diff --git a/.github/workflows/PR_build.yml b/.github/workflows/PR_build.yml index 5e6a005ae..4bb9f0c14 100644 --- a/.github/workflows/PR_build.yml +++ b/.github/workflows/PR_build.yml @@ -160,3 +160,38 @@ jobs: with: name: python-package path: python/dist/* + + nodejs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Set up Emscripten + uses: mymindstorm/setup-emsdk@v14 + with: + version: 3.1.61 + actions-cache-folder: emsdk-cache + - name: Build WebAssembly module + working-directory: nodejs/theengs-decoder + run: | + npm install --ignore-scripts + npm run build + - name: Run base package tests + working-directory: nodejs/theengs-decoder + run: npm test + - name: Run Node-RED wrapper tests + working-directory: nodejs/node-red-contrib-theengs-decoder + run: | + # The wrapper's only dependency is the locally-built base package, + # which isn't on npm yet at PR time, so install it directly. + npm install --no-save ../theengs-decoder + npm test + - uses: actions/upload-artifact@v4 + with: + name: nodejs-wasm + path: nodejs/theengs-decoder/dist/* diff --git a/.github/workflows/publish_npm.yml b/.github/workflows/publish_npm.yml new file mode 100644 index 000000000..eaf587396 --- /dev/null +++ b/.github/workflows/publish_npm.yml @@ -0,0 +1,81 @@ +name: Publish to npm + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to publish (e.g. 2.2.0). Defaults to the release tag.' + required: false + release: + types: [published] + +jobs: + publish: + name: Build and publish Node packages to npm + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Set up Emscripten + uses: mymindstorm/setup-emsdk@v14 + with: + version: 3.1.61 + actions-cache-folder: emsdk-cache + + - name: Resolve publish version + id: version + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + REF_NAME: ${{ github.ref_name }} + run: | + if [ -n "$INPUT_VERSION" ]; then + VERSION="$INPUT_VERSION" + else + VERSION="${REF_NAME#v}" + fi + echo "Publishing version: $VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Stamp package versions + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + cd nodejs/theengs-decoder + npm version "$VERSION" --no-git-tag-version --allow-same-version + cd ../node-red-contrib-theengs-decoder + npm version "$VERSION" --no-git-tag-version --allow-same-version + npm pkg set "dependencies.theengs-decoder=$VERSION" + + - name: Build WebAssembly module + working-directory: nodejs/theengs-decoder + run: | + npm install --ignore-scripts + npm run build + + - name: Run base package tests + working-directory: nodejs/theengs-decoder + run: npm test + + - name: Publish theengs-decoder to npm + working-directory: nodejs/theengs-decoder + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --provenance --access public + + - name: Publish node-red-contrib-theengs-decoder to npm + working-directory: nodejs/node-red-contrib-theengs-decoder + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --provenance --access public diff --git a/CMakeLists.txt b/CMakeLists.txt index c7c253bd4..2dca99689 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,27 @@ if(SKBUILD) target_compile_features(_decoder PRIVATE cxx_std_11) install(TARGETS _decoder LIBRARY DESTINATION TheengsDecoder) +elseif(BUILD_WASM) + message(STATUS "Building WebAssembly module via Emscripten") + + add_executable(theengs_decoder_wasm + nodejs/theengs-decoder/src/wasm_bindings.cc + src/decoder.cpp + ) + + target_include_directories(theengs_decoder_wasm + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src/arduino_json/src + ${CMAKE_CURRENT_SOURCE_DIR}/src + ) + + target_compile_features(theengs_decoder_wasm PRIVATE cxx_std_17) + + set_target_properties(theengs_decoder_wasm PROPERTIES + SUFFIX ".js" + LINK_FLAGS "-lembind -sMODULARIZE=1 -sEXPORT_NAME=createTheengsDecoderModule -sENVIRONMENT=node -sALLOW_MEMORY_GROWTH=1 -sSINGLE_FILE=1 -sNODEJS_CATCH_EXIT=0 -sNODEJS_CATCH_REJECTION=0" + ) + else() add_library(decoder diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index cc7684e25..540a54db7 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -31,7 +31,9 @@ export default defineConfig({ items: [ { text: 'Using the library in a project', link: '/use/include' }, { text: 'Using with ESP32', link: '/use/ESP32' }, - { text: 'Using with Python', link: '/use/python' } + { text: 'Using with Python', link: '/use/python' }, + { text: 'Using with Node.js', link: '/use/nodejs' }, + { text: 'Using with Node-RED', link: '/use/node-red' } ] }, { diff --git a/docs/use/node-red.md b/docs/use/node-red.md new file mode 100644 index 000000000..f1a0e595b --- /dev/null +++ b/docs/use/node-red.md @@ -0,0 +1,84 @@ +# Using with Node-RED + +The `node-red-contrib-theengs-decoder` package adds a **theengs decode** +node to the Node-RED palette. Wire it after any node that produces BLE +advertisement messages — for example an MQTT input from a Theengs Gateway, +or a BLE-scanner node — and it will replace the raw advertisement on +`msg.payload` with the decoded device information. + +The decoder runs as WebAssembly, so installation does not require a C++ +toolchain on the host running Node-RED. + +## Installing + +From inside Node-RED, open the palette manager (☰ → Manage palette → +Install) and search for `node-red-contrib-theengs-decoder`. Click +**Install**. + +Or from the command line in your Node-RED user directory (typically +`~/.node-red`): + +```sh +npm install node-red-contrib-theengs-decoder +``` + +Then restart Node-RED. + +## Wiring it up + +The node accepts `msg.payload` as either an object or a JSON string with +at least one of: + +- `servicedata` — hex string of the BLE service data +- `manufacturerdata` — hex string of the BLE manufacturer data +- `id` — device identifier (MAC), optional, used by some decoders +- `name` — advertised local name, optional, used by some decoders + +A typical flow looks like: + +``` +[ MQTT in (BTtoMQTT/+) ] → [ theengs decode ] → [ debug ] +``` + +When a decoder matches, `msg.payload` becomes an object with `brand`, +`model`, `model_id`, and any device-specific readings (`tempc`, `hum`, +`batt`, …). + +## Configuration + +- **Name** — optional label shown in the editor. +- **Pass through on no match** — when checked (default), advertisements + that no decoder recognizes are forwarded unchanged so the rest of your + flow can still see them. When unchecked, unmatched messages are + dropped. + +## Example flow + +The package ships an importable example flow at +[`examples/decode-flow.json`](https://github.com/theengs/decoder/blob/development/nodejs/node-red-contrib-theengs-decoder/examples/decode-flow.json). +In Node-RED: ☰ → Import → paste the JSON → **Import**. Deploy, click the +inject node, and the debug pane should show the decoded miflora reading. + +## Building a development version + +The Node-RED node depends on the +[`theengs-decoder`](./nodejs) base package. To work on both from a +checkout, build the base package locally first: + +```sh +git clone --recursive https://github.com/theengs/decoder.git +cd decoder/nodejs/theengs-decoder +npm install +npm run build + +cd ../node-red-contrib-theengs-decoder +npm install +npm install --no-save ../theengs-decoder +``` + +Then point Node-RED at the local checkout: + +```sh +cd ~/.node-red +npm install /path/to/decoder/nodejs/node-red-contrib-theengs-decoder +``` diff --git a/docs/use/nodejs.md b/docs/use/nodejs.md new file mode 100644 index 000000000..2f852387c --- /dev/null +++ b/docs/use/nodejs.md @@ -0,0 +1,78 @@ +# Using with Node.js + +The `theengs-decoder` package is a Node.js binding for the Theengs Decoder +library. It runs as WebAssembly, so installation does not require a C++ +toolchain on the user's machine. + +## Installing from npm + +```sh +npm install theengs-decoder +``` + +Requires Node.js 18 or newer. + +## Using + +```js +const { decodeBLE, getProperties, getAttribute } = require('theengs-decoder'); + +const decoded = await decodeBLE({ + servicedata: '71205d0183d20c6d8d7cc40d08100103', +}); +// → { brand: 'Xiaomi', model: 'RoPot', model_id: 'HHCCPOT002', moi: 3, mac: 'C4:7C:8D:6D:0C:D2', ... } +``` + +If the decoder doesn't recognize the advertisement, `decodeBLE` returns +`null`. The input may be either an object or a JSON string — both work. + +The package also exposes `getProperties(model_id)` and +`getAttribute(model_id, attribute)`, mirroring the Python binding: + +```js +const props = await getProperties('HHCCPOT002'); +// → { properties: { moi: { unit: '%', name: 'moisture' }, ... } } + +const brand = await getAttribute('HHCCPOT002', 'brand'); +// → 'Xiaomi' +``` + +These are useful for forwarding decoded values to Home Assistant or other +automation systems with the right unit and naming metadata. + +## Pre-loading the WebAssembly module + +The first call to any of the three functions implicitly loads the +WebAssembly module. To pay that cost at startup instead of on the first +hot-path call, await `ready()` once: + +```js +const { ready, decodeBLE } = require('theengs-decoder'); + +await ready(); +// subsequent decodeBLE() calls don't pay the load cost +``` + +## Building a development version + +Building the module from source requires an +[Emscripten](https://emscripten.org/) toolchain (`emcc`, `emcmake`, +`emmake`). + +```sh +git clone --recursive https://github.com/theengs/decoder.git +cd decoder/nodejs/theengs-decoder +npm install +npm run build +npm test +``` + +## Methods + +- `ready()` — Resolves once the WebAssembly module is loaded. Optional. +- `decodeBLE(input)` — Returns the decoded object, or `null` if no + decoder matched. Input can be an object or a JSON string. +- `getProperties(modelId)` — Returns the properties object for a known + model id, or `null`. +- `getAttribute(modelId, attribute)` — Returns the attribute value as a + string, or `null`. diff --git a/nodejs/node-red-contrib-theengs-decoder/.gitignore b/nodejs/node-red-contrib-theengs-decoder/.gitignore new file mode 100644 index 000000000..504afef81 --- /dev/null +++ b/nodejs/node-red-contrib-theengs-decoder/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/nodejs/node-red-contrib-theengs-decoder/README.md b/nodejs/node-red-contrib-theengs-decoder/README.md new file mode 100644 index 000000000..e2a166033 --- /dev/null +++ b/nodejs/node-red-contrib-theengs-decoder/README.md @@ -0,0 +1,30 @@ +# node-red-contrib-theengs-decoder + +A Node-RED node that decodes BLE advertisement messages using +[Theengs Decoder](https://decoder.theengs.io/). + +## Install + +From the Node-RED palette manager, search for +`node-red-contrib-theengs-decoder` and click install. Or, from the +command line in your Node-RED user directory (typically `~/.node-red`): + +```sh +npm install node-red-contrib-theengs-decoder +``` + +The decoder is shipped as WebAssembly — no native toolchain is required. + +## Usage + +Wire any BLE-scanner node (or an MQTT input from a Theengs Gateway) into +the **theengs decode** node. The input `msg.payload` should be an object +with at least one of `servicedata` or `manufacturerdata` (hex string). +The output `msg.payload` will be the decoded device information. + +An importable example flow is included under +[`examples/`](https://github.com/theengs/decoder/tree/development/nodejs/node-red-contrib-theengs-decoder/examples). + +## License + +GPL-3.0-only — same as the underlying Theengs Decoder. diff --git a/nodejs/node-red-contrib-theengs-decoder/examples/decode-flow.json b/nodejs/node-red-contrib-theengs-decoder/examples/decode-flow.json new file mode 100644 index 000000000..9ce840ba8 --- /dev/null +++ b/nodejs/node-red-contrib-theengs-decoder/examples/decode-flow.json @@ -0,0 +1,55 @@ +[ + { + "id": "theengs-example-tab", + "type": "tab", + "label": "Theengs Decoder", + "disabled": false, + "info": "Sample flow that injects a Xiaomi RoPot BLE advertisement, decodes it with the theengs-decode node, and prints the result to the debug pane." + }, + { + "id": "theengs-example-inject", + "type": "inject", + "z": "theengs-example-tab", + "name": "RoPot sample", + "props": [ + { "p": "payload" } + ], + "repeat": "", + "crontab": "", + "once": false, + "onceDelay": 0.1, + "topic": "", + "payload": "{\"servicedata\":\"71205d0183d20c6d8d7cc40d08100103\"}", + "payloadType": "json", + "x": 160, + "y": 120, + "wires": [["theengs-example-decode"]] + }, + { + "id": "theengs-example-decode", + "type": "theengs-decode", + "z": "theengs-example-tab", + "name": "", + "passOnNoMatch": true, + "x": 380, + "y": 120, + "wires": [["theengs-example-debug"]] + }, + { + "id": "theengs-example-debug", + "type": "debug", + "z": "theengs-example-tab", + "name": "decoded", + "active": true, + "tosidebar": true, + "console": false, + "tostatus": false, + "complete": "payload", + "targetType": "msg", + "statusVal": "", + "statusType": "auto", + "x": 580, + "y": 120, + "wires": [] + } +] diff --git a/nodejs/node-red-contrib-theengs-decoder/package.json b/nodejs/node-red-contrib-theengs-decoder/package.json new file mode 100644 index 000000000..f7a02e939 --- /dev/null +++ b/nodejs/node-red-contrib-theengs-decoder/package.json @@ -0,0 +1,46 @@ +{ + "name": "node-red-contrib-theengs-decoder", + "version": "0.1.0", + "description": "Node-RED node to decode BLE advertisements using Theengs Decoder.", + "keywords": [ + "node-red", + "theengs", + "ble", + "bluetooth", + "decoder", + "iot", + "home-automation" + ], + "homepage": "https://decoder.theengs.io/", + "bugs": "https://github.com/theengs/decoder/issues", + "license": "GPL-3.0-only", + "author": "Theengs", + "main": "theengs-decode.js", + "files": [ + "theengs-decode.js", + "theengs-decode.html", + "examples/", + "README.md", + "LICENSE" + ], + "scripts": { + "test": "node --test test/" + }, + "node-red": { + "version": ">=2.0.0", + "nodes": { + "theengs-decode": "theengs-decode.js" + } + }, + "dependencies": { + "theengs-decoder": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/theengs/decoder.git", + "directory": "nodejs/node-red-contrib-theengs-decoder" + } +} diff --git a/nodejs/node-red-contrib-theengs-decoder/test/wrapper.test.js b/nodejs/node-red-contrib-theengs-decoder/test/wrapper.test.js new file mode 100644 index 000000000..5380c646e --- /dev/null +++ b/nodejs/node-red-contrib-theengs-decoder/test/wrapper.test.js @@ -0,0 +1,91 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const path = require('node:path'); + +const NODE_FILE = path.resolve(__dirname, '..', 'theengs-decode.js'); +const ROPOT_PAYLOAD = '71205d0183d20c6d8d7cc40d08100103'; + +function makeMockRED() { + const RED = { + registered: {}, + nodes: { + createNode(node /*, cfg */) { + node._handlers = {}; + node.error = () => {}; + node.on = (ev, cb) => { + node._handlers[ev] = cb; + }; + }, + registerType(name, ctor) { + RED.registered[name] = ctor; + }, + }, + }; + return RED; +} + +async function runOnce(RED, config, payload) { + const ctor = RED.registered['theengs-decode']; + assert.ok(ctor, 'theengs-decode constructor should be registered'); + const node = {}; + ctor.call(node, config); + const sent = []; + await new Promise((resolve, reject) => { + node._handlers.input( + { payload }, + (m) => sent.push(m), + (err) => (err ? reject(err) : resolve()), + ); + }); + return sent; +} + +test('registers the theengs-decode node type', () => { + const RED = makeMockRED(); + require(NODE_FILE)(RED); + assert.strictEqual(typeof RED.registered['theengs-decode'], 'function'); +}); + +test('decodes a matched payload and forwards enriched message', async () => { + const RED = makeMockRED(); + require(NODE_FILE)(RED); + const sent = await runOnce(RED, {}, { servicedata: ROPOT_PAYLOAD }); + assert.strictEqual(sent.length, 1); + assert.strictEqual(sent[0].payload.brand, 'Xiaomi'); + assert.strictEqual(sent[0].payload.model_id, 'HHCCPOT002'); + assert.strictEqual(sent[0].payload.moi, 3); + assert.strictEqual(sent[0].payload.mac, 'C4:7C:8D:6D:0C:D2'); +}); + +test('passes message through unchanged when no decoder matches (default)', async () => { + const RED = makeMockRED(); + require(NODE_FILE)(RED); + const sent = await runOnce(RED, {}, { servicedata: 'deadbeef' }); + assert.strictEqual(sent.length, 1); + assert.deepStrictEqual(sent[0].payload, { servicedata: 'deadbeef' }); +}); + +test('drops message when passOnNoMatch is false and no decoder matches', async () => { + const RED = makeMockRED(); + require(NODE_FILE)(RED); + const sent = await runOnce( + RED, + { passOnNoMatch: false }, + { servicedata: 'deadbeef' }, + ); + assert.strictEqual(sent.length, 0); +}); + +test('accepts a JSON string payload', async () => { + const RED = makeMockRED(); + require(NODE_FILE)(RED); + const sent = await runOnce( + RED, + {}, + `{"servicedata":"${ROPOT_PAYLOAD}"}`, + ); + assert.strictEqual(sent.length, 1); + assert.strictEqual(sent[0].payload.model_id, 'HHCCPOT002'); +}); diff --git a/nodejs/node-red-contrib-theengs-decoder/theengs-decode.html b/nodejs/node-red-contrib-theengs-decoder/theengs-decode.html new file mode 100644 index 000000000..5efb8235d --- /dev/null +++ b/nodejs/node-red-contrib-theengs-decoder/theengs-decode.html @@ -0,0 +1,64 @@ + + + + + diff --git a/nodejs/node-red-contrib-theengs-decoder/theengs-decode.js b/nodejs/node-red-contrib-theengs-decoder/theengs-decode.js new file mode 100644 index 000000000..1bef94e7e --- /dev/null +++ b/nodejs/node-red-contrib-theengs-decoder/theengs-decode.js @@ -0,0 +1,43 @@ +'use strict'; + +module.exports = function (RED) { + let decoderModule = null; + + function getDecoder() { + if (!decoderModule) { + decoderModule = require('theengs-decoder'); + } + return decoderModule; + } + + function TheengsDecodeNode(config) { + RED.nodes.createNode(this, config); + const node = this; + const passOnNoMatch = config.passOnNoMatch !== false; + const decoder = getDecoder(); + + decoder.ready().catch((err) => { + node.error(`Failed to initialize Theengs Decoder: ${err.message}`); + }); + + node.on('input', async function (msg, send, done) { + send = send || function () { node.send.apply(node, arguments); }; + done = done || function (err) { if (err) node.error(err, msg); }; + + try { + const decoded = await decoder.decodeBLE(msg.payload); + if (decoded) { + msg.payload = decoded; + send(msg); + } else if (passOnNoMatch) { + send(msg); + } + done(); + } catch (err) { + done(err); + } + }); + } + + RED.nodes.registerType('theengs-decode', TheengsDecodeNode); +}; diff --git a/nodejs/theengs-decoder/.gitignore b/nodejs/theengs-decoder/.gitignore new file mode 100644 index 000000000..cbdf26b87 --- /dev/null +++ b/nodejs/theengs-decoder/.gitignore @@ -0,0 +1,4 @@ +build/ +dist/ +node_modules/ +package-lock.json diff --git a/nodejs/theengs-decoder/README.md b/nodejs/theengs-decoder/README.md new file mode 100644 index 000000000..1cbbe443f --- /dev/null +++ b/nodejs/theengs-decoder/README.md @@ -0,0 +1,50 @@ +# theengs-decoder + +Node.js binding for [Theengs Decoder](https://decoder.theengs.io/) — decode +BLE advertisements from a wide range of consumer sensors and devices. + +The decoder runs as WebAssembly, so installation does not require a C++ +toolchain on the user's machine. + +## Install + +```sh +npm install theengs-decoder +``` + +## Usage + +```js +const { decodeBLE, getProperties, getAttribute } = require('theengs-decoder'); + +const decoded = await decodeBLE({ + servicedata: '71205d0183d20c6d8d7cc40d08100103', +}); +// → { brand: 'Xiaomi', model: 'RoPot', model_id: 'HHCCPOT002', moi: 3, mac: 'C4:7C:8D:6D:0C:D2', ... } + +const props = await getProperties('HHCCPOT002'); +const brand = await getAttribute('HHCCPOT002', 'brand'); +``` + +`decodeBLE` accepts either an object or a JSON string and returns the +decoded device information, or `null` if no decoder matched. + +A `ready()` function is also exported; awaiting it pre-loads the WebAssembly +module so the first hot-path call doesn't pay the load cost. + +## Build from source + +Requires a working [Emscripten](https://emscripten.org/) toolchain (`emcc`, +`emcmake`, `emmake`). + +```sh +git clone --recursive https://github.com/theengs/decoder.git +cd decoder/nodejs/theengs-decoder +npm install +npm run build +npm test +``` + +## License + +GPL-3.0-only — same as the underlying Theengs Decoder. diff --git a/nodejs/theengs-decoder/index.d.ts b/nodejs/theengs-decoder/index.d.ts new file mode 100644 index 000000000..9f9d7a156 --- /dev/null +++ b/nodejs/theengs-decoder/index.d.ts @@ -0,0 +1,44 @@ +export interface DecodedBLE { + brand?: string; + model?: string; + model_id?: string; + type?: string; + [key: string]: unknown; +} + +export interface PropertyInfo { + unit?: string; + name?: string; + [key: string]: unknown; +} + +export interface PropertiesInfo { + properties: Record; +} + +export type DecodeInput = string | Record; + +/** + * Pre-load the WebAssembly module. Optional — the first call to + * decodeBLE/getProperties/getAttribute will load it on demand. Calling + * ready() at startup avoids paying that cost on the first hot-path call. + */ +export function ready(): Promise; + +/** + * Decode a BLE advertisement object (or JSON string of one). Returns the + * decoded device information, or null if no decoder matched. + */ +export function decodeBLE(input: DecodeInput): Promise; + +/** + * Look up the property dictionary for a known model_id. Returns null if + * the model_id is unknown. + */ +export function getProperties(modelId: string): Promise; + +/** + * Look up a single attribute value for a known model_id. Returns null if + * the model_id or attribute is unknown. + */ +export function getAttribute(modelId: string, attribute: string): Promise; diff --git a/nodejs/theengs-decoder/index.js b/nodejs/theengs-decoder/index.js new file mode 100644 index 000000000..8391fe4ae --- /dev/null +++ b/nodejs/theengs-decoder/index.js @@ -0,0 +1,46 @@ +'use strict'; + +let _modulePromise = null; +let _decoderInstance = null; + +function _loadModule() { + if (!_modulePromise) { + const createModule = require('./dist/theengs_decoder_wasm.js'); + _modulePromise = createModule(); + } + return _modulePromise; +} + +async function _getDecoder() { + if (_decoderInstance) return _decoderInstance; + const Module = await _loadModule(); + _decoderInstance = new Module.TheengsDecoder(); + return _decoderInstance; +} + +async function ready() { + await _getDecoder(); +} + +async function decodeBLE(input) { + const decoder = await _getDecoder(); + const json = typeof input === 'string' ? input : JSON.stringify(input); + const out = decoder.decodeBLE(json); + if (!out) return null; + return JSON.parse(out); +} + +async function getProperties(modelId) { + const decoder = await _getDecoder(); + const out = decoder.getProperties(modelId); + if (!out) return null; + return JSON.parse(out); +} + +async function getAttribute(modelId, attribute) { + const decoder = await _getDecoder(); + const out = decoder.getAttribute(modelId, attribute); + return out || null; +} + +module.exports = { ready, decodeBLE, getProperties, getAttribute }; diff --git a/nodejs/theengs-decoder/package.json b/nodejs/theengs-decoder/package.json new file mode 100644 index 000000000..a91732b35 --- /dev/null +++ b/nodejs/theengs-decoder/package.json @@ -0,0 +1,41 @@ +{ + "name": "theengs-decoder", + "version": "0.1.0", + "description": "Decode BLE advertisements from supported sensors and devices using TheengsDecoder, in Node.js via WebAssembly.", + "keywords": [ + "theengs", + "ble", + "bluetooth", + "decoder", + "iot", + "wasm", + "webassembly", + "bluetooth-low-energy" + ], + "homepage": "https://decoder.theengs.io/", + "bugs": "https://github.com/theengs/decoder/issues", + "license": "GPL-3.0-only", + "author": "Theengs", + "main": "index.js", + "types": "index.d.ts", + "files": [ + "index.js", + "index.d.ts", + "dist/", + "src/", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "node scripts/build.js", + "test": "node --test test/" + }, + "engines": { + "node": ">=18" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/theengs/decoder.git", + "directory": "nodejs/theengs-decoder" + } +} diff --git a/nodejs/theengs-decoder/scripts/build.js b/nodejs/theengs-decoder/scripts/build.js new file mode 100644 index 000000000..f17b74eb4 --- /dev/null +++ b/nodejs/theengs-decoder/scripts/build.js @@ -0,0 +1,26 @@ +'use strict'; + +const { execFileSync } = require('node:child_process'); +const { mkdirSync, copyFileSync } = require('node:fs'); +const path = require('node:path'); + +const pkgDir = path.resolve(__dirname, '..'); +const repoRoot = path.resolve(pkgDir, '..', '..'); +const buildDir = path.join(pkgDir, 'build'); +const distDir = path.join(pkgDir, 'dist'); + +mkdirSync(buildDir, { recursive: true }); +mkdirSync(distDir, { recursive: true }); + +const run = (cmd, args) => + execFileSync(cmd, args, { cwd: buildDir, stdio: 'inherit' }); + +run('emcmake', ['cmake', '-DBUILD_WASM=ON', repoRoot]); +run('emmake', ['make', 'theengs_decoder_wasm', '-j']); + +copyFileSync( + path.join(buildDir, 'theengs_decoder_wasm.js'), + path.join(distDir, 'theengs_decoder_wasm.js'), +); + +console.log('Built dist/theengs_decoder_wasm.js'); diff --git a/nodejs/theengs-decoder/src/wasm_bindings.cc b/nodejs/theengs-decoder/src/wasm_bindings.cc new file mode 100644 index 000000000..063d93d58 --- /dev/null +++ b/nodejs/theengs-decoder/src/wasm_bindings.cc @@ -0,0 +1,61 @@ +/* + TheengsDecoder - Decode things and devices + + Copyright: (c)Florian ROBERT + + This file is part of TheengsDecoder. + + TheengsDecoder is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + TheengsDecoder is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#include +#include + +#include "decoder.h" + +using namespace emscripten; + +class TheengsDecoderJS { + TheengsDecoder decoder; + + public: + TheengsDecoderJS() = default; + + std::string decodeBLE(const std::string& json_data) { + StaticJsonDocument<1024> doc; + DeserializationError err = deserializeJson(doc, json_data); + if (err) return ""; + JsonObject obj = doc.as(); + if (decoder.decodeBLEJson(obj) < 0) return ""; + std::string buf; + serializeJson(obj, buf); + return buf; + } + + std::string getProperties(const std::string& model_id) { + return decoder.getTheengProperties(model_id.c_str()); + } + + std::string getAttribute(const std::string& model_id, const std::string& attribute) { + return decoder.getTheengAttribute(model_id.c_str(), attribute.c_str()); + } +}; + +EMSCRIPTEN_BINDINGS(theengs_decoder_module) { + class_("TheengsDecoder") + .constructor<>() + .function("decodeBLE", &TheengsDecoderJS::decodeBLE) + .function("getProperties", &TheengsDecoderJS::getProperties) + .function("getAttribute", &TheengsDecoderJS::getAttribute); +} diff --git a/nodejs/theengs-decoder/test/decode.test.js b/nodejs/theengs-decoder/test/decode.test.js new file mode 100644 index 000000000..05e3834e3 --- /dev/null +++ b/nodejs/theengs-decoder/test/decode.test.js @@ -0,0 +1,43 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const { ready, decodeBLE, getProperties, getAttribute } = require('..'); + +const ROPOT_PAYLOAD = '71205d0183d20c6d8d7cc40d08100103'; + +test('decodes a Xiaomi RoPot servicedata advertisement', async () => { + await ready(); + const result = await decodeBLE({ servicedata: ROPOT_PAYLOAD }); + assert.ok(result, 'expected a decoded result'); + assert.strictEqual(result.brand, 'Xiaomi'); + assert.strictEqual(result.model_id, 'HHCCPOT002'); + assert.strictEqual(result.moi, 3); + assert.strictEqual(result.mac, 'C4:7C:8D:6D:0C:D2'); +}); + +test('returns null on a payload with no matching decoder', async () => { + await ready(); + const result = await decodeBLE({}); + assert.strictEqual(result, null); +}); + +test('accepts a JSON string as input', async () => { + await ready(); + const result = await decodeBLE(`{"servicedata":"${ROPOT_PAYLOAD}"}`); + assert.ok(result); + assert.strictEqual(result.model_id, 'HHCCPOT002'); +}); + +test('getProperties returns the property dictionary for a known model', async () => { + await ready(); + const props = await getProperties('HHCCPOT002'); + assert.ok(props && props.properties, 'expected a properties object'); + assert.ok(props.properties.moi, 'expected moisture property'); +}); + +test('getAttribute returns the brand for a known model', async () => { + await ready(); + const brand = await getAttribute('HHCCPOT002', 'brand'); + assert.strictEqual(brand, 'Xiaomi'); +});