Built-in project · Arduino Uno R3

Air quality monitor on an Arduino Uno

An SGP30 and an SHT31 on one I2C bus with a four-digit LED display: CO2 in ppm, then temperature and humidity, and a red LED above 1000 ppm. The whole build, an Arduino Uno and 5 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Intermediate About 25 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $49. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Sharing one I2C bus between several devices
  • Why a gas sensor needs a warm-up period
  • Feeding one sensor's reading into another for accuracy
  • Using a threshold to trigger an alert
  • Showing units on a seven-segment display
  • Difficulty: Intermediate
  • Time: About 25 minutes
  • Board: Arduino Uno R3
  • Parts: 7

How it works

Three I2C parts share two wires: the SGP30 gas sensor, the SHT31 thermometer and the four-digit display. Each has its own address, so the Uno talks to them one at a time. A red LED on pin 8 is the alarm light.

For the first 15 seconds the SGP30 is warming up, so the display shows a countdown after two dashes. After that it rotates: CO2 in ppm for four seconds, then the temperature, then the humidity, one second each.

If the CO2 reading reaches 1000 ppm, the LED turns on and the display blinks once a second. It stops when the reading falls back below 1000.

Parts list

7 parts, plus the jumper wires. They retail for about $49; on Mokxi they're free.

Wiring table

Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.

Part and pinConnects to
Resistor, 220 Ω pin 1Arduino Uno R3 pin 8
Temperature and humidity, SHT31 pin VINArduino Uno R3 pin 5V
Air quality sensor, SGP30 pin VINArduino Uno R3 pin 5V
Four-digit display, HT16K33 backpack pin VCCArduino Uno R3 pin 5V
Temperature and humidity, SHT31 pin GNDArduino Uno R3 pin GND
Air quality sensor, SGP30 pin GNDArduino Uno R3 pin GND
Four-digit display, HT16K33 backpack pin GNDArduino Uno R3 pin GND
LED, red pin CArduino Uno R3 pin GND
Temperature and humidity, SHT31 pin SDAArduino Uno R3 pin A4
Air quality sensor, SGP30 pin SDAArduino Uno R3 pin A4
Four-digit display, HT16K33 backpack pin SDAArduino Uno R3 pin A4
Temperature and humidity, SHT31 pin SCLArduino Uno R3 pin A5
Air quality sensor, SGP30 pin SCLArduino Uno R3 pin A5
Four-digit display, HT16K33 backpack pin SCLArduino Uno R3 pin A5
LED, red pin AResistor, 220 Ω pin 2

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · Arduino Uno R3
// Air quality monitor: an SGP30 gas sensor, an SHT31 thermometer and an
// HT16K33 four-digit LED display share the I2C bus, the way a home-built CO2
// monitor is made.
//
//   SGP30, SHT31, display   SDA -> A4   SCL -> A5   VIN or VCC -> 5 V   GND -> GND
//   red LED                 pin 8 through 220 ohms
//
// The SGP30 reads 400 ppm and 0 ppb for its first 15 seconds while its hot
// plate warms up, so the display counts that down after "--". Then it takes
// turns: the CO2eq in ppm for four seconds, the temperature ("23*C") and the
// humidity ("45rh") for one each. Above 1000 ppm, where a room wants a
// window opening, the red LED comes on and the display blinks.
//
// The SHT31's humidity makes the SGP30 more accurate: the sensor wants it as
// milligrams of water in a cubic meter of air, which is worked out here from
// a table of how much water air can hold, because this board has no exp().
//
// Drag the sliders on the two sensors and watch the display follow.

#include <Wire.h>
#include "Adafruit_LEDBackpack.h"
#include "Adafruit_SHT31.h"
#include "Adafruit_SGP30.h"

const int ALERT_LED = 8;
const uint16_t ALERT_PPM = 1000;

// Segments for the units: A is bit 0, G is bit 6.
const uint8_t DEGREE = 0x63;
const uint8_t LETTER_C = 0x39;
const uint8_t LETTER_R = 0x50;
const uint8_t LETTER_H = 0x74;

Adafruit_7segment display = Adafruit_7segment();
Adafruit_SHT31 sht31 = Adafruit_SHT31();
Adafruit_SGP30 sgp;

// How much water saturated air holds, in mg per cubic meter, at 0, 5, 10 ...
// 50 degrees Celsius.
const uint32_t SATURATION[] = {4847, 6797, 9399, 12830, 17300, 23050,
                               30380, 39630, 51120, 65330, 82770};

uint32_t absoluteHumidity(int32_t centiC, int32_t centiRh) {
  if (centiC < 0) centiC = 0;
  if (centiC > 4999) centiC = 4999;
  uint8_t i = centiC / 500;
  uint32_t frac = centiC % 500;
  uint32_t sat = SATURATION[i] + (SATURATION[i + 1] - SATURATION[i]) * frac / 500;
  return sat * (uint32_t)centiRh / 10000;
}

// Two digits on the left, two unit segments on the right.
void showWithUnit(int32_t value, uint8_t left, uint8_t right) {
  display.print(value * 100, DEC);
  display.writeDigitRaw(3, left);
  display.writeDigitRaw(4, right);
}

unsigned long started;
uint8_t tick = 0;
bool alarm = false;

void setup() {
  pinMode(ALERT_LED, OUTPUT);
  display.begin(0x70);
  display.setBrightness(10);
  if (!sht31.begin(0x44) || !sgp.begin()) {
    // "Err": a sensor did not answer.
    display.writeDigitRaw(0, 0x79);
    display.writeDigitRaw(1, LETTER_R);
    display.writeDigitRaw(3, LETTER_R);
    display.writeDisplay();
    while (1) delay(10);
  }
  started = millis();
}

void loop() {
  bool climate = sht31.measure();
  int32_t centiC = sht31.getTemperatureCentis();
  int32_t centiRh = sht31.getHumidityCentis();
  if (climate) sgp.setHumidity(absoluteHumidity(centiC, centiRh));
  if (!sgp.IAQmeasure()) {
    delay(1000);
    return;
  }

  unsigned long seconds = (millis() - started) / 1000;
  tick = (tick + 1) % 6;
  if (seconds < 15) {
    display.print(15 - seconds, DEC);
    display.writeDigitRaw(0, 0x40);
    display.writeDigitRaw(1, 0x40);
  } else if (tick == 4) {
    showWithUnit((centiC + 50) / 100, DEGREE, LETTER_C);
  } else if (tick == 5) {
    showWithUnit((centiRh + 50) / 100, LETTER_R, LETTER_H);
  } else {
    display.print(sgp.eCO2, DEC);
  }
  display.writeDisplay();

  bool high = seconds >= 15 && sgp.eCO2 >= ALERT_PPM;
  if (high != alarm) {
    alarm = high;
    display.blinkRate(alarm ? HT16K33_BLINK_1HZ : HT16K33_BLINK_OFF);
  }
  digitalWrite(ALERT_LED, alarm ? HIGH : LOW);

  // One measurement a second, as the SGP30's baseline algorithm wants.
  delay(1000 - 70);
}

Code walkthrough

The key functions of the sketch, and what each one does.

absoluteHumidity sketch.ino

The SGP30 wants humidity as milligrams of water per cubic meter of air, but the SHT31 gives relative humidity. This function looks up how much water saturated air holds at the current temperature in the SATURATION table, blends between table entries, then scales by the relative humidity.

uint32_t absoluteHumidity(int32_t centiC, int32_t centiRh) {
  if (centiC < 0) centiC = 0;
  if (centiC > 4999) centiC = 4999;
  uint8_t i = centiC / 500;
  uint32_t frac = centiC % 500;
  uint32_t sat = SATURATION[i] + (SATURATION[i + 1] - SATURATION[i]) * frac / 500;
  return sat * (uint32_t)centiRh / 10000;
}

showWithUnit sketch.ino

Prints a two-digit number on the left of the display, then writes raw segment patterns into the last two digits to spell a unit. Temperature uses DEGREE and LETTER_C. Humidity uses LETTER_R and LETTER_H, which read as rh.

// Two digits on the left, two unit segments on the right.
void showWithUnit(int32_t value, uint8_t left, uint8_t right) {
  display.print(value * 100, DEC);
  display.writeDigitRaw(3, left);
  display.writeDigitRaw(4, right);
}

loop sketch.ino

Each pass reads the SHT31, hands its humidity to the SGP30, then takes an air quality reading. A counter called tick picks what to show. The alarm turns on when CO2 is at or above ALERT_PPM and the warm-up is over. The delay keeps it to about one reading a second.

void loop() {
  bool climate = sht31.measure();
  int32_t centiC = sht31.getTemperatureCentis();
  int32_t centiRh = sht31.getHumidityCentis();
  if (climate) sgp.setHumidity(absoluteHumidity(centiC, centiRh));
  if (!sgp.IAQmeasure()) {
    delay(1000);
    return;
  }

  unsigned long seconds = (millis() - started) / 1000;
  tick = (tick + 1) % 6;
  if (seconds < 15) {
    display.print(15 - seconds, DEC);
    display.writeDigitRaw(0, 0x40);
    display.writeDigitRaw(1, 0x40);
  } else if (tick == 4) {
    showWithUnit((centiC + 50) / 100, DEGREE, LETTER_C);
  } else if (tick == 5) {
    showWithUnit((centiRh + 50) / 100, LETTER_R, LETTER_H);
  } else {
    display.print(sgp.eCO2, DEC);
  }
  display.writeDisplay();

  bool high = seconds >= 15 && sgp.eCO2 >= ALERT_PPM;
  if (high != alarm) {
    alarm = high;
  // ... 7 more lines in the editor

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • The display shows Err and nothing else happens This project
    A sensor did not answer in setup. Check that SDA goes to A4 and SCL to A5 on all three parts, and that each has power and ground.
  • CO2 stays at 400 ppm right after starting This project
    That is normal. The SGP30 reports 400 for its first 15 seconds while it warms up. Wait for the countdown to finish.
  • The LED never lights even with a high reading This project
    Check the LED direction and that the 220 ohm resistor runs from pin 8 to the LED. The alarm only starts at 1000 ppm.
  • The sensor is not found on I2C. Temperature and humidity
    The address is 0x44, or 0x45 when the ADDR pin is high. Power it at 3.3 V to 5 V and add pull-ups on SDA and SCL if the module has none.
  • The temperature reads high or readings fail. Temperature and humidity
    Keep it away from heat sources and regulators. The part needs about 15 ms per reading, so wait. Do not run the built-in heater continuously.
  • The CO2 reading stays at 400 and TVOC at 0. Air quality sensor
    The first 15 seconds after init are a warm-up. Call the init once, and then read once per second. Keep the readings on a steady 1 Hz schedule for good results.
  • The sensor is not found. Air quality sensor
    The I2C address is 0x58 and it is fixed. Power it with 3.3 V or 5 V depending on your board, and pull SDA and SCL up. Run an I2C scanner.
  • The display is blank or not found on the I2C bus. The default address is 0x70, and it can be changed with the A0 to A2 solder jumpers. Four-digit display
    Scan for the address. Send the oscillator on and display on commands, then set the brightness (0 to 15). Use pull-ups on SDA and SCL if the backpack lacks them.

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.