· 12 min read

Building an Air Quality Monitoring App with Arduino and ESP32 (Part 1) - Sensor Introduction

This article was auto-translated from Chinese. Some nuances may be lost in translation.

Introduction

Lately, I’ve been really fascinated by all kinds of IoT applications. I bought a bunch of Arduinos and various sensors—just among the Arduinos I have on hand, there’s an Arduino Uno, an Arduino Mega2560, and three Arduino Nanos. I figured I would play around with them when I had free time to see if I could build some interesting projects, while also taking the opportunity to brush up on high school electronics and understand how various hardware components work.

Conveniently, my company held an internal hackathon with very few restrictions on project topics. I used that time to build an application I had wanted to make for a while: an “Air Quality Monitor.” While it’s called “air quality,” the final build only monitors CO2 concentration, temperature, and humidity. That said, if you have other sensors, adding them wouldn’t be much of an issue.

The main motivation behind this idea was that CO2 levels genuinely impact human cognitive performance and productivity. I’m sure everyone has experienced being stuck in a meeting room and suddenly feeling dizzy or foggy-headed. There’s a strong chance that dizziness is caused by excessively high CO2 levels. In an enclosed space with poor ventilation, CO2 concentrations rise remarkably fast—reaching well over 2,000 ppm in just a few minutes.

Beyond the implementation itself, I really dislike the feeling of blindly importing a bunch of libraries, slapping things together, and calling it a day. I try to dig into the details as much as possible so I don’t end up with that empty feeling of “welp, the libraries are wired up, but I have no idea what actually happened.” (Even though I still used libraries in this implementation, at least I used them with peace of mind! XD)

This series will cover the following topics:

  1. Sensor Introduction - DHT11 and MH-Z14A
  2. Data Communication - UART
  3. Arduino Pitfalls: Explaining issues encountered during implementation—often not bugs, but unfamiliarity with Arduino and hardware
  4. WiFi: To save debugging time, I bought an ESP32 development board, which comes with built-in WiFi and Bluetooth.
  5. MQTT: To transmit data to other devices, I used MQTT, a lightweight communication protocol.
  6. Grafana / Web: Once data is stored in a database, it has to be displayed in style! Here, I used Grafana + Prometheus and Svelte to visualize the data.

Architecture

For ease of explanation, the architecture looks roughly like this:

Infrastructure

  • Arduino Uno sends commands to the CO2 sensor, and the CO2 sensor sends data back to the Arduino; the two communicate via UART.
  • Arduino Uno sends CO2 data to the ESP32 (also via UART).
  • Temperature and humidity data are retrieved from the DHT11 and sent to the ESP32.
  • The ESP32 sends the temperature, humidity, and CO2 data via WiFi to the MQTT Broker.
  • Two applications subscribe to these events: an Analytics Server and an App Server.
    • The Analytics Server is responsible for pushing data to Prometheus, which is then visualized via Grafana.
    • The App Server receives data, stores it in the database, and exposes an API for external consumption.
  • If the CO2 concentration exceeds a certain threshold, a Slack notification is dispatched.

That’s the overall setup. Next, let me answer a few questions that might come to mind:

1. Why use an extra Arduino Uno? Couldn’t an ESP32 handle everything on its own?

My initial thought was to treat the ESP32 as an intermediary to keep the overall architecture cleaner. In hindsight, though, it wasn’t strictly necessary. The only caveat is that some built-in Arduino libraries cannot be used on the ESP32, such as SoftwareSerial (a library that lets you use digital pins as TX/RX pins, which will be discussed in later articles). Without this library, you can only rely on the built-in HardwareSerial, limiting you to a single UART channel.

Another reason: it just looks cooler. It wouldn’t feel quite as badass without an Arduino involved.

2. Why connect the DHT11 to the ESP32 instead of the Arduino Uno?

As mentioned earlier, if connected to the Arduino Uno, the data would have to be forwarded to the ESP32 via UART. To save the hassle, I just connected it directly to the ESP32, especially since the library natively supports ESP32. Connecting it to the Arduino Uno is certainly an option as well.

3. Why choose MQTT?

This protocol is lightweight and carries far less overhead compared to other protocols, making it ideal for IoT scenarios where CPUs are slow and memory is constrained—though this naturally comes with trade-offs in terms of availability and delivery guarantees.

4. Why use two separate databases?

Sharp-eyed readers might notice that the analytics database and the app database are separated. First, I wasn’t as familiar with Prometheus; writing custom queries to extract data might have taken more time. Second, Postgres SQL syntax integrates seamlessly with Node.js and is much more convenient to develop with. I might play around with Prometheus queries more in the future!

Results

A well-known commercial air monitoring product on the market is AWAIR. Beyond a gorgeous UI and monitoring temperature, humidity, and CO2, it can also detect chemicals and PM2.5. However, it’s quite pricey, retailing at 149USD(roughlyNT149 USD (roughly NT4,470). By comparison, the cost breakdown for this build was:

  • DHT11 Temperature & Humidity Sensor: ~NT$60
  • MH-Z14A CO2 Sensor: ~NT$800
  • ESP32 Development Board: ~NT$200
  • Arduino Uno: ~NT$800 (Official board; would be much cheaper using a Nano clone)

Total: ~NT$1,860

Of course, the physical appearance is quite rudimentary XD. But with a few tweaks to the code, it could easily integrate with many applications, such as pairing with Google Home or building a simple mobile app.

Circuit

With a bit of cable management, it just barely squeezes into the box:

Wiring between the Arduino, ESP32, and air quality sensors

Grafana

Web Interface

The UI is quite basic—this is just a proof of concept.

gauge

Sensor Introduction: DHT11 and MH-Z14A

MH-Z14A CO2 Sensor

To measure CO2 concentration, we first need a sensor. After looking online, I found that measuring CO2 levels is commonly done using a method called NDIR (Non-Dispersive Infrared).

The principle behind it leverages the characteristic that specific gases absorb infrared light at specific wavelengths, allowing the gas concentration to be calculated. For example, carbon dioxide absorbs infrared light at a wavelength of 4.26 μm, so the concentration can be derived by measuring absorption at this wavelength. We won’t delve into the mathematical formulas here; what’s worth discussing is how to operate the sensor.

Since I found very few comprehensive resources online, let’s walk through it here alongside the Datasheet. There are many versions floating around online, but this one is the most detailed I’ve come across.

From this datasheet, we can glean several key details:

  1. The MH-Z14A communicates via UART. After sending command codes, you receive a response. In addition to reading the CO2 concentration, commands can perform zero-point calibration, span calibration, etc.

    Command CodeFunctionNotes
    0x86Gas concentration / Read CO2 concentration
    0x87Calibrate zero point value / Zero-point calibrationThis sensor supports three zero-point calibration methods; sending commands is one of them.
    0x99Calibrate span point value / Modify CO2 detection range
    0x79Start/stop auto-calibration function of zero point valueBy default, auto-calibration is enabled out of the factory.
  2. The MH-Z14A supports three output types (super convenient XD): UART, PWM, and Analog. You can choose whichever output you prefer to read the CO2 concentration. For this project, I chose UART because it’s straightforward and requires no extra numerical conversion.

DHT11 Sensor

The DHT11 is a temperature and humidity sensor. It features low power consumption and a simple form factor with only 3 pins—just connect VCC and GND, and it’s ready to go. What makes the DHT11 unique is its data transmission method: it has only one pin acting as the data bus. How can both temperature and humidity data be transmitted over a single pin?

The answer is timing. In hardware, data communication heavily relies on precise timing control. For instance, if I want to read 8 bits of data, I can use 2 ms as a single time unit. By sampling every 2 ms, I can read 8 bits of data across 16 ms.

However, to pull this off, several questions must be addressed:

  • When does the transmitter start sending data? In other words, when should I start counting the 2 ms intervals?
  • If errors occur during transmission (e.g., environmental interference, momentary short circuits, packet loss), how should they be handled?

These details are actually documented in the datasheet. Let’s take a look:

Single-bus data format is used for communication and synchronization between MCU and DHT11 sensor. One communication process is about 4ms. Data consists of decimal and integral parts. A complete data transmission is 40bit, and the sensor sends higher data bit first. Data format: 8bit integral RH data + 8bit decimal RH data + 8bit integral T data + 8bit decimal T data + 8bit check sum. If the data transmission is right, the check-sum should be the last 8bit of “8bit integral RH data + 8bit decimal RH data + 8bit integral T data + 8bit decimal T data”

According to the datasheet, each communication cycle takes about 4 ms. The data consists of: 8-bit integral RH + 8-bit decimal RH + 8-bit integral T + 8-bit decimal T + 8-bit checksum.

This checksum answers the second question: it is calculated by summing all the preceding data bytes and taking the lowest 8 bits (humidity + humidityDec + temp + tempDec != parity).

If the computed sum doesn’t match the checksum, we know something went wrong during transmission. Of course, an error could theoretically result in a matching checksum by pure coincidence, but under normal operating conditions, that probability is negligible.

How Does DHT11 Transmit Data?

To address the first question, we can refer to the timing diagram in the datasheet, which illustrates the entire communication handshake:

Screenshot from 2020-07-21 21-34-22

  1. To initiate a temperature/humidity reading (Arduino requesting data from DHT11), the MCU first sends a HIGH -> LOW signal and holds it for at least 18 ms, then pulls the signal LOW -> HIGH. In Arduino, this can be achieved using delay() and digitalWrite(). (P.S.: I’m not entirely sure why such a long interval is needed for the DHT11 to detect this signal—hardware experts, please feel free to chime in!)
  2. After 20–40 μs, the DHT11 pulls the line LOW. (Remember to set the Arduino pin to INPUT mode here using pinMode(PIN, INPUT)).
  3. The signal will stay LOW for 80 μs, then go HIGH for 80 μs, before being pulled LOW again.
  4. Data transmission begins (continued in the next diagram).

Screenshot from 2020-07-21 21-44-40

Finally, data transmission kicks off! The datasheet explains how to distinguish a 0 from a 1. As shown in the diagram, if the signal stays HIGH for 26–28 μs, the bit represents 0. If the signal stays HIGH for around 70 μs, the bit represents 1. Between each bit, the line is pulled LOW for 50 μs before transitioning HIGH again. This is likely intended to clearly delineate the boundary between bits, minimizing misreads by the Arduino.

To measure these intervals, you can use Arduino’s micros(). A simple implementation looks like this:

void blockUntil(int state)
{
  while (digitalRead(PIN) != state)
  {
  }
}

void main() {
  auto timeS = micros();
  blockUntil(LOW);
  auto timeE = micros();
  auto result = timeE - timeS > 60 ? 1 : 0;
}

Here, I used 60 μs instead of the 50 μs described in the datasheet because having an exact boundary sometimes resulted in read errors. What follows is my speculation—corrections welcome:

I wonder if compiling down to machine code causes the CPU to consume enough instruction cycles during execution to make the 50 μs threshold slightly off. But if that were the case, the threshold should have been adjusted smaller rather than larger. So another likely possibility is simply that the DHT11 is just slow.

Once this process completes, you should have your data! Although this implementation used a library, I might write another article explaining how to implement the whole routine without any external libraries. In reality, as long as you’re willing to invest the effort into reading the datasheet, writing the implementation isn’t overly difficult.

Summary

Today, we introduced the sensors required for this project, explored their use cases, and walked through their datasheets. By now, you should have a solid conceptual understanding of how both sensors transmit data! In the next post, we will cover UART—a common communication protocol between hardware components—and discuss issues encountered during implementation along with their final solutions (which, spoiler alert, involve using libraries!).

Related Posts

Explore Other Topics