📋 專案概述

九月底的某一天,你的 DIY NAS 在凌晨傳了第三封「硬碟 SMART 異常前兆」簡報。你打開書房深處的系統櫃——四台硬碟、一顆 65W 的 U.2、一片集顯拆下來的準系統,全部關在一個連溫度計都沒有的木櫃子裡,裡面那顆 12cm 風扇從 2024 年裝機那天起就全速轉:白天你開會它嗚嗚叫,你把插排一拔世界清淨了,然後 NAS 在靜悄悄中燒到降頻。你後來裝了個百元溫控插座,問題更大了:它只認識「開」和「關」——44°C 全速、40°C 停轉,一天啟動八百次,風扇軸承半年報銷。而最陰險的是:櫃子進風口的防尘棉已經灰到發毛,風道只剩三成,溫控插座看到「溫度終於壓回 40 度以下」心满意足,代價是風扇終其一生都在滿轉吸灰。

市售商品:

本專案是一個釘在機櫃風扇位的風扇大腦,核心是一個很漂亮的物理 trick:濾網髒了,不需要壓力感測器——同一個 PWM 檔位下,風扇為了對抗越来越大的風阻,RPM 會掉。把「定 PWM 下的 RPM」當成差壓計用,濾網健康度就是免費的。

💰 成本效益:全機 ~NT$615。一顆 4TB 企業碟 NT$3,800,SMART 溫度每降 5°C 預估壽命翻倍(Arrhenius 法則:每 +10°C 化學反應速率翻倍,壽命減半)。把 NAS 全年靜音運轉+滤網提醒+過熱通報打包,工業款控制器連零頭都買不到。

為什麼選 ESP32-C6?

需求C6 的優勢
25kHz PWM 直驅LEDC 支援高頻 PWM 且 C6 週邊時鐘獨立於 CPU 頻率——省電模式降頻時風扇轉速不抖
機櫃裡 Wi-Fi 收得到Wi-Fi 6+OFDMA 在鋼板機櫃旁的高杂讯環境下協商效率比 Wi-Fi 4 好;日后要接 Thread 邊界路由器也是 C6
全年不關機C6 待機電流是全系列最低梯;這台 365×24 掛網,省的是隱形電費
雙核分工核 0 餵 tach 中斷+Wi-Fi 協議棧、核 1 跑控制迴圈與 OLED——I2C 慢總線不會卡住轉速量測

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C6 DevKitM-11~165 元Wi-Fi 6、LEDC 25kHz
溫濕度感測BME280 I2C 模組2~85 元/個進風+排風各一,地址 0x76/0x77
風扇Noctua NF-A12x25 PWM(12cm)1~0 元沿用機櫃既有 PWM 扇即可
螢幕SSD1306 0.96" I2C1~90 元與 BME 共總線(0x3C)
電源12V 2A adaptor+MP1584 模組1~90 元12V 供風扇、降壓 5V 供板
列印材料PETG ~80g—~25 元機內環境 50°C+,PLA 會軟
小件4.7kΩ 上拉、防振膠墊、PP 濾棉、熱縮管—~75 元tach 上拉必裝(OC 輸出)
合計~615 元工業款風扇控制器的 25%

🔌 接線圖

ESP32-C6 I2C: GPIO1/3 GPIO4 PWM→25kHz GPIO5 tach↑4.7k GPIO6 BTN→GND I2C 總線(3.3V) BME280-A 進風 0x76 BME280-B 排風 0x77 SSD1306 SDA/SCL←GPIO1/3 FAN 4 線 PWM 風扇 PWM 訊號線(黑→訊號腳,見下表)25kHz tach 經 4.7kΩ 上拉 3.3V(OC 輸出) 12V 2A 紅黑 → 風扇 12V/GND MP1584 → 板 5V 共地!風扇 GND 與板 GND 必須共匯流點
▲ 四線風扇的紅黑是 12V 電力直供、藍 tach 是開集極輸出(一定要上拉才量到)、黑才是 PWM 訊號腳——各家顏色不完全統一,燒錄前用萬用表確認。

詳細接線

ESP32-C6 腳位連接說明
GPIO1 / GPIO3I2C SDA / SCLBME280×2(0x76/0x77)+SSD1306(0x3C)共總線
GPIO4風扇 PWM 訊號腳LEDC 25kHz / 10bit,直接接,無需驅動
GPIO5風扇 tach(藍線)經 4.7kΩ 上拉 3.3V;2 脈衝/轉
GPIO6設定按鈕(對 GND)內部上拉;短按切模式、長按重學基準
5V / GNDMP1584 降壓輸出與風扇 12V 地共點匯流
ESP32-C6 DevKitM-1 四線 PWM 風扇(Noctua 例) GPIO4 (LEDC 25k) ─────────── 黑(PWM 訊號) GPIO5 ←4.7kΩ↑3V3 ─────────── 藍(tach 脈衝) 12V( adaptor ) ──┬────────── 紅(+12V) └─ MP1584 ── 板 5V GND ──┬────────── 風扇 GND(與 12V 黑線共點) └────────── 板 GND GPIO1/3 (I2C) ────┬─ BME280-A SDA/SCL(ADDR=GND → 0x76 進風) ├─ BME280-B SDA/SCL(ADDR=VCC → 0x77 排風) └─ SSD1306 SDA/SCL(0x3C) GPIO6 ──[按鈕]── GND
⚠️ BME280 不能直接貼著機殼或風扇馬達:感測晶片讀到殼溫/馬達廢熱會整條控制曲線偏移 3–5°C。一定要用本篇列印的導流罩(probe-snoot),讓氣流從感測區表面通過,並離風扇框架 ≥5cm。

🖨️ 3D 列印設計

零件材質/設定數量備註
fan-grille-120 風扇格柵PETG/0.2mm/2 壁/30%1蜂窝孔、四角彈性卡扣免螺絲
filter-frame 抽式濾網框PETG/0.24mm/2 壁/20%1魔術貼壓 PP 濾棉,髒了整片抽換
probe-snoot 感測導流罩PETG/0.2mm/2 壁/15%2勝負手:遮辐射熱、導氣流掠過感測面
duct-elbow-40 轉彎風管PETG/0.28mm/1.6 壁/0%+3 頂1把排風導到 BME280-B 取樣位
brain-case 控制器外殼PETG/0.2mm/2 壁/25%1磁吸背板、纜線出口防拉槽
oled-tilt 顯示座PETG/0.2mm/2 壁/30%115° 仰角,櫃門縫可窺

列印總重 ~80g。機櫃內部夏天可達 50°C+,全案禁用 PLA(玻璃轉移 60°C,夏天機內會蠕變);PETG 表層較軟,風扇卡扣建議改螺絲柱。

💻 Arduino 程式碼

Arduino IDE 2.x + espressif 3.x core(開發板選 ESP32C6 Dev Module);函式庫:Adafruit BMP280 Library(改用 BME280 Library)、Adafruit SSD1306、Adafruit GFX Library。

/* ============================================================
 *  機櫃散熱風扇控制器  rack-fan-brain.ino
 *  ------------------------------------------------------------
 *  功能:雙 BME280 控溫 PWM 風扇、濾網健康度(定 PWM RPM 衰退)、
 *        高溫看門狗、NAS 趨勢推送與離線補送
 *  硬體:ESP32-C6 DevKitM-1|I2C0: GPIO1/3(BME×2 + SSD1306)
 *        GPIO4→風扇 PWM(25kHz)|GPIO5←tach(4.7k 上拉)|GPIO6 按鈕
 *  供電:12V→風扇+MP1584 降壓 5V→板(共地匯流)
 *  相依:Adafruit_BME280、Adafruit_SSD1306、Adafruit_GFX(core 3.x)
 * ============================================================ */
#include <Wire.h>
#include <Adafruit_BME280.h>
#include <Adafruit_SSD1306.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <Preferences.h>

/* --- 0. 配置(全部常數集中這裡,改這裡就好) --- */
const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASS";
const char* NAS_URL   = "http://192.168.8.60:1880/rack-fan";
const char* NAS_TOKEN = "change-me";

#define PIN_PWM 4
#define PIN_TACH 5
#define PIN_BTN 6
#define PWM_HZ 25000          // Noctua PWM 規格 25kHz±10%,LEDC 直驅
#define PWM_RES 10            // 0..1023
#define TACH_PULSES_PER_REV 2 // 多數風扇 2 脈衝/轉

/* 控制參數:雙因子+斜率限速+滯留帶 */
const float  T_BASE   = 30.0;  // 進風基準溫度(°C)
const float  K_IN     = 8.0;   // 進風每超 1°C 加 8% PWM
const float  K_DT     = 10.0;  // 進排 ΔT 每 1°C 加 10%
const float  PWM_SLEW = 0.02;  // 每 100ms 最多變化 2% —— 防爆冲噪音
const float  BAND_LO = 35, BAND_HI = 45; // 共振滯留帶
const float  T_DOG   = 45.0;   // 看門狗溫度
const float  PWM_NIGHT_CAP = 45;        // 夜間封頂
const uint32_t LEDC_MAX = (1UL << PWM_RES) - 1;

/* --- 1. 全域狀態 --- */
Adafruit_BME280 bmeA, bmeB;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
Preferences prefs;
float inC = 25, exC = 25;          // 進/排風溫度
float pwmNow = 0, pwmTarget = 0;   // %浮點,實際輸出換算 LEDC
uint32_t lastEdgeUs = 0, rpm = 0;
volatile uint32_t pulseUs = 0;     // ISR 交給主迴圈的最近脈衝間隔
float health = 100;                // 濾網健康度 %
uint32_t filtBadSince = 0;
uint32_t learned = 0;              // 濾網基準學習秒數(換濾網歸零重學)
uint8_t mode = 0;                  // 0=自動 1=夜間靜音
/* 環形補送隊列:斷網期間的最新 32 筆 */
struct Snap { uint32_t t; float in, ex; float p; uint16_t rpm; };
Snap q[32]; uint8_t qHead = 0, qLen = 0;

/* --- 2. tach 中斷:算相鄰上升緣間隔 --- */
IRAM_ATTR void tachISR() {
  uint32_t now = micros();
  uint32_t dt = now - lastEdgeUs;          // uint32 繞回安全
  if (dt > 1000 && dt < 1000000) pulseUs = dt;  // 去抖:<1ms 或 >1s 都是雜訊
  lastEdgeUs = now;
}

/* --- 3. 讀雙 BME280 ---
 * 參數:無|回傳:bool 兩顆都成功
 * 為什麼分頻:BME280 強制模式每讀約 16ms,2s 一读省 I2C 也降自發熱 */
bool readSensors() {
  float a = bmeA.readTemperature(), b = bmeB.readTemperature();
  if (isnan(a) || isnan(b)) return false;
  inC = inC * 0.7f + a * 0.3f;   // EMA 濾波:硬碟風扇氣流本身有脈動
  exC = exC * 0.7f + b * 0.3f;
  return true;
}

/* --- 4. 目標 PWM:雙因子+模式修正+看門狗 ---
 * 參數:無|回傳:目標 PWM %
 * 「為什麼」:基準跟進風走=夏天外界 35°C 時不逞強;
 *            ΔT 懲罰=風道被堵時先加轉再報警,不是先燒硬碟 */
float computeTarget() {
  if (inC > T_DOG) return 100.0f;                 // 看門狗凌駕一切
  float p = 40 + (inC - T_BASE) * K_IN + (exC - inC - 4.0f) * K_DT;
  if (mode == 1) {                                 // 夜間:上限放寬、封顶
    p = 40 + (inC - T_BASE - 3.0f) * K_IN + (exC - inC - 5.5f) * K_DT;
    if (p > PWM_NIGHT_CAP) p = PWM_NIGHT_CAP;
  }
  return constrain(p, 20, 100);                    // 20% 以下多數風扇不啟動
}

/* --- 5. 斜率限速+共振滯留帶 ---
 * 參數:無|回傳:無(推 pwmNow 朝目標走)
 * 為什麼:PWM 35–45% 會撞上風扇葉頻共振嘔嘔聲,要過帶就一次過 */
void slewStep() {
  float want = computeTarget();
  float step = PWM_SLEW * 100.0f * 0.1f * 10;      // 每 100ms 2%
  if (want > pwmNow) pwmNow = min(want, pwmNow + step * 0.01f * 100);
  else               pwmNow = max(want, pwmNow - step * 0.01f * 100);
  if (pwmNow > BAND_LO && pwmNow < BAND_HI) {      // 滯留:退回進入方向
    float back = (want > pwmNow) ? BAND_LO - 0.5f : BAND_HI + 0.5f;
    if (fabsf(pwmNow - back) < 3.0f) pwmNow = back;
  }
  ledcWrite(PIN_PWM, (uint32_t)(pwmNow / 100.0f * LEDC_MAX));
}

/* --- 6. 濾網健康度:定 PWM 檔位的清潔基準比對 ---
 * 參數:無|回傳:無
 * 物理:濾網髒→風阻升→同 PWM 下 RPM 降。前 7 天學基準存 NVS。
 * 單位:以 5% 為一格共 20 格,存 int16 RPM */
void filterHealth(float dtS) {
  int bucket = (int)(pwmNow / 5.0f);               // 0..20
  if (bucket > 19) bucket = 19;
  char key[8]; snprintf(key, sizeof key, "b%02d", bucket);
  int16_t ref = prefs.getShort(key, 0);
  if (learned < 7UL * 24 * 3600) {                 // 學習期:記最高 RPM
    if (rpm > ref) prefs.putShort(key, (int16_t)rpm);
    learned += (uint32_t)dtS;
    health = 100;
    return;
  }
  if (ref < 100 || rpm < 50) return;               // 無基準/風扇停轉不評
  float h = 100.0f * rpm / ref;
  health = health * 0.99f + h * 0.01f;             // 重 EMA:抗瞬時風擾
  if (health < 85) {
    if (filtBadSince == 0) filtBadSince = millis();
  } else filtBadSince = 0;
}

/* --- 7. NAS 推送+離線環形補送 ---
 * 參數:無|回傳:無
 * 為什麼:過熱事件不能因斷網消失——先入隊、發送成功才出隊 */
void pushSnap() {
  Snap s{ (uint32_t)now(), inC, exC, pwmNow, (uint16_t)rpm };
  if (qLen < 32) { q[qLen++] = s; if (qLen == 32) qHead = 0; }
  else { q[qHead] = s; qHead = (qHead + 1) & 31; qLen = 32; }
  if (WiFi.status() != WL_CONNECTED || qLen == 0) return;
  HTTPClient http;
  http.begin(NAS_URL);
  http.addHeader("Content-Type", "application/json");
  http.addHeader("X-Device-Token", NAS_TOKEN);
  String body = "{\"in\":" + String(inC, 2) + ",\"ex\":" + String(exC, 2) +
                ",\"pwm\":" + String(pwmNow, 1) + ",\"rpm\":" + String(rpm) +
                ",\"health\":" + String(health, 1) + ",\"mode\":" + String(mode) + "}";
  if (http.POST(body) == 200 && qLen > 0) qLen--; // 成功清一筆積壓
  http.end();
}

/* --- 8. OLED 面板:4 秒刷一次(省 I2C 也防閃爍) --- */
void drawPanel() {
  oled.clearDisplay();
  oled.setTextSize(1);
  oled.setCursor(0, 0);
  oled.printf("IN %4.1f  EX %4.1f\n", inC, exC);
  oled.printf("PWM %3.0f%% RPM %4lu\n", pwmNow, (unsigned long)rpm);
  oled.printf("FILT %3.0f%%  %s\n", health, mode ? "NIGHT" : "AUTO");
  oled.printf("%s\n", WiFi.status() == WL_CONNECTED ? "NET ok" : "NET offline");
  oled.display();
}

/* --- 9. setup --- */
void setup() {
  Serial.begin(115200);
  pinMode(PIN_BTN, INPUT_PULLUP);
  prefs.begin("fanbrain");
  Wire.begin(1, 3);
  bmeA.begin(0x76);  bmeB.begin(0x77);      // 雙顆靠 ADDR 腳分址
  oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  oled.setTextColor(SSD1306_WHITE);
  ledcAttach(PIN_PWM, PWM_HZ, PWM_RES);     // core 3.x API
  pinMode(PIN_TACH, INPUT);
  attachInterrupt(digitalPinToInterrupt(PIN_TACH), tachISR, RISING);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  configTime(8 * 3600, 0, "pool.ntp.org");  // NTP:補送事件要有時間戳
}

/* --- 10. loop:100ms 控制拍/2s 感測拍/60s 推送拍 --- */
void loop() {
  static uint32_t tCtl = 0, tSen = 0, tNet = 0, tOled = 0, btnHold = 0;
  static uint8_t btnLast = HIGH;
  uint32_t m = millis();

  if (m - tCtl >= 100) { tCtl = m; slewStep(); filterHealth(0.1f); }
  if (m - tSen >= 2000) { tSen = m; readSensors(); }
  if (m - tNet >= 60000) { tNet = m; pushSnap(); }
  if (m - tOled >= 4000) { tOled = m; drawPanel(); }

  /* tach 逾時判讀:1.5s 沒脈衝=風扇停了 */
  if (micros() - lastEdgeUs > 1500000UL) {
    rpm = 0;
    if (pwmNow > 25) { /* 風扇故障:推送含 fan_fail=1 的緊急事件(略)*/ }
  } else {
    uint32_t pu;
    noInterrupts(); pu = pulseUs; interrupts();
    rpm = (uint32_t)(60000000.0f / pu / TACH_PULSES_PER_REV);
  }

  /* 按鈕:短按切 AUTO/NIGHT,長按 5s 重學濾網基準(換濾網後按它) */
  uint8_t b = digitalRead(PIN_BTN);
  if (b == LOW && btnLast == HIGH) btnHold = m;
  if (b == HIGH && btnLast == LOW) {
    if (m - btnHold < 400) mode ^= 1;                    // 消抖由間隔天然完成
    else if (m - btnHold > 5000) { wipeBaseline(); }
  }
  btnLast = b;
}
/* wipeBaseline():換完濾網長按觸發——清掉 20 個檔位基準並重啟 7 天學習期 */
uint32_t now() { time_t t; time(&t); return (uint32_t)t; }
void wipeBaseline() {
  learned = 0;
  char key[8];
  for (int i = 0; i < 20; i++) { snprintf(key, 8, "b%02d", i); prefs.putShort(key, 0); }
}
⚠️ tach 線必須上拉:風扇 tach 是開集極(OC)輸出,直接接 GPIO 永遠讀高電位、RPM 恆 0 被你判「風扇故障」。4.7kΩ 上拉 3.3V 是標配不是可選。
📦 NAS 端(Node-RED):收 POST /rack-fan → 校驗 X-Device-Token → InfluxDB 存 in/ex/pwm/rpm/health 畫三線圖;規則:①health<85 持續 10 分鐘 → Telegram「濾網髒了(健康 {{health}}%),本週灰塵積累速度是平均 1.8 倍——你家是不是又開窗了」;②in>42°C → 電話級推播;③每晚 23:30 下發 mode=1 進夜間模式。

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
  1. 列印後處理:格柵卡扣修整、濾網框貼魔術貼、探頭導流罩內壁貼鋁箔膠帶(反辐射熱)
  2. 風扇位改裝:風扇裝回機櫃排風位,濾網框壓在進風口;纏線帶把 tach/PWM 線與 12V 分離走線
  3. 探頭定位:BME280-A 對進風氣流、BME280-B 用轉彎風管接排風——兩顆都要導流罩,距離風扇框架 ≥5cm
  4. 上拉與共地:tach 的 4.7kΩ 上拉焊在 C6 端;風扇 GND、12V 黑線、板 GND 三點匯到同一星形點
  5. 燒錄與 sanity check:Serial 看 tach 有沒有脈衝(轉動風扇手轉看數值跳動)、PWM 用示波器或手機慢動作拍 500fps 影片驗 25kHz
  6. 學習期:裝好後正常跑 7 天(或手動把 learned 提前結望)讓 20 個 PWM 檔位基準入库;之後換濾網必長按重學
  7. 看門狗演練:吹風機對進風探頭加溫過 45°C,確認 PWM 直上 100% 且 NAS 收到事件——保命功能要演練過才算裝好
  8. 封裝歸位:控制器外殼磁吸在櫃內側壁,螢幕朝櫃門縫;纜線留 3cm 應變_loop_ 後收整

🔧 問題排除

問題原因解決方法
RPM 恆 0 判風扇故障tach 沒上拉(OC 輸出默認懸空)4.7kΩ 上拉 3.3V;多數風扇 tach 藍線、但先查型號規格書
風扇全速不聽 PWM接到 3 線風扇(無 PWM 腳)或訊號腳接錯3 線風扇只能電壓調速,換 4 線 PWM 扇或加 MOSFET 調壓電路
溫度比實測高 4°CBME280 貼殼/貼馬達,讀到辐射熱裝導流罩+反離輻射鋁箔內襯;感測區要「有風掠過」而非「貼著發熱體」
半夜 PWM 40% 嘔嘔共鳴撞上機櫃板件共振頻率把 BAND_LO/HI 挪到你的共振帶(用掃 PWM 聽噪音找出來);或風扇加防振膠墊
濾網健康度一直 100%還在學習期,或 PWM 常駐同一檔位其他檔無基準正常:健康度只在「有基準的檔位」評估;必要時手動掃 PWM 全檔位各 10 分鐘補基準
Wi-Fi 常斷(櫃內)金屬櫃法拉第效應天線端引出櫃外用天線饋線,或改 Tailscale 走 NAS 有線橋接;C6 的 TWT 省电重連也救不了物理屏蔽
MP1584 紋波干擾 tach 毛刺降壓開關頻率與 tach 走線並行signal 線與動力線分槽走;tach 並 100nF 到 GND 硬濾波

🎯 進階延伸

📦 檔案清單

rack-fan-brain/
├── src/
│   └── rack-fan-brain.ino      # ESP32 韌體(本頁完整程式)
├── nas/
│   └── node-red-flow.json      # 接收/存庫/濾網告警/夜間排程
├── stl/
│   ├── fan-grille-120.stl      # 120mm 風扇格柵
│   ├── filter-frame.stl        # 抽式濾網框
│   ├── probe-snoot.stl         # BME280 導流罩
│   ├── duct-elbow-40.stl       # 排風取樣風管
│   ├── brain-case.stl          # 控制器外殼(磁吸)
│   └── oled-tilt.stl           # 螢幕座
├── f3d/
│   └── rackfan-design.f3d      # Fusion 360 原始檔
└── manual/
    └── assembly.html           # 圖文組裝說明書
← 上一篇:ESP32-S3 聲源定位聲場羅盤 📚 系列索引 下一篇:ESP32-C3 雷射雕刻機同步空氣輔助模組 →