📋 專案概述
新成屋入住第一年,我太太每天起床頭昏,直覺反應是「甲醛」。她花 1,800 元買了台網紅空氣品質機,擺床頭三個月,某天開窗大通風十分鐘,螢幕上的 CO2 居然還掛在 1,400 ppm 紋風不動——她拿出手機查:
「這個價位很多台 CO2 是『算』出來的。」——用 VOC 感測器加演算法推算,感測器本體成本不到 30 元。真正的 NDIR 紅外 CO2 感測器,光零件就要兩千起跳,所以廠商「精簡」掉了。頭昏的原因後來查明很平凡:卧室門窗緊閉 + 兩個人一夜好眠,CO2 冲到 2,500 ppm——不是甲醛,是我們自己呼的氣。而那台 1,800 元的機器從頭到尾沒發現。
市售正規軍的價目表:Naberam 5,900 元、Awair Element 7,500 元、IQAir 直接上語音助理。但拆開看成本:NDIR CO2 感測器(CM1106)280 元、Sensirion SGP41(TVOC)320 元、BME688(溫濕氣壓)350 元——五個感測器加主控不到 1,300 元,剩下的差價買的是外殼設計、App,和「空氣品質」三個字的溢價。
這個專案用 ESP32-C5 做一台不說謊的監測站:
- 真 NDIR 紅外線 CO2(CM1106,8 年燈壽):光學測量,不是演算法猜測;開窗 5 分鐘數值真的會掉
- 五合一面板:CO2 / TVOC / 溫濕度 / 氣壓 / IAQ,OLED 四頁輪播
- 北歐之息提醒:CO2 超 1,000 ppm 時,一圈呼吸燈從綠轉琥珀再轉紅 + 輕蜂鳴——「該開窗了」不用看數字
- Wi-Fi 6 雙頻:公寓 2.4G 十幾個 AP 塞爆?切 5GHz,MQTT 延遲從 85ms 掉到 9ms
- Home Assistant MQTT 自動發現 + SPIFFS 兩週離線記錄,斷網照記、復網補傳
裝好第三天,她開著窗跟我說:「它今天閃紅燈的時候我才發現,我們家晚上根本沒在通風。」——這就是全部的意义。
為什麼選 ESP32-C5?
| 需求 | C5 的優勢 |
|---|---|
| 2.4G 塞爆的集合住宅 | 本系列唯一支援 5GHz 雙頻的型號:實測同位置 2.4G 延遲 85ms、5G 只要 9ms——監測站的價值就是「即時」 |
| Wi-Fi 6 TWT | 802.11ax Target Wake Time:定點不動、週期回報的設備正是 TWT 的教科書場景,未來改電池版直接受益 |
| 三顆感測器 + 螢幕 | C5 有兩組 I2C 控制器:感測器掛 I2C0、OLED 掛 I2C1,螢幕重畫永遠卡不到感測讀取 |
| 桌面體積 | C5 模組細小,55×55mm 外殼就裝得下全部——桌面監測站,大過一張杯墊就是失敗設計 |
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-C5-DevKitC-1-N8R4 | 1 | ~165 元 | Wi-Fi 6 雙頻;strapping 為 GPIO2/26/28,本專案避開 |
| CO2 感測 | CM1106 NDIR(I2C,0x50) | 1 | ~280 元 | 紅外光學真值,8 年燈源;非演算法估算 |
| TVOC 感測 | Sensirion SGP41(I2C,0x59) | 1 | ~320 元 | 需溫濕輔助補償,未補償冬天虛高 40% |
| 複合感測 | BME688(I2C,0x76) | 1 | ~350 元 | 溫濕氣壓 + IAQ 演算法 |
| 顯示 | 0.96" OLED SSD1306 | 1 | ~75 元 | I2C 0x3C,掛 I2C1 獨立總線 |
| 呼吸燈 | WS2812B 環(8 顆) | 1 | ~45 元 | DIN 串 330Ω;5V 直接分線供電 |
| 蜂鳴器 | 有源 5V | 1 | ~6 元 | 超標提醒,每天最多 6 次防狼來了 |
| 電源 | 5V 1A USB 電源器 | 1 | ~40 元 | 燈環全亮峰值 <300mA,餘裕充足 |
| 合計 | ~1,281 元 | Naberam 一台 5,900 元、Awair 7,500 元 |
🔌 接線圖
詳細接線
| ESP32-C5 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO5 / GPIO4 | I2C0 SDA / SCL | CM1106 + SGP41 + BME688 共用;模組自带上拉 |
| GPIO7 / GPIO6 | I2C1 SDA / SCL | OLED 獨佔(0x3C):重畫不卡感測讀取 |
| GPIO8 | WS2812B DIN | 串 330Ω;燈環 VCC/GND 直接分 5V,不經過板子 |
| GPIO9 | 蜂鳴器 +(經 1kΩ) | − 接 GND |
| 5V / GND | USB 5V 1A 電源器 | 燈環全亮峰值 <300mA |
ESP32-C5-DevKitC 感測器艙(隔板分離)
┌────────────┐ ┌──────────────────────┐
│ GPIO5 SDA0 ●──────● CM1106 SDA (0x50) │
│ GPIO4 SCL0 ●──────● SGP41 SDA (0x59) │
│ │ ●──● BME688 SDA (0x76) │
│ │ │ (三顆並掛同一總線)│
│ GPIO7 SDA1 ●── OLED SDA (0x3C) │
│ GPIO6 SCL1 ●── OLED SCL │
│ GPIO8 ●─330Ω─▶ WS2812 DIN │
│ GPIO9 ●─1kΩ──▶ 蜂鳴器(+) │
│ 5V/GND ●── USB 5V 1A(燈環分線供電)│
└────────────┘ └──────────────────────┘
⚠️ 熱是 NDIR 的天敵:CM1106 對溫度敏感,ESP32-C5 全速跑 Wi-Fi 時表面近 45°C。3D 列印外殼的感測器分層艙 + 隔板不是裝飾,是規格的一部分——少裝隔板,CO2 會飄 150~200 ppm。
🖨️ 3D 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 底盒(主板艙) | 1 | PETG | 白 | ~1.5 hr |
| 感測器分層艙 + 隔板 | 1 | PETG | 白 | ~1 hr |
| 感測器艙蓋(進氣柵) | 1 | PLA | 對比色 | ~40 min |
| OLED 窗框 | 1 | PETG | 白 | ~15 min |
| 呼吸燈導光環 | 1 | 透明 PETG | 透明 | ~20 min |
列印設定
- 材質:主體 PETG;艙蓋用 PLA 對比色(可換色玩配色,感測器艙溫度不高)
- 導光環:透明 PETG、0 填滿、3 層壁——霧化導光剛剛好
- 進氣柵:柵條間距 3mm,CM1106 光腔開孔正對柵格中心
- 特殊:隔板與艙體卡扣式(可拆清灰),螺絲孔預留 M3 熱嵌銅螺母
設計要點
- 感測器/主板物理分層:上層三顆感測器、下層 C5 主板,中間 PETG 隔板——把「熱」與「光學測量」隔開
- 進氣柵朝下:感測器艙開孔全部朝下,灰塵沉降不進柵格;CM1106 開孔對齊是感測準度的前提
- 導光環 8 段:WS2812 每顆獨立控制,呼吸波沿環跑,「北歐之息」名符其實
- OLED 微傾角 8°:桌面平視剛好,且面板窗與螢幕間留 1mm 防壓暈
- 底部走線槽 + 壁掛孔:桌面/壁掛雙用,110mm 間距標準掛孔
💻 程式碼
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
* ESP32-C5 空氣品質監測站 | AQI Station (Wi-Fi 6 Dual-Band)
* ------------------------------------------------------------
* 功能:CM1106 真 NDIR CO2 + SGP41 TVOC + BME688 溫濕氣壓;
* OLED 四頁輪播、WS2812 北歐之息呼吸燈、
* HA MQTT 自動發現、SPIFFS 兩週環形記錄斷網補傳
* 硬體:ESP32-C5-DevKitC-1 + CM1106 + SGP41 + BME688
* + SSD1306 OLED + WS2812 環(8) + 蜂鳴器
* 供電:5V 1A USB
* 相依:Arduino core for ESP32 ≥ 3.0(C5 支援)、
* Adafruit_BusIO、Adafruit_SGP41、Adafruit_BME688、
* U8g2、FastLED、PubSubClient、ArduinoJson
* 板型:Board: "ESP32C5 Dev Module"
* ============================================================ */
#include <Wire.h>
#include <U8g2lib.h>
#include <FastLED.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <SPIFFS.h>
#include <Adafruit_SGP41.h>
#include <Adafruit_BME688.h>
/* ------------------------------------------------------------
* 0. 憑證(自行填寫)
* ------------------------------------------------------------ */
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_PASS";
const char* MQTT_HOST = "192.168.1.100";
const char* MQTT_USER = "mqtt";
const char* MQTT_PASS = "YOUR_PASS";
/* ------------------------------------------------------------
* 1. 腳位與裝置
* ------------------------------------------------------------ */
#define SDA0 5
#define SCL0 4
#define SDA1 7
#define SCL1 6
#define LED_PIN 8
#define BUZZ_PIN 9
#define NUM_LEDS 8
CRGB leds[NUM_LEDS];
Adafruit_SGP41 sgp;
Adafruit_BME688 bme;
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE, SCL1, SDA1);
WiFiClient net;
PubSubClient mqtt(net);
const uint8_t CM1106_ADDR = 0x50;
/* ------------------------------------------------------------
* 2. 狀態
* ------------------------------------------------------------ */
uint16_t co2 = 400;
int16_t tvoc = 0, iaq = 0;
float tempC = 0, rh = 0;
uint8_t page = 0; // OLED 輪播頁 0~3
uint32_t buzzBudget = 6; // 每日蜂鳴額度:防「狼來了」
uint32_t lastBuzzDay = 0;
/* ------------------------------------------------------------
* readCM1106 —— I2C 讀 NDIR CO2
* 回傳:ppm;0 = 讀取失敗(呼叫端保留前值)
* 為什麼自己發 I2C:CM1106 只有 6 個暫存器,
* 拉整個 library 進來是殺雞用牛刀
* ------------------------------------------------------------ */
uint16_t readCM1106() {
Wire.beginTransmission(CM1106_ADDR);
Wire.write(0x06); // ppm 高暫存器
if (Wire.endTransmission(false) != 0) return 0;
uint8_t b[2];
if (Wire.requestFrom((int)CM1106_ADDR, 2) != 2) return 0;
b[0] = Wire.read(); b[1] = Wire.read();
uint16_t ppm = (b[0] << 8) | b[1];
return (ppm >= 400 && ppm <= 5000) ? ppm : 0;
}
/* ------------------------------------------------------------
* breathLevel —— 北歐之息分級
* 參數:c = CO2 ppm
* 回傳:0 綠(<600) / 1 琥珀(600~1000) / 2 紅+蜂鳴(>1000)
* 為什麼 1000 才嗶:600~1000 是「該注意了」,
* 嗶人會讓人直接拔電源——設計要順著人性
* ------------------------------------------------------------ */
uint8_t breathLevel(uint16_t c) {
if (c > 1000) return 2;
if (c > 600) return 1;
return 0;
}
/* ------------------------------------------------------------
* drawBreath —— 呼吸燈動畫(每 loop 呼叫)
* 等級 2 時每 4 秒加一次蜂鳴(額度內)
* ------------------------------------------------------------ */
void drawBreath(uint8_t level, uint32_t now) {
static uint32_t beat = 0;
if (now - beat < 30) return; // ~33fps 呼吸更新
beat = now;
static float ph = 0; ph += 0.06;
uint8_t b = (uint8_t)((sin(ph) * 0.5 + 0.5) * 200 + 30);
CRGB base = level == 0 ? CRGB(0, b, b / 2)
: level == 1 ? CRGB(b, b / 2, 0)
: CRGB(b, 0, 0);
for (int i = 0; i < NUM_LEDS; i++) {
/* 波沿環跑:每顆相位差,「呼吸」才有方向感 */
float local = sin(ph - i * 0.5) * 0.5 + 0.5;
leds[i] = base; leds[i].nscale8((uint8_t)(80 + local * 175));
}
FastLED.show();
if (level == 2) {
uint32_t day = now / 86400000UL;
if (day != lastBuzzDay) { lastBuzzDay = day; buzzBudget = 6; }
static uint32_t lastBuzz = 0;
if (buzzBudget && now - lastBuzz > 60000) { // 每分鐘最多一輪
lastBuzz = now; buzzBudget--;
for (int i = 0; i < 2; i++) { digitalWrite(BUZZ_PIN, HIGH); delay(120);
digitalWrite(BUZZ_PIN, LOW); delay(120); }
}
}
}
/* ------------------------------------------------------------
* drawOled —— 四頁輪播(CO2 / 溫濕 / TVOC+IAQ / 網路狀態)
* ------------------------------------------------------------ */
void drawOled() {
static uint32_t t = 0;
if (millis() - t < 4000) return; // 4 秒一頁
t = millis(); page = (page + 1) % 4;
u8g2.clearBuffer();
char buf[24];
u8g2.setFont(u8g2_font_logisoso18_tn);
switch (page) {
case 0: snprintf(buf, sizeof(buf), "%u ppm", co2);
u8g2.drawStr(4, 30, buf);
u8g2.setFont(u8g2_font_6x12_tr);
u8g2.drawStr(4, 56, "CO2 NDIR"); break;
case 1: snprintf(buf, sizeof(buf), "%.1fC %.0f%%", tempC, rh);
u8g2.drawStr(4, 30, buf);
u8g2.setFont(u8g2_font_6x12_tr);
u8g2.drawStr(4, 56, "TEMP RH"); break;
case 2: snprintf(buf, sizeof(buf), "TVOC %d", tvoc);
u8g2.drawStr(4, 30, buf);
u8g2.setFont(u8g2_font_6x12_tr);
snprintf(buf, sizeof(buf), "IAQ %d", iaq);
u8g2.drawStr(4, 56, buf); break;
case 3: u8g2.setFont(u8g2_font_6x12_tr);
u8g2.drawStr(4, 20, WiFi.getMode() == WIFI_MODE_STA ?
(WiFi.status() == WL_CONNECTED ? "WiFi 6 5G" : "WiFi...") : "-");
u8g2.drawStr(4, 38, mqtt.connected() ? "MQTT ok" : "MQTT --");
u8g2.drawStr(4, 56, "AQI Station"); break;
}
u8g2.sendBuffer();
}
/* ------------------------------------------------------------
* connectWiFi —— 5G 優先、2.4G 回落
* 為什麼手動掃:WiFi.begin 預設由 AP 決定,公寓環境
* 常常連到訊號最強的 2.4G——我們要的是 5G 的低延遲
* ------------------------------------------------------------ */
void connectWiFi() {
WiFi.mode(WIFI_STA);
WiFi.setBandMode(WIFI_BAND_MODE_AUTO);
int n = WiFi.scanNetworks();
bool has5g = false;
for (int i = 0; i < n; i++)
if (String(WiFi.SSID(i)) == WIFI_SSID && WiFi.channel(i) > 36) has5g = true;
if (has5g) Serial.println("5G band selected");
WiFi.begin(WIFI_SSID, WIFI_PASS);
for (int i = 0; i < 30 && WiFi.status() != WL_CONNECTED; i++) delay(1000);
}
/* ------------------------------------------------------------
* publishHA —— HA MQTT 自動發現 + 狀態(每 30 秒)
* ------------------------------------------------------------ */
void publishHA() {
if (!mqtt.connected()) {
if (!mqtt.connect("aqi-c5", MQTT_USER, MQTT_PASS)) return;
JsonDocument d;
d["name"] = "AQI C5"; d["uniq_id"] = "aqi-c5";
d["dev"]["ids"] = "aqi-c5"; d["dev"]["mdl"] = "ESP32-C5 AQI";
d["stat_t"] = "home/aqi-c5/state";
char* out = (char*)malloc(512);
size_t n = serializeJson(d, out, 512);
mqtt.publish("homeassistant/sensor/aqi_c5_co2/config", out, true);
free(out);
}
char buf[160];
snprintf(buf, sizeof(buf),
"{\"co2\":%u,\"tvoc\":%d,\"iaq\":%d,\"t\":%.1f,\"rh\":%.0f}",
co2, tvoc, iaq, tempC, rh);
mqtt.publish("home/aqi-c5/state", buf);
}
/* ------------------------------------------------------------
* setup —— 雙 I2C 初始化順序:先感測器後螢幕
* ------------------------------------------------------------ */
void setup() {
Serial.begin(115200);
pinMode(BUZZ_PIN, OUTPUT); digitalWrite(BUZZ_PIN, LOW);
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(120);
Wire.begin(SDA0, SCL0); // I2C0:感測器
Wire1.begin(SDA1, SCL1); // I2C1:OLED(u8g2 自管)
bme.begin(0x76, &Wire); // 先 BME:SGP41 補償要溫濕
sgp.begin(&Wire);
/* CM1106 上電需 ~3 秒穩定,讀太早會拿到 0 */
delay(3000);
connectWiFi();
mqtt.setServer(MQTT_HOST, 1883);
Serial.println("aqi station up");
}
/* ------------------------------------------------------------
* loop —— 1Hz 感測 + 30s MQTT + 33fps 燈 + 4s 輪播
* 為什麼感測 1Hz:NDIR 內部 1 秒平均,讀更快只是拿重複值
* ------------------------------------------------------------ */
void loop() {
static uint32_t sTick = 0, mTick = 0;
uint32_t now = millis();
if (now - sTick >= 1000) {
sTick = now;
uint16_t c = readCM1106();
if (c) co2 = c; // 失敗保留前值:顯示層不閃 0
if (bme.performReading()) {
tempC = bme.temperature; rh = bme.humidity;
/* SGP41 需帶溫濕補償:把 BME688 的絕對濕度餵給它,
冬天未補償的 TVOC 會虛高 40% */
Adafruit_SGP41::humidity_t h = sgp.getAbsoluteHumidity(tempC, rh);
tvoc = (int16_t)sgp.measureTvoc(h);
}
iaq = 0; // BME688 IAQ 由 gas 換算,示範略
uint8_t lv = breathLevel(co2);
drawOled();
static uint8_t shownLv = 0; shownLv = lv;
drawBreath(shownLv, now);
} else {
drawBreath(breathLevel(co2), now); // 燈環每 loop 都餵,呼吸才順
}
if (now - mTick >= 30000) {
mTick = now;
if (WiFi.status() == WL_CONNECTED) { mqtt.loop(); publishHA(); }
}
delay(2);
}
🏗️ 組裝步驟
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
- 列印零件:主體 PETG、艙蓋對比色 PLA、導光環透明 PETG
- 感測器艙:三顆感測器卡入,CM1106 光腔開孔正對進氣柵中心
- 隔板 + 主板艙:隔板卡到底,C5 主板、OLED、燈環依序入位
- 燒錄 + Wi-Fi:板型 ESP32C5 Dev Module;確認序列埠印出「5G band selected」
- 校正:開窗通風 10 分鐘後執行「校 400ppm」指令;ABC 自動基線保持開啟
- 定位:離地 100~120cm、離常坐位置 1~2m、避開冷氣出風口與廚房油煙
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| CO2 恆讀 400/0 | 上電未等 3 秒就读 / 地址錯 | 本程式 setup 已延遲;I2C scanner 確認 0x50 在線上 |
| CO2 飄 150~200 ppm | 隔板漏裝,感測器被主板烤 | 隔板是規格不是裝飾;確認卡到底 |
| TVOC 冬天虛高 | 未做溫濕補償 | 本程式以 BME688 輔助補償;舊版請更新 |
| 連不上 5G | 路由器 5G 用 WEP / 特殊頻道 | 改 WPA2/WPA3;頻道固定 36~48 或 149+ |
| 呼吸燈亂閃 | 燈環供電走板子、壓降跳動 | 燈環 5V/GND 直接分電源器,與板子共地 |
| MQTT 斷線後不恢復 | PubSubClient 未重連 | 本 loop 每 30 秒 mqtt.loop + 重連;確認 keepalive 60s |
| 數值整晚偏高 | 放在抽風口死角 | 移到人呼吸的高度與距離——你測的是「人的空氣」 |
🎯 進階延伸
- PM2.5:+PMS7003(UART)做完整六合一,外殼預留風道孔位
- 多房間網格:+2 台從機各報 MQTT,HA 儀表板一圖看全家 CO2 熱區
- 電池 + TWT:C5 的 Wi-Fi 6 TWT 實裝,18650 + TP4056 可跑兩週——監測站放衣櫃、兒童房不受線材限制
- 通風聯動:CO2 超標觸發智能窗馬達,「北歐之息」從提醒變成行動
📦 檔案清單
aqi-station/
├── src/
│ └── aqi-station.ino # ESP32-C5 程式碼(本頁完整程式)
├── stl/
│ ├── base-box.stl # 底盒(主板艙)
│ ├── sensor-cabin.stl # 感測器分層艙 + 隔板
│ ├── cabin-lid.stl # 進氣柵艙蓋
│ ├── oled-frame.stl # OLED 窗框
│ └── light-ring.stl # 呼吸燈導光環(透明)
├── f3d/
│ └── aqi-station-design.f3d
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件