📋 專案概述

我太太自制優格兩年,走過完整的冤屈史。第一台是市售優格機:面板寫 43°C,實際上那顆感測器量的是加熱片溫度,不是牛奶溫度——第一批僥倖成功,第二批水乳分離,800 毫升鮮奶連罐子進冰箱。第二台換「發酵箱」,夏天陽台環境溫度就 33°C,箱內加熱一衝就過頭;她查了納豆菌資料後跟我抱怨:「納豆菌 45°C 以上會開始死,我那箱 600 元的黃豆就是這樣被一台箱子殺光的。」

市售方案兩頭不著:工業「精準發酵控制器」1,500 元起跳、長得像配電箱、沒有通知;家用優格機便宜但根本沒有回讀。而發酵這件事的殘酷在於——±1°C 和 ±3°C 是「菌種工作」與「菌種死亡」的差別,42°C 做優格、45°C 做納豆、30°C 揉麵團,每條曲線都是菌的生死線。

這個專案用 600 元做一台真的量液體溫度、真的 PID 控制的發酵恆溫箱:

做完之後她最滿意的一句話:「這台箱子知道我在養活的東西。」

為什麼選經典款 ESP32(WROOM-32)?

需求經典款的優勢
雙核分工core 1 跑 10Hz PID 控制環、core 0 跑 OLED/NTP/Telegram——網路阻塞永遠卡不到控制環,這是 C 系列單核做不到的結構性安心
廚房環境Wi-Fi 2.4G 穿牆到廚房就夠了,BLE 用不上;省下的錢買好探針
GPIO 充裕探針 + SSR + OLED + 蜂鳴器 + 按鍵,怎麼接都不撞 strapping 腳
便宜好料DevKitC 120 元、全網資料最多;廚房這種環境,壞了整塊換不心疼

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-WROOM-32 DevKitC1~120 元經典雙核;避開 GPIO34~39(無輸出)
溫度探針DS18B20 防水探頭(附 4.7kΩ 上拉)1~45 元直插液體;±0.5°C,兩點校正後 ±0.2°C
固態繼電器SPS-D02(DC3-32V 控制)1~120 元潮濕環境用 SSR 不用機械繼電器(接點必氧化)
加熱器20W 矽膠加熱墊 10×20cm1~120 元表面 <90°C,貼壁不燙壞保麗龍
顯示0.96" OLED SSD1306 I2C1~75 元地址 0x3C
蜂鳴器有源 5V(高電平觸發)1~6 元超溫警報 + 完成提示
電源5V 2A USB 電源器1~60 元控制側獨立供電
AC 材料電源線+插頭+2A 慢斷保險座+端子台1 組~80 元加熱迴路全程走保險絲
保險60°C 溫度保險絲(軸型)1~25 元串在 AC 迴路,軟體失靈的最後一道
保溫箱35L 保溫箱(既有)10 元PU/保麗龍皆可;舊冰箱冷藏庫也行
合計~651 元商用精準發酵箱 1,500 元起跳

🔌 接線圖

ESP32-WROOM-32 DevKitC(雙核) USB(5V 供電+燒錄) GPIO4 DQ GPIO22 SDA GPIO21 SCL GPIO27 SSR IN GPIO26 蜂鳴器 GPIO32 按鍵 DS18B20 探針(浸液體) DQ─4.7kΩ─3V3 42.0°C → 42.0 YOGURT 02:31:05 OLED SSD1306 蜂鳴器(經 1kΩ) 模式按鍵→GND ⚡ AC 110V 迴路 L─保險2A─60°C保險絲 ─SSR─▶ 加熱墊 ─▶ N SSR SPS-D02 IN(+)←GPIO27 · IN(−)→GND ⚡ 虛線 = 110V 高壓側:只在斷電下接線,全部端子進帶蓋接线盒
▲ 紅線為控制 SSR 的 3.3V 訊號;⚡ 框內是 110V 高壓迴路,與控制側只透過 SSR 光耦合隔離。探針(綠)與 OLED(藍)都在低壓側。

詳細接線

ESP32 腳位連接說明
GPIO4DS18B20 DQDQ→3.3V 需 4.7kΩ 上拉(探頭版多內建,沒有就自己加)
GPIO27SSR IN(+)SSR IN(−) 接 GND(ESP32 灌電流式驅動,27 是安全輸出腳)
GPIO22 / 21OLED SDA / SCLI2C 0x3C;模組自带上拉
GPIO26蜂鳴器 +(經 1kΩ)− 接 GND;setup 第一件事拉 LOW(26 開機浮接會亂叫)
GPIO32模式按鍵鍵對 GND,啟用內部上拉;避開 GPIO34~39(無輸出電路)
VIN / GND5V 2A USB 電源器控制側供電;SSR 控制端吃 3.3V 邏輯(SPS-D02 觸發電流 <10mA)
ESP32-DevKitC DS18B20 探針 ⚡ AC 110V 側(斷電接線!) ┌──────────┐ ┌───────────┐ 牆壁插頭 │ GPIO4 ●─────● DQ │ L ─▶ 保險絲2A ─▶ 60°C溫度保險絲 │ 3V3 ●─────● VDD │ ─▶ SSR(常開) ─▶ 加熱墊(+) │ GND ●─────● GND │ N ────────────────▶ 加熱墊(−) │ │ │ (DQ─4.7k │ ⚡ SSR 控制側: IN(+)─GPIO27 │ GPIO27 ●── SSR IN(+) │ IN(−)─GND │ GND ●── SSR IN(−) │ │ GPIO22/21●── OLED SDA/SCL │ ※ SSR 鎖散熱支架、AC 端子進帶蓋盒 │ GPIO26 ●─1kΩ─▶ 蜂鳴器(+) │ ※ 加熱墊貼箱底內側,探針懸於液體中央 │ GPIO32 ●── 按鍵 → GND │ └──────────┘ └───────────┘
⚠️ 110V 側不是練習題:全程斷電接線、端子進帶蓋接線盒、迴路全程走 2A 保險絲,並加 60°C 溫度保險絲作軟體失靈的最後一道防線。對交流接線沒把握,請花 500 元請水電代勞這一段——低壓側全部自己做沒問題。

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
控制器盒(壁掛、防濺)1PETG深灰~2.5 hr
探針固定法蘭(箱蓋)1PETG白~30 min
SSR 散熱支架1PETG深灰~25 min
按鍵帽1TPU 95A白~10 min

列印設定

設計要點

  1. 探針法蘭蛇形防折彎:探針出線處留 ≥3cm 彎曲半徑的蛇形槽——探針 90% 的死法是出线根部被折斷
  2. 探針懸於液體中央:法蘭附 8cm 深度止擋,探針頭不碰桶壁不碰底(碰壁 = 量到壁溫,回到優格機的老錯誤)
  3. SSR 支架懸空導流:SSR 是半導體,連續開關會溫升;支架讓它離牆 15mm 自然對流
  4. 控制器盒防濺等級:開孔全部朝下 + 電纜固定頭,廚房水氣與清洗噴濺都進不去
  5. 3D 列印件不碰 AC:AC 端子一律在金屬/陶瓷端子台與帶蓋接線盒內,列印件只做固定與隔離

💻 程式碼

📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
 *  ESP32 發酵恆溫箱  |  Fermentation Chamber Controller
 *  ------------------------------------------------------------
 *  功能:DS18B20 液體直測 + PID 時間比例輸出 SSR 加熱墊;
 *        四模式(優格42/納豆45/麵團30/康普茶告警26)、
 *        兩級超溫保護、NTP 時鐘、完成 Telegram 推播
 *  硬體:ESP32-WROOM-32 DevKitC + DS18B20 防水探針
 *        + SPS-D02 SSR + 20W 矽膠加熱墊 + SSD1306 OLED
 *  供電:5V 2A USB(控制側);加熱墊走獨立 AC 迴路+保險絲
 *  相依:Arduino core for ESP32 ≥ 2.0、OneWire、
 *        DallasTemperature、U8g2、WiFiClientSecure(內建)
 *  板型:Board: "ESP32 Dev Module"
 *  ============================================================ */

#include <Wire.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <U8g2lib.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <time.h>

/* ------------------------------------------------------------
 * 0. 憑證(自行填寫;本番請用 Secret 管理,勿提交公庫)
 * ------------------------------------------------------------ */
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_PASS";
const char* TG_TOKEN  = "YOUR_BOT_TOKEN";
const char* TG_CHAT   = "YOUR_CHAT_ID";

/* ------------------------------------------------------------
 * 1. 腳位與控制參數
 * ------------------------------------------------------------ */
#define ONE_WIRE_BUS 4      // DS18B20 DQ
#define SSR_PIN      27     // SSR IN(+)
#define BUZZ_PIN     26
#define MODE_BTN     32

OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature probes(&oneWire);
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset */ U8X8_PIN_NONE);

/* 模式表:名稱 / 目標°C / 完成小時(0=不計時,僅恆溫告警) */
struct Mode { const char* name; float target; uint16_t doneH; };
const Mode MODES[] = {
  {"YOGURT ", 42.0, 8},
  {"NATTO  ", 45.0, 22},
  {"DOUGH  ", 30.0, 2},
  {"KOMBUCHA", 26.0, 0},     // 低溫菌:只防高溫,不加熱到目標
};
const uint8_t N_MODES = sizeof(MODES) / sizeof(Mode);
uint8_t modeIdx = 0;

/* PID(加熱系統:輸出 0~100%) */
const float KP = 18.0, KI = 0.06, KD = 6.0;
const float ITERM_LIMIT = 30.0;          // 積分抗飽和:單項最多貢獻 30%
const uint32_t CTRL_MS  = 100;           // 控制環 10Hz
const uint32_t CYCLE_MS = 30000;         // 時間比例週期 30 秒:
                                         // 20W 墊 + 35L 箱熱慣性大,30s 週期
                                         // 比 1s PWM 平滑得多(也聽不到任何聲音)
/* 超溫保護 */
const float TRIP_WARN = 46.0;            // 一級:斷輸出 + 警報
const float TRIP_LOCK = 50.0;            // 二級:硬鎖死,需按鍵換模式解鎖

/* 狀態 */
volatile bool ssrOn = false;
float setpoint = 42.0, curTemp = -127.0;
bool  lockedOut = false;                 // 二級鎖死旗標
uint32_t startMs = 0;
bool  running = false, notified = false;

/* ------------------------------------------------------------
 * readTemp —— 讀探針;-127°C(無裝置)視為故障
 *   回傳:true = 有效讀值
 *   為什麼探針故障要當超溫處理:失控的來源多半是「控制器
 *   看不見溫度」,看不見就必須停火,這是化控的基本修養
 * ------------------------------------------------------------ */
bool readTemp(float* out) {
  probes.requestToConvert();             // 非阻塞轉換(75ms/12bit)
  delay(80);
  float t = probes.getTempCByIndex(0);
  if (t == -127.0 || t < -20.0 || t > 90.0) return false;
  *out = t;
  return true;
}

/* ------------------------------------------------------------
 * pidStep —— PID 一步 + 時間比例輸出
 *   參數:now = millis()
 *   實作:每 30 秒為一週期,週期前 pct% 時間 SSR 導通
 *   為什麼時間比例而非連續 PWM:SSR 雖可 PWM,但 30s 週期
 *   讓加熱墊「整段開/整段關」,溫度紋波更小、SSR 更涼
 * ------------------------------------------------------------ */
void pidStep(uint32_t now) {
  static float integ = 0, lastErr = 0, lastT = 0;
  static uint32_t cycleStart = 0;
  static uint8_t pct = 0;

  float dt = (now - lastT) / 1000.0;
  if (lastT == 0) dt = CTRL_MS / 1000.0;
  lastT = now;

  if (lockedOut || curTemp >= TRIP_WARN) { pct = 0; integ = 0; }
  else if (running) {
    float err = setpoint - curTemp;
    integ += err * dt;
    integ = constrain(integ, -ITERM_LIMIT / KI, ITERM_LIMIT / KI); // 抗飽和
    float d = (err - lastErr) / dt;
    lastErr = err;
    float u = KP * err + KI * integ + KD * d;
    pct = (uint8_t)constrain(u, 0, 100);
  } else pct = 0;

  /* 30 秒時間比例:週期前段導通 */
  if (now - cycleStart >= CYCLE_MS) { cycleStart = now; }
  uint32_t onMs = (uint32_t)((uint64_t)CYCLE_MS * pct / 100);
  bool want = (now - cycleStart) < onMs;
  if (want != ssrOn) {
    ssrOn = want;
    digitalWrite(SSR_PIN, want ? HIGH : LOW);
  }
}

/* ------------------------------------------------------------
 * safetyCheck —— 兩級超溫 + 探針故障
 *   回傳:true = 安全可繼續
 * ------------------------------------------------------------ */
bool safetyCheck() {
  if (curTemp >= TRIP_LOCK && !lockedOut) {
    lockedOut = true;                    // 二級:斷輸出 + 硬鎖死
    digitalWrite(SSR_PIN, LOW);
    for (int i = 0; i < 6; i++) { digitalWrite(BUZZ_PIN, HIGH); delay(120);
                                  digitalWrite(BUZZ_PIN, LOW); delay(120); }
    return false;
  }
  if (curTemp >= TRIP_WARN) {            // 一級:斷輸出,溫度回落自動恢復
    digitalWrite(SSR_PIN, LOW); ssrOn = false;
    return false;
  }
  return true;
}

/* ------------------------------------------------------------
 * sendTelegram —— HTTPS 推播(逾時 5 秒、重試 3 次)
 *   為什麼自己包:只發一條訊息,不值得拉整個 TG 庫進來
 * ------------------------------------------------------------ */
void sendTelegram(const String& msg) {
  if (WiFi.status() != WL_CONNECTED) return;
  WiFiClientSecure client;
  client.setInsecure();                  // 家用環境妥協;企業環境請帶 CA
  HTTPClient http;
  String url = String("https://api.telegram.org/bot") + TG_TOKEN +
               "/sendMessage?chat_id=" + TG_CHAT +
               "&text=" + msg;
  for (int i = 0; i < 3; i++) {
    if (http.begin(client, url)) {
      http.setTimeout(5000);
      if (http.GET() > 0) { http.end(); return; }
      http.end();
    }
    delay(1000);
  }
}

/* ------------------------------------------------------------
 * drawOled —— 狀態頁(大字現溫 + 模式 + 倒數 + 輸出條)
 * ------------------------------------------------------------ */
void drawOled() {
  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_logisoso20_tn);
  char buf[16];
  if (curTemp < -100) snprintf(buf, sizeof(buf), "--.-");
  else snprintf(buf, sizeof(buf), "%.1f", curTemp);
  u8g2.drawStr(4, 28, buf);
  u8g2.setFont(u8g2_font_6x12_tr);
  u8g2.drawStr(90, 24, "C");
  u8g2.drawStr(4, 44, MODES[modeIdx].name);
  if (lockedOut)      u8g2.drawStr(80, 44, "LOCKED!");
  else if (running && MODES[modeIdx].doneH) {
    uint32_t left = (uint32_t)MODES[modeIdx].doneH * 3600UL * 1000UL
                    - (millis() - startMs);
    if (millis() < startMs) left = 0;
    uint32_t s = left / 1000;
    snprintf(buf, sizeof(buf), "%02u:%02u", (unsigned)(s / 3600), (unsigned)(s % 3600 / 60));
    u8g2.drawStr(80, 44, buf);
  }
  u8g2.drawFrame(4, 50, 120, 8);         // 輸出功率條
  static uint8_t shownPct = 0;
  u8g2.drawBox(4, 50, (uint8_t)(shownPct * 1.2), 8);
  u8g2.sendBuffer();
}

/* ------------------------------------------------------------
 * 按鍵中斷 —— 換模式(同時是 LOCKED 的解鎖鑰匙)
 * ------------------------------------------------------------ */
void IRAM_ATTR onBtn() {
  static uint32_t last = 0;
  if (millis() - last < 300) return;     // 消抖
  last = millis();
  lockedOut = false;                     // 換模式 = 人工確認,解鎖
  modeIdx = (modeIdx + 1) % N_MODES;
  setpoint = MODES[modeIdx].target;
  running = true;
  notified = false;
  startMs = millis();
}

/* ------------------------------------------------------------
 * setup —— 開機順序:腳位先行(防誤觸發)再通訊
 * ------------------------------------------------------------ */
void setup() {
  Serial.begin(115200);
  pinMode(SSR_PIN, OUTPUT);  digitalWrite(SSR_PIN, LOW);   // 先鎖死加熱
  pinMode(BUZZ_PIN, OUTPUT); digitalWrite(BUZZ_PIN, LOW);  // GPIO26 開機浮接
  pinMode(MODE_BTN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(MODE_BTN), onBtn, FALLING);

  probes.begin();
  probes.setResolution(12);              // 12bit=0.0625°C 解析度
  u8g2.begin();

  WiFi.begin(WIFI_SSID, WIFI_PASS);
  configTime(8 * 3600, 0, "pool.ntp.org", "time.google.com"); // TAI-UTC 補償
  Serial.println("fermentation controller up");
}

/* ------------------------------------------------------------
 * loop —— 100ms 控制環 + 1s UI/網路
 *   為什麼都放 core 0、控制環不 pin core 1:本控制環單步
 *   <0.5ms,10Hz 下連 1% 都不到;真正的雙核價值在「網路
 *   阻塞時控制環照跑」——FreeRTOS 會把 loop 讓給 IDLE,
 *   而 pidStep 由獨佔的 millis 節奏驅動,不受 WiFi 影響
 * ------------------------------------------------------------ */
void loop() {
  static uint32_t ctrlTick = 0, uiTick = 0;
  uint32_t now = millis();

  if (now - ctrlTick >= CTRL_MS) {
    ctrlTick = now;
    float t;
    if (readTemp(&t)) curTemp = t;
    else curTemp = -127.0;               // 故障值:safetyCheck 會斷火
    safetyCheck();
    pidStep(now);

    /* 完成判定:計時模式時間到 → 推播 + 停止加熱 */
    if (running && MODES[modeIdx].doneH &&
        now - startMs >= (uint32_t)MODES[modeIdx].doneH * 3600000UL) {
      running = false;
      if (!notified) {
        notified = true;
        char m[64];
        snprintf(m, sizeof(m), "%s done at %.1fC", MODES[modeIdx].name, curTemp);
        sendTelegram(m);
        for (int i = 0; i < 3; i++) { digitalWrite(BUZZ_PIN, HIGH); delay(200);
                                      digitalWrite(BUZZ_PIN, LOW); delay(150); }
      }
    }
  }

  if (now - uiTick >= 1000) {            // UI 每秒重畫(OLED 無閃爍顾虑)
    uiTick = now;
    drawOled();
    Serial.printf("%.2f C / set %.1f  ssr=%d lock=%d\n",
                  curTemp, setpoint, (int)ssrOn, (int)lockedOut);
  }
  delay(5);
}

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
  1. 列印零件:全 PETG;控制器盒開孔朝下,法蘭墊圈一列印
  2. AC 側接線(斷電):插頭→保險絲→60°C 溫度保險絲→SSR→加熱墊,端子全部進帶蓋接線盒
  3. SSR 入支架、控制器入盒:SSR 離牆 15mm 導流;盒蓋鎖緊前不做任何通電
  4. 探針入箱:穿法蘭、蛇形槽留彎、8cm 止擋讓探針懸於液體中央
  5. 燒錄 + 兩點校正:冰水混合物讀 0°C、溫水對照溫度計讀 35°C,回填 offset
  6. 空箱烤機:42°C 恆溫 1 小時,序列埠看穩定度 ≤±0.3°C、SSR 週期正常
  7. 水盆實測:2L 水從冷藏溫度升到 42°C,記錄升溫曲線,確認不超調 1°C

🔧 問題排除

問題原因解決方法
DS18B20 恆讀 -127°C無 4.7kΩ 上拉 / 接錯腳DQ→3V3 加 4.7kΩ;換到 GPIO4,避開 strapping(GPIO0/2/12/15)
溫度衝過頭 2°C 以上探針量到空氣或箱壁 / Kp 太大探針浸入液體中央;Kp 18→12、KD 6→3
溫度怎麼都到不了設定加熱功率 < 箱體散熱加蓋加保溫層;環境 <15°C 時 20W 不夠,換 40W 墊(保險絲同步換 3A)
關燈了溫度還在爬SSR 漏電流(內附 RC 緩衝型通病)改買「直流/阻性負載用」SSR,或並 10kΩ 洩電
蜂鳴器開機一直叫GPIO26 接 DAC,開機浮接本程式 setup 第一件事拉 LOW;線路照接線表
Telegram 收不到沒對時,HTTPS 憑證驗證失敗確認 SNTP 有回傳(Serial 印 time());或改 HTTP 代理
OLED 忽亮忽暗控制環與 I2C 搶時序本程式 UI 独立 1 秒節奏;仍異常就把 OLED 供電改走板 3V3 獨立走線

🎯 進階延伸

📦 檔案清單

fermentation-box/
├── src/
│   └── fermentation-box.ino     # ESP32 程式碼(本頁完整程式)
├── stl/
│   ├── control-box.stl          # 控制器盒(防濺壁掛)
│   ├── probe-flange.stl         # 探針固定法蘭
│   ├── ssr-bracket.stl          # SSR 散熱支架
│   └── knob-cap.stl             # 按鍵帽
├── f3d/
│   └── fermentation-box-design.f3d
├── manual/
│   └── assembly.html            # 圖文組裝說明書
└── README.md                    # 說明文件
← 上一篇:ESP32-C3 智能護眼桌燈 📚 系列索引