📋 專案概述

新成屋入住第一年,我太太每天起床頭昏,直覺反應是「甲醛」。她花 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 做一台不說謊的監測站:

裝好第三天,她開著窗跟我說:「它今天閃紅燈的時候我才發現,我們家晚上根本沒在通風。」——這就是全部的意义。

為什麼選 ESP32-C5?

需求C5 的優勢
2.4G 塞爆的集合住宅本系列唯一支援 5GHz 雙頻的型號:實測同位置 2.4G 延遲 85ms、5G 只要 9ms——監測站的價值就是「即時」
Wi-Fi 6 TWT802.11ax Target Wake Time:定點不動、週期回報的設備正是 TWT 的教科書場景,未來改電池版直接受益
三顆感測器 + 螢幕C5 有兩組 I2C 控制器:感測器掛 I2C0、OLED 掛 I2C1,螢幕重畫永遠卡不到感測讀取
桌面體積C5 模組細小,55×55mm 外殼就裝得下全部——桌面監測站,大過一張杯墊就是失敗設計

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C5-DevKitC-1-N8R41~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 SSD13061~75 元I2C 0x3C,掛 I2C1 獨立總線
呼吸燈WS2812B 環(8 顆)1~45 元DIN 串 330Ω;5V 直接分線供電
蜂鳴器有源 5V1~6 元超標提醒,每天最多 6 次防狼來了
電源5V 1A USB 電源器1~40 元燈環全亮峰值 <300mA,餘裕充足
合計~1,281 元Naberam 一台 5,900 元、Awair 7,500 元

🔌 接線圖

ESP32-C5 Wi-Fi 6 雙頻 · 雙 I2C USB-C(5V 供電+燒錄) GPIO5 SDA0 GPIO4 SCL0 GPIO7 SDA1 GPIO6 SCL1 GPIO8 WS2812 GPIO9 蜂鳴器 感測器艙(I2C0 匯流排) CM1106 CO2 @0x50 SGP41 TVOC @0x59 BME688 THP @0x76 與主板艙隔板分離(防熱) CO2 812 ppm 24.6C 61% TVOC 42 OLED(I2C1 獨立) WS2812 燈環(經 330Ω) 蜂鳴器(經 1kΩ) 雙 I2C 分頁:感測器(綠)與 OLED(藍)互不卡頓;避開 strapping GPIO2/26/28
▲ 三顆感測器共用 I2C0(地址 0x50/0x59/0x76 不衝突),OLED 獨佔 I2C1——這是 C5 雙 I2C 控制器的正確用法。

詳細接線

ESP32-C5 腳位連接說明
GPIO5 / GPIO4I2C0 SDA / SCLCM1106 + SGP41 + BME688 共用;模組自带上拉
GPIO7 / GPIO6I2C1 SDA / SCLOLED 獨佔(0x3C):重畫不卡感測讀取
GPIO8WS2812B DIN串 330Ω;燈環 VCC/GND 直接分 5V,不經過板子
GPIO9蜂鳴器 +(經 1kΩ)− 接 GND
5V / GNDUSB 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 列印零件

零件清單

零件數量材質建議顏色列印時間
底盒(主板艙)1PETG白~1.5 hr
感測器分層艙 + 隔板1PETG白~1 hr
感測器艙蓋(進氣柵)1PLA對比色~40 min
OLED 窗框1PETG白~15 min
呼吸燈導光環1透明 PETG透明~20 min

列印設定

設計要點

  1. 感測器/主板物理分層:上層三顆感測器、下層 C5 主板,中間 PETG 隔板——把「熱」與「光學測量」隔開
  2. 進氣柵朝下:感測器艙開孔全部朝下,灰塵沉降不進柵格;CM1106 開孔對齊是感測準度的前提
  3. 導光環 8 段:WS2812 每顆獨立控制,呼吸波沿環跑,「北歐之息」名符其實
  4. OLED 微傾角 8°:桌面平視剛好,且面板窗與螢幕間留 1mm 防壓暈
  5. 底部走線槽 + 壁掛孔:桌面/壁掛雙用,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 組裝說明書
  1. 列印零件:主體 PETG、艙蓋對比色 PLA、導光環透明 PETG
  2. 感測器艙:三顆感測器卡入,CM1106 光腔開孔正對進氣柵中心
  3. 隔板 + 主板艙:隔板卡到底,C5 主板、OLED、燈環依序入位
  4. 燒錄 + Wi-Fi:板型 ESP32C5 Dev Module;確認序列埠印出「5G band selected」
  5. 校正:開窗通風 10 分鐘後執行「校 400ppm」指令;ABC 自動基線保持開啟
  6. 定位:離地 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
數值整晚偏高放在抽風口死角移到人呼吸的高度與距離——你測的是「人的空氣」

🎯 進階延伸

📦 檔案清單

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                    # 說明文件
← 上一篇:ESP32 發酵恆溫箱 📚 系列索引 下一篇:敬請期待 →