diff --git a/nodes/node-red-contrib-ble-seizu/README.md b/nodes/node-red-contrib-ble-seizu/README.md new file mode 100644 index 0000000..6250827 --- /dev/null +++ b/nodes/node-red-contrib-ble-seizu/README.md @@ -0,0 +1,123 @@ +# node-red-contrib-ble-seizu + +A Node-RED node for Bluetooth Low Energy (BLE) read, write and notify operations, based on the `node-ble` library. + +## 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. + 3. `return`: Raw hex data from read or notify operations. + +## Operations + +| Operation | Description | +|---|---| +| `read` | Read value from handle | +| `write` | Write hex data to handle | +| `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 + +### Read +```json +{ + "operation": "read", + "handle": "0x002C", + "mac": "90:38:0C:58:99:42" +} +``` + +### Write +```json +{ + "operation": "write", + "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" +} +``` + +## Node Settings + +| Setting | Description | Default | +|---|---|---| +| MAC Address | BLE device MAC address | - | +| Handle | GATT handle (hex, e.g. `0x002C`) | - | +| Operation | Default operation | `read` | +| 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-seizu +``` + +Then restart Node-RED: +```bash +node-red-restart +``` + +## Requirements +- Node-RED +- `node-ble` library +- Linux with BlueZ (e.g. Raspberry Pi) \ No newline at end of file diff --git a/nodes/node-red-contrib-ble-seizu/ble.html b/nodes/node-red-contrib-ble-seizu/ble.html new file mode 100644 index 0000000..0dcb25b --- /dev/null +++ b/nodes/node-red-contrib-ble-seizu/ble.html @@ -0,0 +1,97 @@ + + + + + \ No newline at end of file diff --git a/nodes/node-red-contrib-ble-seizu/ble.js b/nodes/node-red-contrib-ble-seizu/ble.js new file mode 100644 index 0000000..f5e07f0 --- /dev/null +++ b/nodes/node-red-contrib-ble-seizu/ble.js @@ -0,0 +1,228 @@ +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, node) { + const { bluetooth, destroy } = createBluetooth() + const adapter = await bluetooth.defaultAdapter() + if (!await adapter.isDiscovering()) { + await adapter.startDiscovery() + } + + // Connection timeout 10 seconds + const device = await Promise.race([ + adapter.waitDevice(mac), + new Promise((_, reject) => + setTimeout(() => reject(new Error('connection timeout')), 10000) + ) + ]) + + await device.connect() + node.warn('Connected to ' + mac) + 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, node) { + for (let i = 0; i < retries; i++) { + try { + return await connectAndGetCharacteristic(mac, handle, node) + } catch (err) { + if (i === retries - 1) throw err + await new Promise(res => setTimeout(res, retryDelay)) + } + } + } + + 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 cleanup (device, conn, node) { + node.warn('cleanup() called') + if (device) { + try { + await device.disconnect() + node.warn('cleanup() disconnect OK') + } catch (e) { + node.warn('Cleanup disconnect warning: ' + e.message) + } + } + if (conn) { + try { + conn.destroy() + node.warn('cleanup() destroy OK') + } catch (e) { + node.warn('Cleanup destroy warning: ' + e.message) + } + } + // Wait for BlueZ to fully release the connection + await new Promise(res => setTimeout(res, 1000)) + } + + async function processBluetoothOperation (operation, handle, mac, data, retries, retryDelay, writeHandle, writeData, subscribeTimeout, onNotify, node) { + let device + let conn + + try { + if (operation === 'write') { + conn = await connectWithRetry(mac, handle, retries, retryDelay, node) + device = conn.device + await conn.characteristic.writeValue(Buffer.from(data, 'hex')) + await cleanup(device, conn, node) + return [{ payload: 'Write operation successful' }, null, null] + + } else if (operation === 'read') { + conn = await connectWithRetry(mac, handle, retries, retryDelay, node) + device = conn.device + const value = await conn.characteristic.readValue(0) + await cleanup(device, conn, node) + return [{ payload: 'Read operation successful' }, null, { payload: value.toString('hex') }] + + } else if (operation === 'subscribe_once') { + conn = await connectWithRetry(mac, handle, retries, retryDelay, node) + device = conn.device + return await new Promise(async (resolve, reject) => { + const timer = setTimeout(async () => { + node.warn('subscribe_once: timed out after ' + subscribeTimeout + 'ms') + await cleanup(device, conn, node) + reject(new Error('subscribe_once timed out after ' + subscribeTimeout + 'ms')) + }, subscribeTimeout) + + try { + conn.characteristic.on('valuechanged', async (value) => { + clearTimeout(timer) + await conn.characteristic.stopNotifications() + await cleanup(device, conn, node) + resolve([{ payload: 'Notification received' }, null, { payload: value.toString('hex') }]) + }) + await conn.characteristic.startNotifications() + if (writeHandle && writeData) { + const gattServer = await device.gatt() + await triggerWrite(gattServer, parseInt(writeHandle, 16), writeData) + } + } catch (err) { + clearTimeout(timer) + await cleanup(device, conn, node) + reject(err) + } + }) + + } else if (operation === 'subscribe') { + if (activeSubscriptions.has(mac)) { + throw new Error('Already subscribed for ' + mac) + } + conn = await connectWithRetry(mac, handle, retries, retryDelay, node) + device = conn.device + conn.characteristic.on('valuechanged', (value) => { + onNotify(value.toString('hex')) + }) + await conn.characteristic.startNotifications() + 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] + + } else if (operation === 'unsubscribe') { + const sub = activeSubscriptions.get(mac) + if (!sub) throw new Error('No active subscription for ' + mac) + try { + await sub.characteristic.stopNotifications() + await sub.device.disconnect() + sub.destroy() + } catch (e) { + node.warn('Unsubscribe cleanup warning: ' + e.message) + } + activeSubscriptions.delete(mac) + return [{ payload: 'Unsubscribe successful' }, null, null] + + } else { + return [null, { payload: 'Invalid operation type' }, null] + } + + } catch (error) { + await cleanup(device, conn, node) + 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 + const subscribeTimeout = config.subscribeTimeout || 30000 + const writeHandle = msg.payload.write_handle || null + const writeData = msg.payload.write_data || null + node.log("Connecting...") + 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, writeHandle, writeData, subscribeTimeout, onNotify, node) + 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) { + node.warn('Close cleanup warning: ' + e.message) + } + activeSubscriptions.delete(mac) + } + }) + } + + RED.nodes.registerType('ble-seizu', BluetoothNode) +} \ No newline at end of file diff --git a/nodes/node-red-contrib-ble-seizu/package.json b/nodes/node-red-contrib-ble-seizu/package.json new file mode 100644 index 0000000..f756c14 --- /dev/null +++ b/nodes/node-red-contrib-ble-seizu/package.json @@ -0,0 +1,17 @@ +{ + "name": "node-red-contrib-ble-seizu", + "version": "1.0.9", + "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-seizu": "ble.js" + } + } +} diff --git a/src/GattServer.js b/src/GattServer.js index 47dc160..e940812 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 {Object|null} + */ + 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