Air Quality Sensors

Air Quality Sensors

This section deals with several sensors used to measure air quality—
CO2-equivalents (eCO2), the [total] concentration of volatile organic compounds (TVOC) and air quality index (AQI).

The information provided below relates specifically to the relative performance of the three sensor modules, CCS811, ENS160 and MQ-135, under consideration. More specific configuration details for the individual sensor modules are provided in the relevant pages accessible through the left side menu.

Sensor Calibration

Out of the box and without any calibration, even after their recommended 48 hour burn-in period, the three sensors yielded wildly different readings. To perform a comparison in a common environment, they were configured on a single CubeCell-Board Plus MCU. Unlike the other two air quality sensors, the MQ-135 module operates on a 5V supply. While this could have been provided through the VIN pin on the CubeCell-Board Plus, for the purpose of this testing it was driven through the Vext pin and a boost converter because this would be the way it would ultimately be configured on one of my [battery-powered] Nodes.

The test configuration also included a DS18B20 temperature sensor and a BME280 atmospheric sensor. The former is configured on most of my nodes as a matter of course and the latter would be the way temperature and humidity conditions would be measured for the two sensor modules that did not include this capability. These also provided a measure of the relative performance of the AHT21 sensor on the ENS160 module under test.

MCU Sensors
Multi-Sensor Setup (10068-BHCP + 10068-PDS)

CubeCell Plus Multi-Sensor Hardware Configuration

Multi-Sensor Electrical Circuit
Pin Configuration
CubeCell Plus BME280 CCS811 DS18B20 ENS160
AHT21
FP6277 MQ-135
VIN
VDD/Vext VIN VCC VDD 3V3 Vin + EN
GND GND GND GND GND GND GND
5V 5V
SCL SCL SCL SCL
SDA SDA SDA SDA
GPIO5 WAK
INT
RST
ADD
GPIO9 VQ
ADD
CS
INT
DO
ADC3 AO

VDD for always ON, Vext for manual sensor power control

Sensor Performance

Once the sensors had been calibrated in accordance with their individual requirements (refer to the pages, accessible through the left side menu, describing the individual sensors for details), measurements were recorded using the following sketch. This sketch polls each of the sensors and reports the results both on the CubeCell-Board Plus OLED display and via LoRa to a local gateway then MQTT to a NodeRED host.


Air Quality Sensor Comparison
/*
    This sketch is used to set up/calibrate and compare the relative performance
    of CCS811, ENS160 and MQ-135 air quality sensors using the CubeCell-Board Plus.
 
    Digital Concepts
    16 Sep 2025
    digitalconcepts.net.au
 */

#include <CubeCell_NeoPixel.h>    // CubeCell NeoPixel control library
#include <LoRa_APP.h>             // CubeCell LoRa
#include <HT_SH1107Wire.h>        // CubeCell Plus display library

#include <DFRobot_AHT20.h>        // AHT21
#include <Seeed_BME280.h>         // BME280
#include <DFRobot_CCS811.h>       // CCS811
#include <OneWire.h>              // One-Wire bus (DS18B20)
#include <DallasTemperature.h>    // DS18B20
#include <DFRobot_ENS160.h>       // ENS160
#include "MQ135.h"                // Local version includes calibration settings

#include "EepromHandler.h"		    // EEPROM management
#include "PacketHandler.h"		    // LoRa packet management

#include "LoRa915.h"              // Local LoRa parameters
#include "CubeCellPlusPins.h"     // CubeCell Plus pin definitions

#define sendLoRaMessages  true
#define cyclePeriod       5000    // Milliseconds

#define ccsWakePin        GPIO5   // CCS811 WAK
#define oneWireBus        GPIO9   // DS18B20 bus
#define displayTogglePin  GPIO11  // Interrupt botton (display toggle)
#define mqAoPin           ADC3    // MQ135 analog output ADC

struct softwareRevision {
  const uint8_t major;			// Major feature release
  const uint8_t minor;			// Minor feature enhancement release
  const uint8_t minimus;		// 'Bug fix' release
};

softwareRevision sketchRevision = {0,0,2};
String sketchRev = String(sketchRevision.major) + "." + String(sketchRevision.minor) + "." + String(sketchRevision.minimus);

EepromHandler eeprom;
PacketHandler packet;

uint8_t* descriptor;
uint16_t messageCounter = 0;

SH1107Wire  plusDisplay(0x3c, 500000, SDA, SCL, GEOMETRY_128_64, GPIO10); // addr, freq, sda, scl, resolution, rst
bool toggleFlag = false;
bool oledOn = true;

// NeoPixel Parameters: # pixels, RGB or RGBW device, device colour order + frequency

CubeCell_NeoPixel neo(1, RGB, NEO_GRB + NEO_KHZ800);

uint16_t batteryVoltage = 0;

// Heltec CubeCell-Board Plus
// I2C - SDA & SCL defined in pins_arduino.h

DFRobot_AHT20 aht21;  // I2C Address 0x38
const uint8_t ahtSensorId = 1;
uint8_t ahtStatus;   
float ahtHumidity = 35.0;
float ahtTemperature = 25.0;

#define BME280_I2C_Address_1 0x76
#define BME280_I2C_Address_2 0x77
BME280 bme280;
const uint8_t bmeSensorId = 2;
float bmeHumidity = 35.0;
float bmePressure = 1000.0;
float bmeTemperature = 25.0;

int intHumidity = 0;
int intPressure = 0;
int intTemperature = 0;

//DFRobot_CCS811 ccs811(&Wire, /*I2C_ADDRESS=*/0x5A);
DFRobot_CCS811 ccs811;
const uint8_t ccsSensorId = 3;
uint8_t ccsAqi = 99;
uint16_t ccsEco2 = 0;
uint16_t ccsTvoc = 0;

OneWire oneWire(oneWireBus);
DallasTemperature ds18b20(&oneWire);
const int ds18b20Precision = 9;
const uint8_t ds18b20SensorId = 0;

#define ENS160_I2C_Address_1 0x52
#define ENS160_I2C_Address_2 0x53
DFRobot_ENS160_I2C ens160;
const uint8_t ensSensorId = 4;
uint8_t ensAqi = 99;
uint16_t ensEco2 = 0;
uint16_t ensTvoc = 0;

#define RZERO 310.0     // Measured resistance : from calibration
#define RLOAD 74.2      // Measured resistance kOhms : MQ-135 module pin AO to GND with power off
MQ135 mq135(mqAoPin, RZERO, RLOAD);
const uint8_t mqSensorId = 5;
int mqAdc = 0;
uint8_t mqAqi = 99;
uint16_t mqEco2 = 0;
uint16_t mqTvoc = 65535;
const int mqArrayLength = 11;   // array length 
float mqArray[mqArrayLength];   // array used to store and calculate rolling average of ppm data
int validArrayData = 0;   			// number of entries in the array which are valid data for use in rolling average

static RadioEvents_t RadioEvents;

void setup(void)
{
  boardInitMcu( );
  Serial.begin(115200);
  while (!Serial);

  pinMode(Vext, OUTPUT);                    // Sensor power
  digitalWrite(Vext, LOW);                  // Activate Vext (we're really writing to Vext_Ctrl here)
  pinMode(ccsWakePin,OUTPUT);               // CCS811 enable
  digitalWrite(ccsWakePin, LOW);
  pinMode(mqAoPin, INPUT);                  // MQ-135 anlaog output
  PINMODE_INPUT_PULLUP(displayTogglePin);   // OLED display ON/OFF button
  attachInterrupt(displayTogglePin, toggleIsr, FALLING);

  plusDisplay.init();
  plusDisplay.setFont(ArialMT_Plain_10);
  plusDisplay.screenRotate(ANGLE_180_DEGREE);
    
  plusDisplay.clear();
  plusDisplay.setFont(ArialMT_Plain_10);
  plusDisplay.setTextAlignment(TEXT_ALIGN_LEFT);
  plusDisplay.drawString(5,5,"Air Quality Sensor");
  plusDisplay.display();
  delay(1000);

  Serial.println("[setup] Initialising I2C bus...");
  Wire.begin();

  Serial.println("[setup] Initialising EEPROM...");
  eeprom.begin( &Wire );

  Serial.print("[setup] ");
  descriptor = eeprom.readBytes( EH_DESCRIPTOR );
  int byteCount = eeprom.getParameterByteCount( EH_DESCRIPTOR );
  for (int i = 0; i < byteCount; i++) {
    Serial.print((char)descriptor[i]);
  }
  Serial.println();
  Serial.println("[setup]        Sketch " + sketchRev);
  Serial.println("[setup] PacketHandler " + packet.softwareRevision( PACKET_HANDLER ));
  Serial.println("[setup]   NodeHandler " + packet.softwareRevision( NODE_HANDLER ));
  Serial.println("[setup] EepromHandler " + eeprom.softwareRevision());
  Serial.println();
   
  Serial.println("[setup] Initialising LoRa...");

  RadioEvents.TxDone = onTxDone;
  RadioEvents.TxTimeout = onTxTimeout;

  Radio.Init( &RadioEvents );
  Radio.SetChannel( Frequency );
  Radio.SetTxConfig( MODEM_LORA, OutputPower, 0, SignalBandwidthIndex,
                                 SpreadingFactor, CodingRate,
                                 PreambleLength, FixedLengthPayload,
                                 true, 0, 0, IQInversion, 3000 );

  Radio.Sleep( );

  Serial.println("[setup] Initialising sensors...");

  // Initialise the Packet Handler
  
  packet.begin( eeprom.readUint32( EH_GATEWAY_MAC ), eeprom.readUint32( EH_NODE_MAC ));

  if ((ahtStatus = aht21.begin()) == 0) {
    Serial.println("[setup] AHT21 sensor initialised");
  } else {
    Serial.print("[setup] AHT21 sensor initialization failed. Error status : ");
    Serial.println(ahtStatus);
  }
  readAHT21Sensor();

  Serial.println("[setup] Check possible BME280 sensor addresses...");
  Serial.print("[setup] Try 0x");
  Serial.print(BME280_I2C_Address_1,HEX);
  Serial.print("...");
  if (bme280.init(BME280_I2C_Address_1)) {
    Serial.println("sensor found and initialised");
  } else {
    Serial.println("no reponse");
    Serial.print("[setup] Try 0x");
    Serial.print(BME280_I2C_Address_2,HEX);
    Serial.print("...");
    if (bme280.init(BME280_I2C_Address_2)) {
      Serial.println("sensor found and initialised");
    } else {
      Serial.println("no reponse");
      Serial.println( "[setup] BME280 initialisation failed..." );
    }
  }
  delay(100); // The BME280 needs a moment to get itself together... (50ms is too little time)

  Serial.println("[setup] Initialising CCS811...");
 // Wait for the chip to be initialized completely, and then exit
  while(ccs811.begin() != 0){
      Serial.println("[setup] CCS811 initialisation failed...");
      delay(3000);
  }
  /*!
    * @brief Set baseline
    * @param get from getBaseline.ino
    */
  ccs811.writeBaseLine(0x8BBA);

  Serial.println("[setup] Initialising DS18B20...");
  ds18b20.begin();

  /*
    Ambient temperature and humidity readings, if available, are used to calibrate
    ENS160 air quality measurements
   */

  Serial.println("[setup] Check possible ENS160 sensor addresses...");
  Serial.print("[setup] Try 0x");
  Serial.print(ENS160_I2C_Address_1,HEX);
  Serial.print("...");
  Wire.beginTransmission(ENS160_I2C_Address_1);
  if (Wire.endTransmission() == 0)  {
    Serial.println("sensor found");
    while( NO_ERR != ens160.begin(&Wire, ENS160_I2C_Address_1) ){
      Serial.println("[setup] ENS160 sensor initialisation failed...");
      delay(3000);
    }
  } else {
    Serial.println("no reponse");
    Serial.print("[setup] Try 0x");
    Serial.print(ENS160_I2C_Address_2,HEX);
    Serial.print("...");
    Wire.beginTransmission(ENS160_I2C_Address_2);
    if (Wire.endTransmission() == 0)  {
      Serial.println("sensor found");
      while( NO_ERR != ens160.begin(&Wire, ENS160_I2C_Address_2) ){
        Serial.println("[setup] ENS160 sensor initialisation failed...");
        delay(3000);
      }
    } else {
      Serial.println("no reponse");
      Serial.println( "[setup] Unable to identify ENS160 sensor" );
      while (true);
    }
  }
  /*
    Set Power Mode
    ENS160_SLEEP_MODE    : DEEP SLEEP mode (low power standby)
    ENS160_IDLE_MODE     : IDLE mode (low-power)
    ENS160_STANDARD_MODE : STANDARD Gas Sensing Modes
   */
  Serial.println("[setup] Set Power Mode...");
  ens160.setPWRMode(ENS160_STANDARD_MODE);

  mqArray[0]=1;         // mqArray[0] is index pointing to first [MQ-135] data location

  Serial.println("[setup] Initialisation complete");
  Serial.println();
  
  // Let the gateway/broker know there's been a reset
  
  Serial.println("[setup] Send out a reset advisory...");
  packet.setPacketType( RESET );
  packet.setResetCode( 0 );
  packet.serialOut();
  sendMessage();
  delay(100);
}

void loop() {
  if ( toggleFlag ) {
    Serial.println("[loop] Toggle the display...");
    toggleDisplay();
    toggleFlag = false;
  }
  Serial.println("[loop] Read and increment Sequence Number...");
  messageCounter = eeprom.readUint16( EH_SEQUENCE ) + 1;     
  Serial.println();

  Serial.println("[loop] Read battery voltage...");
  batteryVoltage = getBatteryVoltage();
  Serial.print("[loop] Battery voltage : ");
  Serial.println((float) batteryVoltage/1000, 2);
  packet.setSequenceNumber( messageCounter );
  packet.setPacketType( VOLTAGE );
  packet.setSensorId( 0 );
  packet.setVoltage( batteryVoltage );
  if (sendLoRaMessages ) sendMessage();
  delay(200);

  readAHT21Sensor();
  packet.setPacketType( ATMOSPHERE );      
  packet.setSensorId(ahtSensorId);
  packet.setTemperature( intTemperature );
  packet.setPressure( 0 );
  packet.setHumidity( intHumidity );
  if (sendLoRaMessages ) sendMessage();
  delay(200);

  readBME280Sensor();
  packet.setPacketType( ATMOSPHERE );      
  packet.setSensorId(bmeSensorId);
  packet.setTemperature( intTemperature );
  packet.setPressure( intPressure );
  packet.setHumidity( intHumidity );
  if (sendLoRaMessages ) sendMessage();
  delay(200);

  readDS18B20Sensor();
  packet.setPacketType( TEMPERATURE );      
  packet.setSensorId(ds18b20SensorId);
  packet.setTemperature( intTemperature );
  if (sendLoRaMessages ) sendMessage();
  delay(200);

  readCCS811Sensor();
  packet.setPacketType( AIRQUALITY );      
  packet.setSensorId(ccsSensorId);
  packet.setAqi( ccsAqi );
  packet.setEco2( ccsEco2 );
  packet.setTvoc( ccsTvoc );
  if (sendLoRaMessages ) sendMessage();
  delay(200);

  readENS160Sensor();
  packet.setPacketType( AIRQUALITY );      
  packet.setSensorId(ensSensorId);
  packet.setAqi( ensAqi );
  packet.setEco2( ensEco2 );
  packet.setTvoc( ensTvoc );
  if (sendLoRaMessages ) sendMessage();
  delay(200);

  readMQ135Sensor();
  packet.setPacketType( AIRQUALITY );      
  packet.setSensorId(mqSensorId);
  packet.setAqi( mqAqi );
  packet.setEco2( mqEco2 );
  packet.setTvoc( mqTvoc );
  if (sendLoRaMessages ) sendMessage();
  delay(200);

  if (oledOn) displayData();

  eeprom.writeUint16( EH_SEQUENCE, messageCounter ); // Save the message counter

  Serial.println();
  delay(cyclePeriod);
}

void onTxDone(void)
{
  Serial.println("[OnTxDone] TX done!");
  Radio.Sleep( );
}

void onTxTimeout(void)
{
  Serial.println("[OnTxTimeout] TX Timeout...");
  Radio.Sleep( );
}

void toggleIsr() {
  toggleFlag = true;
}

void toggleDisplay() {
  if (oledOn) {
    plusDisplay.displayOff(); // Switch off display
  } else {
    plusDisplay.displayOn(); // Switch on display
  }
  oledOn = !oledOn;
}

void displayData() {
  plusDisplay.clear();
  plusDisplay.setFont(ArialMT_Plain_10);
  plusDisplay.setTextAlignment(TEXT_ALIGN_LEFT);
  plusDisplay.drawString(5,0,"Air Quality Sensor");
  plusDisplay.drawString(100,0,String((float)batteryVoltage/1000,2) + "V");
//  plusDisplay.setFont(ArialMT_Plain_24);
  plusDisplay.setTextAlignment(TEXT_ALIGN_RIGHT);
  plusDisplay.drawString(60,8,"Temp");
  plusDisplay.drawString(60,16,"CCS eCO2");
  plusDisplay.drawString(60,24,"CCS TVOC");
//  plusDisplay.drawString(60,32,"ENS AQI");
  plusDisplay.drawString(60,32,"ENS eCO2");
  plusDisplay.drawString(60,40,"ENS TVOC");
  plusDisplay.drawString(60,48,"MQ eCO2");
  plusDisplay.setTextAlignment(TEXT_ALIGN_LEFT);
  plusDisplay.drawString(70,8,String(ahtTemperature,2) + "°C");
  plusDisplay.drawString(70,16,String(ccsEco2) + "ppm");
  plusDisplay.drawString(70,24,String(ccsTvoc) + "ppb");
//  plusDisplay.drawString(70,32,String(ensAqi));
  plusDisplay.drawString(70,32,String(ensEco2) + "ppm");
  plusDisplay.drawString(70,40,String(ensTvoc) + "ppb");
  plusDisplay.drawString(70,48,String(mqEco2) + "ppm");
  plusDisplay.display();
}

void readAHT21Sensor() {
  if (aht21.startMeasurementReady(/* crcEn = */true)) {
    ahtTemperature = aht21.getTemperature_C();
    intTemperature = (int)(10 * ahtTemperature);
    Serial.print("[readAHT21Sensor]  Temperature : ");
    // Get temp in Celsius (°C), range -40-80°C
    Serial.print(ahtTemperature);
    Serial.println(" °C");
    // Get temp in Fahrenheit (F)
//    Serial.print(aht20.getTemperature_F());
//    Serial.println(" °F");
    // Get relative humidity (%RH), range 0-100%
    ahtHumidity = aht21.getHumidity_RH();
    intHumidity = (int)ahtHumidity;
    Serial.print("[readAHT21Sensor]     Humidity : ");
    Serial.print(ahtHumidity);
    Serial.println("%");
  } else {
    Serial.println("[readAHT21Sensor] No temperature data available");
    Serial.println("[readAHT21Sensor] Using default ambient conditions for calibration...");
    Serial.print("[readAHT21Sensor]  Temperature : ");
    Serial.print(ahtTemperature);
    Serial.println(" °C");
    Serial.print("[readAHT21Sensor]     Humidity : ");
    Serial.print(ahtHumidity);
    Serial.println("%");
  }
}

void readBME280Sensor() {
  Serial.println("[readBMESensor] Read sensor...");
/*
 * If this node is reading atmospheric conditions from a BME sensor
 * All values recorded as integers, temperature multiplied by 10 to keep one decimal place
*/ 
  bmeTemperature = bme280.getTemperature();
  intTemperature = (int)(10 * bme280.getTemperature());
  bmePressure = bme280.getPressure() / 91.79F;
  intPressure = (int)(bme280.getPressure() / 91.79F);
  bmeHumidity = bme280.getHumidity();
  intHumidity = (int)bme280.getHumidity();

  Serial.print("[readBME280Sensor] Temperature : ");
  Serial.print(bmeTemperature, 1);
  Serial.println(" °C");
  Serial.print("[readBME280Sensor]    Pressure : ");
  Serial.print(bmePressure, 0);
  Serial.println(" hPa");
  Serial.print("[readBME280Sensor]    Humidity : ");
  Serial.print(bmeHumidity, 0);
  Serial.println("%");
}

void readCCS811Sensor() {
  /*
    Set temperatuer & humidity
    */
  ccs811.setInTempHum(ahtTemperature, ahtHumidity);

  if (ccs811.checkDataReady() == true) {
    Serial.print("[readCCS811Sensor]                    eCO2 : ");
    ccsEco2 = ccs811.getCO2PPM();
    Serial.print(ccsEco2);
    Serial.println(" ppm");
    Serial.print("[readCCS811Sensor]                    TVOC : ");
    ccsTvoc = ccs811.getTVOCPPB();
    Serial.print(ccsTvoc);
    Serial.println(" ppb");      
  } else {
    Serial.println("[readCCS811Sensor] Data not ready!");
  }
}

void readDS18B20Sensor() {
  Serial.println("[readDS18B20Sensor] Read sensor...");
  ds18b20.requestTemperatures(); // Send the command to get temperatures
  
  // After we get the temperature(s), we can print it/them here.
  // We use the function ByIndex and get the temperature from the first sensor (there's only one at the moment).
  Serial.println("[readDS18B20Sensor] Device 1 (index 0)");
  float sensorValue = ds18b20.getTempCByIndex(0);
  Serial.print("[readDS18B20Sensor] Returned value: ");
  Serial.println(sensorValue);

  intTemperature = (int) (10*(sensorValue + 0.05));  // °C x 10
  Serial.print("[readDS18B20Sensor] Temperature: ");
  Serial.println((float) intTemperature/10, 1);
}

void readENS160Sensor() {
  /*
    Set temperatuer & humidity
    */
  ens160.setTempAndHum(ahtTemperature, ahtHumidity);
  /*
    Get the Sensor Operating Status
    Return value: 0 - Normal operation, 
                  1 - Warm-Up phase, first 3 minutes after power-on.
                  2 - Initial Start-Up phase, first full hour of operation after initial power-on. Only once in the sensor’s lifetime.
    Note: The status will only be stored in the non-volatile memory after an initial 24h of continuous
          operation. If unpowered before conclusion of said period, the ENS160 will resume "Initial Start-up" mode
          after re-powering.
   */
  uint8_t ensStatus = ens160.getENS160Status();
  Serial.print("[readENS160Sensor] Sensor Operating Status : ");
  Serial.println(ensStatus);

  /*
    Get the Air Quality Index
    Return value: 1 - Excellent
                  2 - Good
                  3 - Moderate
                  4 - Poor
                  5 - Unhealthy
   */
  ensAqi = ens160.getAQI();
  Serial.print("[readENS160Sensor]       Air Quality Index : ");
  Serial.println(ensAqi);

  /*
    Get CO2 equivalent concentration calculated according to the detected data of VOCs and hydrogen (eCO2 – Equivalent CO2)
    Return value range: 400–65000, unit: ppm
    Five levels:  Excellent ( 400 -  600)
                  Good      ( 600 -  800)
                  Moderate  ( 800 - 1000)
                  Poor      (1000 - 1500)
                  Unhealthy (     > 1500)
   */
  ensEco2 = ens160.getECO2();
  Serial.print("[readENS160Sensor]                    eCO2 : ");
  Serial.print(ensEco2);
  Serial.println(" ppm");

  /*
    Get Total Volatile Organic Compound (TVOC) concentration
    Return value range: 0–65000, unit: ppb
   */
  ensTvoc = ens160.getTVOC();
  Serial.print("[readENS160Sensor]                    TVOC : ");
  Serial.print(ensTvoc);
  Serial.println(" ppb");
}

void readMQ135Sensor() {
  mqAdc = analogRead(ADC3);
  Serial.print("[readMQ135Sensor]                      ADC : ");
  Serial.println(mqAdc);

  #ifdef _CALIBRATE
    // Read sensor resistance
    float value = mq135.getRZero();
    Serial.print("[readMQ135Sensor]              R Zero Ohms : ");  
    Serial.println(value);
  #else
    // Read air quality sensor
    float value = mq135.getPPM();
    Serial.print("[readMQ135Sensor]              Air Quality : "); 
    Serial.print(value);
    Serial.println(" ppm");
  #endif

  // Store the value
  mqArray[int(mqArray[0])] = value;
  /*
  Serial.print(ppmArray[0]);
  Serial.print(": ");
  Serial.println(ppmArray[int(ppmArray[0])]);
  */
  mqArray[0]++;
  if (int(mqArray[0]) > mqArrayLength-1) {
    mqArray[0]=1;
  }  
  if (validArrayData < mqArrayLength-1) {
    validArrayData ++;
  }
  
  // Compute rolling average
  float total = 0;
  for (int i = 1; i < validArrayData+1; i++) {
    total = total + mqArray[i];   
  }
  float averageValue = total/validArrayData;   
  Serial.print("[readMQ135Sensor]     ");
  if (validArrayData < 10) Serial.print(" ");
  Serial.print("Rolling Average [");
  Serial.print(validArrayData);
  Serial.print("] : ");
  Serial.print(averageValue);
  Serial.println(" ppm");
  mqEco2 = (uint16_t)averageValue;
}

void sendMessage() {
/*
 * The necessary payload content must be set by the relevant sensor method(s).
 * At this point, we assume that everything is ready to go, the Packet Handler having
 * assembled what it has in the nominated [packetType] format.
 */
 
// Only do this if you want to see what's being sent
// packet.hexDump();    // Hex dump
// packet.serialOut();  // Plain English

  int totalByteCount = packet.packetByteCount();

  Serial.println("[sendMessage] Sending packet...");
  neoPixel(0,32,0);   // NeoPixel [low intensity] GREEN
  Radio.Send((uint8_t *)packet.byteStream(), packet.packetByteCount());
  neoPixel(0,0,0);    // NeoPixel OFF
  Serial.println("[sendMessage] Packet sent");  
  Serial.println();
//  resetWatchdogTimer();
}

void neoPixel(uint8_t red, uint8_t green, uint8_t blue) {
	neo.begin();                                        // Initialise RGB strip object
	neo.clear();                                        // Set all pixel 'off'
	neo.setPixelColor(0, neo.Color(red, green, blue));  // The first parameter is the pixel index, and we only have one
	neo.show();                                         // Send the updated pixel colors to the hardware.
}

The following, then, are illustrations of the Node-RED dashboard showing typical sensor readings in several environments. The third illustration is of the readings shortly after I had soldered on the DS18B20 sensor and cleaned off the residual flux with isopropyl alcohol.

Typical sensor readings in fresh air
Typical sensor readings indoors
Sensor readings following PCB clean with alcohol

The first thing to note is that, as already mentioned, while the three sensors may be measuring the concentrations of similar, or even the same, gases, their responses to different concentrations of different gases seem to be quite different. The MQ-135 is the most basic sensor in that, unlike the CCS811 and ENS160, there is no onboard processor so we are dealing with a simple, raw analog signal. In the case of the two sensors with onboard processors, the CCS811 and ENS160, the methodologies used to calculate eCO2, TVOC and AQI from their internal ADC readings are not explained in their respective datasheets.

In theory, the fresh air readings should reflect the background CO2 level, perhaps augmented by the presence of any background VOCs. My sensors are operating in a rural environment in an area populated by eucalypt trees, which, as well as being a potential CO2 sink, are characterised by the natural release of eucalypt oils—whether or not the volatile eucalypt oils that give the Australian bushland its characteristic blue hue are measured as VOCs, I do not know. My wife is also an avid gardener and there is a range of aromatic plants under her care, so it would be of no surprise if background levels of CO2 and/or VOCs in my local environment were both atypical, either higher or lower than 'normal', and subject to significant variation with the daily weather conditions.

The ENS160 datasheet states that the sensor self-calibrates, but also that it only measures eCO2 in the range 400~65000, so it does not report eCO2 levels below 400ppm. Both the CCS811 and MQ-135 sensors require manual calibration, which involves placing the sensor in fresh air for a period of time, recording some baseline number, and subsequently feeding that back into calculations under operational conditions. I am guessing that, in the case of the MQ-135 at least, it was calibrated at a time when the background levels of CO2 and VOCs were actually higher than those at the time the above illustration was recorded—the MQ-135 sensor is showing a 'fresh air' eCO2 level below what might normally be considered the background level. The fact that the ENS160 sensor has an eCO2 reading sitting hard on 400 suggests that the MQ-135 sensor may well be accurately reporting a sub-400 eCO2. As for the CCS811, fluctuations in its readings are par for the course so it's difficult to read anything much into a single reading.

In the absence of an appropriately calibrated reference then, there is no way of knowing which sensor may or may not be providing the more accurate assessment of relevant gas concentrations. The sensors all return similar readings, after an appropriate conditioning period, in relatively clean air (eCO2 = 400~500ppm) but the ENS160, for example, appears consistently less sensitive to increasing levels of 'pollutant gases'. Simply breathing on the sensors, for example, results in the eCO2 reading on the CCS811 immediately (5 sec sampling) jumping from ~450ppm to ~3000ppm, while the ENS160 jumps from ~450ppm to ~1300ppm and the MQ-135 barely moves from ~450ppm, although, to be fair, in this case the sketch is reporting a rolling average of 10 readings from the MQ‑135 to dampen the characteristic fluctuations of the ADC, so this would probably be expected if the change were only temporary and brief. I am left to conclude that the ENS160 must be doing something similar internally, if to a lesser extent, given that its response to change also appears somewhat dampened. The behaviour of the CCS811 would suggest that it is reporting on every individual reading, without any averaging.

With regard to TVOC readings, any relationship that might exist between those of the CCS811 and ENS160 sensors is not obvious. The CCS811 consistently returns readings that are 2~3 times higher than those reported by the ENS160. The CCS811 readings also fluctuate by up to 100% (between 5 sec samples) while those of the ENS160 remain quite consistent. Without any knowledge of how the onboard MCUs in each case might be processing input, however, it's not really possible to offer much more comment on what's going on there. One can only assume that the two sensors are more or less sensitive to different gases and that this has a more significant impact on their respective TVOC calculations than those for eCO2.

The ENS160 datasheet indicates that this sensor includes four independent sensor elements that are each sensitive to different gases—the CSS811, by comparison, apparently operates on a single element. The respective datasheets don't, however, expand on this, so once again there is no way of knowing exactly how the onboard processors in these sensors calculate the respective values for eCO2, TVOC and, in the case of the ENS160, AQI. It doesn't help that there does not appear to be any standard for what gases might contribute to eCO2 or TVOC calculations, or a single, explicit definition of how an Air Quality Index should be derived.

All that can be said at this point then would seem to be that the AQI is a simplified measure of air pollution. In fact, it is not at all clear to me what the practical difference between eCO2, TVOC and AQI might be— the measurement scale and units used notwithstanding, any one of these can provide an indication of a change in concentration of the gases to which a given sensor is calibrated.

Sensor Sensitivity

Having now sorted out the use of InfluxDB and Grafana for the storage and display of timeseries data, results of longer term measurements generally back up the comments already made in relation to performance. The following are some 24hr Grafana traces of readings taken using the node configuration described above. The node was operating in my 'computer room', which is closed overnight, with an associated rise in 'pollutants'. In the morning, the effect of opening the door and letting in some fresh air can be clearly seen.

CCS811, ES160 & MQ-135 eCO2
CCS811 & ES160 TVOC
AHT21, BME280 & DS18B20 Temperature

Apart from the observation already made in relation to the relative readings from the different sensors, the CCS811 sensor is relatively unstable. This may be due to the way I currently have the module configured since the CCS811 sensor can be configured to take readings at various intervals. At the moment, it is configured to take readings every second, and these readings are effectively what is being sampled, albeit only once a minute, in the present configuration. It may be that, if the sensor is configured to take/provide readings every minute, it will return an average to several readings and present a more stable picture. The MQ-135, however, was also configured to take a single instantaneous reading for the purpose of these observations and its readings (eCO2 only), although somewhat higher than the other two, were still relatively stable.

The CCS811 sensor was reconfigured to take readings every 10 seconds, although it was still only sampled once every 60 seconds. The result was much more stable readings that were much more consistent with those made with the ENS160 sensor. From the 7-day graphs provided below, it is apparent that the sensors respond to different gases with varying degrees of sensitivity. Interestingly, the MQ-135 has become much more sensitive over the intervening period (~one month), although it too might just be responding to different gases. The test configuration has remained in my 'computer room' for the duration, with the temperature graph showing the broad trend in daily temperature fluctuations and all sensors showing more specific variations when the door is opened each morning, when I'm soldering or even just in the room.

CCS811, ES160 & MQ-135 eCO2
CCS811 & ES160 TVOC
AHT21, BME280 & DS18B20 Temperature

The next step will be to recalibrate the MQ-135 sensor and see what difference, if any, that makes.

As a place holder here, as much as anything else, some commentary suggests that the the BME280 is not a good sensor for temperature measurement, as it tends to self heat. In light of the present observations, this is a curious claim, since the BME280 appears to make the lowest temperature recordings of the three used here. In any case, an explanation for the variation between the different sensors noted in our own observations will require a little more work.

Sensor Module Feature Comparison

The following is by no means an exhaustive list of the features of the three modules discussed here, just those thought most relevant in the present context. More details for each module are provided on the individual sensor pages accessible through the left side menu.

CCS811 ENS160+AHT21 MQ-135
Operating Voltage 3.3V 3.3V 2.5~5V(?)
Integrated ADC -
Integrated MCU -
I2C Interface -
SPI Interface - -
Sensor Elements 1 4 1
Low Power Mode -
Initial Burn-In 1 hour 1 hour 48~168 hours
Warm-Up 20 minutes 3 minutes 24 hours
Calibration Manual Automatic Manual
Temperature & Humidity
Compensation
External On-Board None
eCO2 -
TVOC -
AQI - -
Threshold Interrupt
Unit Price (AliExpress) A$12.89 A$6.12 A$1.73

I had originally purchased the MQ-135 for my [battery-powered] application, but its warm-up time quickly took it out of contention. The CCS811 was next on the list, reluctantly at the time because of its price. But I kept putting this whole exercise to one side because I couldn't get either of the first two CCS811 modules I had purchased to work.

It was only when I finally made a concerted effort to find out what was wrong with these modules that I discovered the ENS160, an alternative recommended by several suppliers who had discontinued their CCS811 offering. Fortunately, I chose to purchase two different ENS160 module variants for testing because I couldn't get one of those variants to work either!

Finally though, the ENS160 module variant that included an AHT21 temperature sensor worked as advertised, with very little effort. By this time I also had a [third] CCS811 module that actually worked, but by then the ENS160+AHT21 module was looking like the preferred option.

03-09-2026