📋 專案概述
我太太自制優格兩年,走過完整的冤屈史。第一台是市售優格機:面板寫 43°C,實際上那顆感測器量的是加熱片溫度,不是牛奶溫度——第一批僥倖成功,第二批水乳分離,800 毫升鮮奶連罐子進冰箱。第二台換「發酵箱」,夏天陽台環境溫度就 33°C,箱內加熱一衝就過頭;她查了納豆菌資料後跟我抱怨:「納豆菌 45°C 以上會開始死,我那箱 600 元的黃豆就是這樣被一台箱子殺光的。」
市售方案兩頭不著:工業「精準發酵控制器」1,500 元起跳、長得像配電箱、沒有通知;家用優格機便宜但根本沒有回讀。而發酵這件事的殘酷在於——±1°C 和 ±3°C 是「菌種工作」與「菌種死亡」的差別,42°C 做優格、45°C 做納豆、30°C 揉麵團,每條曲線都是菌的生死線。
這個專案用 600 元做一台真的量液體溫度、真的 PID 控制的發酵恆溫箱:
- DS18B20 防水探針直插內容物:量的是牛奶、不是空氣,這是最多人翻車的第一步
- PID + 30 秒時間比例輸出:SSR 固態繼電器無接點,箱內濕氣環境不氧化;穩定度 ±0.3°C
- 四模式預設:優格 42°C/納豆 45°C/麵團 30°C/康普茶 26°C 高溫告警
- 兩級超溫保護:46°C 斷輸出、50°C 硬鎖死,硬體再串 60°C 溫度保險絲——軟體失靈也不火災
- NTP 時鐘 + Telegram 完成推播:八小時的納豆不用守著,時間到手機響
- OLED 即時狀態 + SPIFFS 溫度記錄,每批都能導 CSV 複盤
做完之後她最滿意的一句話:「這台箱子知道我在養活的東西。」
為什麼選經典款 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 DevKitC | 1 | ~120 元 | 經典雙核;避開 GPIO34~39(無輸出) |
| 溫度探針 | DS18B20 防水探頭(附 4.7kΩ 上拉) | 1 | ~45 元 | 直插液體;±0.5°C,兩點校正後 ±0.2°C |
| 固態繼電器 | SPS-D02(DC3-32V 控制) | 1 | ~120 元 | 潮濕環境用 SSR 不用機械繼電器(接點必氧化) |
| 加熱器 | 20W 矽膠加熱墊 10×20cm | 1 | ~120 元 | 表面 <90°C,貼壁不燙壞保麗龍 |
| 顯示 | 0.96" OLED SSD1306 I2C | 1 | ~75 元 | 地址 0x3C |
| 蜂鳴器 | 有源 5V(高電平觸發) | 1 | ~6 元 | 超溫警報 + 完成提示 |
| 電源 | 5V 2A USB 電源器 | 1 | ~60 元 | 控制側獨立供電 |
| AC 材料 | 電源線+插頭+2A 慢斷保險座+端子台 | 1 組 | ~80 元 | 加熱迴路全程走保險絲 |
| 保險 | 60°C 溫度保險絲(軸型) | 1 | ~25 元 | 串在 AC 迴路,軟體失靈的最後一道 |
| 保溫箱 | 35L 保溫箱(既有) | 1 | 0 元 | PU/保麗龍皆可;舊冰箱冷藏庫也行 |
| 合計 | ~651 元 | 商用精準發酵箱 1,500 元起跳 |
🔌 接線圖
詳細接線
| ESP32 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO4 | DS18B20 DQ | DQ→3.3V 需 4.7kΩ 上拉(探頭版多內建,沒有就自己加) |
| GPIO27 | SSR IN(+) | SSR IN(−) 接 GND(ESP32 灌電流式驅動,27 是安全輸出腳) |
| GPIO22 / 21 | OLED SDA / SCL | I2C 0x3C;模組自带上拉 |
| GPIO26 | 蜂鳴器 +(經 1kΩ) | − 接 GND;setup 第一件事拉 LOW(26 開機浮接會亂叫) |
| GPIO32 | 模式按鍵 | 鍵對 GND,啟用內部上拉;避開 GPIO34~39(無輸出電路) |
| VIN / GND | 5V 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 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 控制器盒(壁掛、防濺) | 1 | PETG | 深灰 | ~2.5 hr |
| 探針固定法蘭(箱蓋) | 1 | PETG | 白 | ~30 min |
| SSR 散熱支架 | 1 | PETG | 深灰 | ~25 min |
| 按鍵帽 | 1 | TPU 95A | 白 | ~10 min |
列印設定
- 材質:全 PETG——PLA 熱變形溫度只有 ~55°C,發酵箱旁 + 夏天廚房就是它的地獄
- 填滿:25% 直線;控制器盒牆厚 2.4mm 加剛性
- 防水:控制器盒所有開孔朝下;接縫處塗食品級矽利康
- 特殊:探針法蘭與箱蓋之間墊 3D 列印墊圈 + 矽膠 O 環,可拆洗不積汙
設計要點
- 探針法蘭蛇形防折彎:探針出線處留 ≥3cm 彎曲半徑的蛇形槽——探針 90% 的死法是出线根部被折斷
- 探針懸於液體中央:法蘭附 8cm 深度止擋,探針頭不碰桶壁不碰底(碰壁 = 量到壁溫,回到優格機的老錯誤)
- SSR 支架懸空導流:SSR 是半導體,連續開關會溫升;支架讓它離牆 15mm 自然對流
- 控制器盒防濺等級:開孔全部朝下 + 電纜固定頭,廚房水氣與清洗噴濺都進不去
- 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 組裝說明書
- 列印零件:全 PETG;控制器盒開孔朝下,法蘭墊圈一列印
- AC 側接線(斷電):插頭→保險絲→60°C 溫度保險絲→SSR→加熱墊,端子全部進帶蓋接線盒
- SSR 入支架、控制器入盒:SSR 離牆 15mm 導流;盒蓋鎖緊前不做任何通電
- 探針入箱:穿法蘭、蛇形槽留彎、8cm 止擋讓探針懸於液體中央
- 燒錄 + 兩點校正:冰水混合物讀 0°C、溫水對照溫度計讀 35°C,回填 offset
- 空箱烤機:42°C 恆溫 1 小時,序列埠看穩定度 ≤±0.3°C、SSR 週期正常
- 水盆實測: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 獨立走線 |
🎯 進階延伸
- 雙探針前饋:+1 支量箱內空氣,偵測「開門冷衝擊」提前補火,開門取樣不再打斷發酵
- 發酵曲線:凌晨自動 42→38°C 慢發酵,乳酸菌風味物質更複雜——把 MODES 換成時間表陣列即可
- 記錄上雲:每 5 分鐘 POST 到自家 InfluxDB + Grafana,兩年發酵史一張圖看完
- 多箱擴展:DS18B20 天生單總線多點,一台 ESP32 掛三支探針三個 SSR,酸奶、納豆、康普茶同時開工
📦 檔案清單
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 # 說明文件