📋 專案概述

去年 12 月,東北季風第 14 個濕天。我週五晚上開始印一批 PA6-CF 的功能件,噴嘴裡傳來熟悉的「劈啪」聲——那是水在 245°C 的噴嘴裡爆炸的聲音。印出來的件表面全是氣孔,層間強度用兩根手指就能摳開,16 小時的列印時間加上 900 元的尼龍,整盤進垃圾桶。更扯的是隔天打開另一捲號稱「密封保存」的 PETG,起絲球捲得像長了毛的毛線。

我認真算過這筆帳:北部 11 月到 3 月空氣濕度常態 80%+,尼龍 24 小時就能吸到影響列印的含水量。對策不是「快點印完」,是把料盒變成恆濕倉。查過市面上的解決方案:

  1. 品牌除濕乾貨機(Polymaker 起跳 2,300 元):只能放一捲、濕度顯示要開機按三轮才看得到、沒有曲線沒有推播——2,300 元買一個「有溫度的盒子」。
  2. 賣場 300 元恆溫烘碗機改:只有溫度沒有濕度閉環。PLA 玻璃轉化溫度約 60°C,保溫 65°C 的烘碗機放一晚,線盤直接黏死在轉軸上——我朋友親手砸過 1,800 元的 ASA。
  3. 密封盒 + 乾燥劑(150 元):對,這是正確方向,但你永遠不知道裡面現在是 20% 還是 60%,乾燥劑飽和了也不會跟你說。

關鍵洞察:乾燥箱的本質是一個濕度閉環系統,而濕度閉環最昂貴的部分(控制器)恰好是 ESP32 免費送你的。把「密封盒 + 乾燥劑」升級成「密封盒 + 乾燥劑 + 會自己開加熱、會自己報警的大腦」,總成本 463 元:

成本效益:品牌乾貨機 2,300 元不含推播與曲線;本專案 463 元全部都有,而且盒子壞了換盒子、電路壞了換電路——乾燥箱是 24 小時運轉 365 天的設備,可維修性就是省錢。

為什麼選 ESP32-C5?

需求C5 的優勢
兩道牆外的路由器全系列唯一雙頻 Wi-Fi 6,5GHz 穿牆後 SNR 遠勝被 AP 海淹沒的 2.4GHz
24/7 運轉RISC-V 雙核 + 4nm 製程,控制 + Wi-Fi 待機平均比經典 ESP32 低約 40%,一年電費差一杯手搖
控制不能斷雙核分工:核心 0 跑濕度狀態機,核心 1 跑 Wi-Fi 推播,重連網路不會卡住加熱控制
GPIO 剛夠加熱 + 風扇 + 門磁 + I2C×2 = 5 支腳,C5 的 27 支 GPIO 大材小用,留給 v2 的 PM2.5 濾芯監測

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C5-DevKitM-1(N4R4)1~120 元選帶外部天線座版本更佳;本專案走 5GHz,板載天線別埋金屬箱壁後方
溫濕度SHT31-D(I2C,±2%RH)1~85 元不要用 DHT22:它的 ±5%RH 在 25~30% 控制帶裡根本分不出啟停點
加熱器PTC 陶瓷加熱片 12V 10W(帶鋁殼)1~60 元PTC 自限溫(表面最高 ~70°C),比電阻絲安全一個等級
加熱開關IRLZ44N 邏輯級 MOSFET1~10 元10kΩ 下拉 + 100Ω 閘極電阻;10W 負載連散熱片都不用
風扇4010 5V 無刷風扇1~25 元經 S8050 + 1N4148 由 GPIO 控制,讓箱內空氣循環過乾燥劑
顯示SSD1306 0.96" I2C OLED1~75 元與 SHT31 共總線(0x3C / 0x44 不撞號)
門磁常開型磁簧開關 + 磁石1 組~8 元開關裝箱框、磁石裝門片,間隙 ≤ 8mm
降壓MP1584 模組(12V→5V)1~15 元與列印機共用 12V 主電源,列印機關機乾燥箱也休息——燈絲只在「可能要用」時需要乾
安全55°C 常閉溫度保險絲(KSD9700)1~15 元串在加熱器 12V 迴路裡,與韌體完全獨立的硬體層保護
其他1N5822、1000µF/25V、電阻組、矽膠線1 組~50 元加熱器端並 1000µF 抑制 PTC 啟停雜訊
合計~463 元不含密封箱(IKEA 密封盒 150 元或既有料箱沿用)

🔌 接線圖

ESP32-C5 DevKitM 雙核 RISC-V · 5GHz Wi-Fi 6 GPIO4 / GPIO3 GPIO7 GPIO5 / GPIO6 5V · GND SHT31-D 溫濕度 SDA→GPIO5 SCL→GPIO6 VCC=3V3(地址 0x44) SSD1306 OLED 共 I2C(地址 0x3C) VCC=3V3 PTC 加熱 12V 10W 12V → 55°C 保險絲 → PTC PTC− → IRLZ44N D G→100Ω→GPIO4(10k 下拉) 4010 風扇 5V S8050:B→1k→GPIO3 1N4148 跨線圈 磁簧開關(門磁) GPIO7 → 開關 → GND 12V 主電源(共用列印機) → MP1584 → 5V → 板 5V 綠:I2C 共總線,兩裝置地址不撞號 紫:加熱器 12V 不過主板,只共地
▲ 加熱器走獨立 12V 迴路(經 55°C 保險絲與 MOSFET),主板只出閘極訊號——10W 加熱器的電流一毫安都不經過 ESP32。

詳細接線

ESP32-C5 腳位連接說明
GPIO5 / GPIO6SHT31 + OLED 的 SDA / SCL共 I2C 總線;模組自帶上拉,不要再加
GPIO4IRLZ44N 閘極(經 100Ω)10kΩ 下拉到 GND:復位期間閘極不浮空,加熱器預設關
GPIO3S8050 基極(經 1kΩ)風扇 5V 從降壓板輸出取,不佔主板 LDO;1N4148 反並線圈
GPIO7磁簧開關 → GND內部上拉,開門 = LOW
5V(MP1584 輸出)主板 5V、風扇 5V降壓板輸出並 470µF,避免 PTC 啟停瞬間把 5V 拉垮造成重啟
GND全部共地單點匯流加熱器迴路地線直接回電源端,不穿過主板地
ASCII 對照表(C5-DevKitM 左列 / 右列)

  ┌──────────────────────────┐
  │ ESP32-C5 DevKitM-1       │
  │  GPIO5  ○──── SHT31 SDA ─┬─ OLED SDA
  │  GPIO6  ○──── SHT31 SCL ─┴─ OLED SCL
  │  GPIO4  ○──── IRLZ44N 閘極(100Ω 串 + 10k 下拉)
  │  GPIO3  ○──── S8050 基極(1kΩ)→ 4010 風扇
  │  GPIO7  ○──── 磁簧開關 ── GND(開門 = LOW)
  │  5V     ○──── MP1584 輸出(並 470µF)
  │  GND    ○──── 單點共地排
  └──────────────────────────┘
  加熱迴路(不過主板):
  12V(+) ── 55°C KSD9700 ── PTC(+) ── PTC(−) ── MOSFET D
             MOSFET S ── 電源 GND(與主板單點共地)
⚠️ 55°C 溫度保險絲必須貼著加熱片本體綁定(鐵氟龍帶纏緊),不是丟在空氣中——它要在加熱片失控的第一時間斷電,而不是等箱內空氣燒到 55°C。

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
控制側掛盒(夾箱壁)1PETG黑~70 min
SHT31 通風護罩1PETG黑~20 min
風扇濾網蓋(內側)1PETG灰~25 min
磁簧固定座(框 + 門片)2PETG黑~15 min

列印設定

設計要點

  1. 側掛式控制盒:電子件全部掛箱外,箱體只開兩個 Ø25mm 孔(風扇、感測器探頭)——密封性靠箱蓋膠條,電子艙與箱內氣密完全解耦,換電池/燒錄不用拆密封。
  2. 氣流路徑:風扇在箱內把空氣打過乾燥劑層 → 加熱片在箱外側掛艙內預熱回風,形成「乾燥劑 → 風扇 → 加熱 → 箱內」的單向循環;加熱片不直接對著線盤吹,避免局部過熱。
  3. 感測器探頭反輻射:SHT31 護罩內加擋板,隔開加熱片輻射熱;護罩朝下開孔,防冷凝水直接落在感測面上。
  4. 磁簧對位:固定座帶 8mm 間隙規(列印時直接當量規用),裝好即對位,不用試錯。

💻 程式碼

📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
📦 相依:Arduino core for ESP32 ≥ 3.0(開發板選「ESP32C5 Dev Module」)、Adafruit_SHT31 ≥ 1.2、Adafruit SSD1306 ≥ 2.5、Adafruit GFX(SSD1306 依賴)。
/* ============================================================
 *  ESP32-C5 智慧燈絲乾燥箱  |  Smart Filament Dry Box
 *  ------------------------------------------------------------
 *  功能:濕度閉環除濕(30/25% 遲滯)+ 雙重過溫保護 + 門磁暫停
 *        + OLED 儀表 + Telegram 推播(乾燥劑失效/門沒關/週報)
 *  硬體:ESP32-C5 DevKitM-1 + SHT31-D + SSD1306 + PTC 12V 10W
 *        + IRLZ44N + S8050 風扇級 + 磁簧開關 + 55°C 保險絲
 *  供電:12V 主電源 → MP1584 5V → 板 5V(加熱走 12V 獨立迴路)
 *  相依:Arduino core for ESP32 ≥3.0(板型 ESP32C5 Dev Module)
 *        Adafruit_SHT31 / Adafruit_SSD1306 / WiFi / HTTPClient
 *  安全:軟體上限 50°C + 硬體 55°C 保險絲;開機預設加熱關閉
 * ============================================================ */
#include <WiFi.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <Adafruit_SHT31.h>
#include <Adafruit_SSD1306.h>

/* ------------------------------------------------------------
 * 1. 設定區(改這裡就好)
 * ------------------------------------------------------------ */
const char* WIFI_SSID = "YOUR_WIFI_5G";     // 注意:連 5GHz SSID
const char* WIFI_PASS = "YOUR_PASSWORD";
const char* TG_TOKEN  = "YOUR_BOT_TOKEN";   // Telegram Bot Token
const char* TG_CHAT   = "YOUR_CHAT_ID";

/* ------------------------------------------------------------
 * 2. 腳位定義
 * ------------------------------------------------------------ */
#define PIN_HEATER  4    // IRLZ44N 閘極(10k 下拉,復位期間預設關)
#define PIN_FAN     3    // S8050 基極(風扇循環)
#define PIN_REED    7    // 磁簧開關(開門 = LOW)
#define PIN_SDA     5
#define PIN_SCL     6

#define OLED_ADDR   0x3C
#define SCREEN_W    128
#define SCREEN_H    64

/* ------------------------------------------------------------
 * 3. 控制參數
 * ------------------------------------------------------------ */
const float    RH_ON        = 30.0f;   // RH 高於此 → 啟動除濕
const float    RH_OFF       = 25.0f;   // RH 低於此 → 停止(5% 遲滯)
const float    T_FORCE_OFF  = 50.0f;   // 箱內溫度軟性上限(PLA 線盤安全)
const float    T_FAN_ONLY   = 45.0f;   // 超過此溫度只停加熱、風扇繼續散
const uint32_t SENSOR_MS    = 2000;    // SHT31 每 2 秒取樣(I2C 壓力小、夠新)
const uint32_t DOOR_RECHECK = 10UL * 60000;  // 關門後 10 分鐘才重估濕度
const uint32_t HUMID_ALARM  = 30UL * 60000;  // RH>50% 撐 30 分鐘 → 推播
const uint32_t WEEKLY_MS    = 7UL * 86400000;

Adafruit_SHT31 sht31;
Adafruit_SSD1306 oled(SCREEN_W, SCREEN_H, &Wire, 0);

/* ------------------------------------------------------------
 * 4. 除濕狀態機
 *    IDLE ─ 未達啟動條件;DRYING ─ 加熱+風扇;
 *    COOLDOWN ─ 到 45°C 停加熱留風扇;PAUSED ─ 門開
 * ------------------------------------------------------------ */
enum DryState { IDLE, DRYING, COOLDOWN, PAUSED, FAULT };
DryState state = IDLE;

float    gTemp = 0, gHum = 0;
bool     sensorOk = false, doorOpen = false;
uint8_t  sensorFailCnt = 0;
uint32_t dryStartMs = 0;        // 本輪除濕開始時間
uint32_t doorClosedMs = 0;      // 關門時間戳(重估倒數)
uint32_t humidAlarmMs = 0;      // RH>50% 計時起點(0 = 未計時)
uint32_t lastCycleMs = 0;       // 上次除濕結束(診斷乾燥劑)
uint32_t weeklyMs = 0;
bool     heaterOn = false, fanOn = false;

/* 推播佇列:控制迴圈不阻塞,失敗最多滯留 3 則 */
String tgQueue[3];
uint8_t tgHead = 0, tgTail = 0;

void tgPush(const String& msg) {
  uint8_t next = (tgHead + 1) % 3;
  if (next == tgTail) return;              // 佇列滿:丟棄最舊不如丟棄最新,保留現場紀錄
  tgQueue[tgHead] = msg;
  tgHead = next;
}

/* ------------------------------------------------------------
 * tgFlush —— 非阻塞送出佇列(只在 Wi-Fi 連線時)
 *   為什麼不同步發:HTTPClient 阻塞可達數秒,
 *   控制迴圈卡住 = 過溫保護慢數秒,不可接受
 * ------------------------------------------------------------ */
void tgFlush() {
  if (tgTail == tgHead || WiFi.status() != WL_CONNECTED) return;
  HTTPClient http;
  http.begin(String("https://api.telegram.org/bot") + TG_TOKEN + "/sendMessage");
  http.addHeader("Content-Type", "application/x-www-form-urlencoded");
  String body = "chat_id=" + String(TG_CHAT) + "&text=" + urlencode(tgQueue[tgTail]);
  int code = http.POST(body);
  http.end();
  if (code == 200) tgTail = (tgTail + 1) % 3;   // 失敗不彈出:下輪重試同一則
}

/* ------------------------------------------------------------
 * readSensor —— SHT31 讀值 + 健康檢查
 *   回傳:成功 true;連續 3 次失敗標記 FAULT 並停加熱
 *   為什麼嚴苛:感測器失靈時還在加熱,是乾燥箱最壞情境
 * ------------------------------------------------------------ */
bool readSensor() {
  float t = sht31.readTemperature();
  float h = sht31.readHumidity();
  if (isnan(t) || isnan(h) || h < 0 || h > 100) {
    if (++sensorFailCnt >= 3) {
      state = FAULT;
      tgPush("乾燥箱:SHT31 連續讀取失敗,已停止加熱,請檢查感測器");
      return false;
    }
    return false;
  }
  sensorFailCnt = 0;
  gTemp = t; gHum = h;
  return true;
}

/* ------------------------------------------------------------
 * setActuators —— 唯一允許操作加熱/風扇的出口
 *   所有過溫判斷收斂到這裡,避免多處直接 digitalWrite 漏判
 * ------------------------------------------------------------ */
void setActuators(bool heat, bool fan) {
  if (gTemp >= T_FORCE_OFF) heat = false;      // 軟性上限壓過一切請求
  if (heat != heaterOn) { heaterOn = heat; digitalWrite(PIN_HEATER, heat ? HIGH : LOW); }
  if (fan != fanOn)     { fanOn = fan;     digitalWrite(PIN_FAN, fan ? HIGH : LOW); }
}

/* ------------------------------------------------------------
 * controlLoop —— 2 秒一次的濕度閉環
 *   遲滯:30% 啟動 / 25% 停止;啟停週期 < 5 分鐘 = 乾燥劑失效訊號
 * ------------------------------------------------------------ */
void controlLoop(uint32_t now) {
  bool reedNow = (digitalRead(PIN_REED) == LOW);
  if (reedNow != doorOpen) {
    doorOpen = reedNow;
    if (doorOpen) { state = PAUSED; setActuators(false, false); }  // 開門:全停,別對整個房間除濕
    else doorClosedMs = now;
  }
  if (state == PAUSED) {
    if (!doorOpen && now - doorClosedMs > DOOR_RECHECK) state = IDLE;  // 關門 10 分鐘後重估
    return;
  }
  if (state == FAULT) { setActuators(false, false); return; }
  if (!sensorOk) return;

  switch (state) {
    case IDLE:
      setActuators(false, false);
      if (gHum > RH_ON) { state = DRYING; dryStartMs = now; }
      break;
    case DRYING:
      if (gTemp > T_FAN_ONLY) { state = COOLDOWN; setActuators(false, true); break; }
      setActuators(true, true);
      if (gHum < RH_OFF) {
        uint32_t cycleMin = (now - dryStartMs) / 60000;
        state = IDLE; setActuators(false, false); lastCycleMs = now;
        if (cycleMin < 5) tgPush("乾燥箱:除濕僅 " + String(cycleMin) + " 分鐘即達標又快速回升——乾燥劑可能已飽和");
      }
      break;
    case COOLDOWN:
      setActuators(false, true);
      if (gTemp < T_FAN_ONLY - 3.0f) state = DRYING;   // 降 3°C 才回調,防抖
      break;
    default: break;
  }
}

/* ------------------------------------------------------------
 * alarmLoop —— 濕度異常 + 每週提醒
 * ------------------------------------------------------------ */
void alarmLoop(uint32_t now) {
  if (!sensorOk) return;
  if (gHum > 50.0f) {
    if (humidAlarmMs == 0) humidAlarmMs = now;
    else if (now - humidAlarmMs > HUMID_ALARM) {
      humidAlarmMs = 0;                              // 發一次就重置,避免洗版
      tgPush("乾燥箱:濕度 > 50% 已持續 30 分鐘,請確認門是否關好/乾燥劑狀態");
    }
  } else humidAlarmMs = 0;

  if (now - weeklyMs > WEEKLY_MS) {
    weeklyMs = now;
    tgPush("乾燥箱週報:目前 " + String(gTemp, 1) + "°C / " + String(gHum, 1) + "%RH");
  }
}

/* ------------------------------------------------------------
 * drawOled —— 500ms 刷新儀表(進度條 = 25~35%RH 線性映射)
 * ------------------------------------------------------------ */
void drawOled() {
  oled.clearDisplay();
  oled.setTextSize(2);
  oled.setTextColor(SSD1306_WHITE);
  if (!sensorOk) {
    oled.setCursor(0, 8); oled.setTextSize(1);
    oled.println(state == FAULT ? "SENSOR FAULT" : "SENSOR WAIT");
    oled.setCursor(0, 28); oled.println("HEATER LOCKED");
  } else {
    oled.setCursor(0, 0);
    oled.print(gHum, 1); oled.print("%");
    oled.setTextSize(1);
    oled.setCursor(0, 22);
    oled.print(gTemp, 1); oled.print("C  ");
    switch (state) {
      case DRYING:   oled.print("DRY+"); break;
      case COOLDOWN: oled.print("COOL"); break;
      case PAUSED:   oled.print("PAUSE"); break;
      case FAULT:    oled.print("FAULT"); break;
      default:       oled.print("IDLE"); break;
    }
    // 進度條:35%RH=空、25%RH=滿(越乾越滿)
    int pct = constrain((int)((35.0f - gHum) / 10.0f * 126), 0, 126);
    oled.drawRect(0, 44, 128, 10, SSD1306_WHITE);
    oled.fillRect(1, 45, pct, 8, SSD1306_WHITE);
  }
  oled.display();
}

void setup() {
  Serial.begin(115200);
  delay(1000);

  // 致動器先歸零再開放任何邏輯(含燒錄 reset 期間的下拉保護)
  pinMode(PIN_HEATER, OUTPUT); digitalWrite(PIN_HEATER, LOW);
  pinMode(PIN_FAN, OUTPUT);    digitalWrite(PIN_FAN, LOW);
  pinMode(PIN_REED, INPUT_PULLUP);

  Wire.begin(PIN_SDA, PIN_SCL);
  oled.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
  oled.display();

  bool shtOk = sht31.begin(0x44);
  Serial.println(shtOk ? "SHT31 OK" : "SHT31 FAIL(加熱鎖定)");
  if (!shtOk) state = FAULT;

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("連 5GHz Wi-Fi");
  uint32_t t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) { delay(300); Serial.print("."); }
  Serial.println(WiFi.status() == WL_CONNECTED ? " OK" : " 失敗(離線控制仍運作)");

  tgPush("乾燥箱上電:" + String(state == FAULT ? "感測器異常,加熱已鎖定" : "控制迴路啟動"));
}

void loop() {
  uint32_t now = millis();
  static uint32_t lastSensor = 0, lastOled = 0;

  if (now - lastSensor >= SENSOR_MS) {
    lastSensor = now;
    sensorOk = readSensor();
    controlLoop(now);
    alarmLoop(now);
  }
  if (now - lastOled >= 500) { lastOled = now; drawOled(); }
  tgFlush();
  delay(5);
}

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
  1. 列印零件:控制側掛盒、SHT31 護罩、風扇濾網蓋、磁簧固定座×2(全 PETG)
  2. 箱體開孔:側壁 Ø25mm 兩孔(風扇、感測探頭),開孔蓋圈用熱熔膠密封
  3. 焊加熱迴路:12V → 55°C 保險絲 → PTC → MOSFET;保險絲鐵氟龍帶纏緊在加熱片本體
  4. 焊主板:I2C、MOSFET 閘極、風扇驅動、磁簧依表上板;全部焊死 + 熱縮管
  5. 安全測試(必做):不裝乾燥劑,手捂感測器升濕驗證加熱啟動;把加熱片溫度超過 50°C 驗證軟體斷電;短接保險絲端驗證迴路斷開
  6. 入殼:主板入側掛盒、乾燥劑鋪箱底、線盤上架、OLED 朝外
  7. 調參:連續觀察 3 天啟停曲線,潮濕季可把 RH_ON 收緊到 25%

🔧 問題排除

問題原因解決方法
濕度降不下來箱體密封不良/乾燥劑飽和紙條夾門縫測試密封;換新乾燥劑後觀察一輪除濕時間
濕度顯示比實際高很多SHT31 護罩被灰塵堵死或貼到箱壁清護罩透氣槽;護罩必須懸在箱內空氣中
加熱器不啟動作業無訊息感測器 FAULT 鎖定看 OLED 是否顯示 FAULT;查 I2C 接線與 SHT31 地址
Wi-Fi 連不上連到 2.4GHz SSID 或天線被金屬箱壁擋C5 預設掃全部頻段,確認路由器 5GHz 有開;板子天線端露出塑膠掛盒
OLED 閃爍重啟PTC 啟停瞬間拉垮 5V降壓板輸出加 470µF;加熱器地線不與主板共用細線
門磁誤判常開磁石與開關間隙 > 8mm 或裝反側用列印間隙規對位;磁簧對磁極不敏感但對距離敏感

🎯 進階延伸

📦 檔案清單

filament-dry-box/
├── src/
│   └── filament-dry-box.ino     # ESP32 程式碼(本頁完整程式)
├── stl/
│   ├── control-pod.stl          # 控制側掛盒
│   ├── sht31-shroud.stl         # SHT31 通風護罩
│   ├── fan-grille.stl           # 風扇濾網蓋
│   └── reed-mount.stl           # 磁簧固定座(含 8mm 間隙規)
├── f3d/
│   └── filament-dry-box.f3d     # Fusion 360 原始檔
├── manual/
│   └── assembly.html            # 圖文組裝說明書
└── README.md                    # 說明文件
← 上一篇:ESP32-C3 自行車智慧轉向燈把手 📚 系列索引 下一篇:ESP32 智慧孵化器 →