← 回文章列表

從一台到多台 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

小結

回頭看,這個專案最重要的決定不是用什麼框架,而是控制邏輯放在哪裡:

  1. 控制迴路留在裝置本地:伺服器掛掉,培養箱照樣運作
  2. 讓裝置主動輪詢:不用處理 NAT 和動態 IP,命令用「確認後才刪除」保證送達
  3. 命令設計成設定值:重複送達也不會出錯
  4. 隔離每台裝置的狀態:一台出問題不影響其他台
  5. 用共用測試向量當作兩種語言實作的對照基準

這些原則不只適用於培養箱,任何「多台 MCU 加上一個中央後端」的 IoT 系統都可以參考。如果你也有類似的機電整合或 IoT 專案需求,歡迎來信討論。