📋 專案概述
九月底的某一天,你的 DIY NAS 在凌晨傳了第三封「硬碟 SMART 異常前兆」簡報。你打開書房深處的系統櫃——四台硬碟、一顆 65W 的 U.2、一片集顯拆下來的準系統,全部關在一個連溫度計都沒有的木櫃子裡,裡面那顆 12cm 風扇從 2024 年裝機那天起就全速轉:白天你開會它嗚嗚叫,你把插排一拔世界清淨了,然後 NAS 在靜悄悄中燒到降頻。你後來裝了個百元溫控插座,問題更大了:它只認識「開」和「關」——44°C 全速、40°C 停轉,一天啟動八百次,風扇軸承半年報銷。而最陰險的是:櫃子進風口的防尘棉已經灰到發毛,風道只剩三成,溫控插座看到「溫度終於壓回 40 度以下」心满意足,代價是風扇終其一生都在滿轉吸灰。
市售商品:
- 溫控插座(開/關型):NT$350,繼電器+溫度探頭,只懂二值控制——風扇不是電燈泡,停停轉轉比constant 慢轉傷十倍。
- 機房恆溫風扇控制器(Pabst、Sunon 工業款):NT$2,500 起,0-10V 模擬控制,沒有網路、不會通知你濾網堵了——工業思維是「壞了再換」。
- 智慧插座+溫度計組合:NT$800,你能看到曲線,但依然是開/關控制,而且沒有任何一個會告訴你「濾網該換了」——因為它們不測風,只測溫。
本專案是一個釘在機櫃風扇位的風扇大腦,核心是一個很漂亮的物理 trick:濾網髒了,不需要壓力感測器——同一個 PWM 檔位下,風扇為了對抗越来越大的風阻,RPM 會掉。把「定 PWM 下的 RPM」當成差壓計用,濾網健康度就是免費的。
- 25kHz LEDC 直驅 4 線 PWM 風扇:Noctua 的 PWM 規格就是 25kHz±10%,ESP32 LEDC 原生頻率直接對上,不需要任何驅動板
- 進風+排風雙 BME280 雙因子控速:基準由「進風溫度」決定(外界熱就認命),修正項由「進排 ΔT」決定(ΔT 拉大=風道不通或負載飆高,先加轉再警報)
- 濾網健康度=定 PWM 參考 RPM 衰退率:前 7 天自動學習每個 PWM 檔位的清潔基準 RPM 存 NVS,之後每小時比對;衰退 >15% 持續 10 分鐘 → Telegram「濾網髒了(健康 71%)」
- 斜率限速 2%/s 與低轉滯留帶:PWM 不跳變;35–45% 之間不来回穿(PWM 音頻共振帶),要過就一次過
- 高溫看門狗:進風 >45°C 無視一切邏輯強制 100%,且風扇故障(PWM 給滿而 RPM<300)60 秒內推播——散熱系統要能報告自己的死訊
- NAS 三線趨勢圖+離線補送:溫度/PWM/RPM 每 60 秒一筆 POST;Wi-Fi 斷線期間事件進 32 格環形隊列,重連 flush——半夜的過熱事件不能因為斷網就消失
💰 成本效益:全機 ~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-1 | 1 | ~165 元 | Wi-Fi 6、LEDC 25kHz |
| 溫濕度感測 | BME280 I2C 模組 | 2 | ~85 元/個 | 進風+排風各一,地址 0x76/0x77 |
| 風扇 | Noctua NF-A12x25 PWM(12cm) | 1 | ~0 元 | 沿用機櫃既有 PWM 扇即可 |
| 螢幕 | SSD1306 0.96" I2C | 1 | ~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 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO1 / GPIO3 | I2C SDA / SCL | BME280×2(0x76/0x77)+SSD1306(0x3C)共總線 |
| GPIO4 | 風扇 PWM 訊號腳 | LEDC 25kHz / 10bit,直接接,無需驅動 |
| GPIO5 | 風扇 tach(藍線) | 經 4.7kΩ 上拉 3.3V;2 脈衝/轉 |
| GPIO6 | 設定按鈕(對 GND) | 內部上拉;短按切模式、長按重學基準 |
| 5V / GND | MP1584 降壓輸出 | 與風扇 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% | 1 | 15° 仰角,櫃門縫可窺 |
列印總重 ~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 組裝說明書
- 列印後處理:格柵卡扣修整、濾網框貼魔術貼、探頭導流罩內壁貼鋁箔膠帶(反辐射熱)
- 風扇位改裝:風扇裝回機櫃排風位,濾網框壓在進風口;纏線帶把 tach/PWM 線與 12V 分離走線
- 探頭定位:BME280-A 對進風氣流、BME280-B 用轉彎風管接排風——兩顆都要導流罩,距離風扇框架 ≥5cm
- 上拉與共地:tach 的 4.7kΩ 上拉焊在 C6 端;風扇 GND、12V 黑線、板 GND 三點匯到同一星形點
- 燒錄與 sanity check:Serial 看 tach 有沒有脈衝(轉動風扇手轉看數值跳動)、PWM 用示波器或手機慢動作拍 500fps 影片驗 25kHz
- 學習期:裝好後正常跑 7 天(或手動把
learned提前結望)讓 20 個 PWM 檔位基準入库;之後換濾網必長按重學 - 看門狗演練:吹風機對進風探頭加溫過 45°C,確認 PWM 直上 100% 且 NAS 收到事件——保命功能要演練過才算裝好
- 封裝歸位:控制器外殼磁吸在櫃內側壁,螢幕朝櫃門縫;纜線留 3cm 應變_loop_ 後收整
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| RPM 恆 0 判風扇故障 | tach 沒上拉(OC 輸出默認懸空) | 4.7kΩ 上拉 3.3V;多數風扇 tach 藍線、但先查型號規格書 |
| 風扇全速不聽 PWM | 接到 3 線風扇(無 PWM 腳)或訊號腳接錯 | 3 線風扇只能電壓調速,換 4 線 PWM 扇或加 MOSFET 調壓電路 |
| 溫度比實測高 4°C | BME280 貼殼/貼馬達,讀到辐射熱 | 裝導流罩+反離輻射鋁箔內襯;感測區要「有風掠過」而非「貼著發熱體」 |
| 半夜 PWM 40% 嘔嘔共鳴 | 撞上機櫃板件共振頻率 | 把 BAND_LO/HI 挪到你的共振帶(用掃 PWM 聽噪音找出來);或風扇加防振膠墊 |
| 濾網健康度一直 100% | 還在學習期,或 PWM 常駐同一檔位其他檔無基準 | 正常:健康度只在「有基準的檔位」評估;必要時手動掃 PWM 全檔位各 10 分鐘補基準 |
| Wi-Fi 常斷(櫃內) | 金屬櫃法拉第效應 | 天線端引出櫃外用天線饋線,或改 Tailscale 走 NAS 有線橋接;C6 的 TWT 省电重連也救不了物理屏蔽 |
| MP1584 紋波干擾 tach 毛刺 | 降壓開關頻率與 tach 走線並行 | signal 線與動力線分槽走;tach 並 100nF 到 GND 硬濾波 |
🎯 進階延伸
- 雙風扇一进一排:再加一路 LEDC+tach,進排聯動(排風 = 進風 × 0.85 維持微負壓,灰塵從濾網進而不是縫隙進)
- 灰塵量化報告:健康度衰退斜率×時間積分,換算「本季吸入灰塵指數」——住臨馬路的人會得到震撼教育
- Thread 感測擴展:C6 可開邊界路由器,櫃溫、漏水、門磁全上 Thread 一網打盡
- 風扇壽命令中制:累計各 PWM 檔位運轉小時數,軸承磨耗加權預警(滿轉 1 小時 = 慢轉 4 小時磨耗)
📦 檔案清單
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 # 圖文組裝說明書