📋 專案概述

我的書桌在頂樓加蓋,通風只有一扇 60×80cm 的窗。八月焊一塊四層板的排線座,四十分鐘焊完,煙全往我臉上跑:松香助焊劑的煙霧是 occupational asthma(職業性氣喘)的明榜名單會員,焊完那晚喉嚨癢到凌晨三點,第二天眼睛乾澀像進了沙子。老婆路過門口只說了一句:「你在焊什麼?整個走廊都是。」

我買過一台市售桌抽(NT$1,480,某牌「靜音」款)。三個問題:一、它不會思考——開機就是全速,八十分鐘的哀嚎風切聲,耳機都壓不住;二、濾心是計壽的——說明書寫「三個月更換」,但我實際只用了十分之一的時間在焊,等於替沒幹活的濾心買單(一片 NT$450);三、它在我最需要它的三秒前還沒啟動——烙鐵剛碰助焊劑那一下煙最濃,等你聽風扇聲音按下開關,最髒的已經在肺裡了。

這台的邏輯反過來:用 PM2.5 雷射感測器直接看「煙」,煙一出現 0.5 秒內風扇已拉轉;煙散了就降風,安靜到可以聽 podcast。全部材料 NT$795(感測器佔一半):

帳要算:市售「智能」款要 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-FiWi-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 SuperMini1~40 元需 USB CDC 可開序列埠者
PM2.5 感測器Plantower PMS5003(或 PMS5003T)1~420 元雷射散射式,本機灵魂;買 3pin UART 版,勿買 I2C 改版
風扇120mm 5V USB 軸流風扇(含框)1~90 元0.15~0.25A;必須是能 PWM 調速的兩線風扇,三線測速版腳位不同
MOSFETS8055 N-MOS 模組(帶閘極電阻)1~12 元5V 邏輯準位;直接推 0.3A 線圈沒壓力
顯示0.96" SSD1306 OLED I2C1~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 寫明的。

🔌 接線圖

ESP32-C3 SuperMini GPIO20 TX1 GPIO21 RX1 GPIO8 SDA GPIO9 SCL GPIO6 PWM PMS5003 RX← TX→ VCC 5V GND SSD1306 SDA SCL 120mm 風扇(5V) 紅→5V、黑→S8055 D S8055 模組 G←GPIO6 · S←GND ⚠ PMS5003 VCC 必接 5V(USB 直供),勿接板子 3V3 WS2812 ×4 → GPIO7 按鍵 → GPIO2↔GND
▲ 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 / GPIO9OLED SDA / SCLC3 的 Wire 預設腳,模組自帶上拉
GPIO6S8055 模組 G 端LEDC 5kHz;模組已含 10k 閘極下拉,免另加
GPIO7WS2812B DINC3 的 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 列印零件

零件清單

零件數量材質建議顏色列印時間
導流罩本體(含風扇孔)1PETG灰~3.5 hr
濾心抽屜1PETG深綠~1.4 hr
感測器艙蓋(帶防塵綿槽)1PETG灰~35 min
進風錐(可疊加延長)2PETG半透明~50 min
橡膠腳墊4TPE黑~10 min

設計要點

  1. PMS5003 獨立艙+只測「進風前」:感測器裝在風扇上游、與濾心下游氣路完全隔離。若裝在出風口,它永遠讀到「乾淨的風」,整套自動控制就成了自我否證的閉環——這是 DIY 抽煙機最常見的邏輯死穴
  2. 感測器進氣防塵綿:進氣口塞一片 2mm 化繊綿(水族過濾棉剪的),攔住焊錫球與大顆粒,雷射腔體壽命差三倍;綿槽設計成可用指甲摳出更換
  3. 風扇四角矽膠減震:孔位直徑 +1mm,風扇螺絲穿矽膠墊圈,把 120mm 扇葉的低頻震動與桌面隔開——安靜的一半來自風,另一半來自不共鳴
  4. 濾心艙負載計數器開窗:抽屜側留 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 組裝說明書
  1. 列印件檢查:風扇孔(116mm 實測)、濾心抽屜滑順度、感測器艙防塵綿槽
  2. 風扇減震安裝:四角矽膠墊圈+螺絲鎖導流罩,鎖完手指撥扇葉確認不碰殼
  3. S8055 串風扇黑線:紅線直 5V 匯流排、黑線進模組 D,模組 S 接 GND、G 接 GPIO6
  4. PMS5003 電源閘:AO3400 模組串在感測器 GND 與系統 GND 之間,閘極接 GPIO10;感測器裝上游艙、進氣口塞綿
  5. OLED 與燈帶:OLED 嵌面板(雙面膠+扎帶)、燈帶貼進風口內側,DIN 朝主板
  6. 上電暖機與煙測:開序列埠看 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 腳特性,不影響使用

🎯 進階延伸

📁 專案檔案結構

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° 導流罩吃掉了。「自動化」的價值不在炫技,而在它讓你不用記得做那件你永遠會忘記的事。下一期換戰場:陽台上那台會看日落自己開燈、還會幫你數今天收了幾隻蚊子的光誘補蚊燈。

← 上一篇:ESP32-S2 手勢簡報筆 📚 系列索引 下一篇:ESP32-C2 光誘捕蚊燈 →