Friday, October 5, 2018

Arduino Air Quality Monitor

I've recently spent some time developing the MonkMakes CO2 Sensor for micro:bit and have spent a fair amount of time researching into indoor air quality. It struck me that I actually had no idea how healthy (or otherwise) the air is, in the house that I both sleep and work in. 

Time for a project! What's more I can reuse some of the sensors that I have accumulated while researching. These are low cost sensors, the whole lot costing probably less that GBP 50 (USD 50).

The project logs the following readings through USB back to the Arduino Serial Monitor, where you can copy and paste it into a spreadsheet:
  • True CO2 level using the serial interface to the MH-Z14A CO2 sensor.
  • tVOC (Total Volatile Organic Compounds) in parts per billion (ppb) using a low cost CCS811 MEMs sensor breakout board
  • eCO2 - equivalent CO2 (derived from the tVOC sensor by the CCS811. You don't need these readings if you have real CO2 measurement, but I wanted them for comparison.
  • Particulates - µg/cubic metre - this was measured using a Sharp GP2Y1010 optical particulate sensor.
It all squeezed onto a breadboard and I used a MonkMakesDuino Uno compatible as the 'Arduino'. 

The output in the Serial Monitor is in Tab separated fields that can just be copied and pasted into a spreadsheet.


MH-Z14A (CO2)

You can find the MH-Z14A on eBay pretty cheap. While not the nicest CO2 sensor (I like the COZIR Ambient) they do have the benefit of being really cheap (for a true CO2 sensor) and when compared with better sensors are accurate enough for this kind of project.

The device has a number of interfaces, but I used the TTL serial interface. This just requires 4 pins from the device (19-Tx, 18-Rx, 17-GND, 16-5V) shown left to right in the figure above.

The sensor has a serial protocol that requires a message to be sent from the Arduino, some-time after which a response will be received from which the PPM (parts per million) of CO2 can be extracted.

The sensor uses spectral absorption to optically measure the CO2 compensation. No compensation is made for temperature (indoor use, so stable temp assumed) or altitude (atmospheric pressure - but this is a small factor).

You can find the datasheet here but the datasheet for the older version of this product here goes into more detail.

CCS811 (tVOC)

This air-quality sensor chip measure Total Volatile Organic Compounds (unhealthy chemicals with a boiling point that makes them likely to get into our air). We breathe these out and they are also found from pollutants and chemicals we use in our lives. There are not many of them in good clean outdoor air - in most places anyway.

My sensor came from eBay, but Sparkfun also sell a breakout board based on the same chip and made an Arduino library that I use in the code below.

It uses an I2C interface, but operates at 3.3V. You can find the datasheet here.

GP2Y1010 (Particulate density)

This is another eBay purchase. It measures reflections from particles in the air from which you can derive an approximate measurement of the number of µg per cubic meter of particles in the air. 

Pin 1 is the rightmost connection (white wire) in the figure above.

This depends on so many factors, not least the unknown mass of the particles, how much light (IR) they reflect etc that the reading has to be taken with a good dose of skepticism. But is does give an idea of how dusty the air is. 

The device uses an analog output with a sensitivity of 0.65 Volts per 100µg per metre cubed.

You can find the datasheet for this device here.


Here's the schematic for the project.

The GP2Y1010 uses high current pulses of IR. To prevent the switching of the IR sender affecting the readings because of drops in the supply voltage, a 150Ω resistor charges a 200µF reservoir capacitor to supply the IR LED with a pulse of power. The internal amplifier of the GP2Y1010 must be supplied directly from the 5V rail (pin 6).

Note that the CCS811 is 3.3V supply. It also needs the WAK (Wake) and AD (I2C address option) pins tying to GND. The module I used has built-in I2C pullup resistors.


Here's the sketch. I've added lots of comments. Between the comments and the datasheets, it should make sense. You will need to add the Sparkfun library to your IDE, before it will compile.

#include <SoftwareSerial.h>
#include "SparkFunCCS811.h"

#define CCS811_ADDR 0x5A

const int ledPin = A3;
const int sensorPin = A0;
const long samplePeriod = 1000L;   // sample period in ms

SoftwareSerial co2Sensor(10, 11); // RX, TX
CCS811 vocSensor(CCS811_ADDR);

 *  Messages to be sent by serial to the MH-Z14 CO2 Sensor
const byte requestReading[] = {0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79};
const byte zeroCalibrate[] =  {0xff, 0x87, 0x87, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf2};

byte result[9];   // buffer into which serial messages from the MH-Z14 are received

 * Globals containing last sensor readings and the last time a set of readings was output
long lastSampleTime = 0;   // more accurately when data was last logged to serial.
int vocCO2;
int tVoc;
int co2;
int partics;

void setup() {
  pinMode(ledPin, OUTPUT);
  digitalWrite(ledPin, HIGH); // active low
  // write out headers for the columns of data
  Serial.println("CO2 (ppm)\teCO2 (ppm)\ttVOC(ppb)\tParts (µg/m3)");

 * Send the calibrate message to the MH-Z14 - setting it to 400ppm
void calibrate() {
  for (int i = 0; i < 9; i++) {

void loop() {
   * Send the Z command after leaving the project in fresh 400ppm air for 5 mins
  if (Serial.available()) {
    char ch = Serial.read();
    if (ch == 'z') {
   * If the tVOC sensor has data ready, update the global variable
  if (vocSensor.dataAvailable()) {
    vocCO2 = vocSensor.getCO2();
    tVoc = vocSensor.getTVOC();
   * If we are due to log another row of data, then take the CO2 and
   * partical readings and then send them all to serial
  long now = millis();
  if (now > lastSampleTime + samplePeriod) {
    lastSampleTime = now;
    co2 = readPPMSerial();
    partics = dustMicroGpm3();
    Serial.print(co2); Serial.print("\t"); 
    Serial.print(vocCO2); Serial.print("\t"); 
    Serial.print(tVoc); Serial.print("\t"); 

 * Send a serial message (9 bytes) to the MH-Z14 and wait for a response
 * Parse the resulting data and return the reported CO2 concentration in ppm
int readPPMSerial() {
  for (int i = 0; i < 9; i++) {
  //Serial.println("sent request");
  while (co2Sensor.available() < 9) {}; // wait for response
  for (int i = 0; i < 9; i++) {
    result[i] = co2Sensor.read(); 
  int high = result[2];
  int low = result[3];
  return high * 256 + low;

 * Return the particulates reading in µg/m3
int dustMicroGpm3() {
  digitalWrite(ledPin, LOW);  // active low pulse for the IR LED
  delayMicroseconds(280);     // wait before taking the analog reading
  int raw = analogRead(sensorPin);
  digitalWrite(ledPin, HIGH); // end the pulse
  delayMicroseconds(9680);    // give the capacitor time to recharge

  float sensitivity = 0.0065; // V/µg/m3   .from the datasheet
  float v = float(raw) * 5.0 / 1023.0;
  return int(v / 0.0065);


Here are the results for CO2, and eCO2, sampling every 10 seconds overnight in our bedroom. So, maybe time to sleep with the door open!


You can find information on healthy levels of CO2 here.

For information on tVOC concentration and what it means, see here.


Unknown said...

So will the tVOC sensor indicate unburnt hydrocarbons (from vehicles) in an outdoor air sniffing situation?

Simon Monk said...

No, for that you need a particulates sensor.