Skip to Content

Measure Air Quality with BME680

Measure Air Quality with BME680

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:

Meaning of IAQ values (source)
Meaning of IAQ values (source)

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 Lolin32

ESP32 lite

USB data cable

USB Data Cable

Dupont wire set

Dupont Wire Set

Half_breadboard56a

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:

BME680 Sensor
BME680 Sensor

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:

Breakout board for BME680
Breakout board for BME680

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:

Connecting BME680 to ESP32
Connecting BME680 to ESP32

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:

BSEC Software Library installed in LIBRARY MANAGER
BSEC Software Library installed in LIBRARY MANAGER

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:

ValueDescriptionUnit/RangeKindType
iaqIndoor Air Quality Index (real-time, dynamic)Index (0–500)Processedfloat
iaqAccuracyConfidence in iaq value0–3Statusuint8_t
staticIaqSmoothed IAQ value (less sensitive to fluctuations)Index (0–500)Processedfloat
staticIaqAccuracyConfidence in staticIaq0–3Statusuint8_t
co2EquivalentEstimated CO₂ concentration from VOCsppmProcessedfloat
co2AccuracyConfidence in co2Equivalent0–3Statusuint8_t
breathVocEquivalentBreath VOC equivalent estimateppmProcessedfloat
breathVocAccuracyConfidence in breathVocEquivalent0–3Statusuint8_t
compGasValueCompensated gas value used in internallyOhms (normalized)Internalfloat
compGasAccuracyConfidence in compGasValue0–3Statusuint8_t
gasPercentageEstimated clean air percentage%Processedfloat
gasPercentageAccuracyConfidence in gasPercentage0–3Statusuint8_t
rawTemperatureUncompensated temperature from sensor°CRawfloat
temperatureCompensated temperature (adjusted for sensor self-heating)°CCompensatedfloat
rawHumidityUncompensated relative humidity%Rawfloat
humidityCompensated relative humidity%Compensatedfloat
pressureBarometric pressurehPaRawfloat
gasResistanceRaw resistance of the gas sensorOhmsRawfloat
stabStatusWhether the sample is stabilized0 or 1 (boolean-like)Statusfloat
runInStatusWhether sensor is still in the run-in phase0 or 1Statusfloat

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:

Meaning of IAQ values (source)
Meaning of IAQ values (source)

Output

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

Output in Serial Monitor
Output in 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:

Output in Serial Monitor after burn-in
Output in Serial Monitor after burn-in

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 ; )