See02: a Mini Air Quality Monitor That Shows You What You're Breathing

by makerkid in Circuits > Arduino

3445 Views, 65 Favorites, 0 Comments

See02: a Mini Air Quality Monitor That Shows You What You're Breathing

See02 pose pic.jpg
photo cover See02.jpg

meet See02 your mini air quality monitor that measures C02, temperature, humidity, and PM2.5 then displays it all on a screen. it also has a graph so you can see how your air quality has changed over a period of 6 hours.

Supplies

All Parts.png

Materials:


Arduino nano (with USB-C) AliExpress (1)

Sensirion SCD30 DigiKey.ca (1)

Waveshare 1.5 inch display module AliExpress (1)

Adafruit PMSA003I DigiKey.ca (1)

M2.5*5mm screws AliExpress (4)

10cm male to male wires AliExpress (15)

data USB-C cable AliExpress (1)



Tools:


Soldering iron

Screwdriver

Hot glue gun

3D printer

3D Printing the Case

Screenshot 2025-09-21 183047.png
Screenshot 2025-09-21 183021.png

i have designed and case for the See02 with Tinkercad. here are the download files:

Soldering the SCD30 and Adafruit PMSA003I

pms003I.png

to start, first grab 8 wires and your Adafruit PMSA003I and Sensirion SCD30.


Adafruit PMSA003I:

solder 4 wires onto GND, VIN, SCL and SDA.


Sensirion SCD30:

solder 4 wires onto GND, VIN, SCL and SDA. When soldering the SCD30 make sure to only touch the pins for a few seconds at a time because it will easily break the sensor.


(I am very sorry I forgot to take the SCD30 picture but the pins are the exact same on both devices:)

Soldering the Wavshare Display

1.5 inch rgb display.png

now time to solder the display! to solder just grab 7 wires and solder them to all of the display pins.

Soldering All Parts to Arduino Nano

arduino nano.png

now, grab your Arduino nano, Adafruit PMSA003I and Sensirion SCD30. when soldering bend the pins down on the Arduino nano or it will not fit. next solder to these pins:


Adafruit PMSA003I:

PMSA003I GND - Nano GND

PMSA003I VIN - Nano 5V

PMSA003I SCL - Nano A5

PMSA003I SDA - Nano A4


Sensirion SCD30:

SCD30 VIN - nano 5V

SCD30 GND - nano GND

SCD30 SCL - Nano A5

SCD30 SDA - Nano A4


after you are done with the air sensors, grab your Waveshare 1.5 inch display and solder to these pins:


Waveshare 1.5 inch display:

display VCC - Nano 5V or 3V3

display GND - Nano GND

display DIN - Nano D11

display CLK - Nano D13

display CS - Nano D10

display DC - Nano D9

display RST - Nano D8

Putting Parts Into Case

arduino gluing.jpg
display in case.JPG
SCD30 case.JPG
SCD30 case 2.JPG
pm2.5 install.jpg
back of case.jpg

after you are done all that soldering it is time to start putting everything together!


we will start with hot gluing the Arduino nano down into its spot.


next screw in your Waveshare display with the 4 screws supplied with it. (when you are screwing do not screw the screws in tightly it will badly break the case).


now take your SCD30 and slide it into its spot make sure it is nicely fitted and you can fully see it on the side.


after you have done all that take your Adafruit PMSA003I and screw in with 2 M2.5*5 screws.


now to finish it off take your case lid and pop it on and make sure that the SCD30 holder is not damaging the SCD30 when you put on the lid. now grab 2 more M2.5*5 screws and screw in the lid.

Uploading the Code

Screenshot 2025-09-21 121146.png
Screenshot 2025-09-21 121102.png
Screenshot 2025-09-21 121047.png
Screenshot 2025-09-21 121026.png
Screenshot 2025-09-21 120939.png

to upload the code, first download the latest version of Arduino IDE. next in the sidebar click on libraries and install these libraries: Adafruit_PM25, Adafruit_GFX, Adafruit_SSD1351, and Adafruit_SCD30. install the ones all by Adafruit. now plug in you Arduino nano and select it in ports, then copy this code and upload!


#include <Wire.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1351.h>
#include <Adafruit_SCD30.h>
#include <Adafruit_PM25AQI.h>
#include <string.h>

// ---------------- Build options ----------------
#define SKIP_I2C_SCAN 0 // 1 = skip scan screen
#define I2C_SCAN_MS 1200 // time-limited scan (ms)
#define FIRST_SAMPLE_MS 30000UL // first graph point ~30s after boot

// ---------- OLED (SSD1351 SPI) ----------
#define OLED_CS 10
#define OLED_DC 9
#define OLED_RST 8
#define SW 128
#define SH 128
Adafruit_SSD1351 display(SW, SH, &SPI, OLED_CS, OLED_DC, OLED_RST);

// ---------- Sensors ----------
Adafruit_SCD30 scd30;
Adafruit_PM25AQI pm25;

// ---------- Colors ----------
#define C_BG display.color565(0,0,0)
#define C_FG display.color565(255,255,255)
#define C_GRID display.color565(70,70,70)
#define C_VGRID display.color565(40,40,40)
#define C_CO2 display.color565(0,200,255) // cyan
#define C_PM25 display.color565(170,0,255) // purple
#define C_TEMP display.color565(255,140,0) // orange
#define C_RH display.color565(0,180,0) // green
#define C_OK display.color565(0,180,0)
#define C_ERR display.color565(200,0,0)

// ---------- Layout ----------
const int HEADER_H = 18;
const int TOP_H = 56; // tighter top panel to enlarge graph
const int TOP_Y = HEADER_H;
const int Y_CO2 = TOP_Y + 6;
const int Y_PM = TOP_Y + 20;
const int Y_T = TOP_Y + 34;
const int Y_H = TOP_Y + 48;

const int LABEL_X = 6;
const int VALUE_X = 70;
const int DOT_X = 46;

const int RIGHT_MARGIN = 0; // full-width graph
const int TOP_PAD = 2;
const int BOT_PAD = 2;
const int SEPARATOR_GAP = 8; // tiny reduction so graph never touches top text

const int GRAPH_Y = HEADER_H + TOP_H + SEPARATOR_GAP;
const int GRAPH_H = SH - GRAPH_Y - 2;
const int GRAPH_W = SW - RIGHT_MARGIN; // 128 columns

// ---------- State / smoothing ----------
template<typename T> T ema(T p, T s, float a){ return p + a*(s - p); }

float co2_f=400, pm25_f=0, t_f=25, rh_f=40; // smoothed top numbers
float co2_raw=400, t_raw=25, rh_raw=40; // latest raw SCD30
float pm25_last = 0, pm10_last = 0; // latest raw PM µg/m³

bool scd_ok=false, scd_have=false;
bool pm_ready=false, pm_have=false;
uint8_t pm_fail_streak=0;

// ---------- Graph buffers ----------
static uint8_t yCO2[GRAPH_W], yPM[GRAPH_W], yT[GRAPH_W], yRH[GRAPH_W];
static int nPoints=0; // 0..GRAPH_W

// ---------- UI & timing ----------
unsigned long lastUI = 0;
const unsigned long UI_PERIOD_MS = 800; // classic smooth cadence

const unsigned long GRAPH_PERIOD_MS = 180000UL; // 3 min columns
unsigned long lastGraph = 0;

// ---------- EPA AQI helpers (PM2.5 and PM10) ----------
static inline float trunc1(float x){ return (int)(x*10.0f)/10.0f; }

int aqiFromPM25(float ug){ // EPA 24h PM2.5 breakpoints
float c=trunc1(ug);
struct BP{ float Cl,Ch; int Il,Ih; };
const BP T[]={
{0.0,12.0,0,50},{12.1,35.4,51,100},{35.5,55.4,101,150},
{55.5,150.4,151,200},{150.5,250.4,201,300},
{250.5,350.4,301,400},{350.5,500.4,401,500}
};
for(auto &b:T) if(c>=b.Cl && c<=b.Ch)
return (int) (((b.Ih-b.Il)*(c-b.Cl))/(b.Ch-b.Cl) + b.Il + 0.5f);
return 500;
}

int aqiFromPM10(float ug){ // EPA 24h PM10 breakpoints
float c = ug; // integer µg/m3
struct BP{ float Cl,Ch; int Il,Ih; };
const BP T[]={
{0, 54, 0, 50},
{55, 154, 51, 100},
{155,254, 101, 150},
{255,354, 151, 200},
{355,424, 201, 300},
{425,504, 301, 400},
{505,604, 401, 500}
};
for(auto &b:T) if(c>=b.Cl && c<=b.Ch)
return (int) (((b.Ih-b.Il)*(c-b.Cl))/(b.Ch-b.Cl) + b.Il + 0.5f);
return 500;
}

const char* aqiLabel(int aqi){
if (aqi<=50) return "Good";
if (aqi<=100) return "Moderate";
if (aqi<=150) return "Unhlth-SG";
if (aqi<=200) return "Unhealthy";
if (aqi<=300) return "Very Unh";
return "Hazardous";
}

// ---------- UI helpers ----------
void centerText(int y, const char* txt, uint16_t col, uint8_t sz=2){
display.setTextSize(sz);
int16_t x1,y1; uint16_t w,h;
display.getTextBounds((char*)txt, 0,0, &x1,&y1,&w,&h);
int x=(SW-(int)w)/2;
display.setTextColor(col);
display.setCursor(x,y);
display.print(txt);
}

void splash(){
display.fillScreen(C_BG);
display.setTextWrap(false);
centerText(30, "See02", C_FG, 3);
centerText(68, "see what you breath", display.color565(180,180,180), 1);
delay(2200);
}

// -------- SAFE I2C scan --------
void showI2CScan(){
if (SKIP_I2C_SCAN) return;
display.fillScreen(C_BG);
display.setTextColor(C_FG); display.setTextSize(1);
display.setCursor(4, 6); display.print(F("I2C scan (safe)"));

bool found12=false, found61=false;
uint8_t row=0, col=0;
unsigned long t0 = millis();

auto probe = [&](uint8_t addr){
Wire.beginTransmission(addr);
uint8_t ok = (Wire.endTransmission()==0);
if (ok){
char buf[8]; snprintf(buf, sizeof(buf), " %02X", addr);
display.setCursor(6 + col*24, 22 + row*12);
display.print(buf);
col++; if (col>=5){ col=0; row++; }
if (addr==0x12) found12=true;
if (addr==0x61) found61=true;
}
};

probe(0x12); probe(0x61);
for (uint8_t addr=0x03; addr<=0x77; addr++){
if (addr==0x12 || addr==0x61) continue;
if ((millis()-t0) > I2C_SCAN_MS) break;
probe(addr);
}

int y = 22 + (row+1)*12 + 6;
display.setCursor(6, y); display.print(F("PM @12: ")); display.fillCircle(56, y-2, 3, found12? C_OK : C_ERR);
display.setCursor(70, y); display.print(F("SCD@61: ")); display.fillCircle(120, y-2, 3, found61? C_OK : C_ERR);
delay(900);
}

// Header with **standard PM AQI** (PM2.5/PM10 max)
void drawHeaderAQI(){
display.fillRect(0, 0, SW, HEADER_H, C_BG);
display.setTextColor(C_FG);
display.setTextSize(1);
display.setCursor(4, 5);
if (pm_have){
int aqi25 = aqiFromPM25(pm25_last);
int aqi10 = aqiFromPM10(pm10_last);
int aqi = (aqi25>aqi10)? aqi25 : aqi10;
display.print(F("AQI: ")); display.print(aqi); display.print(" "); display.print(aqiLabel(aqi));
} else {
display.print(F("AQI: --"));
}
}

void drawTopStaticLabels(){
display.setTextWrap(false);
display.setTextColor(C_CO2); display.setCursor(LABEL_X, Y_CO2); display.print(F("CO2"));
display.setTextColor(C_PM25); display.setCursor(LABEL_X, Y_PM); display.print(F("PM2.5"));
display.setTextColor(C_TEMP); display.setCursor(LABEL_X, Y_T); display.print(F("TEMP"));
display.setTextColor(C_RH); display.setCursor(LABEL_X, Y_H); display.print(F("HUM"));
display.setTextColor(C_FG);
uint16_t colSCD = scd_have ? C_OK : C_ERR;
uint16_t colPM = pm_have ? C_OK : C_ERR;
display.fillCircle(DOT_X, Y_CO2-3, 2, colSCD);
display.fillCircle(DOT_X, Y_T -3, 2, colSCD);
display.fillCircle(DOT_X, Y_H -3, 2, colSCD);
display.fillCircle(DOT_X, Y_PM -3, 2, colPM );
}

void drawTopValuesText(const char* co2Txt, const char* pmTxt, const char* tTxt, const char* hTxt){
display.fillRect(0, TOP_Y, SW, TOP_H, C_BG);
drawHeaderAQI();
drawTopStaticLabels();

display.setTextColor(C_FG);
display.setTextSize(1);
display.setCursor(VALUE_X, Y_CO2); display.print(co2Txt);
display.setCursor(VALUE_X, Y_PM ); display.print(pmTxt);
display.setCursor(VALUE_X, Y_T ); display.print(tTxt);
display.setCursor(VALUE_X, Y_H ); display.print(hTxt);
}

// ---------- mapping -> Y with safe pads ----------
inline int baseY(){ return GRAPH_Y + GRAPH_H - 1; }
inline uint8_t clampY(int y){ int t=GRAPH_Y+TOP_PAD, b=baseY()-BOT_PAD; if(y<t) y=t; if(y>b) y=b; return (uint8_t)y; }
inline uint8_t mapCO2_Y(float v){ if(v<400)v=400; if(v>2000)v=2000; float usable=(GRAPH_H-1-TOP_PAD-BOT_PAD); int y=(baseY()-BOT_PAD)-(int)((v-400.0f)*usable/1600.0f+0.5f); return clampY(y);}
inline uint8_t mapPM_Y (float v){ if(v<0)v=0; if(v>150) v=150; float usable=(GRAPH_H-1-TOP_PAD-BOT_PAD); int y=(baseY()-BOT_PAD)-(int)((v)*usable/150.0f+0.5f); return clampY(y);}
inline uint8_t mapT_Y (float v){ if(v<0)v=0; if(v>40) v=40; float usable=(GRAPH_H-1-TOP_PAD-BOT_PAD); int y=(baseY()-BOT_PAD)-(int)((v)*usable/40.0f+0.5f); return clampY(y);}
inline uint8_t mapRH_Y (float v){ if(v<0)v=0; if(v>100) v=100; float usable=(GRAPH_H-1-TOP_PAD-BOT_PAD); int y=(baseY()-BOT_PAD)-(int)((v)*usable/100.0f+0.5f); return clampY(y);}

// ---------- grid & graph ----------
void drawGrid(){
display.fillRect(0, GRAPH_Y, GRAPH_W, GRAPH_H, C_BG);
int usable = GRAPH_H - TOP_PAD - BOT_PAD;
// Horizontal 25/50/75% dotted lines
for (int i=1; i<=3; ++i){ int y=(baseY()-BOT_PAD)-(usable*i)/4; for (int x=0; x<GRAPH_W; x+=3) display.drawPixel(x,y,C_GRID); }
// Light vertical bands every 16 px for structure
for (int x=0; x<GRAPH_W; x+=16) display.drawFastVLine(x, GRAPH_Y+TOP_PAD, usable, C_VGRID);
// separators
display.fillRect(0, GRAPH_Y - 1, SW, 1, C_BG);
}

void renderGraph(){
drawGrid();
if (nPoints == 0) return;
if (nPoints == 1){
display.drawPixel(0, yCO2[0], C_CO2);
display.drawPixel(0, yPM [0], C_PM25);
display.drawPixel(0, yT [0], C_TEMP);
display.drawPixel(0, yRH [0], C_RH);
} else {
for (int i=1; i<nPoints; ++i){
display.drawLine(i-1, yCO2[i-1], i, yCO2[i], C_CO2);
display.drawLine(i-1, yPM [i-1], i, yPM [i], C_PM25);
display.drawLine(i-1, yT [i-1], i, yT [i], C_TEMP);
display.drawLine(i-1, yRH [i-1], i, yRH [i], C_RH);
}
}
// single-pixel markers (no circles)
int lx = (nPoints-1);
display.drawPixel(lx, yCO2[lx], C_CO2);
display.drawPixel(lx, yPM [lx], C_PM25);
display.drawPixel(lx, yT [lx], C_TEMP);
display.drawPixel(lx, yRH [lx], C_RH);
}

void pushSample(float co2, float pm, float tC, float rh){
uint8_t yc=mapCO2_Y(co2), yp=mapPM_Y(pm), yt=mapT_Y(tC), yh=mapRH_Y(rh);
if (nPoints < GRAPH_W){
yCO2[nPoints]=yc; yPM[nPoints]=yp; yT[nPoints]=yt; yRH[nPoints]=yh; nPoints++;
} else {
memmove(&yCO2[0], &yCO2[1], GRAPH_W-1);
memmove(&yPM [0], &yPM [1], GRAPH_W-1);
memmove(&yT [0], &yT [1], GRAPH_W-1);
memmove(&yRH [0], &yRH [1], GRAPH_W-1);
yCO2[GRAPH_W-1]=yc; yPM[GRAPH_W-1]=yp; yT[GRAPH_W-1]=yt; yRH[GRAPH_W-1]=yh;
}
renderGraph();
}

// ---------- PMSA003I I2C init/retry ----------
bool initPMSA003I_I2C(){ delay(800); for (uint8_t i=0;i<6;i++){ if (pm25.begin_I2C()) return true; delay(220);} return false; }

// ---------- UI orchestration ----------
void refreshUI(){
char co2Txt[20]="--", pmTxt[20]="--", tTxt[20]="--", hTxt[20]="--";
if (scd_have){
snprintf(co2Txt,sizeof(co2Txt), "%u ppm", (unsigned)(co2_f+0.5f));
char tb[10]; dtostrf(t_f,0,1,tb); snprintf(tTxt,sizeof(tTxt), "%s C", tb);
snprintf(hTxt,sizeof(hTxt), "%u %%", (unsigned)(rh_f+0.5f));
}
if (pm_have){
snprintf(pmTxt,sizeof(pmTxt), "%u ug/m3", (unsigned)(pm25_f+0.5f));
}
drawTopValuesText(co2Txt, pmTxt, tTxt, hTxt);
}

// ---------- Setup / Loop ----------
unsigned long tPM=0;

void setup(){
Wire.begin();
Wire.setClock(100000);
#if defined(WIRE_HAS_TIMEOUT)
Wire.setWireTimeout(250, true);
#endif

display.begin(); display.setTextWrap(false);
splash();
showI2CScan();

scd_ok = scd30.begin(); if (scd_ok) scd30.setMeasurementInterval(2); // ~2s cadence
pm_ready = initPMSA003I_I2C();

memset(yCO2,0,sizeof(yCO2)); memset(yPM,0,sizeof(yPM)); memset(yT,0,sizeof(yT)); memset(yRH,0,sizeof(yRH)); nPoints=0;

display.fillScreen(C_BG);
refreshUI();
renderGraph();
lastUI = millis();
lastGraph = millis() - (GRAPH_PERIOD_MS - FIRST_SAMPLE_MS); // early first column
}

void loop(){
// PM ~1 Hz
if (pm_ready && millis() - tPM >= 1000){
tPM = millis(); PM25_AQI_Data d;
if (pm25.read(&d)){
pm25_last = d.pm25_env; // μg/m³ (PM2.5)
pm10_last = d.pm10_env; // μg/m³ (PM10)
pm25_f = ema(pm25_f, pm25_last, 0.18f);
pm_have = true; pm_fail_streak=0;
} else {
if (pm_fail_streak<10) pm_fail_streak++;
if (pm_fail_streak>=10){ pm_ready=false; pm_have=false; }
}
}

// SCD30 when ready (~2 s)
if (scd_ok && scd30.dataReady() && scd30.read()){
scd_have=true;
co2_raw = scd30.CO2; t_raw = scd30.temperature; rh_raw = scd30.relative_humidity;
co2_f=ema(co2_f, co2_raw, 0.15f);
t_f =ema(t_f , t_raw , 0.15f);
rh_f =ema(rh_f , rh_raw, 0.15f);
}

// Live UI numbers + header AQI
if (millis() - lastUI >= UI_PERIOD_MS){
refreshUI();
lastUI = millis();
}

// Every 3 minutes: push a SNAPSHOT (latest available) to the graph
if (millis() - lastGraph >= GRAPH_PERIOD_MS){
float co2_snap = scd_have ? co2_raw : 400.0f;
float pm_snap = pm_have ? pm25_last : 0.0f;
float t_snap = scd_have ? t_raw : 22.0f;
float rh_snap = scd_have ? rh_raw : 45.0f;

pushSample(co2_snap, pm_snap, t_snap, rh_snap);
lastGraph = millis();
}
}


(if there are any bugs with the code let me know. thanks!)

the graph updates every 3 minutes so if you think its broken don't worry its not.

Conclusion

See02 pose pic.jpg
FOJLGAJMFFQCKVL.jpg

this project was a very fun little device to make it Taught me how to work with air sensors. i learned more about Arduino and i hope you do to! this was also a really challenging project because i had to find the right sensors that were accurate and small. have fun with your own See02 air monitor!