1

Simple DIY AQI Module: Measure TVOC, CO2, Humidity, and Temperature

Share

Introduction

Simple DIY module for monitoring air quality using the ESP32 Mini with ESPHome firmware to integrate with Home Assistant. We’ll use the AHT21 and CCS811 sensors to measure Humidity, Temperature, Total Volatile Organic Compound (TVOC), and CO2 levels, and display the data on a 1.3-inch SH1106 OLED display.

All components are connected using an I2C interface through a DIY I2C rail. Additionally, the module includes an AUX button that can be set to toggle the display screen or cycle through display pages.

This project is budget-friendly, costing around $12 for the essential components, and offers a hands-on experience that combines electronics, coding, and 3D printing.

I designed this module specifically to monitor fumes from my 3D printer, ensuring a safer and healthier workspace. Whether you’re a seasoned maker or a beginner, this project is an exciting addition to your smart home setup.

Material List

3D PRINT PARTS

COMPONENTS FOR THIS PROJECT

Key Features of AHT21 and CCS811 Sensors

AHT21 Sensor

The AHT21 is an inexpensive and highly accurate temperature and humidity sensor. It offers a relative humidity accuracy of ±2% and a temperature accuracy of ±0.3 °C.
These specifications make it a reliable choice for indoor air quality monitoring, ensuring that the readings you get for humidity and temperature are precise and consistent.

With its compact size and I2C interface, the AHT21 is easy to integrate into your DIY projects. The I2C interface greatly reduces wiring clutter by enabling multiple sensors on the same bus using just a pair of data wires from the MCU.

This making it an excellent option for various applications, including environmental monitoring and smart home setups.

AHT21 Technical Parameter

  • Supply voltage: DC 2.2 – 5.5V
  • Measuring range (humidity): 0 ~ 100% RH
  • Measuring range (temperature): -40 ~ + 120 ℃
  • Humidity accuracy: ± 2 % RH ( 25 ℃ )
  • Temperature accuracy: ± 0.3 ℃
  • Resolution: temperature: 0.01℃ Humidity: 0.024%RH

CCS811 Sensor

The CCS811 is a versatile air quality sensor capable of measuring equivalent calculated carbon dioxide (eCO2) and Total Volatile Organic Compound (TVOC) concentrations.
It can detect eCO2 concentrations within a range of 400 to 8192 parts per million (ppm) and TVOC concentrations within a range of 0 to 1187 parts per billion (ppb).

According to the datasheet, the CCS811 can detect a variety of organic compounds, including:

  • Alcohols
  • Aldehydes
  • Ketones
  • Organic Acids
  • Amines
  • Aliphatic Hydrocarbons
  • Aromatic Hydrocarbons

These capabilities make the CCS811 an essential component for monitoring indoor air quality, as it can provide insights into the presence of harmful pollutants and help ensure a healthier living environment.

Please note, this sensor, like all VOC/gas sensors, has variability and to get precise measurements you will need to calibrate it against known sources. That said, for general environmental sensors, it will give you a good idea of trends and comparisons.

DIY I2C Hub

To simplify wiring and split the I2C interface. I made a DIY I2C hub using a single-side strip prototype board.
Although it’s slightly more fragile than the green prototype board I usually use, this board is easier to cut and significantly reduces the time required to bridge connections between each connector.

The prototype board was cut to dimensions of 5 rows by 14 columns. Then you can soldered components according to the following diagram.

The pull-up resistors (3.3-10KΩ) were place in parallel from both SDA and SCL line to VCC line. You also need 4 pins JST-XH connectors for the input from ESP32 and output to AHT21 and the OLED display. While CCS811 sensor will need 5 pin connector because it need extra WAK pin that need to connect to GND.

For more information on the I2C bus and the importance of pull-up resistors, you can refer to this useful resource: How Many Devices Can You Connect to the I2C Bus?

Momentary Button

I’ve also use strip prototype board for mounting the momentary switch for the AUX button. I’ve cut the strip board to 5 rows by 6 columns.
I use 2mm drill bit to enlarge the corner holes for mounting it to the enclosure with M2x6 self-tap screws.

I’ve connect ground wire to pin 1 of 6×6 push button and the wire from GPIO27 to pin 3 to trigger the binary sensor.

The 3D Print Enclosure

I print the enclosure with eSun PLA+, it’s easy to print and suitable for indoor use.
The enclosure consists of two parts: a cover and a bottom section. The display is mounted to the cover using M2x8 self-tapping screws.

You may also need plastic washer if the display’s mounting holes are too large for the M2 screw, as the mount hole size can vary depending on the supplier.

I’ve used M3x10 ball head screws to secure the cover to the bottom part. You may need to tap the hole with an M3 tap for easier to screwing in.
The ESP32, sensors, and I2C hub are mounted in the provided slots on the bottom part and secured in place using hot melt glue or 3M VHB tape.

ESPHome YAML

The YAML configuration for AHT21 and CCS811 sensor:

According to data sheet recoomendation, the CCS811 need to burn-in for 48 hours when you first receive it, and then 20 minutes in the desired mode every time the sensor is in use.
This process helps stabilize the sensor’s sensitivity levels, which can fluctuate during early use.

To calibrate the sensor and get baseline: value to obtain more accurate readings, please refer to the “Calibrating Baseline” section in the ESPHome documentation.

i2c:
  sda: GPIO21
  scl: GPIO22
sensor:
  - platform: aht10
    variant: AHT20
    temperature:
      name: Temperature
      id: aht_temp
    humidity:
      name: Humidity
      id: aht_humid
    update_interval: 60s
  - platform: ccs811
    id: WS_css811
    eco2:
      name: "eCO2"
      id: eco2
    tvoc:
      name: "TVOC"
      id: tvoc
    address: 0x5A
    baseline: 0x90BB #Replace with value from your calibration
    temperature: aht_temp
    humidity: aht_humid
    update_interval: 60s
  - platform: internal_temperature
    name: "ESP32 Temperature"
    id: int_temp

The YAML configuration for AUX button:

I’ve use a binary_sensor component for an AUX button that wire to GPIO27. Then use on_click: to control both short and long presses of the button.
Depending on the press duration, it triggers actions through “template switch” or “template button”. These actions toggle the display on or off and enable page navigation within the module.

This AUX button can easily customise, you can edit its action or add additional template buttons or template switches to perform different functions.
Please note that all template buttons and switches are exposed to Home Assistant, allowing control directly from the dashboard.

Binary Sensor: Monitors GPIO27 as an input and handles two types of button presses:

  • Short press (50ms to 350ms): Triggers button.press action for page_button.
  • Long press (3s to 10s): Toggles the display_switch.
binary_sensor:
  - platform: gpio 
    name: "Switch 1"
    id: aqi_switch1
    internal: True
    pin:
      number: GPIO27
      inverted: true
      mode:
        input: true
        pullup: true
    on_click:
      - min_length: 50ms
        max_length: 350ms
        then:
         - button.press: page_button
      - min_length: 3s
        max_length: 10s
        then:
         - switch.toggle: display_switch

Template Button: Perform some action turn on the display and switch display to next page.

button:
  - platform: template
    name: "Page Button"
    id: page_button
    on_press:
      - lambda: id(aqi_display).turn_on();
      - lambda: id(aqi_display).set_contrast(1.0);
      - display.page.show_next: aqi_display
      - component.update: aqi_display

Template Switch: Represents the state of the display and use lambda:Checks the current contrast level of the display (aqi_display) to determine the switch state.

switch:
  - platform: template
    name: "Display Switch"
    id: display_switch
    lambda: |-
      if (id(aqi_display).get_contrast() > 0) {
        return true;
      } else {
        return false;
      }
    turn_on_action:
      - lambda: id(aqi_display).turn_on();
      - lambda: id(aqi_display).set_contrast(1.0);
      - delay: 30s
      - lambda: id(aqi_display).set_contrast(0.5);
    turn_off_action:
      - lambda: id(aqi_display).set_contrast(0.0);
      - lambda: id(aqi_display).turn_off();

The YAML configuration for SH1106 Display:

font:
  #Opensans
  - file: 'fonts/OpenSans-Regular.ttf'
    id: opensans_reg_12
    size: 12
    
  #Opensans - Bold
  - file: 'fonts/OpenSans-Bold.ttf'
    id: opensans_bold_10
    size: 10
  - file: 'fonts/OpenSans-Bold.ttf'
    id: opensans_bold_14
    size: 14

   #Opensans - Condense Bold
  - file: 'fonts/OpenSans-Bold.ttf'
    id: opensans_CondBold_24
    size: 24
  - file: 'fonts/OpenSans-CondBold.ttf'
    id: opensans_CondBold_36
    size: 36
  
display:
  - platform: ssd1306_i2c
    model: "SH1106 128x64"
    id: aqi_display
    address: 0x3C
    contrast: 1.0
    pages:
      - id: page1
        lambda: |-
          // Print TVOC
          it.printf(32, 0, id(opensans_bold_14), TextAlign::TOP_CENTER, "TVoc:");
          if (id(tvoc).has_state()) {
            it.printf(32, 18, id(opensans_CondBold_24), TextAlign::TOP_CENTER, "%.0f", id(tvoc).state);
          } else {
            it.printf(32, 18, id(opensans_CondBold_24), TextAlign::TOP_CENTER, "N/A");
          }
          it.printf(32, 48, id(opensans_bold_10), TextAlign::TOP_CENTER, "ppb");
          // Print eco2
          it.printf(96, 0, id(opensans_bold_14), TextAlign::TOP_CENTER, "eCO2:");
          if (id(eco2).has_state()) {
            it.printf(96, 18, id(opensans_CondBold_24), TextAlign::TOP_CENTER, "%.0f", id(eco2).state);
          } else {
            it.printf(96, 18, id(opensans_CondBold_24), TextAlign::TOP_CENTER, "N/A");
          }
          it.printf(96, 48, id(opensans_bold_10), TextAlign::TOP_CENTER, "ppm");

      - id: page2
        lambda: |-
          // Print Temp
          it.printf(0, 0, id(opensans_bold_10), TextAlign::TOP_LEFT, "Temperature:");
          if (id(aht_temp).has_state()) {
            it.printf(127, 0, id(opensans_bold_14), TextAlign::TOP_RIGHT, "%.1f°C", id(aht_temp).state);
          }
          // Print Humid
          it.printf(0, 24, id(opensans_bold_10), TextAlign::TOP_LEFT, "Humidity:");
          if (id(aht_humid).has_state()) {
            it.printf(127, 24, id(opensans_bold_14), TextAlign::TOP_RIGHT, "%.1f%%", id(aht_humid).state);
          }
          // Print CPU Temp
          it.printf(0, 48, id(opensans_bold_10), TextAlign::TOP_LEFT, "CPU Temp:");
          if (id(int_temp).has_state()) {
            it.printf(127, 48, id(opensans_bold_14), TextAlign::TOP_RIGHT, "%.1f°C", id(int_temp).state);
          }

Conclusion

With just a simple ESP32 development module, a couple of sensors, and a little time, we can make a compact AQI module to monitor real-time environmental data and personalised control in your smart home setup.

Unlike off-the-shelf solutions, this DIY module offers several advantages. Not only is it significantly cheaper than commercial equivalents, but it also provides greater control and customisation options. The ESPHome firmware is very powerful and easy to use. It allows you to tailor the module to your specific needs.