Using BleuIO with Node-RED to Build a BLE Air Quality Dashboard
July 9, 2025
Node-RED, a flow-based development tool for visual programming, has become a favorite among IoT developers for rapidly building and testing automation workflows. In this article, we explore how to use BleuIO, a Bluetooth Low Energy (BLE) dongle, within Node-RED to interact with BLE devices like HibouAir—an affordable and reliable air quality monitoring sensor.
We will demonstrate:
- How to send BLE commands to BleuIO and read back data
- How to scan for BLE advertisements from a HibouAir device
- How to decode and display real-time air quality data like CO2, temperature, and humidity on a live dashboard
By the end, you’ll have a working Node-RED BLE setup using BleuIO and a HibouAir sensor.
What is Node-RED?
Node-RED is an open-source, flow-based programming tool built on Node.js. It offers a browser-based visual interface that allows developers to connect hardware devices, APIs, and services using prebuilt “nodes.” Originally developed by IBM, Node-RED has grown into one of the most accessible platforms for prototyping and building IoT and automation solutions.
What makes Node-RED especially appealing is its simplicity. Without needing to write complex code, developers can drag and drop logic blocks and wire them together to build powerful flows. It supports a wide range of protocols, including MQTT, HTTP, and—through serial communication. With its real-time data handling, debugging tools, and powerful dashboard feature, Node-RED becomes an ideal choice for BLE-based IoT projects like this one.
What is HibouAir?
HibouAir is a compact and affordable air quality monitoring device developed by Smart Sensor Devices. Designed for both indoor and outdoor use, it transmits real-time environmental data over Bluetooth Low Energy (BLE), making it easy to integrate into any smart environment. The sensor measures key air quality parameters such as CO2, temperature, humidity, particulate matter (PM1.0, PM2.5, PM10), VOCs, light intensity, noise levels etc. This simplicity makes it a perfect fit for developers and system integrators working with platforms like Node-RED, where data can be read, decoded, and visualized in minutes.
What We Built
We built a flow in Node-RED that:
- Sends an AT command to put BleuIO in central role (
AT+CENTRAL
) - Sends a scan command to search for HibouAir devices (
AT+FINDSCANDATA
) - Reads the advertisement data from a known board ID (e.g.,
220069
) - Decodes the BLE hex payload using a custom decoder function
- Extracts and displays live air quality values (CO2, temperature, humidity) in the dashboard
Requirements
To replicate this project and visualize air quality data using Node-RED and BleuIO, you’ll need the following hardware and software:
BleuIO Dongle
A plug-and-play USB Bluetooth Low Energy (BLE) dongle that supports AT commands over serial.
Get BleuIO
HibouAir Sensor
An affordable air quality monitoring device that broadcasts environmental data via BLE advertisements.
Get HibouAir
Node-RED
A low-code flow-based development tool to wire together devices, APIs, and services.
Node-RED Installation Guide
Tip: You can install Node-RED globally via npm:
npm install -g --unsafe-perm node-red
Node-RED Dashboard
An additional Node-RED module used to create UI dashboards.
Dashboard GitHub Repo
Install it with:
cd ~/.node-red
npm install node-red-dashboard
HibouAir Decoder Script
A Node.js-based decoding script that extracts sensor values from BLE advertisement data.
// decoder.js
function advDataDecode(data) {
let pos = data.indexOf('5B070');
let dt = new Date();
let currentTs =
dt.getFullYear() +
'/' +
(dt.getMonth() + 1).toString().padStart(2, '0') +
'/' +
dt.getDate().toString().padStart(2, '0') +
' ' +
dt.getHours().toString().padStart(2, '0') +
':' +
dt.getMinutes().toString().padStart(2, '0') +
':' +
dt.getSeconds().toString().padStart(2, '0');
let tempHex = parseInt(
'0x' +
data
.substr(pos + 22, 4)
.match(/../g)
.reverse()
.join('')
);
if (tempHex > 1000) tempHex = (tempHex - (65535 + 1)) / 10;
else tempHex = tempHex / 10;
let noiseAdvValue = parseInt('0x' + data.substr(pos + 14, 4));
noiseAdvValue = noiseAdvValue * -1 + 120;
return {
type: parseInt(data.substr(pos + 6, 2), 16),
light: parseInt(
'0x' +
data
.substr(pos + 14, 4)
.match(/../g)
.reverse()
.join('')
),
noise: noiseAdvValue,
pressure:
parseInt(
'0x' +
data
.substr(pos + 18, 4)
.match(/../g)
.reverse()
.join('')
) / 10,
temp: tempHex,
hum:
parseInt(
'0x' +
data
.substr(pos + 26, 4)
.match(/../g)
.reverse()
.join('')
) / 10,
voc: parseInt(
'0x' +
data
.substr(pos + 30, 4)
.match(/../g)
.reverse()
.join('')
),
pm1:
parseInt(
'0x' +
data
.substr(pos + 34, 4)
.match(/../g)
.reverse()
.join('')
) / 10,
pm25:
parseInt(
'0x' +
data
.substr(pos + 38, 4)
.match(/../g)
.reverse()
.join('')
) / 10,
pm10:
parseInt(
'0x' +
data
.substr(pos + 42, 4)
.match(/../g)
.reverse()
.join('')
) / 10,
co2: parseInt('0x' + data.substr(pos + 46, 4)),
vocType: parseInt('0x' + data.substr(pos + 50, 2)),
ts: currentTs,
};
}
module.exports = { advDataDecode };
Place the decoder file (hibouair-decoder.js
) in your Node-RED user directory and reference it via functionGlobalContext
in your settings.js
:
functionGlobalContext: {
decoderLib: require('./hibouair-decoder.js')
}
How It Works
1. Send AT Commands to BleuIO
We use inject
and function
nodes to send a flush signal (\r\n
) followed by an AT command to the BleuIO dongle via a serial out
node.
let flush = Buffer.from('\r\n');
let command = Buffer.from('AT+CENTRAL\r\n');
node.send([{ payload: flush }, null]);
setTimeout(() => {
node.send([null, { payload: command }]);
}, 500);
return null;
2. Read BLE Advertisements
A serial in
node reads back raw BLE advertisements. These are filtered and passed through a custom decoder only if they match a specific prefix like 5B0705
and contain the HibouAir board ID.
3. Decode Payload
We placed a hibouair-decoder.js
script next to settings.js
and loaded it globally using:
functionGlobalContext: {
decoderLib: require('./hibouair-decoder.js'),
}
The decoder function parses the hex payload into human-readable sensor values.
4. Show on Dashboard
Finally, we use dashboard gauge widgets to show live values:
- CO2 in ppm
- Temperature in °C
- Humidity in %RH
The Node-RED dashboard UI gives a beautiful, real-time snapshot of your air quality.
Live Dashboard
Live readings of CO2, temperature, and humidity.

A snapshot of the working Node-RED flow using BleuIO and HibouAir.

Use Cases
This solution opens doors for a wide variety of applications. In smart classrooms, it ensures students learn in environments with healthy air quality, which can significantly affect concentration and wellbeing. In modern office spaces, monitoring CO2 and temperature helps facilities maintain optimal working conditions, improving both productivity and comfort.
For developers and researchers, this integration offers an easy way to prototype BLE applications, decode custom advertisements, and visualize data with minimal setup. Environmental agencies or facility managers can use this same setup for on-site testing and audits without needing cloud connectivity.
Even at home, you can deploy this as a DIY setup to monitor indoor air conditions and get real-time alerts when CO2 levels get high due to poor ventilation.
What You Can Do Next
Now that you have a live setup showing CO2, temperature, and humidity from HibouAir on a Node-RED dashboard, the possibilities for extending this flow are endless.
To store and track trends, you can add a chart node that logs values over time. This enables historical analysis of indoor air conditions, which is useful for compliance, optimization, or just awareness.
If you’re concerned about thresholds, consider adding a switch node that triggers alerts—say, if CO2 levels rise above 1000 ppm or the temperature exceeds 30°C. This could be used to turn on ventilation or send a mobile notification.
You might also want to persist data to a local SQLite database or forward readings to a cloud-based API for further processing or sharing. This transforms your flow into a powerful edge gateway.
Finally, you can export the complete flow as a template, allowing colleagues, customers, or community users to import it directly and start monitoring with their own HibouAir and BleuIO setup.
Try It Yourself
You can import the full Node-RED flow here and start using it with:
[
{
"id": "310d89a2ee784f54",
"type": "tab",
"label": "Flow 1",
"disabled": false,
"info": "",
"env": []
},
{
"id": "27fc4a581ee7c31e",
"type": "tab",
"label": "BleuIO HibouAir Scanner",
"disabled": false,
"info": "",
"env": []
},
{
"id": "3e00bbe41084b2d0",
"type": "serial-port",
"name": "",
"serialport": "/dev/tty.usbmodem4048FDEBA6D01",
"serialbaud": "9600",
"databits": "8",
"parity": "none",
"stopbits": "1",
"waitfor": "",
"newline": "\\r\\n",
"bin": "false",
"out": "char",
"addchar": "",
"responsetimeout": "10000"
},
{
"id": "71fb9c335c790a65",
"type": "ui_base",
"theme": {
"name": "theme-light",
"lightTheme": {
"default": "#0094CE",
"baseColor": "#0094CE",
"baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif",
"edited": true,
"reset": false
},
"darkTheme": {
"default": "#097479",
"baseColor": "#097479",
"baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif",
"edited": false
},
"customTheme": {
"name": "Untitled Theme 1",
"default": "#4B7930",
"baseColor": "#4B7930",
"baseFont": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif"
},
"themeState": {
"base-color": {
"default": "#0094CE",
"value": "#0094CE",
"edited": false
},
"page-titlebar-backgroundColor": {
"value": "#0094CE",
"edited": false
},
"page-backgroundColor": {
"value": "#fafafa",
"edited": false
},
"page-sidebar-backgroundColor": {
"value": "#ffffff",
"edited": false
},
"group-textColor": {
"value": "#1bbfff",
"edited": false
},
"group-borderColor": {
"value": "#ffffff",
"edited": false
},
"group-backgroundColor": {
"value": "#ffffff",
"edited": false
},
"widget-textColor": {
"value": "#111111",
"edited": false
},
"widget-backgroundColor": {
"value": "#0094ce",
"edited": false
},
"widget-borderColor": {
"value": "#ffffff",
"edited": false
},
"base-font": {
"value": "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif"
}
},
"angularTheme": {
"primary": "indigo",
"accents": "blue",
"warn": "red",
"background": "grey",
"palette": "light"
}
},
"site": {
"name": "Node-RED Dashboard",
"hideToolbar": "false",
"allowSwipe": "false",
"lockMenu": "false",
"allowTempTheme": "true",
"dateFormat": "DD/MM/YYYY",
"sizes": {
"sx": 48,
"sy": 48,
"gx": 6,
"gy": 6,
"cx": 6,
"cy": 6,
"px": 0,
"py": 0
}
}
},
{
"id": "06150d2ac284223f",
"type": "ui_tab",
"name": "HibouAir",
"icon": "dashboard",
"order": 1,
"disabled": false,
"hidden": false
},
{
"id": "fa02b7029c4379aa",
"type": "ui_group",
"name": "Sensor Values",
"tab": "06150d2ac284223f",
"order": 1,
"disp": true,
"width": 6,
"collapse": false,
"className": ""
},
{
"id": "2cb6402037188267",
"type": "inject",
"z": "27fc4a581ee7c31e",
"name": "Send AT+CENTRAL",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 150,
"y": 80,
"wires": [
[
"b17ce9f8843985f7"
]
]
},
{
"id": "b17ce9f8843985f7",
"type": "function",
"z": "27fc4a581ee7c31e",
"name": "Flush then CENTRAL",
"func": "let flush = Buffer.from('');\nlet command = Buffer.from('AT+CENTRAL\\r\\n');\n\nnode.send([{ payload: flush }, null]);\nsetTimeout(() => {\n node.send([null, { payload: command }]);\n}, 500);\n\nreturn null;",
"outputs": 2,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 350,
"y": 80,
"wires": [
[
"871745e5fe30720c"
],
[
"871745e5fe30720c"
]
]
},
{
"id": "4d092f4b416e0c3c",
"type": "inject",
"z": "27fc4a581ee7c31e",
"name": "Send SCAN",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 150,
"y": 140,
"wires": [
[
"d56ecb6cd4d62906"
]
]
},
{
"id": "d56ecb6cd4d62906",
"type": "function",
"z": "27fc4a581ee7c31e",
"name": "Flush then SCAN",
"func": "let flush = Buffer.from('');\nlet command = Buffer.from('AT+FINDSCANDATA=220069=3\\r\\n');\n\nnode.send([{ payload: flush }, null]);\nsetTimeout(() => {\n node.send([null, { payload: command }]);\n}, 500);\n\nreturn null;",
"outputs": 2,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 350,
"y": 140,
"wires": [
[
"871745e5fe30720c"
],
[
"871745e5fe30720c"
]
]
},
{
"id": "871745e5fe30720c",
"type": "serial out",
"z": "27fc4a581ee7c31e",
"name": "BleuIO Write",
"serial": "3e00bbe41084b2d0",
"x": 620,
"y": 100,
"wires": []
},
{
"id": "4073fa55dc0171d7",
"type": "serial in",
"z": "27fc4a581ee7c31e",
"name": "BleuIO Read",
"serial": "3e00bbe41084b2d0",
"x": 150,
"y": 220,
"wires": [
[
"a770c5735942970f"
]
]
},
{
"id": "a770c5735942970f",
"type": "function",
"z": "27fc4a581ee7c31e",
"name": "Clean Output",
"func": "msg.payload = msg.payload.trim();\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 350,
"y": 240,
"wires": [
[
"229eb49191d8cc43"
]
]
},
{
"id": "be5d9035305e24b0",
"type": "debug",
"z": "27fc4a581ee7c31e",
"name": "BleuIO Response",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 850,
"y": 180,
"wires": []
},
{
"id": "229eb49191d8cc43",
"type": "function",
"z": "27fc4a581ee7c31e",
"name": "Decoder",
"func": "const ADV_PREFIX = '5B0705';\nconst { advDataDecode } = global.get('decoderLib');\n\nconst line = msg.payload;\n\n// Look for advertising data with 5B0705\nif (typeof line === 'string' && line.includes(ADV_PREFIX)) {\n const match = line.match(/([0-9A-F]{40,})/i);\n if (match) {\n const hexPayload = match[1];\n const decoded = advDataDecode(hexPayload);\n msg.payload = decoded;\n return msg;\n }\n}\n\n// Ignore other messages\nreturn null;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 520,
"y": 240,
"wires": [
[
"a445d2f694c3bde7",
"d4b606693bb1244d",
"0384ee99e42fdedd"
]
]
},
{
"id": "a445d2f694c3bde7",
"type": "function",
"z": "27fc4a581ee7c31e",
"name": "co2",
"func": "msg.payload = msg.payload.co2;\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 730,
"y": 320,
"wires": [
[
"aed5ab0a3929659a"
]
]
},
{
"id": "aed5ab0a3929659a",
"type": "ui_gauge",
"z": "27fc4a581ee7c31e",
"name": "co2",
"group": "fa02b7029c4379aa",
"order": 0,
"width": 0,
"height": 0,
"gtype": "gage",
"title": "Co2",
"label": "ppm",
"format": "{{value}}",
"min": "400",
"max": "2000",
"colors": [
"#00b500",
"#e6e600",
"#ca3838"
],
"seg1": "",
"seg2": "",
"diff": false,
"className": "",
"x": 890,
"y": 320,
"wires": []
},
{
"id": "d4b606693bb1244d",
"type": "function",
"z": "27fc4a581ee7c31e",
"name": "temperature",
"func": "msg.payload = msg.payload.temp;\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 710,
"y": 400,
"wires": [
[
"4ee1b9d45026b3ea"
]
]
},
{
"id": "4ee1b9d45026b3ea",
"type": "ui_gauge",
"z": "27fc4a581ee7c31e",
"name": "temperature",
"group": "fa02b7029c4379aa",
"order": 1,
"width": 0,
"height": 0,
"gtype": "gage",
"title": "Temperature",
"label": "°C",
"format": "{{value}}",
"min": 0,
"max": "50",
"colors": [
"#00b500",
"#e6e600",
"#ca3838"
],
"seg1": "",
"seg2": "",
"diff": false,
"className": "",
"x": 910,
"y": 400,
"wires": []
},
{
"id": "0384ee99e42fdedd",
"type": "function",
"z": "27fc4a581ee7c31e",
"name": "Humidity",
"func": "msg.payload = msg.payload.hum;\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 680,
"y": 480,
"wires": [
[
"6743dd3a283e3219"
]
]
},
{
"id": "6743dd3a283e3219",
"type": "ui_gauge",
"z": "27fc4a581ee7c31e",
"name": "Humidity",
"group": "fa02b7029c4379aa",
"order": 1,
"width": 0,
"height": 0,
"gtype": "gage",
"title": "Humidity",
"label": "%rH",
"format": "{{value}}",
"min": 0,
"max": "100",
"colors": [
"#00b500",
"#e6e600",
"#ca3838"
],
"seg1": "",
"seg2": "",
"diff": false,
"className": "",
"x": 900,
"y": 480,
"wires": []
}
]
- A BleuIO dongle plugged into your computer
- A nearby broadcasting HibouAir sensor
Just install Node-RED, load this flow, and you’ll start seeing real-time air quality readings in your browser dashboard.
This tutorial shows how BleuIO seamlessly integrates with platforms like Node-RED to help developers quickly build BLE-powered applications. Combined with a device like HibouAir, this setup makes monitoring air quality simple, affordable, and accessible—without any advanced hardware or coding requirements.
We encourage you to extend this example, share your flows, or reach out to us with new ideas. BLE development doesn’t have to be hard. With BleuIO and Node-RED, it’s just a few clicks away.