In this project, we'll monitor several parameters of indoor air quality with a Raspberry Pi and the following sensors:
- MH-Z19 -> CO2
- VMA342, consisting of:
- BME280 -> temperature + humidity + air pressure
- CCS811 -> volatile organic compounds (TVOC) [Work in progress]
- SPS30 -> particulate matter (PM1.0, PM2.5, PM4, PM10)
(SPS30 not shown in the image above.)
A Streamlit dashboard will allow you to monitor the current air quality as well as the evolution over time:
![]() |
![]() |
- Raspberry Pi*, including:
- MicroSD card
- Micro USB power cable and adapter
- Protective case
- WiFi dongle (for RPi's older than model 3)
- VMA342 sensor
- MH-Z19 sensor**
- SPS30 sensor + JST-ZHR 5p to DuPont cable
- Small breadboard
- Jumper cables (male-female & some male-male)
- Ethernet cable for the initial setup
*I used a Raspberry Pi model 2B for this project. Other models likely work as well, but weren't tested.
**The MH-Z19 sensor comes in multiple versions. I used the MH-Z19B. The mh-z19 python library seems to support at least the MH-Z19 and MH-Z19B. If your sensor doesn't come with output pins (like mine), you'll have to solder some stacking headers yourself.
- Install Raspberry Pi Imager.
- Insert micro SD card and install the Raspberry Pi OS using Raspberry Pi Imager. Before starting the instalation process, select "Edit Settings" when asked to customize the OS image. In the settings menu, make sure to:
- Set your host name, username and password (for this guide, we assume
raspberrypi.local,piandraspberryrespectively) - Enable SSH (with password authentication)
- Configure Wifi
- Set your host name, username and password (for this guide, we assume
- Insert the SD card and boot your Raspberry Pi by plugging in the power cord. Give it a few minutes to complete the setup.
- Connect with the Pi via your terminal:
ssh [email protected], default password =raspberry.
Connect the Raspberry Pi and MH-Z19 as follows:
| RPi | MH-Z19 |
|---|---|
| 5V | Vin |
| GND | GND |
| TXD | Rx |
| RXD | Tx |
Note how the TXD and RXD are cross connected between the RPi and MH-Z19.
Connect the Raspberry Pi and VMA342 as follows:
| RPi | VMA342 |
|---|---|
| 3V3 | 3.3V |
| GND | GND |
| SDA | SDA |
| SCL | SCL |
| GND | WAKE |
Connect the Raspberry Pi and SPS30 as follows (shares the I²C lines with the VMA342):
| RPi | SPS30 | Notes |
|---|---|---|
| 5V | VDD (pin 1) | Sensor requires 5 V supply |
| SDA | SDA (pin 2) | Connect to the same SDA line as the VMA342 |
| SCL | SCL (pin 3) | Connect to the same SCL line as the VMA342 |
| (Tie to GND) | SEL (pin 4) | Hold low to enable the I²C interface |
| GND | GND (pin 5) | Common ground shared with VMA342 |
Clone this repository in the Documents folder:
cd Documents
git clone https://github.com/StijnGoossens/rpi-airquality.git
Subsequently, install the packages below in order to interact with the sensors.
-
Enable Serial via
sudo raspi-config(source) -
Grant your user access to the serial device so
mh-z19can read/dev/serial0:sudo adduser $USER dialout(log out and back in afterward). -
Install SWIG once so the
lgpiodependency can build:sudo apt install swig -
Install the native GPIO library used at link time:
sudo apt install liblgpio-dev -
Install core scientific packages via apt so Python wheels do not need to compile from source on the Raspberry Pi:
sudo apt install python3-numpy python3-pandas python3-dev libopenblas-dev liblapack-dev gfortran- On older Raspberry Pi OS releases (Bullseye/Bookworm) you can install
libatlas-base-devinstead oflibopenblas-dev liblapack-dev.
-
Install the mh-z19 package inside a Python virtual environment to avoid Raspberry Pi OS's system package guard:
python3 -m venv --system-site-packages ~/venvs/airquality
source ~/venvs/airquality/bin/activate
pip install --upgrade pip
pip install mh-z19Re-activate the environment (source ~/venvs/airquality/bin/activate) before running the project or installing additional Python packages. Using --system-site-packages lets the virtual environment reuse Python packages installed via apt (e.g. python3-numpy) so they do not need to be rebuilt from source.
- The commands below assume the virtual environment from the MH-Z19 section is active.
- Install RPi.GPIO with
export CFLAGS=-fcommonandpip3 install RPi.GPIO(source) - Enable I2C via
sudo raspi-config(source):Interface Options -> I2C -> Yes - Optionally adding the I2C module to the kernel:
sudo nano /etc/modulesand addi2c-devto the end of the file. - Reduce the baudrate in order to make the sensor compatible with Raspberry Pi:
sudo nano /boot/firmware/config.txtand adddtparam=i2c_arm_baudrate=10000(source).
Tip: i2cdetect -y 1 shows the current I2C connections.
- Ensure the virtual environment is active, then run
pip install RPi.bme280
Example to try out the CCS811 library:
import smbus2
import bme280
port = 1
address = 0x77
bus = smbus2.SMBus(port)
calibration_params = bme280.load_calibration_params(bus, address)
# the sample method will take a single reading and return a
# compensated_reading object
data = bme280.sample(bus, address, calibration_params)
# the compensated_reading class has the following attributes
print(data.id)
print(data.timestamp)
print(data.temperature)
print(data.pressure)
print(data.humidity)
# there is a handy string representation too
print(data)pip3 install adafruit-circuitpython-ccs811
Example to try out the CCS811 library:
import board
import adafruit_ccs811
i2c = board.I2C() # uses board.SCL and board.SDA
ccs811 = adafruit_ccs811.CCS811(i2c, 0x5b)
# Wait for the sensor to be ready
while not ccs811.data_ready:
pass
while True:
print("CO2: {} PPM, TVOC: {} PPB".format(ccs811.eco2, ccs811.tvoc))
time.sleep(0.5)Note that 0x5b is the I2C address of the CCS811 on the VMA342 board (default is 0x5a).
- Uses the same I2C bus as the VMA342 board. Connect
VDDto 5V,GNDto ground and wireSDA/SCLto the Raspberry Pi's I2C pins as shown in the wiring diagram above. - Install the Sensirion drivers inside the virtual environment activated earlier:
pip install sensirion-i2c-driver sensirion_i2c_sps30After installing the drivers, monitor.py will automatically start the SPS30 and log the mass concentration readings for PM1.0, PM2.5, PM4 and PM10 in the records table.
Example to sanity-check the sensor interactively:
import time
import contextlib
from sensirion_i2c_sps30 import Sps30Device, commands
from sensirion_driver_adapters.i2c_adapter.linux_i2c_channel_provider import (
LinuxI2cChannelProvider,
)
provider = LinuxI2cChannelProvider("/dev/i2c-1")
provider.prepare_channel()
channel = provider.get_channel(
slave_address=0x69, crc_parameters=(8, 0x31, 0xFF, 0x00)
)
sensor = Sps30Device(channel)
try:
sensor.start_measurement(commands.OutputFormat.OUTPUT_FORMAT_FLOAT)
time.sleep(1) # give the fan a moment to spin up
while not sensor.read_data_ready_flag():
time.sleep(0.2)
mc1, mc25, mc4, mc10, *_ = sensor.read_measurement_values_float()
print(f"PM1.0={mc1:.1f} µg/m³, PM2.5={mc25:.1f}, PM4={mc4:.1f}, PM10={mc10:.1f}")
finally:
with contextlib.suppress(Exception):
sensor.stop_measurement()
with contextlib.suppress(Exception):
provider.release_channel_resources()pip install streamlit==0.62.0(Installing Streamlit>0.62 on Raspberry Pi isn't straightforward because of dependency on PyArrow)pip install "click<8"(Streamlit 0.62 expects Click 7.x; Debian's Click 8.x causesAttributeError: module 'click' has no attribute 'get_os_args')- Python 3.12+ requires Streamlit's WebSocket message size limit to be an integer. After installing Streamlit, edit
$HOME/venvs/airquality/lib/python3.13/site-packages/streamlit/server/server_util.pyand changeMESSAGE_SIZE_LIMIT = 50 * 1e6toMESSAGE_SIZE_LIMIT = int(50 * 1e6)(or50 * 1_000_000). - Bind Streamlit to all interfaces so other devices on your LAN can reach it: run
streamlit run src/dashboard.py --server.address 0.0.0.0 --server.port 8501(or set the same values in~/.streamlit/config.toml). - If you install Streamlit outside the virtual environment (e.g. with
pip install --user), add your local bin directory toPATHso thestreamlitcommand is found:export PATH="$HOME/.local/bin:$PATH"(source). When using the virtual environment described above, thestreamlitbinary is already onPATHaftersource ~/venvs/airquality/bin/activate.
Optional
For some reason a tornado.iostream.StreamClosedError: Stream is closed error might occur after a running the Streamlit dashboard for a while. This can be resolved by editing the files inside your virtual environment (e.g. ~/venvs/airquality/lib/python3.13/site-packages/streamlit/server/…):
- Change
MESSAGE_SIZE_LIMITinserver_util.pyfrom50 * 1e6to600 * 1e6. - Change the
websocket_ping_timeoutparameter inServer.pyfrom60to200.
chmod 664 ~/Documents/rpi-airquality/src/monitor.py- Run
crontab -eand append the following command to the bottom of the file:
@reboot (/bin/sleep 30; $HOME/venvs/airquality/bin/python $HOME/Documents/rpi-airquality/src/monitor.py > $HOME/cronjoblog-monitor 2>&1)
@reboot (/bin/sleep 30; $HOME/venvs/airquality/bin/streamlit run $HOME/Documents/rpi-airquality/src/dashboard.py --server.address 0.0.0.0 --server.port 4202 > $HOME/cronjoblog-dashboard 2>&1)
*/5 * * * * /bin/ping -c 2 www.google.com > $HOME/cronjoblog-ping.txt 2>&1
This will start the monitoring script and Streamlit dashboard on startup. Logs (including the optional keep-alive ping) will be printed to the specified files under your home folder.
Note that these commands call the interpreter and Streamlit executable directly from the virtual environment so no extra PATH changes are required. If your virtual environment lives elsewhere, update the paths accordingly. Cron runs with a minimal PATH (/bin:/usr/bin), so absolute paths (or environment variables such as $HOME) avoid command-not-found errors (source).
Extra: to confirm the cron jobs have run, use the system journal on recent Raspberry Pi OS releases: sudo journalctl -u cron --since "10 minutes ago". On older setups that still log to /var/log/syslog, grep CRON /var/log/syslog remains an option. Tail the log files with tail -F $HOME/cronjoblog-monitor and tail -F $HOME/cronjoblog-dashboard.
Raspberry Pi 3
- Run
crontab -eand append the following command to the bottom of the file (source):
@reboot (/bin/sleep 30; sudo sh -c 'echo 0 > /sys/class/leds/led0/brightness')
@reboot (/bin/sleep 30; sudo sh -c 'echo 0 > /sys/class/leds/led1/brightness')
Raspberry Pi 4
sudo nano /boot/config.txt- Add the following lines below the
[Pi4]settings (source):
# Disable the PWR LED
dtparam=pwr_led_trigger=none
dtparam=pwr_led_activelow=off
# Disable the Activity LED
dtparam=act_led_trigger=none
dtparam=act_led_activelow=off
# Disable ethernet port LEDs
dtparam=eth_led0=4
dtparam=eth_led1=4
- The lights will turn off once the Raspberry Pi has been restarted.
The Streamlit dashboard can be viewed from any device on the same network by visiting http://raspberrypi.local:4202/ (or http://:4202/ if mDNS isn’t available).



