Monitoring Indoor Air Quality with Prometheus, Grafana and a CO2 Sensor

Low indoor air quality - or high CO2 - negatively impacts cognitive performance, causes headaches, drowsiness and more. It's easy to fix though, just use a CO2 sensor and open a window from time-to-time.

But why stop there, when you can set up complete air quality monitoring solution using CO2 sensor, Prometheus, Grafana dashboards and alerts?

Note: All code used in this article is available in this repository.

What You Will Need

Let's start with the tools we will need to build this:

  • CO2 Sensor - Obviously, we will need a sensor to measure CO2 (and optionally other metrics like temperature or humidity). I use Aranet4 Home, but to make this setup work, any bluetooth-capable device would work (e.g. AirThings Wave Plus), or you could even build your own with ESP32/Arduino (see also this full build). Search GitHub tags for more ideas.
  • Raspberry Pi (RPi) - to collect data from the sensor and send it to Prometheus. Again, any network-connected device that can run continuously and can receive data over Bluetooth would work here. To test things out, your computer will work just fine.
  • Prometheus instance - I use Grafana Cloud free account, feel free to use any other service or self-managed instance.

The Architecture

As for the design/architecture, this how it works:

  • The CO2 sensor measures the metrics in some intervals and makes them available through its Bluetooth interface
  • RPi runs a Prometheus Exporter that collects the data over bluetooth in some intervals. The exporter then publishes the data on /metrics endpoint in Prometheus-compatible format
  • RPi also runs Grafana Cloud agent that scrapes the /metrics and pushes the data to Prometheus instance

The Setup

The deployment has following 3 parts:

  • Install necessary packages on RPi, such as bluetooth.
  • Use bluetoothctl CLI to find and pair RPi with the CO2 sensor
  • Deploy the Prometheus exporter along with an agent, and start scraping and pushing the data

To make mine (and possibly also your) life easier, I automated most of the set-up using Ansible. All the code, as well as some notes are available in this repository. The following steps assume that you have the repository cloned on your machine.

Now, to actually run the set-up, I will assume that you have RPi on your local network. You can find its IP with:

nmap -sn
Starting Nmap 7.80 ( ) at 2023-12-14 14:20 CET
Nmap scan report for raspberrypi (192.168....)
Host is up (0.0048s latency).
Nmap done: 256 IP addresses (3 hosts up) scanned in 2.51 seconds

And to make your life easier, I suggest you add it to ~/.ssh/config, e.g.:

Host rpi
 Hostname 192.168....
 User pi

And you can test it out with ssh rpi or ssh pi@rpi.

Additionally, to run Ansible playbooks, you will need to put the IP in inventory. So, update the ansible/inventory file in the cloned repository.

With that done you can run the first playbook:

cd ansible
ansible-playbook rpi.yaml

Which completes the first part of the setup - that is - install Docker and docker-compose, as well as Bluetooth-related packages.

You might be wondering why we need to Docker here - while it's not necessary, and you could run the agent as a binary or systemd service, I think it's more practical to use Docker and docker-compose, mainly because of environment variables.

As for the second part - finding and pairing the Bluetooth device - this cannot be easily automated, because bluetoothctl is meant as an interactive CLI. So, here's a manual setup:

ssh pi
sudo su -

systemctl start hciuart

> power on
> agent on
> scan on
[NEW] Device DD:...:1F Aranet4 26D0F
> scan off
> pair DD:...:1F
# enter PIN displayed on Aranet4 device
> trust DD:...:1F
> agent off
> quit

We first ssh into the RPi and switch to root and start the hciuart service which is needed for communication through Bluetooth interface. After that, we start interactive bluetoothctl session - we power on the controller; start the Bluetooth agent (similar to SSH agent) and start a scan for devices. You will need to find your sensor in the output and take a note of its MAC address, after which you can stop the scan. Next, we run pair command with the MAC address noted earlier - this should prompt for a PIN, which should display on the Aranet4 sensor screen. Finally, we trust the device; stop the agent and exit the session.

After this, we can do the last step - which is - deploying the exporter and agent.

First we populate the environment file at ansible/files/.env with the URL and credentials to Prometheus which will be used by the agent, and then we simply run the aranet.yaml playbook, substituting the aranet_device variable with MAC of your sensor and the "friendly name" in format {MAC}={Name}. You can optionally also set the scraping interval with aranet_interval. It defaults to 60 seconds, the higher the frequency, the faster will the battery drain.

ansible-playbook aranet.yaml --extra-vars "aranet_device='DD:...:1F=office' aranet_interval=60"

This playbook copies the environment variables file and starts the agent using docker-compose. It also installs the exporter as a systemd service. Be aware that this part will only work if you're using Aranet4 - if you have different sensor, then you will have to find/write exporter that can scrape data from your sensor, such as this one for AirThings.

With all that done, we can test out whether the exporter is able to scrape the data from sensor:

curl http://localhost:9100/metrics | grep aranet

# Should show something like:
# aranet4_co2_ppm{room="office"} 995
# ...

Data, Dashboards and Alerts

At this point, the data should be flowing to Prometheus. You can use the Explore feature of Prometheus to read the metrics listed in the output of curl http://localhost:9100/metrics, but I also created a Grafana dashboard that shows all the available metrics from Aranet4:

Grafana Dashboard

Here you can see CO2, temperature, humidity and atmospheric pressure measurements as well as battery life.

You can find the dashboard JSON config in the repository here, which can be imported into your Grafana.

Be aware that Aranet4 measures absolute humidity, so it needs to be adjusted for altitude. The included dashboard adds 24 due my altitude, you will probably need to adjust that. Compare local weather station relative humidity with Aranet4's absolute value to find the offset.

The dashboard also assumes that the Prometheus datasource is named DS_GRAFANACLOUD-PROM, you will need to change that as well.

Finally, with all the data available, we could also set up also alerts using Alertmanager, but I will leave that as "exercise for the reader", because I personally don't want to be spammed via email or Slack about high CO2 in my room. 😉

Closing Thoughts

Could you just use the phone app that comes with sensor? Sure. Is this setup over-engineered? Probably. Is it unnecessary? Most definitely; but it works, and it's fun to tinker with hardware.

It also gives you full control of the data; transforms it into standardized format in case you want to play with it further, and you don't have to rely on closed-source smartphone app.