See02:一款可以显示你呼吸的空气质量的迷你空气质量监测器


暗哑于秋
转载
发布时间: 2025-09-28 14:47:14 | 阅读数 0收藏数 0评论数 0
封面
See02 是您的迷你空气质量监测器,它可以测量二氧化碳、温度、湿度和 PM2.5,然后将其全部显示在屏幕上。它还有一个图表,因此您可以看到 6 小时内空气质量的变化情况。

准备工作:

材料:

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

Sensirion SCD30 DigiKey.ca (1)

Waveshare 1.5英寸显示模块AliExpress (1)

Adafruit PMSA003I DigiKey.ca (1)

M2.5*5mm 螺丝AliExpress (4)

10cm 公对公电线AliExpress (15)

数据 USB-C 线缆AliExpress (1)



工具:

烙铁

螺丝刀

热胶枪

3D打印机


1

3D打印外壳

我已经使用 Tinkercad 为 See02 设计了一个外壳。以下是下载文件:



STL
case+lid.stl
74.89KB
STL
case+body.stl
203.30KB
2

焊接SCD30和Adafruit PMSA003I

首先抓住 8 根电线和 Adafruit PMSA003I 和 Sensirion SCD30。


Adafruit PMSA003I:

将 4 根电线焊接到 GND、VIN、SCL 和 SDA 上。


Sensirion SCD30:

将4根线焊接到GND、VIN、SCL和SDA上。焊接SCD30时,请确保每次只接触引脚几秒钟,否则很容易损坏传感器。


(很抱歉我忘了拍 SCD30 的照片,但两个设备上的引脚完全相同:)


3

焊接Wavshare显示屏

现在是时候焊接显示器了!焊接时只需抓住 7 根电线并将它们焊接到所有显示引脚上。



4

将所有部件焊接到Arduino Nano

现在,拿起你的 Arduino nano、Adafruit PMSA003I 和 Sensirion SCD30。焊接时,将 Arduino nano 上的引脚向下弯曲,否则将无法安装。接下来焊接到这些引脚:


Adafruit PMSA003I:

PMSA003I GND - 纳米 GND

PMSA003I VIN - 纳米 5V

PMSA003I SCL - 纳米 A5

PMSA003I SDA - 纳米 A4


Sensirion SCD30:

SCD30 VIN-纳米5V

SCD30 GND-纳米GND

SCD30 SCL - 纳米 A5

SCD30 SDA - 纳米 A4


完成空气传感器后,抓住 Waveshare 1.5 英寸显示屏并焊接到以下引脚:


Waveshare 1.5英寸显示屏:

显示VCC-Nano 5V或3V3

显示器 GND - 纳米 GND

显示 DIN - Nano D11

显示 CLK - Nano D13

显示器 CS-Nano D10

展示 DC-Nano D9

显示 RST - Nano D8


5

将零件放入盒中

完成所有焊接后,就可以开始将所有东西组装起来了!


我们将首先用热胶将 Arduino nano 粘到其位置。


接下来,使用随附的 4 颗螺丝将 Waveshare 显示器拧紧。(拧紧时,请勿将螺丝拧紧,否则会严重损坏外壳)。


现在拿起您的 SCD30 并将其滑入其位置,确保它安装良好并且您可以从侧面完全看到它。


完成所有这些操作后,取出 Adafruit PMSA003I 并用 2 个 M2.5*5 螺丝拧紧。


现在要完成它,请取下机箱盖并将其弹上,并确保盖上盖子时 SCD30 支架不会损坏 SCD30。现在再抓住 2 个 M2.5*5 螺丝并拧紧盖子。


6

上传代码

要上传代码,首先下载最新版本的Arduino IDE。接下来在侧边栏中单击库并安装这些库:Adafruit_PM25、Adafruit_GFX、Adafruit_SSD1351 和 Adafruit_SCD30。安装所有由 Adafruit 提供的库。现在插入你的 Arduino nano 并在端口中选择它,然后复制此代码并上传!


#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();
}
}


如果代码有任何错误请告诉我。谢谢!)

该图表每 3 分钟更新一次,因此如果您认为它有问题,请不要担心,它没有问题。


7

结论

这个项目是一个非常有趣的小装置,它教会了我如何使用空气传感器。我学到了更多关于Arduino的知识,希望你也能如此!这也是一个非常有挑战性的项目,因为我必须找到合适的、精确的、小巧的传感器。祝你使用你自己的See02空气监测器玩得开心!



阅读记录0
点赞0
收藏0
禁止 本文未经作者允许授权,禁止转载
猜你喜欢
评论/提问(已发布 0 条)
评论 评论
收藏 收藏
分享 分享
pdf下载 下载
pdf下载 举报