從一台到多台 ESP32:苔蘚培養箱的多裝置協調後端設計
我們之前分享過一個苔蘚培養箱的實作案例:用溫度感測器、製冷和加濕的 PID 控制,加上模擬日照長度的照明,維持苔蘚需要的環境。
培養箱從一台變成好幾台之後,原本的後端就不夠用了。這篇整理我們怎麼把它改寫成能管理多台 ESP32 的協調後端 moss-climate-hub,以及過程中的設計取捨。
架構演進:控制邏輯放在哪裡?
這個專案經歷了三個階段:
| 階段 | 架構 | 問題 |
|---|---|---|
| 第一代 | Raspberry Pi 直接接感測器和繼電器,Pi 上跑控制與網頁 | 一台培養箱就需要一台 Pi |
| 第二代 | ESP32 負責感測與控制,Pi 只負責收資料、下命令、存歷史 | 後端是單裝置設計,只能管一台 |
| 第三代 | 後端改寫成多裝置版本,一個服務管理多台 ESP32 | 本篇的主題 |
第二代的關鍵決定是:PID 控制、日夜排程、加濕與照明排程,全部都在 ESP32 上執行。伺服器不碰任何 GPIO,只扮演協調者:
- 收 ESP32 回報的遙測資料(溫度、繼電器狀態、排程狀態)
- 把使用者從網頁送出的設定,排隊轉交給 ESP32
- 記錄歷史資料,提供網頁和 API 查詢
這個分工帶來一個很大的好處:網路斷線或伺服器重開機時,培養箱照樣維持溫度。控制迴路完全在本地,伺服器掛掉只是暫時看不到資料、改不了設定而已。對需要長時間穩定運作的環境控制來說,這比任何高可用架構都可靠。
也因為伺服器只是協調層,要支援多台裝置時,只需要改寫後端,不用動 ESP32 的控制邏輯。
整體架構
ESP32 #1 ─┐ POST /api/devices/<id>/telemetry?key=...
ESP32 #2 ─┼──────────────────────────────────────▶ Flask(gunicorn:1 worker、多 thread)
ESP32 #N ─┘ │
├─ DeviceManager
網頁 / App ── Authorization: Bearer <ADMIN_TOKEN> ──▶ │ ├─ DeviceController #1
│ ├─ DeviceController #2
│ └─ DeviceController #N
│
┌─────────────────┴──────────────────┐
SQLite(裝置清單、溫度歷史, 每台裝置一個 JSON 狀態檔
每一筆都帶 device_id) data/devices/<id>/state.json
- DeviceManager:依照
device_id建立並快取每台裝置的控制器 - DeviceController:一台裝置的協調器,擁有自己的命令佇列和狀態檔
- SQLite:裝置清單和溫度歷史共用一個資料庫,每筆資料都帶
device_id
設計 1:用 HTTP 輪詢下發命令
伺服器要怎麼把「把夜間溫度改成 18°C」這種設定送到 ESP32?
最直覺的做法是伺服器直接連 ESP32,但 ESP32 在家用網路的 NAT 後面,IP 也可能變動。我們反過來,讓 ESP32 主動來拿:
ESP32 伺服器
│ POST telemetry(目前狀態 + 已套用的命令 ID) │
│ ───────────────────────────────────────▶ │ 1. 從佇列移除已套用的命令
│ │ 2. 記錄遙測、更新最後上線時間
│ 回應:伺服器時間 + 尚未套用的命令 │
│ ◀─────────────────────────────────────── │
│ 套用命令,記下命令 ID │
│ (下一次 POST 時一起回報) │
ESP32 每秒回報一次遙測。伺服器的回應裡附上兩樣東西:
- 目前的 Unix 時間與時區:ESP32 平時用 NTP 校時;如果 NTP 還沒校時成功,就改用這個時間,日夜排程和照明排程才不會因為時間錯誤而亂掉
- 尚未套用的命令:ESP32 逐一套用後,把命令 ID 記下來,下一次回報時再告訴伺服器,伺服器才從佇列刪除
命令佇列本身很單純:
class CommandQueue(object):
def enqueue(self, op, **fields):
cmd = {"id": "c-%d" % self._next_id, "op": op}
cmd.update(fields)
self._next_id += 1
self._items.append(cmd)
return cmd
def emergency_stop(self, **fields):
# 緊急停止插到最前面
cmd = {"id": "c-%d" % self._next_id, "op": "emergency_stop"}
cmd.update(fields)
self._next_id += 1
self._items.insert(0, cmd)
return cmd
def ack(self, ids):
drop = set(ids or [])
self._items = [c for c in self._items if c.get("id") not in drop]
這是至少送達一次(at-least-once)的設計:如果 ESP32 套用了命令,但回報前就斷線,下一次會再收到同一個命令。所以命令都設計成設定值(例如「把設定溫度設為 18」),而不是增量(例如「設定溫度加 1」)。重複套用設定值不會有副作用。
另外兩個小細節:
- 緊急停止插隊:一般命令依序排在後面,緊急停止永遠排在最前面
- 失敗時退避:ESP32 回報失敗時,回報間隔加倍,最長 10 秒;成功後恢復為 1 秒,避免伺服器重開時被大量重試淹沒
設計 2:每台裝置一個狀態檔
命令佇列、最後一次回報的內容、最後上線時間這些執行期狀態,我們沒有放進資料庫,而是每台裝置一個 JSON 檔。
原因是隔離:某台裝置的狀態檔壞掉,只會影響那一台,不會拖垮其他裝置或整個資料庫。而且這沿用了第二代已經驗證過的程式碼,只是把檔案路徑改成帶 device_id。
寫入時用的是常見的原子寫入:先寫到暫存檔,fsync 確保內容落到磁碟,再用 os.replace 換掉正式檔案。如果寫到一半斷電,最多只會留下沒寫完的暫存檔,正式檔案永遠是完整的。
tmp = self.path + ".tmp"
with open(tmp, "w") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, self.path)
讀取時如果檔案損壞,就當作沒有狀態,從預設值重新開始,不會讓服務啟動失敗。
設計 3:兩種身分、兩種認證
系統裡有兩種使用者,權限完全不同:
| 身分 | 能做什麼 | 認證方式 |
|---|---|---|
| 裝置(ESP32) | 只能回報自己的遙測 | 每台裝置一組 api_key |
| 管理者(網頁、App) | 管理所有裝置、修改設定 | Authorization: Bearer <ADMIN_TOKEN> |
裝置的 api_key 用 secrets.token_urlsafe(32) 產生,資料庫只存 SHA-256 雜湊值,明文只在建立裝置時顯示一次。因為 key 本身是高熵的隨機值,不需要像密碼那樣用 bcrypt 加鹽。管理者 token 的比對使用 secrets.compare_digest,避免時序攻擊。
這裡有一個我們刻意接受的取捨:裝置的 api_key 是放在網址的 query string 裡(?key=...),而不是 HTTP header。原因是現有韌體透過 WiFiManager 只能設定一個完整網址,不會額外加 header。這樣新後端不用改韌體就能直接接上現有的 ESP32。
代價是 key 可能出現在 web server 或 proxy 的存取日誌裡。目前服務只在區網內運作,風險可以接受;等韌體升級後,會改成放在 header,並在過渡期同時接受兩種方式。
設計 4:Python 和 C++ 共用測試向量
PID 和排程邏輯有兩份實作:ESP32 上的 C++ 版本負責實際控制,Python 版本則給開發用的模擬裝置使用。兩份程式要怎麼確保行為一致?
我們的做法是準備一份黃金測試向量(golden vectors),當作兩邊共同的對照基準:
{
"pid_kp": 8.0, "pid_ki": 0.3, "pid_kd": 1.0,
"pid_setpoint": 22.0,
"pid_temps": [24.0, 23.5, 23.0, 22.8, 22.5],
"pid_outs": [16.0, 11.95, 8.25, 7.19, 4.84],
"comment": "C++ native tests should match pid_outs and schedule flags."
}
給定相同的參數和溫度序列,兩個版本算出來的 PID 輸出必須一模一樣。目前 Python 端的測試會逐一比對這組數值;檔案裡也註明 C++ 端的原生測試要對上同一組輸出,這是接下來要補齊的部分。
這個做法的價值在於:只要有一份數值是兩邊都認的,任何一邊改了演算法而忘了改另一邊,就有明確的依據可以抓出差異,不必靠人工比對兩種語言的程式碼。
設計 5:不用硬體也能開發
在 .env 設定 SIMULATE_DEVICE_IDS=dev-sim1,就會啟動一台模擬的 ESP32。它用 Python 版的控制邏輯模擬溫度變化,照著真實裝置的協定回報遙測、接收命令。
這讓網頁和 API 可以在沒有任何硬體的情況下開發和測試,也能同時模擬好幾台裝置,驗證裝置之間真的互不干擾。專案目前有 99 個測試,其中有一組專門驗證多裝置隔離:A 裝置的遙測不會出現在 B 裝置的狀態裡,送給 A 的命令也不會進到 B 的佇列。
刻意保持簡單的地方
個人規模的專案,最容易犯的錯是過度設計。幾個我們刻意不做的事:
- 繼續用 SQLite,不換 PostgreSQL:幾台裝置、每秒一次回報,SQLite 綽綽有餘,而且不用維運資料庫
- gunicorn 只開 1 個 worker、多個 thread:每台裝置的控制器狀態放在記憶體裡,多個 process 之間會不同步。等真的需要擴充到多 process,再把狀態全部收進資料庫
- 不做多使用者帳號系統:先用單一管理者 token。之後要開放多人使用時,這個 token 可以直接轉成第一個管理者帳號
- 用 Docker 部署:目前跑在家裡的機器上,但隨時可以搬到雲端或 VPS
小結
回頭看,這個專案最重要的決定不是用什麼框架,而是控制邏輯放在哪裡:
- 控制迴路留在裝置本地:伺服器掛掉,培養箱照樣運作
- 讓裝置主動輪詢:不用處理 NAT 和動態 IP,命令用「確認後才刪除」保證送達
- 命令設計成設定值:重複送達也不會出錯
- 隔離每台裝置的狀態:一台出問題不影響其他台
- 用共用測試向量當作兩種語言實作的對照基準
這些原則不只適用於培養箱,任何「多台 MCU 加上一個中央後端」的 IoT 系統都可以參考。如果你也有類似的機電整合或 IoT 專案需求,歡迎來信討論。