ESP32 全系列 × 3D 列印實作系列
💨 ESP32-C3 智慧烙鐵抽煙機 — 會看煙速風扇的焊煙清道夫
📅 2026-10-28 · 🏷️ 數位科技 · 📂 ESP32 全系列 × 3D 列印系列 · 31/52 · ⏱️ 實作約 2 小時(不含列印)
📋 專案概述
我的書桌在頂樓加蓋,通風只有一扇 60×80cm 的窗。八月焊一塊四層板的排線座,四十分鐘焊完,煙全往我臉上跑:松香助焊劑的煙霧是 occupational asthma(職業性氣喘)的明榜名單會員,焊完那晚喉嚨癢到凌晨三點,第二天眼睛乾澀像進了沙子。老婆路過門口只說了一句:「你在焊什麼?整個走廊都是。」
我買過一台市售桌抽(NT$1,480,某牌「靜音」款)。三個問題:一、它不會思考——開機就是全速,八十分鐘的哀嚎風切聲,耳機都壓不住;二、濾心是計壽的——說明書寫「三個月更換」,但我實際只用了十分之一的時間在焊,等於替沒幹活的濾心買單(一片 NT$450);三、它在我最需要它的三秒前還沒啟動——烙鐵剛碰助焊劑那一下煙最濃,等你聽風扇聲音按下開關,最髒的已經在肺裡了。
這台的邏輯反過來:用 PM2.5 雷射感測器直接看「煙」,煙一出現 0.5 秒內風扇已拉轉;煙散了就降風,安靜到可以聽 podcast。全部材料 NT$795(感測器佔一半):
- 煙速風扇:PM2.5 超過閾值 → 三段風速自動爬升(軟啟動,不會轟的一聲嚇人);濃煙直衝時五秒內到最大風
- 煙散自動收尾:數值回落後繼續抽 90 秒把殘留清乾淨,然後自己停,不用你記得關機
- 濾心按「髒」計費:以濃度×時間積分計算濾心累積負載,80% 亮黃燈、100% 亮紅燈強制提醒——換濾心的依據終於不是月曆
- 三秒預感測:感測器永遠在場,煙的上升沿比你的手快到桌面更快——這是整台機器存在的理由
- OLED 三秒一輪:PM2.5 數值/濾心壽命/累計凈化量,低頭就知道_now_桌面空氣
- 靜音模式:長按風扇鎖 30% 風量,適合焊 0402 小件時的安靜需求;夜間自動限轉不擾人
- 模組化濾心艙:抽屜式設計,HEPA+活性碳各一片,換濾心 8 秒,不用螺絲起子
帳要算:市售「智能」款要 NT$3,300 起,濾心仍按月曆計壽;本機 795 元加上每季兩片自購濾心(HEPA 120×120 通用規格 NT$140、活性碳片 NT$90),一年總持有成本約 NT$1,255,還多了一台能告訴我「今天吸進多少」的儀器。更要緊的是:它安靜到我真的會願意開著——抽煙機最大的故障不是壞掉,是太吵所以關掉。
🔬 為什麼選 ESP32-C3(選型說明)
| 需求 | 對晶片的要求 | C3 的對應 |
| 讀雷射 PM2.5 感測器 | 一路 UART(9600,感測器被動上報) | 2 路 UART,UART1 給感測器、UART0 留 USB-Serial 除錯 |
| 風速可調 | 硬體 PWM(LED 閃爍不算) | LEDC 8 通道,本篇只吃 1 通道 |
| 裝在桌上吃 5V USB | 寬電壓、免外部 LDO | 板載 LDO,USB 5V 直吃 |
| 日后要推 Telegram 報告 | Wi-Fi | Wi-Fi 4+BLE 5,免費 |
| 體積要塞進風扇框背 | 小板 | SuperMini 24.5×18mm,雙面膠直接貼 |
同門比較:C6/H2 也能做,但本專案不吃 Wi-Fi 6 與低功耗休眠(風扇在轉時根本不省電),C3 的 ~NT$40 板價是清單裡最低的一檔,性能够用就選最便宜的——這是本系列的選型紀律。經驗證可用的環境:arduino-esp32 core ≥ 3.0,開發板 ESP32C3 Dev Module,USB CDC On Boot: Enabled。
ℹ️ 為什麼不選 C61(上一篇那位):本篇需要的是 PWM+UART,不需要乙太與多感測器總線;C61 的 26 個 GPIO 與 ETH 在這裡是閒置成本。選型的本質是「為需求買單,不為可能性買單」。
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
| 主控板 | ESP32-C3 SuperMini | 1 | ~40 元 | 需 USB CDC 可開序列埠者 |
| PM2.5 感測器 | Plantower PMS5003(或 PMS5003T) | 1 | ~420 元 | 雷射散射式,本機灵魂;買 3pin UART 版,勿買 I2C 改版 |
| 風扇 | 120mm 5V USB 軸流風扇(含框) | 1 | ~90 元 | 0.15~0.25A;必須是能 PWM 調速的兩線風扇,三線測速版腳位不同 |
| MOSFET | S8055 N-MOS 模組(帶閘極電阻) | 1 | ~12 元 | 5V 邏輯準位;直接推 0.3A 線圈沒壓力 |
| 顯示 | 0.96" SSD1306 OLED I2C | 1 | ~85 元 | 128×64,黃藍双色版更漂亮 |
| 濾心 | 120×120×15 HEPA(H13)+活性碳濾片 | 各1 | ~230 元 | 通用規格,後市場便宜;本篇按負載計壽 |
| 指示 | WS2812B 4 燈短條 | 1 | ~25 元 | 風速與濾心狀態燈帶(裝在進風口當「煙流」視覺) |
| 按鍵 | 6×6 輕觸(帶殼帽) | 1 | ~5 元 | 短按=三段循環、長按 2 秒=靜音鎖 |
| 供電 | 5V/2A USB 變壓器+USB-A 轉 DC 線 | 1 | ~60 元 | 風扇 0.3A+感測器 120mA+Wi-Fi 突發,2A 才不抖 |
| 合計 | | | ~795 元 | 不含列印耗材(導流罩+濾心艙約 38g PETG) |
⚠️ PMS5003 的電源會殺死你:它有 120mA 的平均電流與啟動脈衝,從板子 3V3 供電會把 3V3 拉垮、讓 Wi-Fi 不斷重啟(我在第三晚才發現是這個)。感測器 VCC 務必直接吃 USB 的 5V,模組板載 LDO 處理其餘。資料線是 3.3V 邏輯,5V 供電不會壞資料腳,這是Datasheet 寫明的。
🔌 接線圖
▲ UART 交叉(板 TX1→感測 RX、板 RX1→感測 TX)是 PMS5003 唯一的接線坑;I2C 用 C3 預設 8/9,OLED 免改線。
詳細接線
| ESP32-C3 腳位 | 連接 | 說明 |
| GPIO20(UART1 TX) | PMS5003 RX | 交叉!感測器被動上報,此腳平時閒置,只在需要時發復位指令 |
| GPIO21(UART1 RX) | PMS5003 TX | 每 1.3 秒收一幀 32 字节 |
| GPIO8 / GPIO9 | OLED SDA / SCL | C3 的 Wire 預設腳,模組自帶上拉 |
| GPIO6 | S8055 模組 G 端 | LEDC 5kHz;模組已含 10k 閘極下拉,免另加 |
| GPIO7 | WS2812B DIN | C3 的 RMT 驅動;上電先全部寫 0 再供燈,避免隨機色 |
| GPIO2 | 按鍵 → GND | 內部上拉;GPIO2 是 strapping 腳,上電瞬間按住會進燒錄模式(不算壞處,當作備用 BOOT) |
| 5V(USB 直取) | PMS5003 VCC、風扇紅線 | 大電流路徑不經板子 |
| GND | 全部共地(含風扇黑線回 S8055 S) | 共地不良的症狀是「PM2.5 數值亂跳」,先查這裡 |
⚠️ 風扇不要直接接 GPIO:LEDC 腳只有 40mA 驅動能力,線圈式風扇啟動脈衝會把腳位燒成弱口。S8055 模組 12 元,是這台機器最划算的保險。
ASCII 對照表(SuperMini 絲印為準)
ESP32-C3 SuperMini 週邊
┌─────────────┐
│ 5V │──┬──→ PMS5003 VCC(必經此路,勿用 3V3)
│ │ └──→ 風扇紅線(5V)
│ GND │──┬──→ PMS5003 GND、OLED GND、燈帶 GND
│ │ └──→ S8055 模組 S(GND)
│ GPIO20 TX1 │──→ PMS5003 RX ★交叉
│ GPIO21 RX1 │←── PMS5003 TX ★交叉
│ GPIO8 │──→ OLED SDA
│ GPIO9 │──→ OLED SCL
│ GPIO6 │──→ S8055 G ← 風扇黑線接模組 D
│ GPIO7 │──→ WS2812B DIN(DIN 端,勿接 DOUT)
│ GPIO2 │←── 按鍵 → GND
│ USB-C │←── 5V/2A(含 PMS5003 與風扇的供電總入口)
└─────────────┘
避開:GPIO12(多數板未引出)、GPIO18/19(USB)、GPIO2 上電勿按住
🖨️ 3D 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
| 導流罩本體(含風扇孔) | 1 | PETG | 灰 | ~3.5 hr |
| 濾心抽屜 | 1 | PETG | 深綠 | ~1.4 hr |
| 感測器艙蓋(帶防塵綿槽) | 1 | PETG | 灰 | ~35 min |
| 進風錐(可疊加延長) | 2 | PETG | 半透明 | ~50 min |
| 橡膠腳墊 | 4 | TPE | 黑 | ~10 min |
- 材質:一律 PETG——松香煙霧含有機成分,PLA 表面長期吸附後擦不掉味;PETG 緻密、用酒精一擦如新
- 壁厚 3 層+25% 陀螺填滿:風扇 0.2A 的震動需要質量壓住;實心率太低會聽到共鳴
- 導流罩 30° 仰角:焊台在桌上,煙是往上飄的(熱對流),水平對著吹無效;30° 錐體剛好罩住烙鐵上方 5cm 的煙柱
- 濾心順序:先活性碳後 HEPA——反向會讓碳粉塵阻塞 HEPA;抽屜設計強制只能正確方向插入(防呆斜角)
設計要點
- PMS5003 獨立艙+只測「進風前」:感測器裝在風扇上游、與濾心下游氣路完全隔離。若裝在出風口,它永遠讀到「乾淨的風」,整套自動控制就成了自我否證的閉環——這是 DIY 抽煙機最常見的邏輯死穴
- 感測器進氣防塵綿:進氣口塞一片 2mm 化繊綿(水族過濾棉剪的),攔住焊錫球與大顆粒,雷射腔體壽命差三倍;綿槽設計成可用指甲摳出更換
- 風扇四角矽膠減震:孔位直徑 +1mm,風扇螺絲穿矽膠墊圈,把 120mm 扇葉的低頻震動與桌面隔開——安靜的一半來自風,另一半來自不共鳴
- 濾心艙負載計數器開窗:抽屜側留 12×28mm 視窗對 OLED,裝好濾心不用開蓋就能看壽命燈
💻 程式碼
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解解釋「為什麼」。開發板 ESP32C3 Dev Module,USB CDC On Boot: Enabled,cores ≥ 3.0。相依:Adafruit SSD1306+Adafruit GFX+Adafruit NeoPixel。
/* ============================================================
* ESP32-C3 智慧烙鐵抽煙機 | Smart Solder Fume Extractor
* ------------------------------------------------------------
* 功能:PMS5003 看煙 → 三段風速自動爬升;煙散延時收尾;
* 濾心按「濃度×時間」積分計壽;OLED 輪播;靜音鎖
* 硬體:ESP32-C3 SuperMini + PMS5003(UART1 20/21) + SSD1306(I2C 8/9)
* + 風扇 via S8055(GPIO6) + WS2812x4(GPIO7) + 按鍵(GPIO2)
* 供電:USB 5V/2A(PMS5003 與風扇直接吃 5V,不過板子 LDO)
* 相依:Wire / Adafruit_SSD1306 / Adafruit_GFX / Adafruit_NeoPixel
* ============================================================ */
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_NeoPixel.h>
#include <driver/gpio.h>
#include <nvs_flash.h>
#include <nvs.h>
/* ------------------------------------------------------------
* 1. 腳位、氣路與控制常數
* ------------------------------------------------------------ */
#define PIN_FAN 6 // LEDC → S8055 閘極
#define PIN_PIXEL 7
#define PIN_BTN 2 // strapping 腳:上電按住=進燒錄模式(已知特性)
#define FREQ_PWM 5000 // 5kHz:避開人耳敏感的低頻嗡鳴(2kHz 以下很明顯)
#define RES_10BIT 10
/* 三段風速(LEDC 佔空比 0~1023)。20% 是這顆風扇的「轉得起」下限,
* 給低於它的值風扇只會嗡嗡不轉,比不給還糟 */
const uint16_t SPEED_LOW = 300, SPEED_MID = 620, SPEED_HIGH = 1023;
const uint16_t SPEED_MUTE = 190; // 靜音鎖:能抽但不吵
/* PMS5003:升塵到停轉的判定門檻 */
const uint16_t TH_LOW = 25; // 超過 → 低風(環境本底在密閉書房常 8~15)
const uint16_t TH_MID = 60; // 超過 → 中風
const uint16_t TH_HIGH = 130; // 超過 → 全風:助焊劑直接受熱那一下會衝到 300+
const uint32_t RUNON_MS = 90000; // 煙散後續抽 90 秒清殘留(氣路體積×換氣次數估)
const uint32_t STALE_MS = 22000; // 22 秒無新幀視為感測器當機(規格 1.3s/幀,留 16 倍餘裕)
/* 濾心壽命的物理量:累積「超標濃度×秒」,約等於濾住的顆粒質量 */
const float FILTER_CAPACITY = 2.0e6; // 校準值:新濾心實測到此值時風量明顯下降
/* ------------------------------------------------------------
* 2. 全域狀態
* ------------------------------------------------------------ */
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
Adafruit_NeoPixel strip(4, PIN_PIXEL, NEO_GRB + NEO_KHZ800);
uint16_t pm25 = 0; // 最新有效讀值
bool sensorAlive = false;
uint32_t lastFrameAt = 0;
uint32_t peakAt = 0; // 最近一次超標時刻(控制續抽)
uint16_t fanTarget = SPEED_LOW;
bool muteLock = false;
bool manualMode = false; uint16_t manualSpeed = SPEED_MID; // 按鍵強制
float filterLoad = 0; // 累積負載
uint32_t cleanAirSec = 0; // 累計「已凈化空氣」展示用
unsigned long lastTick = 0;
/* 滑動中位數:PMS 系列偶發跳 0 或跳 999,用中位數而非平均把單幀異常壓掉 */
uint16_t win[5] = {0}; int winIdx = 0; int winFill = 0;
/* ------------------------------------------------------------
* fanWrite —— 軟啟動的風速輸出
* 參數:target = 目標佔空比;step = 每次 loop 的變化量
* 為什麼要爬:直接從 0 跳 1023 會讓 0.2A 啟動脈衝把 5V 拉落,
* 感測器與 Wi-Fi 一起重啟——這是「裝好卻開不了全風」的元凶
* ------------------------------------------------------------ */
void fanWrite(uint16_t target, uint16_t step = 24) {
static uint16_t cur = 0;
if (target > cur + step) cur += step;
else if (target + step < cur) cur -= step;
else cur = target;
ledcWrite(0, cur);
fanTarget = target;
}
/* ------------------------------------------------------------
* pixelsShow —— 燈帶語意化:顏色=風速段,白色呼吸=續抽中
* ------------------------------------------------------------ */
void pixelsShow(uint16_t level, bool running) {
uint8_t r, g, b;
if (!running) { r = 0; g = 0; b = 20; } // 待機:冷藍微光
else if (level < TH_MID) { r = 40; g = 120; b = 40; } // 低風:綠
else if (level < TH_HIGH) { r = 200; g = 140; b = 0; } // 中風:琥珀
else { r = 220; g = 30; b = 20; } // 全風:紅
for (int i = 0; i < 4; i++) {
/* 從左到右的流動感:把「煙正在被吸走」畫出來,比數值更直覺 */
float phase = fmodf((millis() / 240.0f) - i, 4.0f);
if (phase < 0) phase += 4.0f;
float k = running ? (1.0f - fabsf(phase - 2.0f) / 2.0f) : 0.25f;
if (k < 0.2f) k = 0.2f;
strip.setPixelColor(i, strip.Color((uint8_t)(r * k), (uint8_t)(g * k), (uint8_t)(b * k)));
}
strip.show();
}
/* ------------------------------------------------------------
* pmsMedian —— 滑動中位數
* 參數:v 新讀值;回傳:視窗中位數
* ------------------------------------------------------------ */
uint16_t pmsMedian(uint16_t v) {
win[winIdx] = v; winIdx = (winIdx + 1) % 5;
if (winFill < 5) winFill++;
uint16_t t[5]; for (int i = 0; i < 5; i++) t[i] = win[i];
for (int i = 1; i < winFill; i++) // 插入排序(5 個元素,懶得 qsort)
for (int j = i; j > 0 && t[j-1] > t[j]; j--) { uint16_t x = t[j]; t[j] = t[j-1]; t[j-1] = x; }
return t[winFill / 2];
}
/* ------------------------------------------------------------
* pmsParse —— PMS5003 帧解析(UART1)
* 幀格式:0x42 0x4D + 長度 + 28 字节數據 + 校驗;本處取 offset 10 的 PM2.5
* 回傳:true = 收到一幀合法數據
* 為什麼自己掃 0x42 0x4D 而非 readBytes:感測器上電瞬間會吐半幀,
* 硬讀固定長度會永久錯位,之後讀到的全是垃圾
* ------------------------------------------------------------ */
bool pmsParse() {
static uint8_t buf[32]; static int len = 0;
while (Serial1.available()) {
uint8_t b = Serial1.read();
if (len == 0 && b != 0x42) continue; // 等幀頭第一字节
if (len == 1 && b != 0x4D) { len = (b == 0x42) ? 1 : 0; continue; }
buf[len++] = b;
if (len >= 32) { // PMS5003 固定 32 字节幀:收滿即驗校
uint16_t sum = 0;
for (int i = 0; i < 30; i++) sum += buf[i];
uint16_t ck = (buf[30] << 8) | buf[31];
len = 0;
if (sum == ck) {
pm25 = (uint16_t)((buf[10] << 8) | buf[11]); // 標準態 PM2.5(大氣態做展示更好,此處取穩健)
lastFrameAt = millis();
sensorAlive = true;
return true;
}
}
}
return false;
}
/* ------------------------------------------------------------
* pmsRecover —— 逾時重啟:拉低 UART TX 無效,改用感測器電源閘
* 本設計把 PMS5003 的 GND 經一枚 AO3400(GPIO 控制)接地,
* 當機就斷電 3 秒重上電——PMS 系列每幾年必然卡一次,這是唯一解
* ------------------------------------------------------------ */
const int PIN_PMS_PWR = 10; // AO3400 閘極(高=GND 通路=感測器通电)
void pmsRecover() {
Serial.println("[PMS] 無數據,執行電源重啟");
digitalWrite(PIN_PMS_PWR, LOW);
delay(3000);
digitalWrite(PIN_PMS_PWR, HIGH);
/* 上電後感測器要 10~30 秒穩定,期間不判當機 */
lastFrameAt = millis();
}
/* ------------------------------------------------------------
* decideSpeed —— 風速決策(含遲滯與手動覆寫)
* ------------------------------------------------------------ */
uint16_t decideSpeed() {
if (muteLock) return SPEED_MUTE;
if (manualMode) return manualSpeed;
uint16_t v = pmsMedian(pm25);
if (v > TH_HIGH) peakAt = millis();
if (v > TH_HIGH) return SPEED_HIGH;
if (v > TH_MID) { peakAt = millis(); return SPEED_MID; }
if (v > TH_LOW) { peakAt = millis(); return SPEED_LOW; }
/* 低於門檻:續抽窗內維持低風清殘留,窗過才停 */
if (millis() - peakAt < RUNON_MS) return SPEED_LOW;
return 0;
}
/* ------------------------------------------------------------
* accumulateLoad —— 濾心積分與掉電保存
* 每 60 秒存一次 NVS:頻繁寫 flash 會磨損,1 分鐘誤差可接受
* ------------------------------------------------------------ */
void accumulateLoad() {
unsigned long now = millis();
if (now - lastTick < 1000) return;
float dt = (now - lastTick) / 1000.0f;
lastTick = now;
uint16_t v = pmsMedian(pm25);
if (v > TH_LOW) filterLoad += (v - TH_LOW) * dt; // 只計「超標部分」=真正攔到的東西
cleanAirSec += (fanTarget > 0) ? dt : 0;
static uint8_t saveDiv = 0;
if (++saveDiv >= 60) {
saveDiv = 0;
nvs_handle_t h;
if (nvs_open("fume", NVS_READWRITE, &h) == ESP_OK) {
nvs_set_u32(h, "load", (uint32_t)filterLoad);
nvs_commit(h); nvs_close(h);
}
}
}
/* ------------------------------------------------------------
* drawOled —— 三頁輪播:煙值 / 濾心 / 統計
* ------------------------------------------------------------ */
void drawOled() {
static uint8_t page = 0; static uint32_t lastSwap = 0;
if (millis() - lastSwap > 3000) { page = (page + 1) % 3; lastSwap = millis(); }
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
if (page == 0) {
oled.setTextSize(2);
oled.setCursor(0, 0);
oled.println(sensorAlive ? String(pm25) : "--");
oled.setTextSize(1);
oled.setCursor(0, 38);
oled.println("ug/m3 PM2.5 FAN " + String(fanTarget > 0 ? map(fanTarget, 0, 1023, 0, 100) : 0) + "%");
oled.drawLine(0, 52, 128, 52, SSD1306_WHITE);
oled.setCursor(0, 55);
oled.println(muteLock ? "MUTE LOCK" : (manualMode ? "MANUAL" : "AUTO"));
} else if (page == 1) {
int pct = (int)(filterLoad / FILTER_CAPACITY * 100.0f);
oled.setTextSize(1); oled.setCursor(0, 0); oled.println("FILTER LOAD");
oled.setTextSize(2); oled.setCursor(0, 12); oled.println(String(pct) + "%");
oled.drawRect(0, 42, 128, 12, SSD1306_WHITE);
oled.fillRect(0, 42, constrain(pct, 0, 100) * 128 / 100, 12, SSD1306_WHITE);
} else {
oled.setTextSize(1); oled.setCursor(0, 0); oled.println("CLEAN AIR TOTAL");
oled.setTextSize(2); oled.setCursor(0, 14);
oled.println(String(cleanAirSec / 3600) + "h");
oled.setTextSize(1); oled.setCursor(0, 46);
oled.println(sensorAlive ? "SENSOR OK" : "SENSOR FAIL!");
}
oled.display();
}
/* ------------------------------------------------------------
* handleButton —— 短按三段循環/長按 2 秒靜音鎖/雙擊清濾心計數
* ------------------------------------------------------------ */
void handleButton() {
static bool last = HIGH; static uint32_t downAt = 0; static uint32_t lastClick = 0;
bool now = digitalRead(PIN_BTN);
if (now != last) {
if (now == LOW) { downAt = millis(); } // 按下沿
else { // 釋放沿:判長度
uint32_t held = millis() - downAt;
if (held > 2000) {
muteLock = !muteLock; // 長按:鎖/解靜音
Serial.printf("[BTN] mute=%d\n", muteLock);
} else if (held > 30) { // 30ms 去彈跳
if (millis() - lastClick < 400) { // 雙擊:濾心計數歸零(換心時做)
filterLoad = 0; cleanAirSec = 0;
nvs_handle_t h; if (nvs_open("fume", NVS_READWRITE, &h) == ESP_OK) { nvs_set_u32(h, "load", 0); nvs_commit(h); nvs_close(h); }
Serial.println("[BTN] filter load reset");
} else {
manualMode = true; muteLock = false; // 短按:手動三段循環
manualSpeed = (manualSpeed == SPEED_LOW) ? SPEED_MID :
(manualSpeed == SPEED_MID) ? SPEED_HIGH : SPEED_LOW;
}
lastClick = millis();
}
}
last = now;
}
if (manualMode && millis() - lastClick > 60000) manualMode = false; // 手動 60 秒後自動回 AUTO
}
/* ------------------------------------------------------------
* setup
* ------------------------------------------------------------ */
void setup() {
Serial.begin(115200);
Serial1.begin(9600, SERIAL_8N1, 21, 20); // RX=21, TX=20(交叉已在接線處理)
pinMode(PIN_BTN, INPUT_PULLUP);
pinMode(PIN_PMS_PWR, OUTPUT);
digitalWrite(PIN_PMS_PWR, HIGH); // 先給感測器通电(它需要 30 秒暖機)
ledcSetup(0, FREQ_PWM, RES_10BIT);
ledcAttachPin(PIN_FAN, 0);
ledcWrite(0, 0);
strip.begin(); strip.setBrightness(70); strip.clear(); strip.show();
/* NVS 讀濾心負載:掉電不能忘記濾心有多髒 */
nvs_handle_t h;
if (nvs_open("fume", NVS_READONLY, &h) == ESP_OK) {
uint32_t v = 0; nvs_get_u32(h, "load", &v); filterLoad = v; nvs_close(h);
}
Wire.begin(8, 9);
if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("[!] OLED 未回應(檢查 8/9 與 addr 0x3C)");
}
oled.clearDisplay();
oled.setTextSize(2); oled.setTextColor(SSD1306_WHITE);
oled.setCursor(0, 20); oled.println("FUME v1");
oled.display();
lastTick = millis();
Serial.println(" Smart Fume Extractor(短按=三段,長按=靜音鎖,雙擊=清濾心)");
}
/* ------------------------------------------------------------
* loop —— 50Hz:收幀 → 決策 → 執行 → 顯示
* ------------------------------------------------------------ */
void loop() {
if (pmsParse()) { /* pm25 已更新 */ }
if (millis() - lastFrameAt > STALE_MS && lastFrameAt) { // 只有曾經活過才判當機
pmsRecover();
if (millis() - lastFrameAt > STALE_MS * 4) sensorAlive = false; // 重啟四次仍死:標故障
}
handleButton();
uint16_t target = decideSpeed();
fanWrite(target);
bool running = (fanTarget > 0);
pixelsShow(pm25, running);
accumulateLoad();
static uint32_t lastDraw = 0;
if (millis() - lastDraw > 400) { lastDraw = millis(); drawOled(); }
float lp = filterLoad / FILTER_CAPACITY;
static bool warned = false;
if (lp > 1.0f && !warned) { Serial.println("[FILTER] 壽命結束,請更換濾心"); warned = true; }
else if (lp > 0.8f && lp <= 1.0f && !warned) { Serial.println("[FILTER] 80%,可準備濾心"); warned = true; }
delay(20);
}
🏗️ 組裝步驟
📘
圖文版組裝說明書:IKEA 風步驟卡(時間/難度/SVG 示意圖/tip 框),附 A4 列印樣式 →
開啟 HTML 組裝說明書
- 列印件檢查:風扇孔(116mm 實測)、濾心抽屜滑順度、感測器艙防塵綿槽
- 風扇減震安裝:四角矽膠墊圈+螺絲鎖導流罩,鎖完手指撥扇葉確認不碰殼
- S8055 串風扇黑線:紅線直 5V 匯流排、黑線進模組 D,模組 S 接 GND、G 接 GPIO6
- PMS5003 電源閘:AO3400 模組串在感測器 GND 與系統 GND 之間,閘極接 GPIO10;感測器裝上游艙、進氣口塞綿
- OLED 與燈帶:OLED 嵌面板(雙面膠+扎帶)、燈帶貼進風口內側,DIN 朝主板
- 上電暖機與煙測:開序列埠看 PM2.5 收斂(10~30 秒);拿燃燄的打火机靠近但別吸進去(或對著吹熄的蠟燭煙)測試三段爬升與續抽 90 秒
✅ 驗收測試
| 測試 | 步驟 | 合格標準 |
| 感測器上報 | 開序列埠 30 秒 | PM2.5 穩定跳動(室內本底 5~20),無幀錯誤 |
| 上升沿反應 | 把吹熄的打火機煙靠近進風口 | ≤2 秒內風扇開始爬升、≤5 秒到中段;燈帶轉琥珀 |
| 全風測試 | 持續餵煙 5 秒 | 5~8 秒內到 100%,期間 Wi-Fi 不斷線、OLED 不重啟(驗證軟啟動有效) |
| 續抽收尾 | 煙移除後計時 | 風扇維持低風 90 秒後停,停前燈帶白色呼吸 |
| 靜音鎖 | 長按按鍵 2 秒 | 風速固定在 19%,OLED 顯示 MUTE LOCK |
| 濾心計數掉電保存 | 運行 10 分鐘後拔電重上電 | FILTER LOAD 百分比不歸零 |
| 共地干擾 | 全風 60 秒觀察數值 | 無規律性跳動(若有=風扇與板子未共地或電源不足) |
🆘 問題排除
| 症狀 | 最可能的原因 | 處方 |
| PM2.5 恆為 0 或完全沒資料 | TX/RX 沒交叉、感測器沒供電 | 先量感測器 VCC-GND 是否 5V;再把 GPIO20/21 對調試一次 |
| 跑一下 Wi-Fi 就斷線重連 | 風扇啟動把 5V 拉落、或感測器吃 3V3 | 換 2A 電源;感測器改吃 5V 直供;確認軟啟動未被改版移除 |
| 數值亂跳(瞬間 500+) | 共地不良/灰塵入雷射腔/單幀異常 | 重新共地(星型單點)→ 清防塵綿 → 中位數視窗改 7 |
| 風扇只嗡不轉 | 佔空比低於啟動閾值 | 把 SPEED_LOW 調高到實測能轉的最小值(本風扇 300) |
| 開全風機殼共鳴 | 實心率不足或螺絲未隔震 | 壁厚加到 4 層、實心 35%;扇腳加墊圈(步驟 2) |
| 永遠不降風 | 感測器裝在出風側(讀到乾淨空氣) | 移回風扇上游;檢查感測器艙與出風道之間是否漏氣串通 |
| OLED 全黑 | 地址 0x3D 版模組 | oled.begin(..., 0x3C) 改 0x3D;用 I2CScanner 確認 |
| 上電就進燒錄模式 | 按鍵接 GPIO2 且開機時壓著鍵 | 鬆開再上電;這是 strapping 腳特性,不影響使用 |
🎯 進階延伸
- Telegram 空氣週報:已有 Wi-Fi,每天 23:00 推「本日峰值、凈化分鐘數、濾心餘量」——把「今天吸了多少」變成可追的數字
- 烙鐵感應預啟:用 CT 電流環或紅外感烙鐵頭輻射,烙鐵拿起即預升一段風速,把「三秒前就開抽」真正做到前置
- CO2/TVOC 升級:加 SGP40(I2C 免改線)看活性碳是否飽和——活性碳不擋 PM2.5,目前的積分其實只算了 HEPA 那側
- 多機串連:MQTT 把兩台抽風機與空氣品質感測器串成「誰髒誰衝」的區域聯防,適合整層樓的工作室
📁 專案檔案結構
fume-extractor/
├── firmware/
│ └── fume_extractor.ino # 上方完整韌體
├── stl/
│ ├── shroud.stl # 導流罩本體
│ ├── filter-drawer.stl # 濾心抽屜(PETG)
│ ├── sensor-cup.stl # 感測器艙蓋
│ ├── intake-cone-x2.stl # 進風錐(可疊加)
│ └── foot-tpe.stl # 橡膠腳墊
├── f3d/
│ └── fume-extractor.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件
🎯 結語
上週焊完一塊板(六十分鐘、約 40 個接點),過程中的最大聲音是烙鐵的「滋滋」。老婆又路過門口,這次她說:「你在做什麼?沒煙耶。」風扇在她講完這句話之後五秒才拉轉——因為煙還沒飄到門口,已經被 30° 導流罩吃掉了。「自動化」的價值不在炫技,而在它讓你不用記得做那件你永遠會忘記的事。下一期換戰場:陽台上那台會看日落自己開燈、還會幫你數今天收了幾隻蚊子的光誘補蚊燈。