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');
+});