Learning Objectives
- 從需求出發,規劃感測節點的電路、MQTT 主題與訊息格式
- 把 Wi-Fi、MQTT、感測與指令處理整合成一個結構清楚的程式
- 用 ticks_diff 安排主迴圈:常常檢查指令、每隔一段時間才量測發布
- 處理感測器讀取失敗與連線逾時,讓節點長時間穩定運作
把前面學過的東西組成一個作品
這一課的目標是一個教室環境感測節點:
- 每隔一段時間量測溫濕度,發布到 MQTT,讓儀表板或手機看得到。
- 接收遠端指令:開關燈號、調整量測間隔。
- 溫度太高時發出警報。
- 斷電重開、Wi-Fi 不穩、感測器線鬆掉,都不會讓它「卡住」。
用到的每一塊你都學過:GPIO(第 3 課)、DHT22(第 9 課)、Wi-Fi(第 12 課)、MQTT(第 14 課)、省電(第 15 課)、設定檔(第 16 課)。 專題的難處不在新語法,而在先規劃清楚,再一塊一塊接起來、一塊一塊測試。
第一步:寫下規格
| 項目 | 決定 |
|---|---|
| 量測 | DHT22 溫濕度,預設每 2 秒一次(正式使用可以改成幾分鐘一次) |
| 輸出 | 板上 LED(GPIO2)當作遠端可控制的燈號 |
| 供電 | USB 變壓器,一直開著(要用電池時改成第 15 課的深度睡眠,只發布、不收指令) |
| 網路 | Wi-Fi+MQTT,Wi-Fi 名稱、密碼、broker、節點名稱放在 config.json |
第二步:設計主題與訊息
| 主題 | 方向 | 內容(JSON) |
|---|---|---|
uedu/node1/data |
節點發布 | {"temp": 26.2, "hum": 61.7} |
uedu/node1/status |
節點發布(保留訊息) | online |
uedu/node1/alert |
節點發布 | {"temp": 31.2} |
uedu/node1/cmd |
節點訂閱 | {"led": 1}、{"interval": 5} |
所有主題都以 uedu/節點名稱/ 開頭:之後多裝幾個節點,只要改設定檔裡的名稱;儀表板訂閱 uedu/+/data 就能一次收到全部節點。
溫度與濕度放在同一則 JSON 裡,兩個數字一定是同一次量測的,接收端也只要處理一則訊息。
逐步看:讀設定、有逾時的連線、宣告上線
程式拆成兩個函式:connect_wifi 負責 Wi-Fi,connect_mqtt 負責 broker。工作目錄裡已經有一份 config.json。
注意 connect_wifi 不會永遠等下去:超過時間就回傳 False,由主程式決定怎麼辦。
主迴圈怎麼安排
節點同時有兩件事要做,節奏不一樣:
- 檢查指令:越常越好,按下手機按鈕之後燈才會馬上亮。
- 量測發布:每隔幾秒或幾分鐘一次就夠了;DHT22 本身也不適合讀得太頻繁。
如果迴圈寫成「量測、發布、sleep(2)」,指令最慢要等 2 秒才處理(第 14 課練習的問題)。
比較好的寫法是:迴圈每 0.2 秒轉一圈、每圈都檢查指令,再用 ticks_diff 看看「距離上次量測過了多久」,到了才量測:
last = time.ticks_ms() - interval_ms # 讓第一次馬上量測
while True:
client.check_msg() # 每圈都做
now = time.ticks_ms()
if time.ticks_diff(now, last) >= interval_ms:
last = now # 時間到了才做
...量測、發布...
time.sleep(0.2)
這個寫法也讓「量測間隔」變成一個變數,可以被遠端指令修改。
逐步看:常常檢查指令、定時發布
模擬的 broker 在 1.5 秒送來 {"led": 1}、2.6 秒送來 {"interval": 1}。
看網路紀錄:一開始每 2 秒發布一次資料,收到 interval 指令之後變成每 1 秒一次。可以用時間軸拖到後半段比較。
會壞的地方要先想好
感測節點常常裝在沒人看著的地方,要假設每一個外部的東西都可能出錯:
| 可能出錯 | 會發生什麼 | 怎麼處理 |
|---|---|---|
| 感測器線鬆掉 | measure() 拋出 OSError |
用 try 接住,這次不發布,記下失敗次數 |
| Wi-Fi 連不上 | isconnected() 一直是 False |
等待加上逾時,隔一段時間重試,最後可以先睡覺 |
| broker 斷線 | publish()、check_msg() 拋出 OSError |
接住後重新連線(必要時重新連 Wi-Fi) |
| 程式卡住 | 節點不再回應 | 看門狗計時器(machine.WDT):程式太久沒「餵」它就自動重開機 |
| 意外斷電 | 重新開機 | 程式存成 main.py,開機自動執行;設定都在 config.json |
感測器讀取失敗的寫法:
def read_temp():
try:
sensor.measure()
return sensor.temperature()
except OSError: # 感測器沒有回應(例如線鬆了)
return None # 告訴呼叫的人「這次沒有數字」
temp = read_temp()
if temp is not None: # 量不到就不發布,不要發布 0 或上一次的值
client.publish(BASE + "data", json.dumps({"temp": temp}))
連線前還可以用 client.set_last_will(base + "status", "offline", retain=True) 交給 broker 一則遺囑訊息:
節點意外斷線時,儀表板會看到 offline。
下一個範例的 Wi-Fi 密碼少打一個字,看程式怎麼重試、最後先睡覺,而不是卡在等待的迴圈裡。
逐步看:連不上時重試,不要卡住
密碼錯了,每一次連線都會失敗。看 connect_wifi 怎麼在 1 秒後放棄,for 迴圈怎麼重試三次、每次多等一點,最後進入深度睡眠(第 15 課)。
小測驗:規劃與結構
有 5 個感測節點,名稱 node1~node5,資料都發布到 uedu/節點名稱/data。儀表板想一次收到全部節點的資料,要訂閱什麼?
主迴圈每 0.2 秒轉一圈,interval_ms 是 3000。大約每幾圈會量測發布一次?
DHT22 讀取失敗時,下面哪一種處理最恰當?
小測驗:程式細節
下面的程式會印出什麼?
interval_ms = 2000
def on_cmd(cmd):
interval_ms = cmd["interval"] * 1000
on_cmd({"interval": 5})
print(interval_ms)
下面的程式模擬主迴圈的時間判斷,會印出什麼?
last = 0
interval = 1000
sent = []
for now in range(0, 3001, 200):
if now - last >= interval:
last = now
sent.append(now)
print(sent)
部署與測試清單
程式在模擬器與 Thonny 裡都正常之後:
- 把程式存成
main.py,設定存成config.json,上傳到板子(第 16 課)。 - 用 USB 電源(不接電腦)重新開機,確認它自己連線、開始發布。
- 用另一個 MQTT 用戶端(手機 App 或電腦上的程式)訂閱
uedu/node1/#,看得到 data 與 status,發布 cmd 指令看燈有沒有反應。 - 故意製造問題:拔掉 DHT22 的線、關掉 Wi-Fi 分享器再打開、拔電再插上。每一種情況節點都要能自己恢復。
- 把各種情況與結果記下來,就是專題報告裡的「測試」一節。
可以繼續擴充的方向:加 OLED 顯示目前溫度(第 9 課)、用 NeoPixel 顯示舒適度(第 8 課)、 改成電池供電+深度睡眠(第 15 課),或在樹莓派上架自己的 broker 與儀表板(樹莓派軌)。
重點整理
- 專題先寫規格:量測什麼、輸出什麼、怎麼供電、主題與訊息格式。主題用「專案/節點/項目」分層,內容用 JSON。
- 程式拆成函式(連 Wi-Fi、連 MQTT、讀感測器、處理指令),每一塊可以單獨測試。
- 連線要有逾時;上線狀態用保留訊息,意外離線用遺囑訊息。
- 主迴圈每圈都
check_msg(),用ticks_diff判斷量測時間到了沒;回呼函式要修改全域設定時用global。 - 感測器失敗時用 try 接住、不發布假的數字;部署成
main.py+config.json,再逐一測試斷線、斷電、感測器脫落。
ESP32 軌到這裡結束。你已經能從一顆 LED 做到一個連網、可遠端控制的感測節點。