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.
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.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Half breadboard
- 1 × Temperature and humidity, SHT31
- 1 × Air quality sensor, SGP30
- 1 × Four-digit display, HT16K33 backpack
- 1 × Resistor, 220 ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 1 × LED, redIn Starter Kit, Halloween Prop Kit, Logic Lab Kit.
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 pin | Connects to |
|---|---|
| Resistor, 220 Ω pin 1 | Arduino Uno R3 pin 8 |
| Temperature and humidity, SHT31 pin VIN | Arduino Uno R3 pin 5V |
| Air quality sensor, SGP30 pin VIN | Arduino Uno R3 pin 5V |
| Four-digit display, HT16K33 backpack pin VCC | Arduino Uno R3 pin 5V |
| Temperature and humidity, SHT31 pin GND | Arduino Uno R3 pin GND |
| Air quality sensor, SGP30 pin GND | Arduino Uno R3 pin GND |
| Four-digit display, HT16K33 backpack pin GND | Arduino Uno R3 pin GND |
| LED, red pin C | Arduino Uno R3 pin GND |
| Temperature and humidity, SHT31 pin SDA | Arduino Uno R3 pin A4 |
| Air quality sensor, SGP30 pin SDA | Arduino Uno R3 pin A4 |
| Four-digit display, HT16K33 backpack pin SDA | Arduino Uno R3 pin A4 |
| Temperature and humidity, SHT31 pin SCL | Arduino Uno R3 pin A5 |
| Air quality sensor, SGP30 pin SCL | Arduino Uno R3 pin A5 |
| Four-digit display, HT16K33 backpack pin SCL | Arduino Uno R3 pin A5 |
| LED, red pin A | Resistor, 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.
// 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 editorTroubleshooting
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 insetup. 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.