Using BleuIO with Node-RED to Build a BLE Air Quality Dashboard

July 9, 2025
Using BleuIO with Node-RED to Build a BLE Air Quality Dashboard

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:

  1. Sends an AT command to put BleuIO in central role (AT+CENTRAL)
  2. Sends a scan command to search for HibouAir devices (AT+FINDSCANDATA)
  3. Reads the advertisement data from a known board ID (e.g., 220069)
  4. Decodes the BLE hex payload using a custom decoder function
  5. 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.

Share this post on :

Leave a Reply

Your email address will not be published. Required fields are marked *

Follow us on LinkedIn :

Order Now