Learn how to Measure Air Quality with the BME680 Environmental Sensor and an ESP32. The BME680 is a tiny sensor that can not only measure temperature, humidity, pressure but also the concentration of Volatile Organic Compound (VOC) gases, which are bad for your health.
The BSEC-Arduino-library for the BME680 makes it especially easy to assess air quality, since it returns an Index of Air Quality (IAQ) with values in the range from 0 to 400. The following table shows how to interpret those IAQ values:

We will use this functionality to build an Air Quality Meter.
Required Parts
Below you will find the parts required for this project. Apart from the BME680 sensor you will need a microcontroller. I picked an older ESP32 lite but any other ESP32 will work just fine as well. Note that the BSEC-Arduino-library we are going to use supports other microcontrollers also. Have a look at its readme file.

BME680 Sensor

ESP32 lite

USB Data Cable

Dupont Wire Set

Breadboard
Makerguides is a participant in affiliate advertising programs designed to provide a means for sites to earn advertising fees by linking to Amazon, AliExpress, Elecrow, and other sites. As an Affiliate we may earn from qualifying purchases.
Overview of the BME680 Gas Sensor
The BME680 is a tiny but powerful 4-in-1 environmental sensor that can measure temperature, humidity, barometric pressure, and most importantly for this project, gas resistance, which is used to estimate Indoor Air Quality (IAQ). The picture below shows the sensor, which is just 3x3x1 mm in dimension:

The BME680 uses a gas sensor based on metal-oxide (MOX) technology. It doesn’t detect individual gases directly, but it reacts to a broad range of volatile organic compounds (VOCs) and gases like carbon monoxide (CO) in the air. These compounds are commonly released from:
- Cleaning products and aerosols
- Paints and varnishes
- Furniture and carpets (especially new ones)
- Cooking and smoking
- Human breath and body odor
- Combustion appliances
Although the sensor doesn’t measure gas concentrations in parts per million (ppm), the BSEC library uses machine learning models to translate the sensor’s gas resistance readings into a meaningful IAQ score, making it suitable indoor air quality monitoring.
What is IAQ and Why It Matters
The IAQ value returned by the BME680’s software library gives a numerical estimate of indoor air pollution. It combines multiple environmental readings into a single score, where:
- 0–50 = Excellent air quality
- 51–100 = Good
- 101–150 = Lightly polluted
- 151–200 = Moderately polluted
- 201–300 = Heavily polluted
- 301–500 = Severely polluted
This value is particularly useful because it gives you a clear and easy-to-understand number rather than raw gas resistance readings, which can be hard to interpret on their own.
Breakout board for BME680
Since the actual BME680 sensor is so tiny, we use a breakout board to connect it to the ESP32. This has also the advantage that we can run the sensor on 3.3V or 5V, since the board comes with a voltage regulator. The picture below shows the breakout board with the regulator and the BME680 sensor:

The BME680 is the small square metal box, on the right side. On the left side are the pins for the I2C or SPI interface, along with the power supply connections (VCC, GND).
We are going to use the I2C interface, since it only requires us to connect SDA and SCL. Note that he SDO pin determines the sensor’s I2C address. Leaving SDO unconnected sets the address to 0x77, while connecting it to ground changes the address to 0x76.
For more information on the BME680 sensor see our BME680 Environmental Sensor with Arduino tutorial, the BME680 Datasheet and maybe the Bosch Website for the BME680.
Connecting BME680 to ESP32
To connect the BME680 to an ESP32 lite via I2C, we need to connect SCL to pin 23, and SDA to pin 19. The pins for hardware I2C depend on your board and might differ. See the Find I2C and SPI default pins tutorial to figure out the pins for your board.
Otherwise, we just need to connect GND to ground (G) and VCC to the 3V (3.3V) pin of the ESP32. The picture below shows the complete wiring:

Code to Measure Environmental Data with BME680
As mentioned before we are going to use the BSEC-Arduino-library to read data from the BME680. You can install it via the LIBRARY MANAGER. Search for “bsec” and install the “BSEC Software Library” as shown below:

Note, that there is also the newer Bosch-BSEC2-Library, which I did not try. I have, however, used the simpler Adafruit BME680 library in another tutorial (BME680 Environmental Sensor with Arduino). But that library does not return easily interpretable measures of air quality (IAQ), so we can’t use it here.
The following code shows you how to capture air quality data such as IAQ, gas percentage and also simple environmental data such temperature, humidity, and pressure with the BME680. I’ll break the code down piece by piece and explain what each section does:
#include "bsec.h"
Bsec sensor;
void checkSensor() {
if (sensor.bsecStatus != BSEC_OK) {
Serial.println("BSEC status: " + String(sensor.bsecStatus));
}
if (sensor.bme68xStatus != BME68X_OK) {
Serial.println("BME68X status: " + String(sensor.bme68xStatus));
}
}
void setup(void) {
Serial.begin(115200);
delay(1000);
sensor.begin(BME68X_I2C_ADDR_HIGH, Wire);
checkSensor();
bsec_virtual_sensor_t sensorList[8] = {
BSEC_OUTPUT_IAQ,
BSEC_OUTPUT_STATIC_IAQ,
BSEC_OUTPUT_CO2_EQUIVALENT,
BSEC_OUTPUT_BREATH_VOC_EQUIVALENT,
BSEC_OUTPUT_RAW_PRESSURE,
BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE,
BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY,
BSEC_OUTPUT_GAS_PERCENTAGE
};
sensor.updateSubscription(sensorList, 8, BSEC_SAMPLE_RATE_LP);
checkSensor();
}
void loop(void) {
if (sensor.run()) {
Serial.printf("Acc: %d\n", sensor.iaqAccuracy);
Serial.printf("IAQ: %.0f\n", sensor.iaq);
Serial.printf("sIAQ: %.0f\n", sensor.staticIaq);
Serial.printf("gas: %.0f %%\n", sensor.gasPercentage);
Serial.printf("temp: %.1f C\n", sensor.temperature);
Serial.printf("hum: %.0f %%\n", sensor.humidity);
Serial.printf("pres: %.0f hPa\n", sensor.pressure / 100.0);
Serial.println();
}
}
libraries
At the top, the code includes the BSEC library, which is short for Bosch Sensortec Environmental Cluster. This library combines raw sensor data from the BME680 and applies algorithms to output meaningful air quality measurements, like IAQ and gas percentage.
#include "bsec.h"
objects
Next we create an instance of the Bsec class named sensor. This object gives you access to all BSEC functions.
Bsec sensor;
checkSensor
The checkSensor() function is a simple error-checking routine. It checks both the BSEC algorithm status and the BME688 hardware status. If there’s a problem, it prints the corresponding error codes to the Serial Monitor so you can debug them.
void checkSensor() {
if (sensor.bsecStatus != BSEC_OK) {
Serial.println("BSEC status: " + String(sensor.bsecStatus));
}
if (sensor.bme68xStatus != BME68X_OK) {
Serial.println("BME68X status: " + String(sensor.bme68xStatus));
}
}
Note that positive status codes indicate a warning, while negative status codes indicate an error!
setup
In the setup() function, the code begins serial communication at 115200 baud so it can print output to the Serial Monitor. It also includes a short delay to ensure the serial port is ready before continuing.
void setup(void) {
Serial.begin(115200);
delay(1000);
Then, the BME688 sensor is initialized via I2C using the sensor.begin() function. The address BME68X_I2C_ADDR_HIGH is used, which corresponds to 0x77. After initialization, the code checks the sensor status.
sensor.begin(BME68X_I2C_ADDR_HIGH, Wire); checkSensor();
The constant is called “HIGH”, since the if the SDO pin is not connected, an internal resistor pulls it high. If you want to use the other I2C address (0x76) by connecting SDO to ground, change this accordingly.
Next, the sketch defines which virtual sensors to subscribe to. This allows you to select, which sensors (temperature, humidity, …) and which derived values (e.g. raw versus heat-compensated temperature) to use. This is important, if you want to reduce power consumption by disabling unused sensors.
The sensor.updateSubscription() function tells the library what data from which sensor we want and how often:
bsec_virtual_sensor_t sensorList[8] = {
BSEC_OUTPUT_IAQ,
BSEC_OUTPUT_STATIC_IAQ,
BSEC_OUTPUT_CO2_EQUIVALENT,
BSEC_OUTPUT_BREATH_VOC_EQUIVALENT,
BSEC_OUTPUT_RAW_PRESSURE,
BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE,
BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY,
BSEC_OUTPUT_GAS_PERCENTAGE
};
sensor.updateSubscription(sensorList, 8, BSEC_SAMPLE_RATE_LP);
checkSensor();
}
The constant BSEC_SAMPLE_RATE_LP means low power (LP) sampling. This is another way to reduce the power consumptions of the sensor. There is, for instance, a BSEC_SAMPLE_RATE_ULP for ultra low power (ULP) consumption. You can even define different sample rates for different sets of virtual sensor. See the basic_config_state_ULP_LP example.
loop
The loop() function does the real-time work. The sensor.run() function checks if new data is available from the BSEC library. If so, the code prints various readings to the Serial Monitor:
void loop(void) {
if (sensor.run()) {
Serial.printf("Acc: %d\n", sensor.iaqAccuracy);
Serial.printf("IAQ: %.0f\n", sensor.iaq);
Serial.printf("sIAQ: %.0f\n", sensor.staticIaq);
Serial.printf("gas: %.0f %%\n", sensor.gasPercentage);
Serial.printf("temp: %.1f C\n", sensor.temperature);
Serial.printf("hum: %.0f %%\n", sensor.humidity);
Serial.printf("pres: %.0f hPa\n", sensor.pressure / 100.0);
Serial.println();
}
}
Note that this is just a small subset of the possible measurements and values the BSEC library can return. The following table lists all values, their meaning and range:
| Value | Description | Unit/Range | Kind | Type |
|---|---|---|---|---|
iaq | Indoor Air Quality Index (real-time, dynamic) | Index (0–500) | Processed | float |
iaqAccuracy | Confidence in iaq value | 0–3 | Status | uint8_t |
staticIaq | Smoothed IAQ value (less sensitive to fluctuations) | Index (0–500) | Processed | float |
staticIaqAccuracy | Confidence in staticIaq | 0–3 | Status | uint8_t |
co2Equivalent | Estimated CO₂ concentration from VOCs | ppm | Processed | float |
co2Accuracy | Confidence in co2Equivalent | 0–3 | Status | uint8_t |
breathVocEquivalent | Breath VOC equivalent estimate | ppm | Processed | float |
breathVocAccuracy | Confidence in breathVocEquivalent | 0–3 | Status | uint8_t |
compGasValue | Compensated gas value used in internally | Ohms (normalized) | Internal | float |
compGasAccuracy | Confidence in compGasValue | 0–3 | Status | uint8_t |
gasPercentage | Estimated clean air percentage | % | Processed | float |
gasPercentageAccuracy | Confidence in gasPercentage | 0–3 | Status | uint8_t |
rawTemperature | Uncompensated temperature from sensor | °C | Raw | float |
temperature | Compensated temperature (adjusted for sensor self-heating) | °C | Compensated | float |
rawHumidity | Uncompensated relative humidity | % | Raw | float |
humidity | Compensated relative humidity | % | Compensated | float |
pressure | Barometric pressure | hPa | Raw | float |
gasResistance | Raw resistance of the gas sensor | Ohms | Raw | float |
stabStatus | Whether the sample is stabilized | 0 or 1 (boolean-like) | Status | float |
runInStatus | Whether sensor is still in the run-in phase | 0 or 1 | Status | float |
In the code we are printing the iaqAccuracy (Acc), iaq (IAQ), staticIaq (sIAQ), gasPercentage (gas), temperature (temp), humidity (hum) and pressure (pres) values. Temperature and humidity are the compensated values (taking into account the heater of the gas sensor) and pressure is divided by 100 to return the pressure in hectopascal (hPA).
The most interesting values are the iaq (IAQ) and staticIaq (sIAQ), where the latter is just a more stable version of the former. The IAQ value is directly linked to an actionable interpretation of Air Quality as shown in the following table:

Output
If you upload and run the code you should see the measurements appearing in the Serial Monitor:

However, the gas sensor of the BME680 takes considerable time for the initial burn-in, ranging from ~5 to 30 minutes depending on ambient air conditions and sampling rate. Until that the IAQ value typically appears to be fixed at 25 or 50.
The iaqAccuracy value, which is printed as Acc to the Serial Monitor is a confidence indicator for IAQ (and other) gas related measurements.
At the beginning you will see a value of 0, which means the IAQ measurements are not yet valid (see the example output above). Over time the iaqAccuracy value will go up from 0 → 1 → 2 → 3 and once it reaches 3 the IAQ readings are considered valid! Below an example output after a completed burn-in of the sensor with an iaqAccuracy (Acc) of 3:

Obviously, it is very annoying to have to wait up to 20 minutes for valid measurements every time the BME680 resets or looses power. Luckily the burn-in can be shortened by saving and restoring the state of the BME680 sensor. How we can do this is described in the next section.
Code to Save and Restore BME680 state
The code below extends our previous code by two functions, saveState and loadState, which allow us to save the state of the BME680 sensor and shorten the time until gas related measurements are available:
#include <Preferences.h>
#include "bsec.h"
const unsigned long savePeriod = 3600 * 1000; // 1 hour
Bsec sensor;
Preferences prefs;
void checkSensor() {
if (sensor.bsecStatus != BSEC_OK) {
Serial.println("BSEC status: " + String(sensor.bsecStatus));
}
if (sensor.bme68xStatus != BME68X_OK) {
Serial.println("BME68X status: " + String(sensor.bme68xStatus));
}
}
void saveState() {
uint8_t state[BSEC_MAX_STATE_BLOB_SIZE];
sensor.getState(state);
prefs.begin("bsec", false);
prefs.putBytes("state", state, BSEC_MAX_STATE_BLOB_SIZE);
prefs.end();
Serial.println("Sensor state saved");
}
void loadState() {
prefs.begin("bsec", true);
if (prefs.getBytesLength("state") > 0) {
uint8_t state[BSEC_MAX_STATE_BLOB_SIZE];
prefs.getBytes("state", state, BSEC_MAX_STATE_BLOB_SIZE);
sensor.setState(state);
Serial.println("Sensor state loaded");
}
prefs.end();
}
void setup(void) {
Serial.begin(115200);
delay(1000);
sensor.begin(BME68X_I2C_ADDR_HIGH, Wire);
checkSensor();
loadState();
bsec_virtual_sensor_t sensorList[8] = {
BSEC_OUTPUT_IAQ,
BSEC_OUTPUT_STATIC_IAQ,
BSEC_OUTPUT_CO2_EQUIVALENT,
BSEC_OUTPUT_BREATH_VOC_EQUIVALENT,
BSEC_OUTPUT_RAW_PRESSURE,
BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE,
BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY,
BSEC_OUTPUT_GAS_PERCENTAGE
};
sensor.updateSubscription(sensorList, 8, BSEC_SAMPLE_RATE_LP);
checkSensor();
}
void loop(void) {
static unsigned long lastSave = millis();
if (sensor.run()) {
Serial.printf("Acc: %d\n", sensor.iaqAccuracy);
Serial.printf("IAQ: %.0f\n", sensor.iaq);
Serial.printf("sIAQ: %.0f\n", sensor.staticIaq);
Serial.printf("gas: %.0f %%\n", sensor.gasPercentage);
Serial.printf("temp: %.1f C\n", sensor.temperature);
Serial.printf("hum: %.0f %%\n", sensor.humidity);
Serial.printf("pres: %.0f hPa\n", sensor.pressure / 100.0);
Serial.println();
if (millis() - lastSave > savePeriod && sensor.iaqAccuracy >=3) {
saveState();
lastSave = millis();
}
}
}
saveState
The saveState function uses the Preferences library of the ESP32 to store the state of the BME680 in the on-board non-volatile memory (NVS) of the ESP32. This data is retained across restarts and loss of power events to the system.
void saveState() {
uint8_t state[BSEC_MAX_STATE_BLOB_SIZE];
sensor.getState(state);
prefs.begin("bsec", false);
prefs.putBytes("state", state, BSEC_MAX_STATE_BLOB_SIZE);
prefs.end();
Serial.println("Sensor state saved");
}
The state includes learned baselines for gas resistance, temperature, and humidity, which the sensor uses to compute accurate IAQ and other outputs.
The function reserves a block of memory of size BSEC_MAX_STATE_BLOB_SIZE for the state and then copies the sensor state into this memory block by calling getState(state).
We then begin a namespace called "bsec" in the ESP32’s NVS by calling prefs.begin("bsec", false), where the false indicates read&write mode.
Next we store the binary state array into flash memory under the key "state" and close the NVS session (prefs.end) to ensure data is committed
loadState
The loadState function retrieves the previously save state, if it exists:
void loadState() {
prefs.begin("bsec", true);
if (prefs.getBytesLength("state") > 0) {
uint8_t state[BSEC_MAX_STATE_BLOB_SIZE];
prefs.getBytes("state", state, BSEC_MAX_STATE_BLOB_SIZE);
sensor.setState(state);
Serial.println("Sensor state loaded");
}
prefs.end();
}
If prefs.getBytesLength("state") returns 0, we have not yet stored the sensor state and skip the reading of the state.
Otherwise we call prefs.getBytes("state", state, BSEC_MAX_STATE_BLOB_SIZE) to retrieve the state and write it to the sensor via sensor.setState(state).
setup
In the setup function we call loadState, after initializing the sensor. Therefore, if the ESP32 and consequently the BME680 sensor looses power or is reset, setup is called and we restore the sensor state – provided it was saved.
void setup(void) {
...
sensor.begin(BME68X_I2C_ADDR_HIGH, Wire);
loadState();
...
}
loop
In the loop function we regularly save the sensor state, for instance every hour, provided iaqAccuracy is 3 (or greater):
void loop(void) {
static unsigned long lastSave = millis();
if (sensor.run()) {
Serial.printf("Acc: %d\n", sensor.iaqAccuracy);
...
if (millis() - lastSave > savePeriod && sensor.iaqAccuracy >=3) {
saveState();
lastSave = millis();
}
}
}
The time period for saving the state is controlled by the savePeriod constant. I picked one hour but you don’t have to save that frequently. Even every three or four hours is fine.
const unsigned long savePeriod = 3600 * 1000; // 1 hour
Note that despite saving and restoring the sensor state the iaqAccuracy will still be at 0 after a restart of sensor but the time until it reaches 3 will be shorter.
Conclusion
In this tutorial you learned how to measure Air Quality with the BME680 Environmental Sensor, an ESP32 and the BSEC-Arduino-library.
The BSEC-Arduino-library has several code examples that are worth looking at. The code in this tutorial is derived from the basic.ino example. The basic_config_state.ino example shows you how to save and restore sensor state. And with the basic_config_state_ulp_plus.ino you can run the sensor in ultra-low power (ulp) mode. Finally, for deep-sleep see the esp32DeepSleep.ino example.
The BME680 also measures temperature, humidity and pressure but if that is your main interest and you don’t need IAQ values, use the Adafruit BME680 library instead of the more complex BSEC-Arduino-library. See the BME680 Environmental Sensor with Arduino tutorial for details.
If you don’t need gas measurements at all, I suggest the BME280 sensor, which is smaller and cheaper. See the How To Use BME280 Pressure Sensor With Arduino tutorial for more information.
If you have any questions, feel free to leave them in the comment section.
Happy Tinkering ; )
Stefan is a professional software developer and researcher. He has worked in robotics, bioinformatics, image/audio processing and education at Siemens, IBM and Google. He specializes in AI and machine learning and has a keen interest in DIY projects involving Arduino and 3D printing.

