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