From 062feb3134b5c02587c9fddcd208e2f23e2037a7 Mon Sep 17 00:00:00 2001 From: seizu Date: Fri, 13 Sep 2024 13:29:58 +0200 Subject: [PATCH 01/12] GattServer.getUUIDbyHandle added --- src/GattServer.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/GattServer.js b/src/GattServer.js index 47dc160..ee30af3 100644 --- a/src/GattServer.js +++ b/src/GattServer.js @@ -54,6 +54,38 @@ class GattServer { throw new Error('Service not available') } + + /** + * Find the service UUID and characteristic UUID based on a characteristic handle + * @param {number} charHandle + * @returns {} + */ + async getUUIDbyHandle(charHandle) { + const handle = charHandle - 1; + const handleStr = 'char' + handle.toString(16).padStart(4, '0'); + + for (const key in this.dbus._matchRules) { + if (key.includes(handleStr)) { + const match = key.match(/service[0-9a-fA-F]+/); + if (match) { + const service = match[0]; + const services = this._services; + for (const skey in services) { + if (services[skey].service === service) { + const characteristics = services[skey]._characteristics; + for (const ckey in characteristics) { + if (characteristics[ckey].characteristic === handleStr) { + return { 'service': skey, 'char': ckey }; + } + } + return null; + } + } + } + } + } + return null; + } } module.exports = GattServer From 435be4a492f84ff95d1c0b728aa4a025ad2a3a33 Mon Sep 17 00:00:00 2001 From: seizu Date: Mon, 7 Oct 2024 23:45:35 +0200 Subject: [PATCH 02/12] jsdoc fixed --- src/GattServer.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/GattServer.js b/src/GattServer.js index ee30af3..e940812 100644 --- a/src/GattServer.js +++ b/src/GattServer.js @@ -54,38 +54,38 @@ class GattServer { throw new Error('Service not available') } - + /** * Find the service UUID and characteristic UUID based on a characteristic handle * @param {number} charHandle - * @returns {} + * @returns {Object|null} */ - async getUUIDbyHandle(charHandle) { - const handle = charHandle - 1; - const handleStr = 'char' + handle.toString(16).padStart(4, '0'); + async getUUIDbyHandle (charHandle) { + const handle = charHandle - 1 + const handleStr = 'char' + handle.toString(16).padStart(4, '0') for (const key in this.dbus._matchRules) { if (key.includes(handleStr)) { - const match = key.match(/service[0-9a-fA-F]+/); + const match = key.match(/service[0-9a-fA-F]+/) if (match) { - const service = match[0]; - const services = this._services; + const service = match[0] + const services = this._services for (const skey in services) { if (services[skey].service === service) { - const characteristics = services[skey]._characteristics; + const characteristics = services[skey]._characteristics for (const ckey in characteristics) { if (characteristics[ckey].characteristic === handleStr) { - return { 'service': skey, 'char': ckey }; + return { service: skey, char: ckey } } } - return null; + return null } } } } } - return null; - } + return null + } } module.exports = GattServer From f12c96e03231af08cbec3aaa298cc7d491f53864 Mon Sep 17 00:00:00 2001 From: seizu Date: Fri, 30 Jan 2026 03:38:43 +0100 Subject: [PATCH 03/12] ble node added --- node-red-contrib-ble-rw.zip | Bin 0 -> 3685 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 node-red-contrib-ble-rw.zip diff --git a/node-red-contrib-ble-rw.zip b/node-red-contrib-ble-rw.zip new file mode 100644 index 0000000000000000000000000000000000000000..b923e6808a1ec863795f38a27ca6d13888fff581 GIT binary patch literal 3685 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 Date: Wed, 22 Apr 2026 12:30:08 +0200 Subject: [PATCH 04/12] update node-red-contrib-ble-rw node --- node-red-contrib-ble-rw.zip | Bin 3685 -> 0 bytes nodes/node-red-contrib-ble-rw/README.md | 53 +++++++ nodes/node-red-contrib-ble-rw/ble.html | 85 +++++++++++ nodes/node-red-contrib-ble-rw/ble.js | 168 +++++++++++++++++++++ nodes/node-red-contrib-ble-rw/package.json | 17 +++ 5 files changed, 323 insertions(+) delete mode 100644 node-red-contrib-ble-rw.zip create mode 100644 nodes/node-red-contrib-ble-rw/README.md create mode 100644 nodes/node-red-contrib-ble-rw/ble.html create mode 100644 nodes/node-red-contrib-ble-rw/ble.js create mode 100644 nodes/node-red-contrib-ble-rw/package.json diff --git a/node-red-contrib-ble-rw.zip b/node-red-contrib-ble-rw.zip deleted file mode 100644 index b923e6808a1ec863795f38a27ca6d13888fff581..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3685 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 + RED.nodes.registerType('ble', { + category: 'input', + color: '#a6bbcf', + defaults: { + name: { value: "" }, + mac: { value: "" }, + handle: { value: "" }, + operation: { value: "read", required: true }, + retries: { value: 3 }, + retryDelay: { value: 2000 } + }, + inputs: 1, + outputs: 3, + outputLabels: ["stdout", "stderr", "return"], + icon: "bluetooth.png", + label: function() { + return this.name || "BLE Node"; + } + }); + + + + + \ No newline at end of file diff --git a/nodes/node-red-contrib-ble-rw/ble.js b/nodes/node-red-contrib-ble-rw/ble.js new file mode 100644 index 0000000..8967996 --- /dev/null +++ b/nodes/node-red-contrib-ble-rw/ble.js @@ -0,0 +1,168 @@ +module.exports = function (RED) { + const { createBluetooth } = require('node-ble') + + // Store active subscriptions: mac -> { device, characteristic, destroy } + const activeSubscriptions = new Map() + + async function connectAndGetCharacteristic (mac, handle) { + const { bluetooth, destroy } = createBluetooth() + const adapter = await bluetooth.defaultAdapter() + if (!await adapter.isDiscovering()) { + await adapter.startDiscovery() + } + const device = await adapter.waitDevice(mac) + await device.connect() + const gattServer = await device.gatt() + const uuid = await gattServer.getUUIDbyHandle(handle) + if (!uuid) throw new Error('UUIDs not found for the given handle') + const service = await gattServer.getPrimaryService(uuid.service) + const characteristic = await service.getCharacteristic(uuid.char) + return { device, characteristic, bluetooth, destroy } + } + + async function connectWithRetry (mac, handle, retries, retryDelay) { + for (let i = 0; i < retries; i++) { + try { + return await connectAndGetCharacteristic(mac, handle) + } catch (err) { + if (i === retries - 1) throw err + await new Promise(res => setTimeout(res, retryDelay)) + } + } + } + + async function processBluetoothOperation (operation, handle, mac, data, retries, retryDelay, onNotify) { + let device + + try { + if (operation === 'write') { + const conn = await connectWithRetry(mac, handle, retries, retryDelay) + device = conn.device + const dataToWrite = Buffer.from(data, 'hex') + await conn.characteristic.writeValue(dataToWrite) + await device.disconnect() + conn.destroy() + return [{ payload: 'Write operation successful' }, null, null] + + } else if (operation === 'read') { + const conn = await connectWithRetry(mac, handle, retries, retryDelay) + device = conn.device + const value = await conn.characteristic.readValue(0) + await device.disconnect() + conn.destroy() + return [{ payload: 'Read operation successful' }, null, { payload: value.toString('hex') }] + + } else if (operation === 'subscribe_once') { + const conn = await connectWithRetry(mac, handle, retries, retryDelay) + device = conn.device + return await new Promise(async (resolve, reject) => { + try { + conn.characteristic.on('valuechanged', async (value) => { + await conn.characteristic.stopNotifications() + await device.disconnect() + conn.destroy() + resolve([{ payload: 'Notification received' }, null, { payload: value.toString('hex') }]) + }) + await conn.characteristic.startNotifications() + } catch (err) { + reject(err) + } + }) + + } else if (operation === 'subscribe') { + if (activeSubscriptions.has(mac)) { + throw new Error('Already subscribed for ' + mac) + } + const conn = await connectWithRetry(mac, handle, retries, retryDelay) + device = conn.device + conn.characteristic.on('valuechanged', (value) => { + onNotify(value.toString('hex')) + }) + await conn.characteristic.startNotifications() + activeSubscriptions.set(mac, { device, characteristic: conn.characteristic, destroy: conn.destroy }) + return [{ payload: 'Subscribe successful' }, null, null] + + } else if (operation === 'unsubscribe') { + const sub = activeSubscriptions.get(mac) + if (!sub) throw new Error('No active subscription for ' + mac) + await sub.characteristic.stopNotifications() + await sub.device.disconnect() + sub.destroy() + activeSubscriptions.delete(mac) + return [{ payload: 'Unsubscribe successful' }, null, null] + + } else { + return [null, { payload: 'Invalid operation type' }, null] + } + + } catch (error) { + if (device && operation !== 'subscribe' && operation !== 'unsubscribe') { + try { await device.disconnect() } catch (e) {} + } + throw new Error('Bluetooth error: ' + error.message) + } + } + + function BluetoothNode (config) { + RED.nodes.createNode(this, config) + const node = this + + node.on('input', async function (msg) { + const operation = msg.payload.operation || config.operation + const mac = msg.payload.mac || config.mac + const rawHandle = msg.payload.handle || config.handle + const data = msg.payload.data || '' + const retries = config.retries || 3 + const retryDelay = config.retryDelay || 2000 + + node.status({ fill: 'blue', shape: 'dot', text: 'connecting...' }) + + let handle = NaN + if (typeof rawHandle === 'string') { + handle = parseInt(rawHandle, 16) + } else { + handle = rawHandle + } + + if (!mac || isNaN(handle) || !operation) { + node.status({ fill: 'red', shape: 'ring', text: 'missing params' }) + return node.send([null, { payload: 'Missing parameter: mac, handle or operation' }, null]) + } + + if (operation === 'write' && (!data || data.length === 0)) { + node.status({ fill: 'red', shape: 'ring', text: 'no data' }) + return node.send([null, { payload: 'Missing write data' }, null]) + } + + try { + const onNotify = (hexValue) => { + node.status({ fill: 'green', shape: 'dot', text: 'notification' }) + node.send([null, null, { payload: hexValue }]) + } + + const result = await processBluetoothOperation(operation, handle, mac, data, retries, retryDelay, onNotify) + node.status({ fill: 'green', shape: 'dot', text: 'done' }) + node.send(result) + + } catch (error) { + node.status({ fill: 'red', shape: 'dot', text: 'error' }) + node.error(error.message) + node.send([null, { payload: error.message }, null]) + } + }) + + // Cleanup on node redeploy/stop + node.on('close', async () => { + for (const [mac, sub] of activeSubscriptions) { + try { + await sub.characteristic.stopNotifications() + await sub.device.disconnect() + sub.destroy() + } catch (e) {} + activeSubscriptions.delete(mac) + } + }) + } + + RED.nodes.registerType('ble', BluetoothNode) +} \ No newline at end of file diff --git a/nodes/node-red-contrib-ble-rw/package.json b/nodes/node-red-contrib-ble-rw/package.json new file mode 100644 index 0000000..addc835 --- /dev/null +++ b/nodes/node-red-contrib-ble-rw/package.json @@ -0,0 +1,17 @@ +{ + "name": "node-red-contrib-ble-rw", + "version": "1.0.2", + "description": "A Node-RED node for BLE read/write operations using node-ble", + "main": "ble.js", + "scripts": {}, + "author": "seizu", + "license": "MIT", + "dependencies": { + "node-ble": "^1.11.0" + }, + "node-red": { + "nodes": { + "ble": "ble.js" + } + } +} From 89d415b7049448f8f7e93535a7b6ffa5740aaab8 Mon Sep 17 00:00:00 2001 From: seizu Date: Wed, 22 Apr 2026 12:49:08 +0200 Subject: [PATCH 05/12] update subscribe_once and subscribe --- nodes/node-red-contrib-ble-rw/README.md | 84 ++++++++++++++++++++-- nodes/node-red-contrib-ble-rw/ble.js | 27 +++++-- nodes/node-red-contrib-ble-rw/package.json | 2 +- 3 files changed, 101 insertions(+), 12 deletions(-) diff --git a/nodes/node-red-contrib-ble-rw/README.md b/nodes/node-red-contrib-ble-rw/README.md index df4adb5..569af46 100644 --- a/nodes/node-red-contrib-ble-rw/README.md +++ b/nodes/node-red-contrib-ble-rw/README.md @@ -5,6 +5,8 @@ A Node-RED node for Bluetooth Low Energy (BLE) read, write and notify operations ## Features - **Hybrid Configuration**: Set MAC, Handle, and Operation in the node settings or override them via `msg.payload`. - **Auto-Retry**: Configurable retry attempts and delay for connection errors. +- **Notify Support**: Subscribe to BLE notifications, either once or continuously. +- **Integrated Write Trigger**: Optionally send a write command after notify is active (no timing issues). - **Three Outputs**: 1. `stdout`: Success confirmation. 2. `stderr`: Error messages. @@ -16,18 +18,59 @@ A Node-RED node for Bluetooth Low Energy (BLE) read, write and notify operations |---|---| | `read` | Read value from handle | | `write` | Write hex data to handle | -| `subscribe_once` | Enable notify, receive first notification, auto-unsubscribe | -| `subscribe` | Enable notify, forward every notification to output 3 | +| `subscribe_once` | Enable notify, send optional write trigger, receive first notification, auto-unsubscribe | +| `subscribe` | Enable notify, send optional write trigger, forward every notification to output 3 | | `unsubscribe` | Disable notify and disconnect | ## Input Payload Format -You can trigger the node by sending a JSON object: + +### Read +```json +{ + "operation": "read", + "handle": "0x002C", + "mac": "90:38:0C:58:99:42" +} +``` + +### Write ```json { "operation": "write", - "handle": "0x0074", - "mac": "D8:71:4D:00:6D:3D", - "data": "10000000" + "handle": "0x002A", + "mac": "90:38:0C:58:99:42", + "data": "50" +} +``` + +### Subscribe Once (with optional write trigger) +```json +{ + "operation": "subscribe_once", + "handle": "0x002C", + "mac": "90:38:0C:58:99:42", + "write_handle": "0x002A", + "write_data": "50" +} +``` + +### Subscribe (with optional write trigger) +```json +{ + "operation": "subscribe", + "handle": "0x002C", + "mac": "90:38:0C:58:99:42", + "write_handle": "0x002A", + "write_data": "50" +} +``` + +### Unsubscribe +```json +{ + "operation": "unsubscribe", + "handle": "0x002C", + "mac": "90:38:0C:58:99:42" } ``` @@ -41,10 +84,37 @@ You can trigger the node by sending a JSON object: | Max Retries | Connection retry attempts | `3` | | Retry Delay | Delay between retries in ms | `2000` | +## Example: RadonEye RD200 + +Single Node-RED inject with `subscribe_once` — no separate write node needed: + +```json +{ + "operation": "subscribe_once", + "mac": "90:38:0C:58:99:42", + "handle": "0x002C", + "write_handle": "0x002A", + "write_data": "50" +} +``` + +Response on output 3 (hex): `500a0a000000000000000300` + +Parse radon value: +- **Byte 1** = current Radon in Bq/m³ +- **Byte 2** = short term average in Bq/m³ + ## Installation +Local installation: +```bash +cd ~/.node-red +npm install /path/to/node-red-contrib-ble-rw +``` + +Then restart Node-RED: ```bash -npm install node-red-contrib-ble-rw +node-red-restart ``` ## Requirements diff --git a/nodes/node-red-contrib-ble-rw/ble.js b/nodes/node-red-contrib-ble-rw/ble.js index 8967996..554608e 100644 --- a/nodes/node-red-contrib-ble-rw/ble.js +++ b/nodes/node-red-contrib-ble-rw/ble.js @@ -31,15 +31,22 @@ module.exports = function (RED) { } } - async function processBluetoothOperation (operation, handle, mac, data, retries, retryDelay, onNotify) { + async function triggerWrite (gattServer, writeHandle, writeData) { + const writeUuid = await gattServer.getUUIDbyHandle(writeHandle) + if (!writeUuid) throw new Error('UUIDs not found for write handle') + const writeService = await gattServer.getPrimaryService(writeUuid.service) + const writeChar = await writeService.getCharacteristic(writeUuid.char) + await writeChar.writeValue(Buffer.from(writeData, 'hex')) + } + + async function processBluetoothOperation (operation, handle, mac, data, retries, retryDelay, writeHandle, writeData, onNotify) { let device try { if (operation === 'write') { const conn = await connectWithRetry(mac, handle, retries, retryDelay) device = conn.device - const dataToWrite = Buffer.from(data, 'hex') - await conn.characteristic.writeValue(dataToWrite) + await conn.characteristic.writeValue(Buffer.from(data, 'hex')) await device.disconnect() conn.destroy() return [{ payload: 'Write operation successful' }, null, null] @@ -64,6 +71,11 @@ module.exports = function (RED) { resolve([{ payload: 'Notification received' }, null, { payload: value.toString('hex') }]) }) await conn.characteristic.startNotifications() + // Optional write after notify is active + if (writeHandle && writeData) { + const gattServer = await device.gatt() + await triggerWrite(gattServer, parseInt(writeHandle, 16), writeData) + } } catch (err) { reject(err) } @@ -79,6 +91,11 @@ module.exports = function (RED) { onNotify(value.toString('hex')) }) await conn.characteristic.startNotifications() + // Optional write after notify is active + if (writeHandle && writeData) { + const gattServer = await device.gatt() + await triggerWrite(gattServer, parseInt(writeHandle, 16), writeData) + } activeSubscriptions.set(mac, { device, characteristic: conn.characteristic, destroy: conn.destroy }) return [{ payload: 'Subscribe successful' }, null, null] @@ -114,6 +131,8 @@ module.exports = function (RED) { const data = msg.payload.data || '' const retries = config.retries || 3 const retryDelay = config.retryDelay || 2000 + const writeHandle = msg.payload.write_handle || null + const writeData = msg.payload.write_data || null node.status({ fill: 'blue', shape: 'dot', text: 'connecting...' }) @@ -140,7 +159,7 @@ module.exports = function (RED) { node.send([null, null, { payload: hexValue }]) } - const result = await processBluetoothOperation(operation, handle, mac, data, retries, retryDelay, onNotify) + const result = await processBluetoothOperation(operation, handle, mac, data, retries, retryDelay, writeHandle, writeData, onNotify) node.status({ fill: 'green', shape: 'dot', text: 'done' }) node.send(result) diff --git a/nodes/node-red-contrib-ble-rw/package.json b/nodes/node-red-contrib-ble-rw/package.json index addc835..94eab60 100644 --- a/nodes/node-red-contrib-ble-rw/package.json +++ b/nodes/node-red-contrib-ble-rw/package.json @@ -1,6 +1,6 @@ { "name": "node-red-contrib-ble-rw", - "version": "1.0.2", + "version": "1.0.3", "description": "A Node-RED node for BLE read/write operations using node-ble", "main": "ble.js", "scripts": {}, From e62b03ca09a2a0192a30b3936594fa60446caf6f Mon Sep 17 00:00:00 2001 From: seizu Date: Wed, 22 Apr 2026 13:31:28 +0200 Subject: [PATCH 06/12] fix unsubscribe --- nodes/node-red-contrib-ble-rw/ble.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/nodes/node-red-contrib-ble-rw/ble.js b/nodes/node-red-contrib-ble-rw/ble.js index 554608e..4359fff 100644 --- a/nodes/node-red-contrib-ble-rw/ble.js +++ b/nodes/node-red-contrib-ble-rw/ble.js @@ -102,9 +102,14 @@ module.exports = function (RED) { } else if (operation === 'unsubscribe') { const sub = activeSubscriptions.get(mac) if (!sub) throw new Error('No active subscription for ' + mac) - await sub.characteristic.stopNotifications() - await sub.device.disconnect() - sub.destroy() + try { + await sub.characteristic.stopNotifications() + await sub.device.disconnect() + sub.destroy() + } catch (e) { + // Connection may have dropped, cleanup anyway + node.warn('Unsubscribe cleanup warning: ' + e.message) + } activeSubscriptions.delete(mac) return [{ payload: 'Unsubscribe successful' }, null, null] From b8000d45ad071fd950ec26972ac0b82ce3356d79 Mon Sep 17 00:00:00 2001 From: seizu Date: Wed, 22 Apr 2026 13:36:21 +0200 Subject: [PATCH 07/12] add subscribe timeout --- nodes/node-red-contrib-ble-rw/ble.html | 14 +++++++++++- nodes/node-red-contrib-ble-rw/ble.js | 26 +++++++++++++++++----- nodes/node-red-contrib-ble-rw/package.json | 2 +- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/nodes/node-red-contrib-ble-rw/ble.html b/nodes/node-red-contrib-ble-rw/ble.html index 48ffe99..803539d 100644 --- a/nodes/node-red-contrib-ble-rw/ble.html +++ b/nodes/node-red-contrib-ble-rw/ble.html @@ -8,7 +8,8 @@ handle: { value: "" }, operation: { value: "read", required: true }, retries: { value: 3 }, - retryDelay: { value: 2000 } + retryDelay: { value: 2000 }, + subscribeTimeout: { value: 30000 } }, inputs: 1, outputs: 3, @@ -56,6 +57,11 @@ + +
+ + +
- -