A Raspberry Pi air quality monitor built around the Bosch BME680 is one of the most useful small sensor projects you can put in an office, workshop or bedroom. In this tutorial you will wire the sensor, read temperature, humidity, pressure and gas resistance in Python, turn those raw values into a simple indoor air quality (IAQ) estimate, and publish everything over MQTT so any dashboard or phone can follow it live.
What the BME680 Actually Measures
The BME680 combines four measurements in one tiny package: temperature, relative humidity, barometric pressure and the resistance of a heated metal-oxide gas layer. The first three are straightforward physical values. The fourth is the interesting one, and also the one most often misunderstood.
The gas sensor does not report a specific gas or a concentration in ppm. Volatile organic compounds (VOCs) from cleaning products, cooking, paint, people and furniture react with the heated sensing layer and lower its resistance. Clean air gives a high resistance, polluted air a lower one. That makes the raw value a relative indicator: great for spotting trends and events ("someone just started cooking", "the room needs ventilation"), not a laboratory measurement.
Bosch ships a closed-source library called BSEC that converts these readings into a calibrated IAQ index from 0 to 500 and estimated CO2 equivalents. It works well but is a binary blob with its own licence and integration effort. For a hobby or lab monitor, a transparent baseline-based score is often enough, and that is what we will build here. If you later need BSEC, the MQTT side of this project stays exactly the same.
| Reading | Unit | Typical indoor range | What it tells you |
|---|---|---|---|
| Temperature | °C | 18 to 26 | Comfort, and a correction factor for the gas layer |
| Relative humidity | % | 30 to 60 | Comfort, mould risk, part of the IAQ score |
| Pressure | hPa | 950 to 1050 | Weather trend, not air quality |
| Gas resistance | Ohm | Tens to hundreds of kOhm | Relative VOC level; higher is cleaner |
Parts and Wiring
You need:
- Any Raspberry Pi with a 40-pin header (Zero 2 W, 3, 4 or 5) running Raspberry Pi OS.
- A BME680 breakout board. Adafruit, Pimoroni and many generic boards work; check whether yours uses I2C address
0x76or0x77. - Four jumper wires.
Connect the breakout to the Pi over I2C:
| BME680 pin | Raspberry Pi pin |
|---|---|
| VIN / 3V3 | Pin 1 (3.3 V) |
| GND | Pin 6 (GND) |
| SDA / SDI | Pin 3 (GPIO 2, SDA) |
| SCL / SCK | Pin 5 (GPIO 3, SCL) |
Mount the sensor away from the Pi itself. The CPU and power regulator warm the board, and a sensor sitting directly over the SoC will read several degrees too high and push humidity down. A 10 to 15 cm cable or a small standoff makes a noticeable difference.
Preparing the Raspberry Pi
Enable I2C, confirm the sensor is visible, and create a Python virtual environment (Raspberry Pi OS Bookworm and later require one for pip installs):
sudo raspi-config nonint do_i2c 0
sudo apt update && sudo apt install -y i2c-tools python3-venv
i2cdetect -y 1 # expect 76 or 77 in the grid
python3 -m venv ~/aq-env
source ~/aq-env/bin/activate
pip install adafruit-circuitpython-bme680 paho-mqtt
If i2cdetect shows an empty grid, recheck SDA and SCL (they are easy to swap) and make sure the board is powered from 3.3 V. The official Raspberry Pi configuration documentation covers I2C setup in more detail if you use a non-standard image.
Building the Raspberry Pi Air Quality Monitor Script
The script below does four things: reads the sensor every ten seconds, runs a five-minute burn-in to establish a gas baseline, computes an IAQ estimate from gas and humidity, and publishes a JSON message to the free public test broker at broker.connectmq.com. It also sets a Last Will so subscribers know when the monitor goes offline.
#!/usr/bin/env python3
"""Raspberry Pi air quality monitor: BME680 -> MQTT."""
import json
import time
import uuid
import board
import adafruit_bme680
import paho.mqtt.client as mqtt
BROKER = "broker.connectmq.com"
PORT = 1883
DEVICE_ID = "pi-aq-" + uuid.uuid4().hex[:6]
BASE_TOPIC = f"connectmq/demo/{DEVICE_ID}/air"
I2C_ADDRESS = 0x77 # change to 0x76 for Pimoroni-style boards
INTERVAL = 10 # seconds between readings
BURN_IN = 300 # seconds used to learn the clean-air baseline
HUM_BASELINE = 40.0 # ideal indoor humidity in percent
HUM_WEIGHT = 0.25 # share of the score coming from humidity
def on_connect(client, userdata, flags, reason_code, properties):
print("Connected:", reason_code)
client.publish(f"{BASE_TOPIC}/status", "online", qos=1, retain=True)
def iaq_score(gas, hum, gas_baseline):
"""0-100, higher is better. Simple, transparent estimate, not Bosch BSEC."""
hum_offset = hum - HUM_BASELINE
if hum_offset > 0:
hum_score = (100 - HUM_BASELINE - hum_offset) / (100 - HUM_BASELINE)
else:
hum_score = (HUM_BASELINE + hum_offset) / HUM_BASELINE
hum_score *= HUM_WEIGHT * 100
gas_offset = gas_baseline - gas
if gas_offset > 0:
gas_score = (gas / gas_baseline) * (100 - HUM_WEIGHT * 100)
else:
gas_score = 100 - HUM_WEIGHT * 100
return round(max(0.0, min(100.0, hum_score + gas_score)), 1)
def main():
i2c = board.I2C()
sensor = adafruit_bme680.Adafruit_BME680_I2C(i2c, address=I2C_ADDRESS)
sensor.sea_level_pressure = 1013.25
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=DEVICE_ID)
client.will_set(f"{BASE_TOPIC}/status", "offline", qos=1, retain=True)
client.on_connect = on_connect
client.connect(BROKER, PORT, keepalive=60)
client.loop_start()
print(f"Burn-in for {BURN_IN} s, publishing to {BASE_TOPIC}/#")
samples = []
start = time.time()
while time.time() - start < BURN_IN:
samples.append(sensor.gas)
time.sleep(1)
gas_baseline = sum(samples[-50:]) / len(samples[-50:])
print(f"Gas baseline: {gas_baseline:.0f} Ohm")
while True:
gas = sensor.gas
hum = sensor.relative_humidity
payload = {
"ts": int(time.time()),
"temperature_c": round(sensor.temperature, 2),
"humidity_pct": round(hum, 1),
"pressure_hpa": round(sensor.pressure, 1),
"gas_ohm": int(gas),
"iaq_score": iaq_score(gas, hum, gas_baseline),
}
client.publish(f"{BASE_TOPIC}/telemetry", json.dumps(payload), qos=1)
print(payload)
time.sleep(INTERVAL)
if __name__ == "__main__":
main()
Run it with python aq_monitor.py. During the burn-in the heater stabilises; a brand-new sensor drifts for its first day or two, so do not trust the baseline from the very first run. A practical habit is to start the script in fresh air (open a window for the first five minutes) because the baseline defines what "clean" means for the rest of the session.
Interpreting the IAQ estimate
The score is inverted compared with Bosch's index: 100 means air as clean as the baseline with ideal humidity, lower means worse. A rough reading guide:
| Score | Meaning | Typical action |
|---|---|---|
| 80 to 100 | Close to baseline | Nothing to do |
| 60 to 80 | Noticeable VOCs or humidity off target | Keep an eye on it |
| 40 to 60 | Clearly worse than baseline | Ventilate the room |
| Below 40 | Strong source nearby | Find the source, ventilate now |
Because the baseline is relative, compare scores over time in the same room rather than between different devices.
Watching the Data Live
Open a second terminal (on the Pi or your laptop) and subscribe with the Mosquitto client. Replace the device ID with the one your script printed:
mosquitto_sub -h broker.connectmq.com -p 1883 -i aq-viewer-$RANDOM \
-t "connectmq/demo/pi-aq-1a2b3c/air/#" -v
You should see the retained online status first, then a telemetry message every ten seconds. Stop the Python script with Ctrl+C and, after the keepalive expires, the broker delivers the offline Last Will. Keep in mind that the public MQTT test broker is readable by anyone who knows the topic, so room-level air data is fine but nothing personal should go there.
Running the Monitor Unattended
A sensor that stops when you close your SSH session is not much of a monitor. Create a systemd unit so the script starts at boot and restarts after failures:
# /etc/systemd/system/aq-monitor.service
[Unit]
Description=BME680 air quality monitor
After=network-online.target
Wants=network-online.target
[Service]
User=pi
ExecStart=/home/pi/aq-env/bin/python /home/pi/aq_monitor.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now aq-monitor
journalctl -u aq-monitor -f
Adjust the user name and paths to match your installation. The same pattern is explained in more depth for other sensors in the DS18B20 temperature project and the DHT22 humidity tutorial, which you can combine with this one on a single Pi.
Moving to a Private Broker
For anything beyond experiments, publish to your own broker with credentials and TLS. On ConnectMQ you sign up, create a broker and get a generated username, password and client ID. Then change the connection block:
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="YOUR_CLIENT_ID")
client.username_pw_set("YOUR_USERNAME", "YOUR_PASSWORD")
client.tls_set() # system CA store validates the Let's Encrypt certificate
client.will_set(f"{BASE_TOPIC}/status", "offline", qos=1, retain=True)
client.connect("broker.connectmq.com", 8883, keepalive=60)
With a hosted MQTT broker you do not have to keep a Mosquitto instance patched and reachable yourself, and the portal's live topic explorer shows the telemetry arriving without extra tools. You can also add a threshold alert rule on iaq_score or humidity_pct so the portal's alerts log records every time the room crosses a limit.
Troubleshooting
ValueError: No I2C device at address: the address is wrong. Use whati2cdetectshows (0x76or0x77).- Gas resistance stuck very low and never rising: the sensor is still conditioning. Leave it powered for several hours; first-use drift is normal.
- Temperature several degrees too high: heat from the Pi or the sensor's own heater. Move the sensor away from the board or apply a fixed offset after comparing with a reference thermometer.
- Connection refused on the private broker: check that the client ID matches the issued credentials exactly; ConnectMQ binds client IDs to credentials.
- Messages stop after a network blip: paho reconnects automatically while
loop_start()is running, but QoS 1 messages published while offline are only queued in memory. For long outages, add a local buffer.
Ideas for Extending the Project
Once the data flows, the rest is pure MQTT. Node-RED can subscribe and drive a ventilation fan through a relay. Telegraf can write the JSON into InfluxDB for long-term charts. A second Pi in another room can publish under its own device ID so you compare spaces side by side. If you are new to the platform, the Raspberry Pi MQTT tutorial covers the basics of clients, topics and QoS that this project builds on.
Get Started with ConnectMQ
Move your Raspberry Pi air quality monitor from the public test broker to a private, TLS-secured one in a few minutes. ConnectMQ gives you a managed broker with an in-browser MQTT client, live topic explorer and threshold alerts. Create a free Serverless broker, no credit card required, and point your script at it.
Frequently Asked Questions
Is the BME680 IAQ value the same as the Bosch BSEC index?
No. The score in this tutorial is a transparent estimate based on a gas baseline and humidity. Bosch's BSEC library produces a calibrated 0 to 500 index with automatic baseline tracking, and it can be swapped in later without changing the MQTT topics.
How long does the BME680 need to warm up?
The heater stabilises within minutes, which is why the script uses a five-minute burn-in. A brand-new sensor keeps drifting for the first one or two days of operation, so readings become more consistent after that.
Can I measure CO2 with a BME680?
Not directly. The BME680 reacts to VOCs, and any CO2 figure derived from it is an estimate. For real CO2 measurement use an NDIR sensor such as the SCD40 or SCD41 and publish it alongside the BME680 data.
Which MQTT broker should I use for a home air quality monitor?
The public test broker is fine for trying things out, but everyone can read it. For permanent use, a private broker with credentials and TLS, such as a free ConnectMQ Serverless broker, keeps your data private.



