Đo chất lượng không khí trong phòng bằng ESP32: CO2 với SCD40, bụi PM2.5 với PMS5003, AQI giờ
Làm máy đo không khí trong phòng bằng ESP32: CO2 thật với SCD40, bụi PM2.5 với PMS5003, OLED, tính AQI giờ theo VN_AQI và NowCast, biết lúc nào mở cửa, lúc nào bật máy lọc.
Không khí trong phòng có hai thứ đáng đo nhất: CO2 cho biết phòng có đủ thông gió không (ngủ phòng kín dậy nặng đầu, học trong lớp đóng cửa buồn ngủ), và bụi mịn PM2.5 cho biết lúc nào nên bật máy lọc hoặc đóng cửa khi ngoài trời ô nhiễm. Bài này làm máy đo bằng ESP32 với cảm biến CO2 SCD40, cảm biến bụi PMS5003 và màn hình OLED 0.96 inch; chương trình tự đọc cả hai cảm biến không cần thư viện riêng và tính AQI giờ cho PM2.5 theo cách tính VN_AQI.
1. Chọn cảm biến

- SCD40 (Sensirion) đo CO2 bằng nguyên lý quang âm: tia hồng ngoại đúng bước sóng CO2 hấp thụ làm khí trong buồng đo giãn nở, micro nhỏ nghe được "tiếng" đó. Kết quả là nồng độ CO2 thật (ppm), sai số ±(50 ppm + 5 %) trong khoảng 400–2000 ppm theo datasheet, kèm nhiệt độ và độ ẩm. Giao tiếp I2C địa chỉ 0x62.
- PMS5003 (Plantower) có quạt hút không khí qua buồng đo, đếm hạt bụi bằng tia laser tán xạ, tự tính nồng độ PM1.0, PM2.5, PM10 (µg/m3). Giao tiếp UART 9600 baud, tự gửi số đo khoảng mỗi giây.
- MQ135 không dùng ở đây. Phần tử oxit kim loại của nó phản ứng với nhiều loại khí và với độ ẩm, nên con số "ppm CO2" đổi từ đường cong mẫu chỉ là ước lượng; MQ135 hợp để báo có khí lạ hơn là đo CO2.
2. Chuẩn bị
- ESP32 DevKit V1 và dây USB
- Module SCD40 (loại có sẵn mạch ổn áp, chân VDD, GND, SCL, SDA)
- PMS5003 kèm mạch chuyển đầu nối (đầu nối gốc của PMS5003 là 8 chân bước 1,27 mm)
- Màn hình OLED 0.96 inch SSD1306 I2C
- Breadboard và dây cắm
- Thư viện Adafruit SSD1306 (cài qua Library Manager) cho màn hình; SCD40 và PMS5003 không cần thư viện.
3. Nối dây

- Quạt của PMS5003 cần 5 V (lấy từ VIN khi cắm USB), còn chân TX, RX là mức 3,3 V nên nối thẳng vào ESP32.
- Đặt PMS5003 sao cho cửa hút và cửa thổi không bị che, không đặt sát nguồn bụi (máy in, bếp) hay luồng gió mạnh.
- Đặt SCD40 ở chỗ có không khí lưu thông, ngang tầm thở, xa cửa sổ đang mở và xa chỗ người ngồi thở trực tiếp vào cảm biến.
4. Hai cảm biến nói chuyện thế nào
SCD40 nhận lệnh 16 bit qua I2C. Chương trình gửi lệnh bắt đầu đo định kỳ (0x21B1), sau đó cứ mỗi giây hỏi "đã có số mới chưa" (0xE4B8); có rồi thì đọc 9 byte (0xEC05): CO2, nhiệt độ, độ ẩm, mỗi giá trị 2 byte kèm 1 byte CRC-8. Byte CRC giúp phát hiện dữ liệu bị nhiễu trên dây I2C.
PMS5003 tự gửi khung 32 byte:

Có hai bộ số PM: "CF=1" theo bụi chuẩn của nhà sản xuất và "khí quyển" cho không khí thường. Theo tài liệu của hãng, đo không khí xung quanh thì dùng bộ khí quyển; chương trình dùng bộ này.
5. Tính AQI giờ cho PM2.5
Ở Việt Nam, chỉ số chất lượng không khí VN_AQI tính theo Quyết định 1459/QĐ-TCMT năm 2019 của Tổng cục Môi trường. Với bụi PM2.5, AQI giờ không dùng nồng độ tức thời mà dùng giá trị NowCast từ trung bình 12 giờ gần nhất, rồi đổi sang thang 0–500 theo bảng ngưỡng:

- Mỗi giờ, chương trình lấy trung bình mọi số PM2.5 đã đọc trong giờ đó, đẩy vào mảng 12 giờ.
- Tính hệ số
w* = nhỏ nhất / lớn nhấtcủa 12 giá trị; nếuw*nhỏ hơn hoặc bằng 0,5 thì lấyw = 0,5. Giờ gần nhất có trọng số 1, giờ trước đów, rồiw²… - NowCast là trung bình có trọng số. Cần ít nhất 2 trong 3 giờ gần nhất có số liệu, nên sau 2 giờ chạy mới có AQI.
- Nội suy tuyến tính trong bảng ngưỡng để ra AQI, làm tròn thành số nguyên.
Hướng dẫn viết riêng công thức cho trường hợp w = 0,5; chương trình dùng chung một công thức chia cho tổng trọng số, khi đủ 12 giờ kết quả gần như trùng khớp. VN_AQI dùng cho trạm quan trắc ngoài trời; trong phòng, con số này dùng để so sánh và theo dõi, không thay cho số liệu chính thức.
6. Chương trình
// PYWORLD.VN - Đo chất lượng không khí trong phòng bằng ESP32: CO2 bằng SCD40, bụi PM2.5 bằng PMS5003, hiện trên OLED 0.96 inch
// PYWORLD.VN - SCD40 và OLED chung I2C (SDA D21, SCL D22); PMS5003: TX vào GPIO16, RX nối GPIO17, nguồn 5 V; đèn D2 nháy khi không khí kém
// PYWORLD.VN - Tính AQI giờ cho PM2.5 theo cách của VN_AQI: trung bình mỗi giờ, giá trị NowCast 12 giờ, bảng ngưỡng BP
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
const int CHAN_RX_PMS = 16; // nối chân TX của PMS5003
const int CHAN_TX_PMS = 17; // nối chân RX của PMS5003
const int CHAN_DEN = 2;
const uint8_t DIA_CHI_SCD = 0x62;
const uint16_t CO2_MO_CUA = 1000; // ppm: từ mức này đèn nháy nhắc thông gió
const uint32_t MOT_GIO_MS = 3600000;
const float BP_PM25[8] = {0, 25, 50, 80, 150, 250, 350, 500}; // µg/m3, bảng 2 của hướng dẫn VN_AQI
const int I_AQI[8] = {0, 50, 100, 150, 200, 300, 400, 500};
HardwareSerial pms(2);
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
bool coOled = false;
uint16_t co2 = 0;
float nhietDo = NAN, doAm = NAN;
uint16_t pm1 = 0, pm25 = 0, pm10 = 0;
bool coPms = false;
float trungBinhGio[12]; // [0] là giờ vừa xong (c1), [11] cách đây 12 giờ (c12); NAN = không có
double tongGio = 0;
uint32_t demGio = 0, batDauGio = 0;
int soGioDaQua = 0;
// ---- SCD40: lệnh 16 bit, mỗi từ dữ liệu 16 bit kèm 1 byte CRC-8 (đa thức 0x31, giá trị đầu 0xFF) ----
uint8_t crc8(const uint8_t *d, int n) {
uint8_t crc = 0xFF;
for (int i = 0; i < n; i++) {
crc ^= d[i];
for (int b = 0; b < 8; b++) crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x31) : (uint8_t)(crc << 1);
}
return crc;
}
bool lenhScd(uint16_t lenh) {
Wire.beginTransmission(DIA_CHI_SCD);
Wire.write(lenh >> 8);
Wire.write(lenh & 0xFF);
return Wire.endTransmission() == 0;
}
bool docScd(uint16_t lenh, uint16_t *tu, uint8_t soTu) {
if (!lenhScd(lenh)) return false;
delay(1); // chip cần 1 ms sau lệnh đọc
uint8_t n = soTu * 3;
if (Wire.requestFrom(DIA_CHI_SCD, n) != n) return false;
for (uint8_t i = 0; i < soTu; i++) {
uint8_t b[3] = {(uint8_t)Wire.read(), (uint8_t)Wire.read(), (uint8_t)Wire.read()};
if (crc8(b, 2) != b[2]) return false;
tu[i] = (b[0] << 8) | b[1];
}
return true;
}
void capNhatScd() {
uint16_t sanSang;
if (!docScd(0xE4B8, &sanSang, 1) || (sanSang & 0x07FF) == 0) return; // 11 bit thấp bằng 0: chưa có số mới
uint16_t w[3];
if (!docScd(0xEC05, w, 3)) return;
co2 = w[0];
nhietDo = -45 + 175.0f * w[1] / 65535;
doAm = 100.0f * w[2] / 65535;
}
// ---- PMS5003: khung 32 byte bắt đầu bằng 0x42 0x4D, tổng kiểm tra là tổng 30 byte đầu ----
uint8_t khung[32];
int viTri = 0;
bool docPms() {
bool coMoi = false;
while (pms.available()) {
uint8_t b = pms.read();
if (viTri == 0 && b != 0x42) continue;
if (viTri == 1 && b != 0x4D) {
viTri = 0;
continue;
}
khung[viTri++] = b;
if (viTri < 32) continue;
viTri = 0;
uint16_t tong = 0;
for (int i = 0; i < 30; i++) tong += khung[i];
if (tong != ((khung[30] << 8) | khung[31]) || ((khung[2] << 8) | khung[3]) != 28) continue;
pm1 = (khung[10] << 8) | khung[11]; // dữ liệu 4-6: nồng độ theo điều kiện khí quyển
pm25 = (khung[12] << 8) | khung[13];
pm10 = (khung[14] << 8) | khung[15];
coMoi = true;
}
return coMoi;
}
// ---- AQI giờ cho PM2.5 ----
float nowCast() {
int co = 0;
for (int i = 0; i < 3; i++) if (!isnan(trungBinhGio[i])) co++;
if (co < 2) return NAN; // cần ít nhất 2 trong 3 giờ gần nhất
float nho = 1e9, lon = -1;
for (int i = 0; i < 12; i++) {
if (isnan(trungBinhGio[i])) continue;
nho = min(nho, trungBinhGio[i]);
lon = max(lon, trungBinhGio[i]);
}
float w = lon > 0 ? nho / lon : 1;
if (w <= 0.5f) w = 0.5f;
float tuSo = 0, mauSo = 0, heSo = 1; // giờ không có số liệu thì bỏ qua (trọng số 0)
for (int i = 0; i < 12; i++) {
if (!isnan(trungBinhGio[i])) {
tuSo += heSo * trungBinhGio[i];
mauSo += heSo;
}
heSo *= w;
}
return tuSo / mauSo;
}
int aqiPm25(float c) {
if (c >= BP_PM25[7]) return 500;
for (int i = 0; i < 7; i++) {
if (c < BP_PM25[i + 1]) return lroundf((I_AQI[i + 1] - I_AQI[i]) / (BP_PM25[i + 1] - BP_PM25[i]) * (c - BP_PM25[i]) + I_AQI[i]);
}
return 500;
}
const char *mucAqi(int aqi) {
if (aqi <= 50) return "Tot";
if (aqi <= 100) return "Trung binh";
if (aqi <= 150) return "Kem";
if (aqi <= 200) return "Xau";
if (aqi <= 300) return "Rat xau";
return "Nguy hai";
}
const char *mucCo2(uint16_t ppm) { // mức tham khảo thường dùng cho không khí trong phòng
if (ppm < 800) return "tot";
if (ppm < 1000) return "nen thong gio";
if (ppm < 1500) return "can mo cua";
return "kem, mo cua ngay";
}
void ketThucGio() {
for (int i = 11; i > 0; i--) trungBinhGio[i] = trungBinhGio[i - 1];
trungBinhGio[0] = demGio ? tongGio / demGio : NAN;
tongGio = 0;
demGio = 0;
soGioDaQua++;
float nc = nowCast();
Serial.printf("Het gio thu %d: PM2.5 trung binh %.1f", soGioDaQua, trungBinhGio[0]);
if (isnan(nc)) Serial.println(", chua du 2 gio de tinh NowCast");
else Serial.printf(", NowCast %.1f, AQI gio %d (%s)\n", nc, aqiPm25(nc), mucAqi(aqiPm25(nc)));
}
void veOled() {
if (!coOled) return;
float nc = nowCast();
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(1);
oled.setCursor(0, 0);
oled.print("CO2 ppm");
oled.setTextSize(2);
oled.setCursor(56, 0);
oled.print(co2);
oled.setTextSize(1);
oled.setCursor(0, 20);
if (coPms) oled.printf("PM2.5 %u ug/m3", pm25);
else oled.print("PM2.5: cho cam bien");
oled.setCursor(0, 32);
if (isnan(nc)) oled.print("AQI gio: sau 2 gio");
else oled.printf("AQI gio %d %s", aqiPm25(nc), mucAqi(aqiPm25(nc)));
oled.setCursor(0, 44);
if (!isnan(nhietDo)) oled.printf("%.1f C %.0f %%", nhietDo, doAm);
oled.setCursor(0, 56);
if (co2 >= CO2_MO_CUA) oled.print("Nen mo cua thong gio");
else if (!isnan(nc) && aqiPm25(nc) > 100) oled.print("Bui cao: bat may loc");
oled.display();
}
void setup() {
Serial.begin(115200);
pinMode(CHAN_DEN, OUTPUT);
for (int i = 0; i < 12; i++) trungBinhGio[i] = NAN;
pms.begin(9600, SERIAL_8N1, CHAN_RX_PMS, CHAN_TX_PMS); // PMS5003: 9600 baud, tự gửi khung khoảng mỗi giây
Wire.begin(21, 22);
coOled = oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
lenhScd(0x3F86); // dừng đo (nếu chip đang đo từ lần chạy trước)
delay(500);
if (lenhScd(0x21B1)) Serial.println("SCD40: bat dau do, 5 giay mot lan"); // đo định kỳ
else Serial.println("Khong thay SCD40 o 0x62: kiem tra day I2C");
batDauGio = millis();
}
void loop() {
if (docPms()) {
coPms = true;
tongGio += pm25;
demGio++;
}
static uint32_t lanDoc = 0, lanIn = 0;
if (millis() - lanDoc >= 1000) {
lanDoc = millis();
capNhatScd();
veOled();
}
if (millis() - lanIn >= 5000) {
lanIn = millis();
float nc = nowCast();
Serial.printf("CO2 %u ppm (%s), %.1f C, %.0f %% | PM1.0 %u, PM2.5 %u, PM10 %u ug/m3", co2, mucCo2(co2), nhietDo, doAm, pm1, pm25, pm10);
if (isnan(nc)) Serial.println();
else Serial.printf(" | AQI gio %d\n", aqiPm25(nc));
}
if (millis() - batDauGio >= MOT_GIO_MS) {
batDauGio += MOT_GIO_MS;
ketThucGio();
}
float nc = nowCast();
bool kem = co2 >= CO2_MO_CUA || (!isnan(nc) && aqiPm25(nc) > 100);
digitalWrite(CHAN_DEN, kem && (millis() / 500) % 2); // nháy 1 lần mỗi giây
}
Giải thích
docScd()gửi lệnh, chờ 1 ms, đọc từng từ 3 byte và kiểm tra CRC-8 (đa thức 0x31, giá trị đầu 0xFF). Nhiệt độ đổi theo công thức của datasheet:−45 + 175 × số đọc / 65535; độ ẩm100 × số đọc / 65535.docPms()đọc từng byte: chờ đúng 0x42 rồi 0x4D mới bắt đầu gom, đủ 32 byte thì kiểm tra độ dài 28 và tổng kiểm tra. Khung lỗi bị bỏ, không làm sai số đo.ketThucGio()chạy mỗi giờ: tính trung bình giờ, dịch mảng 12 giờ, in NowCast và AQI giờ.- Đèn D2 nháy khi CO2 từ 1000 ppm hoặc AQI giờ trên 100. Màn hình ghi rõ nên làm gì: CO2 cao thì mở cửa thông gió; bụi cao thì bật máy lọc (mở cửa lúc ngoài trời đang ô nhiễm sẽ làm bụi trong phòng tăng thêm).
- SCD40 mặc định bật tự hiệu chuẩn: nó giả định trong mỗi tuần có lúc được thông gió ra không khí ngoài trời (khoảng 400 ppm). Phòng đóng kín cả tuần thì số đo có thể trôi; khi đó nên mở cửa định kỳ.
7. Kết quả và cách đọc

Các mức CO2 thường dùng để tham khảo cho không khí trong phòng (không phải giới hạn an toàn):
| CO2 (ppm) | Ý nghĩa |
|---|---|
| khoảng 420 | Không khí ngoài trời |
| dưới 800 | Phòng thông gió tốt |
| 800–1000 | Nên thông gió |
| 1000–1500 | Thiếu thông gió, dễ buồn ngủ, kém tập trung: mở cửa |
| trên 1500 | Không khí kém: mở cửa ngay, bật quạt hút |

Phòng ngủ đóng kín và bật điều hòa gần như không trao đổi khí với bên ngoài, vì máy điều hòa treo tường chỉ làm lạnh không khí trong phòng, không đưa khí tươi vào. Đặt máy đo một đêm sẽ thấy rõ CO2 tăng thế nào và hé cửa, quạt hút giúp được bao nhiêu.
8. Lỗi hay gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| "Khong thay SCD40 o 0x62" | Sai dây SDA/SCL, module chưa có nguồn | Chạy chương trình quét I2C; kiểm tra VDD |
| CO2 đứng yên ở 0 | SCD40 chưa bắt đầu đo, hoặc CRC sai do dây dài | Dây I2C ngắn dưới khoảng 30 cm; khởi động lại |
| CO2 ngoài trời đo được 500–600 ppm | Cảm biến mới chưa tự hiệu chuẩn xong, hoặc gần chỗ người thở | Chạy liên tục vài ngày, có thông gió; tránh thở vào cảm biến |
| PM2.5 luôn bằng 0 | Nhầm TX, RX; quạt không quay vì thiếu 5 V | TX của PMS5003 vào GPIO16; đo 5 V ở chân VCC |
| PM2.5 rất cao lúc trời nồm | Độ ẩm rất cao làm hạt nước bị đếm như bụi | Số đo lúc độ ẩm trên khoảng 90 % chỉ để tham khảo |
| Nhiệt độ của SCD40 cao hơn thực tế | Cảm biến tự nóng và gần ESP32 | Đặt xa ESP32; nhiệt độ chỉ là số phụ |
9. Hướng mở rộng
- Thêm trang web và đồ thị 24 giờ như bài trạm thời tiết mini cùng nhóm dự án.
- Điều khiển quạt hút qua module relay: CO2 trên 1000 ppm thì bật, dưới 800 ppm thì tắt.
- Để tiết kiệm tuổi thọ quạt và laser của PMS5003, kéo chân SET xuống thấp để cảm biến ngủ, mỗi vài phút đánh thức 30 giây để đo.
10. Xem thêm
- Cảm biến khí ga: MQ, điện hóa, xúc tác, NDIR – cấu tạo và nguyên lý
- I2C là gì: địa chỉ, ACK và cách đọc ghi thanh ghi trên ESP32
- UART là gì và cách dùng Serial2 trên ESP32
- Bài MQ-2, MQ-135 trong loạt mô-đun: cảnh báo khí ga, khói.
Xem thêm