DIY Air Quality Meter

by Electro Retro in Circuits > Gadgets

292 Views, 5 Favorites, 0 Comments

DIY Air Quality Meter

IMG_20260104_175223.jpg

In this Instructable, I’m going to show you how to build a compact air quality monitor using the PMS7003 particulate matter sensor and the SHT41 temperature and humidity sensor. This device measures PM1.0, PM2.5, PM10, along with ambient temperature and humidity, giving a clear picture of the air you’re breathing every day.

The design and UI of this project are inspired by the Fallout game series. In the world of Fallout, a Pip-Boy or a Geiger counter is essential for survival. In the real world, we might not be dodging radiation, but the air we breathe filled with invisible dust, smoke, and pollutants is just as important to track. So let’s get started!

Supplies

IMG_20260102_170637.jpg
IMG_20260102_170620.jpg
IMG_20260102_170458.jpg
IMG_20260102_170932.jpg
IMG_20260103_025748.jpg
IMG_20260102_190456.jpg
IMG_20260102_232818.jpg

For this project 3 main components are ESP32-S3 development board with GC9A01 round display, PMS7003 and SHT41.

1) ESP32-S3 development board with GC9A01 round display - 1

2) PMS7003 Sensor - 1

3) SHT41 Sensor - 1

4) 1.27mm 2x40 Pin Male Double Row Header Strip - 1

5) M2 8mm screw - 2

6) M3 8mm CSK Allen screw - 4

7) Wires

8) Heat shrink tubes

9) 3D Printing PLA filament

10) Chrome spray paint (optional)

Designing the Case

Screenshot 2026-01-04 015726.png

For this project, I designed the enclosure using Tinkercad. I chose Tinkercad because it’s easy to learn, works directly in the browser, and makes it simple to modify and share designs. It’s perfect for quick prototyping, especially if you don’t want to dive into complex CAD software.

You can find the enclosure design for this project here:

[Link to the enclosure]

Feel free to remix or customize the design to match your own style or hardware setup.

3D Printing and Preparation

IMG_20260101_154344.jpg
IMG_20260101_215929.jpg
IMG_20260102_000404.jpg
IMG_20260102_171101.jpg

Once I was happy with the design in Tinkercad, it was time to bring it into the physical world. I exported the STL files and printed the enclosure using standard settings, keeping everything simple and easy to replicate. After the print was finished, I carefully removed the supports using a needle-nose plier.

Painting

IMG_20260101_163705.jpg
IMG_20260102_143643.jpg

To give the enclosure a retro, Fallout-style look, I painted the top cover using chrome spray paint. This was actually my first time using chrome paint, so there was a bit of trial and learning involved.

One important thing I learned is that the surface needs to be perfectly clean and smooth before painting. Even a tiny bit of dust or an uneven spot can ruin the finish, so take your time with sanding and cleaning before spraying. Also its a fingerprint magnet!

After painting, the final result didn’t look like a mirror chrome finish, but it ended up looking more like brushed aluminum, which honestly worked really well with the overall design and gave it a nice feel.

Assembly

IMG_20260102_185420.jpg
IMG_20260101_155515.jpg
IMG_20260102_185858.jpg
IMG_20260102_190856.jpg

This development board doesn’t have any mounting holes, so to secure it inside the case, I used two small 3D-printed square supports to hold the display in place.

The board fits perfectly inside the 3D-printed enclosure, but there’s one important thing to watch out for. The top edge of the PCB has a small mouse-bite left over from manufacturing. You’ll need to file or sand this down, otherwise the board won’t sit properly inside the case.

Once everything was aligned and sitting flat, I secured the board in place using M2 screws.

Mounting the Sensors

IMG_20260102_184616.jpg
IMG_20260102_184740.jpg
IMG_20260102_192246.jpg
IMG_20260102_204059.jpg
IMG_20260102_204335.jpg
IMG_20260102_204448.jpg

With the display secured, it’s time to install the "nose" of our device the PMS7003 and SHT41 sensors. I used double-sided tape to secure both sensors. Before attaching the SHT41 sensor, I first soldered wires to it.

Wiring

AIR-TEC_CIRCUIT DIAGRAM.png
Screenshot 2026-01-03 220305.png
Screenshot 2026-01-03 222521.png
IMG_20260102_205814.jpg
IMG_20260102_210530.jpg
IMG_20260102_221102.jpg
IMG_20260102_222248.jpg
IMG_20260102_231231.jpg

The next step is wiring everything together. The connections are fairly simple.


SHT41 → ESP32 (3.3V Sensor)

3.3V → 3.3V

GND → GND

SDA → GPIO 15

SCL → GPIO 14


PMS7003 → ESP32 (5V Sensor)

5V → 5V

GND → GND

RX → GPIO 37

TX → GPIO 38


One important thing to note is that the SHT41 runs on 3.3V, while the PMS7003 requires 5V, so make sure the power connections are correct. Refer to the circuit diagram and pinout images provided to avoid mistakes.

To connect the sensors, I used 1.27 mm header strips. These headers aren’t really meant for soldering wires directly, so it can be a bit tricky. After soldering all the connections, I added heat shrink tubing for insulation and reinforced everything with a bit of hot glue.

Final Assembly

IMG_20260102_233101.jpg
IMG_20260102_233503.jpg
IMG_20260102_233546.jpg
IMG_20260102_233701.jpg
IMG_20260102_233745.jpg

To finish the assembly, I placed the top cover onto the enclosure and secured it using four M3 countersunk (CSK) Allen screws.

Programming

IMG_20260102_233836.jpg
Screenshot 2026-01-04 222202.png
Screenshot 2026-01-04 222240.png
Screenshot 2026-01-04 222350.png
Screenshot 2026-01-04 222857.png

The next step is programming the ESP32. I’m using the Arduino IDE for this project.

I’m assuming you already have ESP32 support installed in your Arduino IDE. If not, you’ll need to add the ESP32 Board Manager URL and install the ESP32 package using the Boards Manager. (There are plenty of guides online if this is your first time setting up ESP32 with Arduino.)


Installing Required Libraries

Next, install the following libraries using the Arduino Library Manager:

TFT_eSPI.h – for the round display

Adafruit_SHT4x.h – for the SHT41 temperature and humidity sensor

PMS.h – PMS7003 library by Mariusz Kacki

All of these libraries can be installed directly from the Library Manager, so no manual downloads are needed.


Display Configuration (Important)

Before uploading the code, we need to configure the display settings in the TFT_eSPI library.

Navigate to: Documents > Arduino > libraries > TFT_eSPI > User_Setup.h

Open the User_Setup.h file and uncomment the following lines:

#define GC9A01_DRIVER


#define TFT_WIDTH 240

#define TFT_HEIGHT 240


#define TFT_MOSI 11 // May be labeled as SDA on some display boards

#define TFT_SCLK 10

#define TFT_CS 9

#define TFT_DC 8

#define TFT_RST 12

#define TFT_BL 40 // Backlight pin

Make sure all other display driver definitions are commented out to avoid conflicts.


Uploading the Code

Now, copy my project code into the Arduino IDE.

From the Tools menu, select:

Board: ESP32S3 Dev Module

Port: Select the correct COM port

And set the following options:

Flash Size: 16MB (128Mb)

Partition Scheme: 16M Flash (3MB APP / 9MB FATFS)

PSRAM: QSPI PSRAM


Once everything is set, click Upload and wait for the code to flash successfully.


/**
* Platform: ESP32-S3 (Waveshare 1.28" Round LCD)
* Sensors: SHT41 (Temp/Hum), PMS7003 (Particulate Matter)
* * Dependencies:
* - TFT_eSPI (Bodmer) - Requires correct User_Setup.h for GC9A01
* - Adafruit SHT4x
* - PMS Library (Plantower)
*/

#include <Wire.h>
#include <HardwareSerial.h>
#include <PMS.h>
#include "Adafruit_SHT4x.h"
#include <SPI.h>
#include <TFT_eSPI.h>
#include <math.h>

// --- Hardware Pin Definitions ---
// Note: Ensure your TFT_eSPI User_Setup.h matches the Waveshare driver (GC9A01)
#define I2C_SDA 15
#define I2C_SCL 14
#define PMS_RX 38 // ESP32 RX -> Sensor TX
#define PMS_TX 37 // ESP32 TX -> Sensor RX
#define LCD_BL_PIN 40 // Backlight Control

// --- Visual Config (Pip-Boy Colors) ---
#define COLOR_PIP_FG 0x07E0 // Classic Phosphor Green
#define COLOR_PIP_BG 0x0300 // Dark Green Background
#define COLOR_BLACK 0x0000
#define DEG2RAD 0.0174532925

// --- Timing Constants ---
const unsigned long WARM_UP_DURATION = 30000; // 30 Seconds for PMS sensor
const unsigned long DISPLAY_INTERVAL = 500; // Update UI every 500ms

// --- Structs for Data Management ---
struct SensorReadings {
float temp = 0.0;
float hum = 0.0;
int pm1 = 0;
int pm2_5 = 0;
int pm10 = 0;
};

// --- Global Objects ---
HardwareSerial SerialPMS(1);
PMS pms(SerialPMS);
PMS::DATA data;
Adafruit_SHT4x sht4 = Adafruit_SHT4x();
TFT_eSPI tft = TFT_eSPI();
TFT_eSprite canvas = TFT_eSprite(&tft);

// --- Global Variables ---
SensorReadings currentReadings;
bool isWarmedUp = false;
unsigned long warmUpStartTime = 0;
unsigned long lastDisplayTime = 0;

// --- Helper Functions ---

// Standard map() is integer only. This allows smooth needle movement.
float mapFloat(float x, float in_min, float in_max, float out_min, float out_max) {
return (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
}

// Draw retro CRT scanlines
void drawScanlines() {
for (int y = 0; y < 240; y += 3) {
canvas.drawFastHLine(0, y, 240, COLOR_PIP_BG);
}
}

/**
* Draws a curved gauge (Volt-meter style)
* @param val: Current value to display
* @param minVal: Gauge minimum
* @param maxVal: Gauge maximum
* @param label: Text label (e.g., "T" or "H")
* @param isRightSide: If true, arcs to the right. If false, arcs to the left.
*/
void drawCurvedGauge(float val, float minVal, float maxVal, const char* label, bool isRightSide) {
int centerX = 120, centerY = 120, radius = 108;

// Define start/end angles based on side
float startAngle = isRightSide ? 40 : 140;
float endAngle = isRightSide ? -40 : 220;

// 1. Draw The Track (Arc)
int arcStart = isRightSide ? 320 : 220;
int arcEnd = isRightSide ? 40 : 140;
canvas.drawSmoothArc(centerX, centerY, radius, radius-2, arcStart, arcEnd, COLOR_PIP_BG, COLOR_PIP_BG, true);

// 2. Draw Ticks and Numbers
for (int i = 0; i <= 4; i++) {
float angle = mapFloat(i, 0, 4, startAngle, endAngle);
float rad = angle * DEG2RAD;

// Draw Tick Line
int innerR = radius - 8;
canvas.drawLine(
centerX + cos(rad) * innerR, centerY + sin(rad) * innerR,
centerX + cos(rad) * radius, centerY + sin(rad) * radius,
COLOR_PIP_FG
);

// Draw Number
canvas.setTextColor(COLOR_PIP_FG);
canvas.setTextDatum(MC_DATUM);
int numVal = (int)mapFloat(i, 0, 4, minVal, maxVal);

// Offset numbers slightly inward
int textR = radius - 20;
canvas.drawNumber(numVal, centerX + cos(rad) * textR, centerY + sin(rad) * textR, 1);
}

// 3. Draw Needle
// Constrain value to prevent needle flying off-chart
float safeVal = constrain(val, minVal, maxVal);
float valAngle = mapFloat(safeVal, minVal, maxVal, startAngle, endAngle);
float valRad = valAngle * DEG2RAD;

int tipX = centerX + cos(valRad) * radius;
int tipY = centerY + sin(valRad) * radius;

// Calculate base of the needle (triangle shape)
float baseRad1 = valRad + (3 * DEG2RAD);
float baseRad2 = valRad - (3 * DEG2RAD);
int bx1 = centerX + cos(baseRad1) * (radius + 10);
int by1 = centerY + sin(baseRad1) * (radius + 10);
int bx2 = centerX + cos(baseRad2) * (radius + 10);
int by2 = centerY + sin(baseRad2) * (radius + 10);

canvas.fillTriangle(tipX, tipY, bx1, by1, bx2, by2, COLOR_PIP_FG);

// 4. Draw Label (T or H) inside the gauge
float midAngle = (startAngle + endAngle) / 2 * DEG2RAD;
canvas.drawString(label, centerX + cos(midAngle) * (radius - 35),
centerY + sin(midAngle) * (radius - 35), 2);
}

// Draws the horizontal bars for PM levels
void drawCentralBar(int y, int percent, const char* label, float val) {
int x = 65, w = 110, h = 15;

canvas.setTextColor(COLOR_PIP_FG);

// Label and Value
canvas.setTextDatum(ML_DATUM);
canvas.drawString(label, x, y - 10, 2);

canvas.setTextDatum(MR_DATUM);
canvas.drawNumber((int)val, x + w, y - 10, 2);

// Bar Outline
canvas.drawRect(x, y, w, h, COLOR_PIP_FG);

// Bar Fill
int fillW = map(constrain(percent, 0, 100), 0, 100, 0, w - 2);
if(fillW > 0) canvas.fillRect(x + 1, y + 1, fillW, h - 2, COLOR_PIP_FG);
}

// Draws the precise digital readouts at the bottom
void drawBottomReadouts(float t, float h) {
int boxW = 65, boxH = 26, yPos = 188, centerX = 120;

// Temperature Box
canvas.drawRect(centerX - boxW - 5, yPos, boxW, boxH, COLOR_PIP_FG);
canvas.setTextDatum(MC_DATUM);
canvas.drawString(String(t, 1) + " C", centerX - (boxW / 2) - 5, yPos + (boxH / 2) + 1, 2);

// Humidity Box
canvas.drawRect(centerX + 5, yPos, boxW, boxH, COLOR_PIP_FG);
canvas.drawString(String(h, 1) + " %", centerX + (boxW / 2) + 5, yPos + (boxH / 2) + 1, 2);

// Connector Line
canvas.drawFastHLine(centerX - 5, yPos + (boxH / 2), 10, COLOR_PIP_FG);
}

// --- Screens ---

void drawBootScreen(unsigned long elapsed) {
canvas.fillSprite(COLOR_BLACK);
drawScanlines();

canvas.setTextColor(COLOR_PIP_FG);
canvas.setTextDatum(MC_DATUM);

// Retro Boot Text
canvas.drawString("AIR-TEC ENV V1.0", 120, 60, 2);
canvas.drawString("SYSTEM INITIALIZING", 120, 90, 2);
canvas.drawString("WARMING SENSORS...", 120, 110, 1);

// Progress Bar
int barW = 160; int barH = 20;
int barX = (240 - barW) / 2;
int barY = 140;

canvas.drawRect(barX, barY, barW, barH, COLOR_PIP_FG);

float progress = (float)elapsed / (float)WARM_UP_DURATION;
int fill = (int)(progress * (barW - 4));

if (fill > 0) {
// Draw segmented progress blocks
for(int i=0; i < fill; i+=5) {
if(i + 4 < (barW-4)) canvas.fillRect(barX + 2 + i, barY + 2, 3, barH - 4, COLOR_PIP_FG);
}
}

int remaining = (WARM_UP_DURATION - elapsed) / 1000;

canvas.drawString(String(remaining) + " SEC", 120, 210, 2);
}

void drawMainInterface() {
canvas.fillSprite(COLOR_BLACK);
drawScanlines();

canvas.setTextColor(COLOR_PIP_FG);
canvas.setTextDatum(MC_DATUM);
canvas.drawString("AIR-TEC ENV V1.0", 120, 30, 2);

// UPDATED: Reverted labels to "T" and "H" to avoid overlap
drawCurvedGauge(currentReadings.temp, 0, 50, "T", false);
drawCurvedGauge(currentReadings.hum, 0, 100, "H", true);

// PM Bars
drawCentralBar(75, map(constrain(currentReadings.pm1, 0, 100), 0, 100, 0, 100), "PM1.0", (float)currentReadings.pm1);
drawCentralBar(115, map(constrain(currentReadings.pm2_5, 0, 150), 0, 150, 0, 100), "PM2.5", (float)currentReadings.pm2_5);
drawCentralBar(155, map(constrain(currentReadings.pm10, 0, 200), 0, 200, 0, 100), "PM10", (float)currentReadings.pm10);

drawBottomReadouts(currentReadings.temp, currentReadings.hum);

canvas.pushSprite(0, 0);
}

// --- Main Setup & Loop ---

void setup() {
Serial.begin(115200);

// 1. Initialize Display
pinMode(LCD_BL_PIN, OUTPUT);
digitalWrite(LCD_BL_PIN, HIGH);
tft.init();
tft.setRotation(0);
canvas.createSprite(240, 240);

// 2. Initialize SHT41
Wire.begin(I2C_SDA, I2C_SCL);
if (!sht4.begin()) {
Serial.println("SHT4x not found");

} else {
sht4.setPrecision(SHT4X_HIGH_PRECISION);
sht4.setHeater(SHT4X_NO_HEATER);
}

// 3. Initialize PMS7003
SerialPMS.begin(9600, SERIAL_8N1, PMS_RX, PMS_TX);

warmUpStartTime = millis();
}

void loop() {
unsigned long currentMillis = millis();

// --- 1. PMS Sensor Read ---
if (pms.read(data)) {
currentReadings.pm1 = data.PM_AE_UG_1_0;
currentReadings.pm2_5 = data.PM_AE_UG_2_5;
currentReadings.pm10 = data.PM_AE_UG_10_0;
}

if (SerialPMS.available() > 32) {
while(SerialPMS.available()) SerialPMS.read();
}

// --- 2. Warm Up Phase ---
if (!isWarmedUp) {
if (currentMillis - warmUpStartTime >= WARM_UP_DURATION) {
isWarmedUp = true;
} else {
if (currentMillis % 100 == 0) {
drawBootScreen(currentMillis - warmUpStartTime);
canvas.pushSprite(0, 0);
}
return;
}
}

// --- 3. Main Interface Update ---
if (currentMillis - lastDisplayTime >= DISPLAY_INTERVAL) {
lastDisplayTime = currentMillis;

// Read SHT41
sensors_event_t humidity, temp;
sht4.getEvent(&humidity, &temp);
currentReadings.temp = temp.temperature;
currentReadings.hum = humidity.relative_humidity;

drawMainInterface();
}
}

The Final Look & Testing

IMG_20260104_174506.jpg
IMG_20260104_174057.jpg
IMG_20260104_174113.jpg
IMG_20260104_174557.jpg
IMG_20260104_175223.jpg
IMG_20260104_175605.jpg
IMG_20260104_175855.jpg
IMG_20260104_175948.jpg
IMG_20260104_180014.jpg

With everything assembled and programmed, the project is now complete.

I designed the UI with a Fallout-inspired theme, giving it that retro-futuristic PIP BOY vibe. On startup, the PMS7003 sensor needs about 30 seconds to warm up before it provides stable readings. During this time, the display shows a 30-second progress bar, so you know the device is getting ready.

Once the warm-up is complete, the screen displays five key parameters:

PM1.0

PM2.5

PM10

Temperature

Humidity


The result is a compact, portable air quality monitor that’s both functional and fun to look at.