Building an Air Quality Monitoring App with Arduino and ESP32 (Part 1) - Sensor Introduction
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:
- Sensor Introduction - DHT11 and MH-Z14A
- Data Communication - UART
- Arduino Pitfalls: Explaining issues encountered during implementation—often not bugs, but unfamiliarity with Arduino and hardware
- WiFi: To save debugging time, I bought an ESP32 development board, which comes with built-in WiFi and Bluetooth.
- MQTT: To transmit data to other devices, I used MQTT, a lightweight communication protocol.
- 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:

- 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 4,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:
Grafana

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

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:
-
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 Code Function Notes 0x86 Gas concentration / Read CO2 concentration 0x87 Calibrate zero point value / Zero-point calibration This sensor supports three zero-point calibration methods; sending commands is one of them. 0x99 Calibrate span point value / Modify CO2 detection range 0x79 Start/stop auto-calibration function of zero point value By default, auto-calibration is enabled out of the factory. -
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:

- 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()anddigitalWrite(). (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!) - After 20–40 μs, the DHT11 pulls the line LOW. (Remember to set the Arduino pin to INPUT mode here using
pinMode(PIN, INPUT)). - The signal will stay LOW for 80 μs, then go HIGH for 80 μs, before being pulled LOW again.
- Data transmission begins (continued in the next diagram).

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
- When a Measure Becomes a Target: From the Window Tax to Pull Request Counts I once wrote a script to tally how many PRs I contributed in a quarter, how many reviews I left, and how many tickets I closed, hoping to use numbers to prove my output to my manager. My manager simply remarked that performance isn't just about output. Years later, I finally understood—when a measure becomes a target, it ceases to be a good measure. From the British window tax and the Hanoi rat bounty to evaluating developers by PR counts today, the underlying mechanism is exactly the same.
- Using Cloudflare Images for Image Storage and Transformation Putting an image on a webpage is the simplest task in frontend development. But doing it properly—including resizing, generating multiple formats, and withstanding heavy traffic—is actually an entire end-to-end solution. Eventually, I offloaded everything to Cloudflare Images, keeping only a single original image.
- Stop Using AWS Access Keys Access Keys are an easily overlooked security risk in AWS. By pairing OIDC with IAM Roles, GitHub Actions can securely operate AWS resources without storing any secrets.
- Database Primary Keys: AUTO_INCREMENT, UUID, and UUIDv7 Backend developers often face the choice of primary keys: should you use auto-increment or UUID? What about collisions? How does UUIDv7 compare to created_at + index in performance? Here are the design decisions and benchmark results from testing 20 million rows.