把溫控從 Raspberry Pi 搬到 ESP32:苔蘚培養箱韌體的實戰筆記
我們的苔蘚培養箱第一代是用 Raspberry Pi 直接接感測器和繼電器,Pi 上同時跑控制邏輯和網頁。第二代把感測、PID 控制、所有排程都搬到 ESP32,Pi 只負責收資料、轉發設定、畫歷史曲線。
這篇整理 ESP32 韌體的設計,以及從 2.0.10 一路改到 2.0.21 的過程中踩過的坑。後來的多裝置後端,請參考從一台到多台 ESP32。
為什麼要把控制搬到 ESP32
| 第一代(Pi 直接控制) | 第二代(ESP32 控制) | |
|---|---|---|
| 控制迴路 | 在 Pi 上 | 在 ESP32 上 |
| Pi 重開機、更新程式時 | 控制中斷 | 培養箱照常運作 |
| 每台培養箱的硬體 | 一台 Pi | 一顆 ESP32 |
環境控制最怕的是「控制者不在」。Pi 是一台完整的 Linux 電腦,更新套件、部署新版程式、SD 卡出問題都可能讓它停下來。把控制迴路放到專心做一件事的 MCU 上,Pi 掛掉時最多是看不到資料,培養箱的溫度照樣守得住。
硬體配置
| 通道 | 功能 |
|---|---|
| DS18B20 × 2 | 培養箱溫度、散熱水箱溫度(同一條 1-Wire 匯流排) |
| Relay 1 | 製冷片(PID 控制) |
| Relay 2 | 箱內循環風扇 |
| Relay 3 | 培養照明 |
| Relay 4 | 加濕器 |
韌體用 PlatformIO 加 Arduino 框架,同時支援 ESP32-WROOM 和 ESP32-S3 兩種板子。兩者的腳位不同,透過 platformio.ini 的 build flags 切換,同一份程式碼編出兩個版本:
[env:wroom]
board = esp32dev
build_flags =
-D PIN_DS18B20=13
-D PIN_RELAY1=16
...
[env:s3]
board = esp32-s3-devkitc-1
韌體設計重點
1. 用一般繼電器做 PID:時間比例控制
製冷片接的是一般的 On/Off 繼電器,沒辦法調整功率。所以 PID 算出 0~100% 的輸出後,轉成10 秒為一個週期的開關時間比例:輸出 40%,就是每 10 秒開 4 秒、關 6 秒。
float on_time = window_sec_ * (duty / 100.0f); // window_sec_ = 10
want = elapsed < on_time;
if (duty <= 1.0f) want = false; // 太小就不要開,避免繼電器頻繁跳動
else if (duty >= 99.0f) want = true;
頭尾的兩個門檻很重要:輸出 1% 時硬開 0.1 秒,只會讓繼電器白白多跳一次。
2. 加速降溫,但要有遲滯
溫差很大時(例如剛開機或剛開過箱門),溫和的 PID 參數降溫太慢。韌體在誤差超過門檻時改用更積極的參數,誤差降到門檻的一半以下才換回來:
if (abs_err > boost_threshold) {
boosting = true;
} else if (abs_err < boost_threshold * 0.5f) {
boosting = false;
}
如果進出用同一個門檻,誤差剛好在門檻附近時,參數會一直來回切換。用兩個不同的門檻(遲滯),就能避免這種抖動。
3. 日夜溫度切換:降溫要漸進
培養箱白天和夜間的目標溫度不同。升溫直接切換沒問題,因為只是減少製冷;但降溫如果一次切換,製冷片會瞬間滿載衝刺。所以目標溫度變低時,改成在 120 分鐘內線性下降:
// 只有往較低目標切換時才漸進
if (target >= prev || ramp_min <= 0.0f) return target;
float ramp_s = ramp_min * 60.0f;
if ((float)elapsed >= ramp_s) return target;
return prev + (target - prev) * ((float)elapsed / ramp_s);
漸進進度是用本地時鐘算出來的,而不是存一個計數器。所以就算 ESP32 在漸進途中重開機,也能直接算出「現在應該是幾度」。
4. 製冷停止後,風扇再多轉 60 秒
Relay 2 接的是箱內的循環風扇。製冷片停下來的瞬間,冷端還很冷,風扇如果跟著立刻停,這些冷量就留在冷端,不但浪費,也容易結露。所以製冷結束後,風扇會延遲 60 秒才停。
這裡有一個 MCU 上常見的細節:millis() 大約 49 天會溢位歸零。時間比較一律寫成 now_ms - last_cool_ms_ >= 延遲時間,用無號整數相減,溢位時結果仍然正確。
5. 不寫死任何網路設定
Wi-Fi 帳密和伺服器網址都不寫在程式裡,而是用 WiFiManager 配網:
- 第一次開機,或連不上 Wi-Fi 超過 30 秒時,ESP32 會開一個 AP,用手機連上去就能設定
- 設定存在 NVS,重開機後自動套用
- 開機後長按 BOOT 鍵 3 秒,可以清除設定重新配網
ESP32 只會主動連出去回報資料,不開任何連入的服務,也不會在網路上掃描伺服器的 IP。
6. 時間:NTP 為主,伺服器時間備援
日夜排程和照明排程都需要正確的時間。ESP32 開機後用 NTP 校時,並設定台灣時區:
// POSIX 時區字串 CST-8 = UTC+8。不要用 configTime(0, 0),那會把時區設成 UTC
configTzTime("CST-8", "pool.ntp.org", "time.nist.gov");
另外,伺服器回應每次回報時都會附上目前的 Unix 時間。如果 ESP32 還沒透過 NTP 校時成功,例如區網連不到外部的 NTP 伺服器,就改用這個時間校時。這樣即使斷了對外網路,排程也不會因為時間錯誤而亂掉。
踩過的坑
坑 1:Wi-Fi 打斷了 1-Wire 的時序
這是整個專案花最多時間的問題。症狀是溫度讀取一直失敗:presence 訊號正常,代表探頭有接上,但搜尋不到裝置,或者讀出來的資料 CRC 錯誤。
我們依序嘗試了:
| 版本 | 嘗試 | 結果 |
|---|---|---|
| 2.0.11 | 改用已知的 ROM 序號直接讀取 | 短暫成功後又失敗 |
| 2.0.12 | 換成 OneWireNg 函式庫 | 仍然讀不到 |
| 2.0.13~15 | 用 Skip-ROM 指令、不依賴搜尋,CRC 失敗重試 3 次 | 讀得到,但偶爾仍有 CRC 錯誤 |
| 2.0.17 | 把資料腳從 GPIO4 改到 GPIO13 | 排除腳位問題,CRC 錯誤依舊 |
| 2.0.18 | 改用 RMT 硬體產生 1-Wire 時序 | 解決 |
根本原因是:1-Wire 是靠微秒級的精準時序溝通的協定,傳統的函式庫用 CPU 控制 GPIO、搭配 delayMicroseconds 來產生波形(bit-bang)。但 ESP32 上同時跑著 Wi-Fi 和 FreeRTOS,CPU 隨時可能被中斷,時序就被打亂了。函式庫雖然在送每一個 bit 時都會關掉中斷,但 bit 與 bit 之間、480 微秒的重置脈衝期間,仍然可能被切走。
最後的解法是改用 ESP32 的 RMT(Remote Control)周邊。它原本是設計來產生紅外線遙控訊號的,可以由硬體精準地輸出和接收脈衝,完全不佔用 CPU,也就不受 Wi-Fi 影響:
// RMT TX ch0 / RX ch1:脈衝由硬體產生,不受 Wi-Fi/FreeRTOS 打斷
bus = new OneWire32((uint8_t)pin, 0, 1, 0);
教訓: 在 ESP32 上,任何需要精準時序的協定,都應該優先找硬體周邊來做,例如 RMT、I2S、SPI,而不是用 CPU bit-bang。
坑 2:接上第二顆探頭後,溫度跳到 36°C
後來在散熱水箱加了第二顆 DS18B20。一接上去,培養箱溫度突然從 26°C 跳到 36°C,PID 立刻衝到 100% 全力製冷。
原因是先前為了解決坑 1 改用的 Skip-ROM 指令:它的意思是「匯流排上的所有裝置都聽好」,只適用於只有一顆探頭的情況。兩顆同時回應時,讀到的其實是另一顆的溫度。
修正方式是改回逐顆用 ROM 序號讀取。但新的問題是:哪一顆是培養箱、哪一顆是水箱? 程式裡原本寫死的預設序號,剛好跟實機的兩顆相反。
最後的做法是用物理規則來判斷:水箱一定比培養箱熱。
- 兩顆的溫差達到 1.0°C 以上時,判定較冷的是培養箱,然後把兩顆的序號寫入 NVS 鎖定
- 溫差不到 1.0°C(例如剛上電時兩顆溫度接近)就只暫定,不寫入
- 鎖定之後不再每次比較溫度,因為製冷停止時,水箱可能降溫到比培養箱還冷
- 鎖定的培養箱探頭如果離線,就當作讀取失敗,PID 維持上一次的輸出,絕對不拿水箱溫度來頂替
- 提供「對調」和「重新判定」兩個指令,萬一判斷錯誤可以從網頁修正
教訓: 用 PID 控制的系統,讀錯感測器比讀不到更危險。寧可讀取失敗、維持現狀,也不要拿錯的數值去控制。
坑 3:冷氣自動化,每天多耗 7 度電
夏天室溫高時,製冷片常常滿載。我們試過讓 Pi 依照 PID 輸出,透過米家的冷氣伴侶自動開關房間的冷氣,來分擔製冷片的負載。
為了避免冷氣一下開一下關,規則從一開始的「PID 輸出 100% 持續 20 分鐘就開」,改成用每分鐘平均值判斷,再加上最短運轉時間、每日啟動次數上限等限制。
上線兩天後發現:每天多耗大約 7 度電。冷氣是整個房間的大負載,用它來幫一個小培養箱降溫,代價遠高於讓製冷片多跑一點。最後我們把自動開關拿掉,只在儀表板保留手動遙控,需要時由人決定。
教訓: 自動化不一定比較划算。加入新的控制手段前,先算清楚它的能源成本;上線後也要實際量測,不要只看功能有沒有動。
坑 4:燒錄後忘了改版本號
有一次燒錄新韌體時,忘了更新程式裡的版本字串,儀表板顯示的版本跟舊版一樣,完全分不出新韌體到底有沒有燒進去。之後每次改韌體都會先改版本號,ESP32 也會在每次回報時附上版本,從儀表板就能確認目前執行的是哪一版。
小結
這個專案的韌體本身不複雜,但每一個坑都來自真實環境:
- 控制迴路放在 MCU 本地,伺服器只當協調者
- 精準時序交給硬體周邊,不要跟 Wi-Fi 搶 CPU
- 感測器的角色要用物理規則判斷並鎖定,讀錯比讀不到更危險
- 繼電器控制要考慮遲滯和最小動作時間,避免頻繁跳動
- 自動化要算能源帳,並且上線後實際量測
如果你也在做溫控、環境監控之類的 ESP32 或機電整合專案,歡迎來信交流。