顯示具有 Django 標籤的文章。 顯示所有文章
顯示具有 Django 標籤的文章。 顯示所有文章

2026年8月26日 星期三

[水井村USR] 從遠端控制走向自主維運:異常告警、雲端診斷與安全維護模式實測

水井三寶故事機|V1.0-14

從遠端控制走向自主維運:異常告警、雲端診斷與安全維護模式實測

HUB 8735 Ultra+JQ6500+Django,不只會播放地方故事,更能自行回報健康、接受安全維護命令,並在展場端保留必要的實體停止能力。

100設備健康分數
GOOD健康等級
-27 dBmWi-Fi RSSI
0離線待送事件

前一版 V1.0-13 已完成安全遠端命令、執行結果回報與逾時保護;V1.0-14 再向真正的展場自主維運前進。這次測試不是只確認「雲端按鈕能不能動」,而是驗證設備能否回答三個更重要的問題:設備現在健康嗎?需要維護時能否安全鎖定?維護結束後能否立即恢復服務?

一、V1.0-14增加了什麼?

功能用途展場價值
設備健康分數依網路、播放器、事件佇列、復原次數與告警計算健康狀態管理者不必到現場逐台檢查
異常告警將故障或維護狀態同步到Django儀表板快速辨認需要處理的設備
RUN_DIAGNOSTIC由雲端要求設備產生完整診斷快照遠端取得韌體、RSSI、按鈕、BUSY及JQ6500狀態
MAINTENANCE_ON進入維護模式並阻擋一般播放與音量命令避免維修時被觀眾誤觸啟動
MAINTENANCE_OFF解除維護鎖定,恢復故事播放服務完成維護後不必重新燒錄程式
復原頻率限制限制單位時間內的播放器自動復原次數防止故障設備陷入無限重置

二、雲端儀表板已成為展場維運中心

Django儀表板同時呈現展覽互動成果與設備健康資訊。管理者可以看見故事啟動、完整播放率、設備在線狀態、健康分數、健康等級、告警數、復原層級、最新診斷,以及最近一筆遠端命令結果。


圖:SHUIJING-002在線,健康分數100、等級GOOD、告警0;下方可直接建立V1.0-14自主維運命令。
發表圖片提醒:ChatGPT工作區圖片不是公開網址。請在Blogger編輯器上傳本次儀表板截圖,取得Blogger圖片網址後,把本文唯一的 BLOGGER_IMAGE_URL 換掉,圖片就能穩定顯示。

三、第一階段:遠端完整診斷成功

我們先在Django後台建立 RUN_DIAGNOSTIC 命令。設備透過Heartbeat領取命令後,立即產生診斷快照,再把執行結果送回雲端。

[REMOTE] Heartbeat received RUN_DIAGNOSTIC
[DIAGNOSTIC] fw=V1.0-14,health=100/GOOD,state=IDLE,
track=0,vol=16,rssi=-27,queue=0,jq=0,busy=0,
btn=111111,alert=NONE
[REMOTE] Executed RUN_DIAGNOSTIC | result queued SUCCESS
[REMOTE] Result ACK SUCCESS

診斷資料如何解讀?

health=100/GOOD設備健康,沒有需要立即處理的異常。
state=IDLE故事機處於待機狀態。
vol=16JQ6500目前音量為16。
rssi=-27Wi-Fi訊號非常良好。
queue=0沒有尚未同步的離線事件。
jq=0本次運作尚未發生JQ6500復原。
busy=0播放器沒有正在播放。
btn=111111六個按鈕皆為釋放狀態,沒有腳位異常拉低。
alert=NONE目前沒有作用中的設備告警。

真正關鍵的不是看到 Executed,而是最後收到 Result ACK SUCCESS。這代表「雲端建立命令、設備領取、設備執行、結果回送、伺服器確認」五個環節全部完成。

四、第二階段:從雲端開啟維護模式

Django建立命令
Heartbeat領取
設備啟用鎖定
結果ACK確認
[REMOTE] Heartbeat received MAINTENANCE_ON
[MAINTENANCE] Enabled by cloud
[REMOTE] Executed MAINTENANCE_ON | result queued SUCCESS
[REMOTE] Result ACK SUCCESS

維護模式不是關閉整台設備,而是選擇性鎖定具有風險的操作。故事播放及音量調整會被拒絕,但停止功能仍保留,讓現場人員遇到播放器異常時仍能立即處理。

維護模式中的操作處理結果設計理由
故事按鈕拒絕防止維修時意外啟動音訊
Web故事播放拒絕避免遠端使用者干擾現場維護
音量+/-拒絕維持檢修時的固定條件
停止按鈕保留確保現場仍可立即停止播放器
遠端診斷保留維護期間仍需觀察設備狀態
解除維護保留讓雲端可以安全恢復服務

五、實體按鈕真的被鎖住了嗎?

進入維護模式後,我們分別按下GPIO11烏龜故事與GPIO10白馬故事,系統確實讀到按鍵,但沒有啟動JQ6500:

[BUTTON] Pressed GPIO11
[MAINTENANCE] Story command rejected
[BUTTON] Pressed GPIO10
[MAINTENANCE] Story command rejected

接著按下GPIO20停止鍵,系統仍然接受停止操作;因為當時播放器原本就在待機,所以正確回報:

[BUTTON] Pressed GPIO20
[STOP] Ignored: already idle

這個結果十分重要:維護模式沒有讓整個按鍵掃描停止,而是由命令層判斷哪些動作可以執行。設備仍持續掃描實體輸入,因此不會重演早期版本「網路工作時按鈕失去反應」的問題。

六、解除維護後立即恢復播放

[REMOTE] Heartbeat received MAINTENANCE_OFF
[MAINTENANCE] Disabled by cloud
[REMOTE] Executed MAINTENANCE_OFF | result queued SUCCESS
[REMOTE] Result ACK SUCCESS

解除維護後再次按下GPIO10,白馬故事成功播放,雲端事件也正常寫入本機佇列:

[BUTTON] Pressed GPIO10
[CLOUD QUEUE] + SHUIJING-002-G68F044D7-0000000033
| STORY_START | pending=1
[JQ] Play track 1
[STATE] STORY_PLAYING | 白馬故事
[BUSY] 0 -> 1 | idle=0

這表示解除維護不需要重新開機,也不需要重新燒錄程式;實體按鈕、JQ6500播放、BUSY狀態與雲端事件佇列一起恢復正常。

七、為什麼一直看到NUL filtered=1?

[HEARTBEAT HTTP] NUL filtered=1
[HEARTBEAT] Accepted HTTP 200

這不是錯誤。實測發現Ameba SSL接收的HTTP封包偶爾會夾帶一個 0x00 NUL位元組。早期版本會因此無法解析HTTP狀態列或JSON;現在程式會先過濾NUL,再解析完整回應。

判讀原則:只要NUL過濾後緊接著出現 Accepted HTTP 200,就代表封包已修正並成功處理。這一行是防護機制的工作紀錄,不是通訊失敗。

八、這次完整測試結果

  • STA網路連線與HTTPS Heartbeat正常。
  • Django成功送出RUN_DIAGNOSTIC命令。
  • 設備回傳完整健康診斷快照。
  • 命令執行結果收到雲端ACK。
  • MAINTENANCE_ON成功啟用。
  • 維護期間實體故事按鈕受到阻擋。
  • 維護期間停止按鈕仍可使用。
  • MAINTENANCE_OFF成功解除鎖定。
  • 解除維護後白馬故事成功播放。
  • STORY_START事件成功進入離線保護佇列。
  • JQ6500 BUSY腳位正確由0切換至1。
  • 設備健康分數100、等級GOOD、告警NONE。
實測結論:V1.0-14核心功能全部通過。

水井三寶故事機已從「可以被遠端操作的播放器」,進一步成為「可以自我回報、接受安全維護、保留現場控制並完成雲端稽核」的展場智慧設備。

九、這次最寶貴的工程經驗

展場設備的可靠性,不只取決於功能多不多,而是發生異常時能不能被看見、被限制、被診斷、被恢復。V1.0-14建立的健康分數、異常告警、維護鎖定、遠端診斷與結果ACK,形成一條完整的維運證據鏈。

感知設備健康
雲端建立命令
邊緣安全執行
回報並完成稽核

這也讓地方故事、樹藝作品與智慧展覽不再只是一次性的展示,而是可以長期運作、跨場域部署並由遠端團隊共同維護的數位文化服務。

測試平台:HUB 8735 Ultra、JQ6500、實體按鈕、LED、STA Wi-Fi、HTTPS、Django/PythonAnywhere。版本:V1.0-14「展場自主維運+異常告警+遠端診斷版」。

2026年8月25日 星期二

[水井村USR] 看得見538 Bytes,卻讀不到HTTP:一次珍貴的Ameba SSL封包除錯紀錄

水井三寶故事機|V1.0-13 R3.3

看得見538 Bytes,卻讀不到HTTP:一次珍貴的Ameba SSL封包除錯紀錄

本次測試完成了HUB 8735 Ultra故事機的安全遠端命令、執行結果回報與重新啟動,也找到一個非常隱密的封包問題:SSL回應中只要混入一個NUL(0x00),Arduino的字串解析就可能看似收到資料,實際上卻找不到HTTP狀態列。

最終結果:六種遠端命令全部成功,Django後台均顯示「成功」,每筆命令只領取一次;設備重新啟動後,開機語音也正常播放。

一、這一版要完成什麼?

水井三寶故事機先前已具備實體按鈕、手機Web控制、JQ6500播放、STA主要連線、AP故障備援、NTP校時、Flash離線事件佇列與設備心跳。V1.0-13再向前一步:讓管理者可以從Django雲端安全地下達維護命令,設備執行後還必須回報結果。

Django建立命令
心跳領取命令
8735執行命令
結果ACK後結案
安全領取命令包含Device ID、API Key、UUID與逾期時間。
只執行一次透過命令UUID避免同一指令被重複執行。
結果可追蹤Django必須收到SUCCESS或FAILED,才能將命令結案。

二、R3.1已經成功,為何後面又失敗?

R3.1第一次證明「心跳夾帶命令」的架構可行。設備成功收到並執行狀態回報:

[HEARTBEAT] Accepted HTTP 200
[REMOTE] Heartbeat received REPORT_STATUS | bba846e1-...
[REMOTE] Executed REPORT_STATUS | result queued SUCCESS

但是下一次心跳要把執行結果送回Django時,卻出現:

[HEARTBEAT] POST | state=IDLE | queue=0
[HEARTBEAT] Failed code=0

這不是命令沒有執行,也不是Django拒絕請求,而是RTL8735B端沒有正確解析伺服器回傳的HTTP狀態。

三、最關鍵的線索:538 Bytes與空白Preview

R3.2先改成收完整HTTP Header,並加入原始資料長度診斷。結果出現非常矛盾的訊息:

[HEARTBEAT HTTP] Unparsed bytes=538 | preview=
[HEARTBEAT] Failed code=0
問題焦點:如果完全沒有收到資料,長度應該是0;現在明明收到538 bytes,為何預覽是空白,而且找不到HTTP/1.1 200 OK

答案是回應內容最前方混入了NUL,也就是數值為0的位元組:

第1 byte00 NUL控制字元
後續文字HTTP/1.1 200 OK
自訂HeaderX-Ameba-Result-Ack: 1
JSON Body{"ok":true,...}

Arduino的String仍可能把這個0x00算入長度,所以看到538 bytes;但許多字串函式會把NUL視為C字串結尾。於是:

  • raw.length()仍顯示收到資料。
  • raw.indexOf("HTTP/")可能找不到後面的HTTP狀態列。
  • Serial.println(raw)從第一個NUL就停止,所以Preview看起來完全空白。
  • HTTP狀態碼最後被判定為0。
這次最重要的除錯觀念:「收到幾個bytes」不等於「收到可直接當成文字處理的bytes」。網路除錯不能只看字串,也要保留位元組、控制字元與十六進位的觀點。

四、R3.3如何修正?

R3.3不再把讀到的0x00直接加入HTTP文字緩衝區,而是在SSL讀取階段排除NUL,同時計算排除數量:

int value = client.read();
if (value == 0) {
    nulCount++;
} else if (value > 0) {
    raw += (char)value;
}

若解析仍失敗,程式還會輸出前32 bytes的HEX資料。這使除錯從「猜測HTTP為何不見」變成「直接觀察真正收到的位元組」。

[HEARTBEAT HTTP] Unparsed bytes=... | NUL filtered=... | preview=...
[HEARTBEAT HTTP] HEX: 48 54 54 50 2F 31 2E 31 ...

其中48 54 54 50就是ASCII的HTTP。這種診斷方式未來也適合用於UART、MQTT、Modbus、WebSocket及其他嵌入式通訊問題。

五、R3.3實機測試結果

更新R3.3後,日誌第一次直接證實問題來源:

[HEARTBEAT HTTP] NUL filtered=1
[HEARTBEAT] Accepted HTTP 200
[REMOTE] Result ACK SUCCESS | d7d05b25-...

只是一個NUL,就足以讓前一版完全讀不到HTTP;排除後,命令領取、執行與ACK全部恢復正常。

六種遠端命令測試

遠端命令設備動作結果
REPORT_STATUS立即回報設備狀態成功
SYNC_EVENTS立即補送Flash離線事件成功
SYNC_TIMENTP校時;失敗時切換HTTP時間成功
SET_VOLUME音量20調整為16並延遲保存成功
PLAYER_RESET重新初始化JQ6500播放器成功
DEVICE_RESTART回報成功並保存資料後重新啟動成功

六、安全重新啟動不是「收到就重開」

DEVICE_RESTART尤其值得記錄。設備不是一收到命令便立刻重新啟動,而是依序完成:

  1. 領取具有UUID的重新啟動命令。
  2. 將執行結果排入下一次心跳。
  3. 等待Django回傳X-Ameba-Result-Ack: 1
  4. 保存Flash中的統計、音量與佇列資料。
  5. 最後才執行系統重新啟動。
[REMOTE] Executed DEVICE_RESTART | result queued SUCCESS
[HEARTBEAT] Accepted HTTP 200
[REMOTE] Result ACK SUCCESS | 0a815d53-...
[FLASH] Batch saved: before remote restart
[SYSTEM] Restart scheduled: heartbeat command restart
[SYSTEM] Restarting: heartbeat command restart

重新開機後,音量16成功保留,開機提示語音正常播放,STA重新連上Wi-Fi,證明「命令回報、Flash保存、重啟復原」三個環節都通過。

七、Django後台驗證

後台六筆命令全部顯示「成功」,Delivery Attempts均為1,Completed At也有完成時間。這代表:

  • 命令沒有因輪詢或網路延遲而重複執行。
  • 每筆命令都由相同UUID貫穿領取、執行與結果確認。
  • 設備重新啟動前,雲端已收到成功結果。
  • 心跳既是健康監控,也是低負擔的安全命令通道。
命令類型狀態領取次數完成確認
重新啟動設備成功1已記錄
重設播放器成功1已記錄
設定音量成功1已記錄
立即網路校時成功1已記錄
立即補送事件成功1已記錄
立即回報狀態成功1已記錄

八、這次經驗教會我們什麼?

1. HTTP仍是Bytes即使最終看到的是文字協定,底層傳輸仍可能包含控制字元。
2. 長度不能代表內容538 bytes可能以NUL開頭,字串函式看到的卻是空字串。
3. 要有HEX診斷文字預覽失效時,十六進位輸出是最可靠的證據。
4. ACK後才能破壞性操作重啟前先回報與保存,才能避免雲端永遠停在「已領取」。
5. UUID保證冪等同一命令不因重送而執行兩次,對展場設備非常重要。
6. 現場功能優先網路同步與心跳都不能阻塞實體按鈕及故事播放。

九、版本結論

V1.0-13 R3.3已完成從「會播放故事的單機」到「可被雲端安全維護的展場設備」的重要跨越。它不只會執行遠端命令,還能辨識命令、避免重複、回報結果、等待ACK、保存狀態,並在必要時安全重啟。

本次最珍貴的發現:真正讓系統卡住的,不是538 bytes的大問題,而是藏在最前面、肉眼看不見的1 byte——NUL(0x00)。物聯網系統的可靠性,常常就建立在願不願意把「看似空白」繼續追查到底。

[水井村USR] 從HTTP 400到HTTP 201:HUB 8735 Ultra設備身分、Django同步與離線佇列實測

樹藝AI故事機|V1.0-11

從HTTP 400到HTTP 201:HUB 8735 Ultra設備身分、Django同步與離線佇列實測

這次測試讓「水井三寶故事機」跨出重要一步:故事不只在現場播放,還能以設備身分將匿名互動事件送到Django;網路或API暫時異常時,資料先留在Flash,待問題排除後再依序補送。

SHUIJING-002獨立設備身分
Flash Queue離線事件保存
HTTP 201Django接收成功

一、V1.0-11要解決什麼問題?

前一版已完成實體按鈕、手機Web控制、JQ6500播放、STA主要連線、AP故障備援及NTP校時。但展場系統若要長期營運,還必須回答三個問題:

  1. 是哪一台故事機送出的資料?每台設備必須有獨立Device ID與API Key。
  2. 斷網時互動紀錄會不會消失?事件必須先寫入本機Flash。
  3. 恢復連線後如何補送?佇列必須按照事件順序逐筆送往Django。
實體按鈕/手機Web
HUB 8735事件建立
Flash離線佇列
HTTPS+設備金鑰
Django事件資料庫

二、設備身分與事件格式

本次測試設備編號為:

[CLOUD] Device ID: SHUIJING-002

韌體送出請求時,以Header攜帶設備身分與個別金鑰。正式API Key不應出現在部落格、序列紀錄或公開程式庫。

POST /api/exhibition/events/ HTTP/1.1
Content-Type: application/json
X-Device-ID: SHUIJING-002
X-Device-Key: ********

每一筆事件都有唯一的event_uuid,例如:

SHUIJING-002-0000000014

如此即使網路重試,也能由Django利用UUID避免同一筆事件被重複建立。

三、第一次上線:本機正常,雲端卻回傳400

故事機能連上STA、取得IP、完成NTP校時,按鈕與Web也都能播放故事,但離線事件始終停留在佇列:

[TIME] Synced by NTP | 2026-08-25 09:29:21
[MODE] STA online service ready
[CLOUD] POST SHUIJING-002-0000000001 | pending=1
[CLOUD] Failed code=400
重要判斷:HTTP 400不代表Wi-Fi失敗。它表示HUB 8735已成功連到Django,但Django認為送來的內容不符合API規則。

當時韌體只顯示狀態碼,無法知道伺服器拒絕哪個欄位。因此加入「安全截取Django回應本文」診斷,並限制最大長度,避免錯誤頁占用過多記憶體。

[CLOUD] Failed code=400
[CLOUD] Django response: {"ok": false, "error": "invalid event_type"}

這一行就是整次除錯的關鍵證據:網路、TLS、網址、設備驗證都已經通過,真正問題是Arduino與Django使用了不同的事件名稱

四、找出兩端的事件名稱差異

原Arduino事件Django模型事件最後處理
SYSTEMDEVICE_BOOTArduino改送DEVICE_BOOT
STORY_STARTSTORY_START保持不變
STORY_ENDSTORY_COMPLETEArduino改送STORY_COMPLETE
STORY_INTERRUPTEDSTOPSTOP表示中止,利用story與completed區分
STOPSTOP保持不變

Arduino最後使用的轉換函式如下:

const char *cloudEventTypeName(uint8_t type)
{
    if (type == CLOUD_STORY_START) return "STORY_START";
    if (type == CLOUD_STORY_END) return "STORY_COMPLETE";
    if (type == CLOUD_STORY_INTERRUPTED) return "STOP";
    if (type == CLOUD_STOP) return "STOP";
    if (type == CLOUD_SYSTEM) return "DEVICE_BOOT";
    return "DEVICE_ERROR";
}

五、Django端實際採取的修正

本次正式測試沒有更換views.py,只調整models.py的事件選項,使其與韌體送出的正式名稱一致。原View原本就透過Model動態檢查:

if event_type not in dict(ExhibitionEvent.EVENT_TYPES):
    return None, "invalid event_type"

因此Model選項更新後,View會自動採用新選項,不必再維護第二份事件清單。這也避免Model與View日後再次出現名稱不一致。

EVENT_TYPES = [
    ("DEVICE_BOOT", "設備啟動"),
    ("EXHIBIT_WAKE", "展區喚醒"),
    ("STORY_START", "故事開始"),
    ("STORY_COMPLETE", "故事完成"),
    ("PLAY_TIMEOUT", "播放逾時"),
    ("STOP", "停止播放"),
    ("DEVICE_ERROR", "設備錯誤"),
    ("QR_OPEN", "QR延伸閱讀"),
]

六、為什麼舊的10筆事件不用清除?

這次設計最有價值的地方,是Flash佇列保存的不是最後的JSON文字,而是精簡的事件型別代碼、故事編號、來源、時間與序號。真正準備上傳時,才由cloudEventTypeName()轉成目前的正式名稱。

因此更新韌體後,原本保存在Flash裡的舊CLOUD_SYSTEM事件,送出時會自動變成DEVICE_BOOT;舊CLOUD_STORY_END則會變成STORY_COMPLETE。資料不必刪除,也不必重新製造。

重新開機後的證據

[FLASH] V1.0-11 data restored
[CLOUD] Offline queue: 10
[CLOUD QUEUE] + SHUIJING-002-0000000011 | DEVICE_BOOT | pending=11

前10筆失敗事件仍然存在;重新開機又新增第11筆設備啟動事件,證明Flash恢復與事件序號持續運作。

七、HTTP 201:離線事件開始依序補送

修正事件名稱後,從管理後台按下「立即補送」,Django開始接受事件:

[CLOUD] POST SHUIJING-002-0000000001 | pending=13
[CLOUD] Accepted HTTP 201 | remaining=12
[CLOUD] POST SHUIJING-002-0000000002 | pending=12
[CLOUD] Accepted HTTP 201 | remaining=11
[CLOUD] POST SHUIJING-002-0000000003 | pending=11
[CLOUD] Accepted HTTP 201 | remaining=10

後續事件繼續補送:

[CLOUD] POST SHUIJING-002-0000000007 | pending=9
[CLOUD] Accepted HTTP 201 | remaining=8
[CLOUD] POST SHUIJING-002-0000000008 | pending=8
[CLOUD] Accepted HTTP 201 | remaining=7
HTTP 201代表建立成功。這證明設備金鑰、Device ID、JSON、事件類型、Story對應與Django資料建立流程均已通過。

八、補送期間仍可正常播放故事

雲端補送不能犧牲展場體驗。測試過程中,使用者仍可從手機Web播放白馬故事,也能按下GPIO11播放烏龜故事:

[WEB] GET /api/play/horse HTTP/1.1
[CLOUD QUEUE] + SHUIJING-002-0000000012 | STORY_START | pending=12
[JQ] Play track 1
[STATE] STORY_PLAYING | 白馬故事

[BUTTON] Pressed GPIO11
[CLOUD QUEUE] + SHUIJING-002-0000000014 | STORY_START | pending=11
[JQ] Play track 2
[STATE] STORY_PLAYING | 烏龜故事

故事播放完成後,再建立STORY_COMPLETE

[CLOUD QUEUE] + SHUIJING-002-0000000015 | STORY_COMPLETE | pending=12
[STATE] IDLE | 待機中

這表示系統採取的不是「先等網路完成才能播放」,而是現場互動優先、事件同步在後

九、這次測試建立的除錯方法

看到的現象代表意義優先檢查
無法取得STA IP尚未連上區域網路SSID、密碼、Channel、訊號
NTP失敗可能無Internet或UDP受限Gateway、DNS、HTTP時間備援
HTTP 401設備驗證失敗Device ID、API Key、啟用狀態
HTTP 400API收到請求但內容不合規Django回應本文、JSON欄位、事件名稱
HTTP 201事件建立成功確認佇列remaining持續下降
HTTP 409UUID可能已存在視為冪等成功或檢查重複事件

十、展場驗收清單

  • 每台設備使用獨立Device ID與API Key。
  • API Key不出現在序列監控、部落格與公開GitHub。
  • STA連線後能自動取得DHCP位址並完成NTP校時。
  • 實體按鈕與手機Web均能播放故事。
  • 開始播放建立STORY_START
  • 正常播完建立STORY_COMPLETE
  • 斷網或API錯誤時,事件保留於Flash。
  • 恢復正常後,舊事件依UUID順序補送。
  • 佇列最終回到0,Django後台筆數一致。

十一、這次經驗最重要的價值

這次測試不是單純把HTTP 400修成HTTP 201,而是建立了一套更接近真實展場的IoT資料策略:

Local First:按鈕與故事播放永遠優先,事件先安全留在本機。

Cloud Later:網路正常後再送Django,失敗不丟資料。

Evidence-based Debugging:不只看Wi-Fi是否連上,而是沿著IP、NTP、HTTP狀態碼、伺服器回應及資料模型逐層定位。

Shared Contract:Arduino與Django必須共用相同事件語彙;一個名稱不同,就可能讓整條FIFO佇列停住。

2026年8月22日 星期六

網站上線後才是開始: Log、備份、監控與效能

封包旅行記 EP07|從部署走向營運

網站上線後才是開始:
Log、備份、監控與效能

Django網站可以開啟,只代表部署完成;智慧養殖、產銷平台與水井三寶場域真正要長期運作,還必須在出錯時找得到原因、資料遺失時救得回來、異常發生時有人知道、使用量增加時仍然回得動。

一、上線不是終點,而是系統生命週期的起點

Log|留下證據誰在何時做了什麼?在哪一層失敗?可否用Request ID串起整條路徑?
備份|保住成果資料刪錯、主機故障或程式更新失敗時,能恢復到哪個時間點?
監控|提早知道網站掛掉、API變慢、錯誤率上升或ESP32斷線時,誰會收到通知?
效能|量測改善慢在網路、Django、資料庫、檔案系統,還是外部服務?

營運能力=看得見+救得回+叫得到人+持續改善。沒有Log只能猜,沒有還原演練的備份只是希望,沒有告警的監控只是漂亮圖表,沒有量測的效能改善只是碰運氣。

二、先定義「什麼叫系統正常」

智慧養殖系統不能只監看首頁是否回200。即使網站正常,裝置可能已經兩小時沒有上傳,儀表板仍顯示昨天最後一筆水溫。

層級正常條件例失敗時代表
網站存活/health/live/能快速回200Web worker、程式或平台可能無法服務
服務就緒/health/ready/可連資料庫與必要服務網站進程活著,但尚不能完成真正工作
API品質成功率、P95延遲符合門檻部分功能壞掉或逐漸變慢
資料新鮮度各ESP32最近上傳時間未超過預期裝置、Wi-Fi、MQTT/HTTP或排程可能中斷
業務正確性溶氧警報能產生、通知並被確認技術層看似正常,但場域流程失效

三、Log第一課:不要只寫「發生錯誤」

一筆有用的Log至少要回答:時間、嚴重程度、事件、request_id、裝置或場域代碼、結果與可採取的下一步。

2026-08-22T07:45:10+08:00 INFO
event=reading.accepted request_id=7bc2...
device_id=POND01-ESP32-01 event_uuid=POND01-000062
status=201 duration_ms=84

2026-08-22T07:46:03+08:00 WARNING
event=reading.rejected request_id=2f91...
device_id=POND01-ESP32-01 reason=ph_out_of_range
status=400 duration_ms=12

應該記錄

事件名稱、Request ID、匿名或內部裝置ID、狀態碼、耗時、錯誤分類、重試次數與程式版本。

不應記錄

完整Token、密碼、Session Cookie、個資、完整信用卡資料或未遮罩的敏感Payload。

Log本身也是敏感資料。必須限制存取、設定保存期限、避免無限增長,且不能讓使用者輸入破壞Log格式或偽造紀錄。

四、Django Logging設定與使用

# settings.py:教學版,以console交給部署平台收集
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "verbose": {
            "format": "{asctime} {levelname} {name} {message}",
            "style": "{",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "verbose",
        },
    },
    "loggers": {
        "aquaculture": {
            "handlers": ["console"],
            "level": "INFO",
            "propagate": False,
        },
        "django.request": {
            "handlers": ["console"],
            "level": "WARNING",
            "propagate": False,
        },
    },
}
# views.py
import logging
logger = logging.getLogger("aquaculture.api")


def record_success(request_id, device_id, event_uuid, duration_ms):
    logger.info(
        "event=reading.accepted request_id=%s device_id=%s "
        "event_uuid=%s duration_ms=%d",
        request_id, device_id, event_uuid, duration_ms,
    )


def record_failure(request_id, device_id):
    logger.exception(
        "event=reading.failed request_id=%s device_id=%s",
        request_id, device_id,
    )

logger.exception()應在例外處理區使用,它會留下Stack Trace。Django官方建議以Python logging取得具名Logger,並透過LOGGING設定Handler、Formatter與Level。

Level用途場域例
DEBUG開發診斷細節序列化前後欄位;正式環境通常不大量開啟
INFO正常的重要事件資料接收、備份完成、裝置重新上線
WARNING尚能服務但值得注意資料過舊、重試增加、磁碟逼近門檻
ERROR某項操作失敗資料庫寫入失敗、外部通知失敗
CRITICAL系統性重大故障核心資料庫不可用或大量API全面失敗

五、Request ID:把ESP32、API與資料庫串成一條線

ESP32每筆事件已有event_uuid,HTTP請求再加入X-Request-ID。若Client未提供,Django產生新的UUID,回應時也帶回,學生便能從裝置Serial Log一路查到伺服器Log。

# middleware.py(簡化示例)
import uuid


class RequestIdMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        incoming = request.headers.get("X-Request-ID", "")
        request.request_id = incoming[:80] if incoming else str(uuid.uuid4())

        response = self.get_response(request)
        response["X-Request-ID"] = request.request_id
        return response

不要無條件信任Header。限制長度與格式,避免把任意使用者輸入直接插入結構化Log;Request ID只用於追蹤,不是安全憑證。

六、PythonAnywhere上要看哪幾種Log?

Log主要內容適合回答
Access LogURL、狀態碼、回應大小與回應時間哪個API慢?錯誤率何時上升?
Error Log載入、WSGI與部署錯誤為何Reload後網站無法啟動?
Server/應用LogDjango logger、Traceback及自訂事件是哪個裝置或資料造成例外?
Task LogScheduled/Always-on Task執行結果備份、清理或監控排程是否真的完成?

平台介面與方案限制可能調整,請以PythonAnywhere目前帳號的Web與Tasks頁面為準。正式營運應建立固定巡檢責任,而不是出事後才第一次找Log。

七、備份不是複製檔案,而是一套可還原的制度

備什麼資料庫、媒體檔、環境設定、部署文件與程式版本。
多久一次依可接受資料損失RPO決定,例如每日完整+更頻繁增量。
放哪裡不可只留在同一帳號或同一主機;至少一份異地/離線。
怎麼證明定期在隔離環境還原,核對筆數、附件與核心流程。

先定義RPO與RTO

指標問題範例
RPO最多可接受遺失多久的資料?水質資料最多遺失1小時
RTO故障後多久必須恢復服務?4小時內恢復API與儀表板

3-2-1原則:至少3份資料、2種不同媒介、1份異地。再加上加密、權限、保留週期與還原演練,才形成真正的備份策略。

八、不同資料庫的備份方式不能混用

# PostgreSQL:自訂格式,使用pg_restore還原
pg_dump -Fc -d DATABASE_NAME -f backup_YYYYMMDD.dump
pg_restore --clean --if-exists -d RESTORE_TEST_DB backup_YYYYMMDD.dump

# MySQL:邏輯備份示意
mysqldump --single-transaction DATABASE_NAME > backup_YYYYMMDD.sql

# SQLite:網站低流量或停止寫入時,以sqlite3一致性備份
sqlite3 db.sqlite3 ".backup 'backup_YYYYMMDD.sqlite3'"

帳密不要直接寫在可被其他使用者看到的命令或程式碼中;應使用平台提供的安全環境變數或憑證機制。命令選項需依資料庫版本、權限與託管平台調整。

不要直接複製正在大量寫入的SQLite檔案當成可靠備份。也不要只備資料庫而忘記使用者上傳的照片、附件與對應程式版本。

還原演練的五步驟

  1. 建立隔離的空白測試資料庫。
  2. 用備份檔還原,不覆蓋正式環境。
  3. 執行Migration/相容性檢查。
  4. 核對關鍵表筆數、最新時間與附件。
  5. 用Postman/pytest跑核心API流程並記錄實際RTO。

九、監控:圖表不是目的,能採取行動才是

LatencyP50、P95與P99延遲,不只看平均值。
Traffic每分鐘請求數、活躍裝置與資料寫入量。
Errors4xx、5xx、例外與失敗工作比例。
SaturationCPU、記憶體、磁碟、連線池與Worker負荷。

智慧養殖還要增加資料新鮮度:每座魚塭最後上傳時間、離線裝置數、離線佇列深度、警報未確認時間。

監控項目告警例第一個行動
網站可用性連續3次健康檢查失敗確認平台狀態、Error Log與最近部署
API錯誤率5分鐘內5xx超過門檻依Request ID抽樣Traceback
API延遲P95連續10分鐘超標分解網路、Web、DB與外部服務時間
裝置資料新鮮度預期5分鐘上傳,15分鐘未收到查LWT、Wi-Fi、電源與離線佇列
備份排程失敗、檔案為0或超過期限停止自動刪除舊備份並人工處理
磁碟可用空間低於設定門檻查Log、媒體與備份增長來源

十、健康檢查:Liveness與Readiness要分開

# views.py(簡化示例)
from django.db import connection
from django.http import JsonResponse


def live(request):
    return JsonResponse({"status": "alive"})


def ready(request):
    try:
        with connection.cursor() as cursor:
            cursor.execute("SELECT 1")
            cursor.fetchone()
        return JsonResponse({"status": "ready"})
    except Exception:
        return JsonResponse({"status": "not_ready"}, status=503)

健康端點要快、穩定且不洩密。不要回傳資料庫密碼、Token、完整例外或伺服器內部路徑;也不要讓Liveness依賴所有外部服務,否則短暫外部故障可能引起不必要重啟。

十一、告警要包含「誰、何時、怎麼處理」

不好的告警可行動的告警
網站壞了!PROD API 5xx達12%,開始07:42,影響/api/v1/readings/,Runbook:OPS-API-01,值班:A組
裝置離線POND01-ESP32-01已15分鐘無資料,最後RSSI -76、最後LWT offline、最近event_uuid與場域聯絡方式

同一問題重複發送數百次會造成告警疲勞。應設定持續時間、合併、冷卻與恢復通知;重大告警必須有負責人及Runbook。

十二、效能第一原則:先量測,找出真正瓶頸

瀏覽器/ESP32總耗時Access Log伺服器耗時網路傳輸與連線成本

PythonAnywhere官方說明建議比較瀏覽器Network總時間與Access Log回應時間,先區分網路與Web App。接著再把Django處理拆成資料庫、檔案、外部API與Python運算。

瓶頸觀察證據常見改善
網路/TLSClient慢,但Access Log處理快縮小Payload、連線重用、選擇合適部署區域
N+1查詢清單筆數越多,SQL數量線性增加select_related()prefetch_related()
缺索引依device_id、measured_at查詢越來越慢依實際Query與執行計畫建立索引
傳太多資料API回傳數萬筆水質紀錄分頁、時間區間、只取必要欄位
同步重工作寄信、AI分析或報表拖慢Request移至背景工作並提供狀態
靜態/媒體檔大量圖片由Django直接處理正確Static/Media服務、壓縮與快取
Worker不足單次不慢,但多人同時使用就排隊縮短請求、增加合適Worker或方案資源

十三、Django ORM效能:少查、查對、只取需要的資料

# 不佳:迴圈中每次存取pond,可能形成N+1查詢
readings = PondReading.objects.all()[:100]
for reading in readings:
    print(reading.pond.name)

# 改善:ForeignKey使用select_related一次Join
readings = (
    PondReading.objects
    .select_related("pond")
    .only("event_uuid", "measured_at", "water_temp_c", "pond__name")
    .order_by("-measured_at")[:100]
)
# 常用查詢可考慮複合索引,但必須先量測
class PondReading(models.Model):
    device_id = models.CharField(max_length=80)
    measured_at = models.DateTimeField()

    class Meta:
        indexes = [
            models.Index(fields=["device_id", "-measured_at"]),
        ]

索引不是免費午餐。它會占空間並增加寫入成本;應以真實查詢、資料量與執行計畫決定。快取也會帶來資料過期與失效策略問題,不應拿來掩蓋錯誤查詢。

十四、部署後的日、週、月維運節奏

頻率建議工作
每日自動健康檢查、備份結果、5xx、裝置離線、磁碟與憑證到期監控
每週人工抽查Log、慢API、備份檔、未確認告警及使用量趨勢
每月隔離還原演練、帳號/Token盤點、套件更新評估、效能基準比較
每次部署Migration備份、部署清單、Smoke Test、版本標記與Rollback方案
每次事故後無責檢討:時間線、根因、影響、復原與防止再發措施

PythonAnywhere排程:可用Scheduled Task執行備份檢查或Django Management Command;功能與額度依帳號方案而異。排程「被建立」不代表「有成功」,必須監控Exit Code、輸出與備份新鮮度。

十五、故障演練:讓學生真的把系統救回來

No-AI|Log追蹤

教師提供ESP32 Serial Log、Access Log與Django Traceback;學生用event_uuid與Request ID重建故障時間線,找出第一個失敗點。

AI Pair|維運假設

請AI提出網站變慢的可能原因,學生依量測證據排序、駁回無證據猜測,並設計每個假設的最小驗證。

Challenge AI|災難復原

在隔離環境模擬資料誤刪、備份還原、裝置斷線與API延遲;量測RPO/RTO並完成Runbook與事後檢討。

十六、上線營運驗收清單

檢核通過證據
Log可追查可用Request ID從ESP32追到API結果,且無敏感資料
備份可還原在隔離環境成功還原並通過Postman/pytest核心測試
監控可告警網站、5xx、延遲、資料新鮮度、備份與磁碟皆有門檻
告警有人處理每項重大告警有負責人、Runbook與升級路徑
效能有基準記錄P50/P95、Query數與資料量,改善前後可比較
部署可回復每個版本有標記、Migration計畫、Smoke Test與Rollback方案

十七、延伸閱讀(官方文件)

部署平台、資料庫與Django版本會變動;正式操作前應以目前使用版本及帳號方案的官方文件為準。

封包旅行記 EP07|真正的上線,不是網址能打開;而是故障能追、資料能救、異常有人知道、系統能持續變好。

Django REST API安全第一課: HTTPS、CSRF、CORS與Token

封包旅行記 EP05|API安全邊界

Django REST API安全第一課:
HTTPS、CSRF、CORS與Token

ESP32把JSON送到Django,不只要問「送到了嗎」,還要問:途中有沒有被看見或竄改?伺服器怎麼辨認裝置?瀏覽器為何擋下請求?Cookie與Token應該用哪一套防護?

一、先記住:四個機制保護的是不同問題

HTTPS保護傳輸途中,提供加密、完整性與伺服器身分驗證。
CSRF防止瀏覽器在自動攜帶Cookie時,被惡意網站借用登入身分送出操作。
CORS是瀏覽器的跨來源讀取規則,決定哪個網頁來源可讀取API回應。
Token讓API辨認呼叫者;仍須搭配權限、過期/輪替、撤銷與HTTPS。

HTTPS+身分驗證+權限+輸入驗證才是一條完整防線。CORS不是登入機制,CSRF Token也不是API存取權杖,Token更不能取代HTTPS。

二、兩條連線路徑,安全需求並不相同

瀏覽器管理介面

人員登入Session CookieCSRF

Django後台或同站儀表板通常使用Session。瀏覽器會自動帶Cookie,因此POST、PUT、PATCH、DELETE等修改操作需要CSRF防護。

ESP32裝置API

裝置身分Authorization HeaderToken

ESP32不是瀏覽器,不受瀏覽器同源政策約束;通常使用HTTPS並在Header主動攜帶裝置Token,不使用使用者Session Cookie。

呼叫者常見驗證CSRFCORS
Django同站網頁Session Cookie修改資料時需要同來源通常不涉及
不同網域的前端SPACookie或Header Token使用Cookie驗證時仍要正確處理瀏覽器需要允許該Origin
ESP32/伺服器程式Token、API Key或更強的裝置憑證若不依賴瀏覽器自動帶Cookie,通常不是此威脅模型不由瀏覽器執行,CORS不會保護或阻擋它

三、HTTPS:先保護道路,再談通行證

HTTP明文傳輸時,Token、感測資料與控制命令可能被同網段或傳輸路徑上的攻擊者讀取或竄改。HTTPS透過TLS建立安全通道,但它不會自動判斷使用者能不能查看某座魚塭,也不會驗證JSON欄位是否合理。

ESP32驗證伺服器憑證TLS加密通道Authorization TokenDjango權限與資料驗證

Django正式環境安全設定示意

# settings.py(正式環境示意,需依部署架構調整)
DEBUG = False
ALLOWED_HOSTS = ["api.example.org"]

SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True

# 只有在「可信任的反向代理」確實設定此Header時才使用:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

不要盲目複製:HSTS與Proxy Header設定錯誤可能造成網站無法存取或偽造HTTPS判定。應先確認Nginx、平台代理及網域均已正確提供HTTPS,再逐項啟用並測試。

ESP32端也要驗證憑證。僅使用「insecure」模式雖然畫面上有HTTPS,卻放棄了伺服器身分驗證,可能受到中間人攻擊。應正確同步時間並配置可信任CA。

四、CSRF:防止別的網站借用你的Cookie

假設教師已登入Django後台,Session Cookie保存在瀏覽器。若瀏覽器被誘導開啟惡意網站,該網站可能嘗試向Django送出「刪除資料」請求;瀏覽器可能自動附上Cookie。CSRF Token用來證明這次修改操作來自可信任頁面流程。

<form method="post">
  {% csrf_token %}
  <button type="submit">更新設備名稱</button>
</form>

AJAX使用SessionAuthentication時,需取得CSRF Cookie並在不安全方法的Request Header傳送X-CSRFToken。Django REST Framework官方文件指出,SessionAuthentication下的POSTPUTPATCHDELETE需要有效CSRF Token。

不要用@csrf_exempt當作通用除錯方法。若真正需求是讓ESP32呼叫API,應建立清楚的Token驗證API View,而不是關掉整個網站的CSRF保護。

五、CORS:瀏覽器的讀取許可,不是API門鎖

Origin由通訊協定+主機+Port組成。https://dashboard.example.org中的JavaScript呼叫https://api.example.org就是跨來源;瀏覽器可能先送出OPTIONS預檢,伺服器再以CORS Header表示是否允許。

CORS能做什麼

讓受信任的前端網頁在瀏覽器中讀取API回應;限制哪些Origin、Method與Header可使用。

CORS不能做什麼

不能阻止curl、ESP32或攻擊者伺服器直接呼叫API,也不能取代Authentication與Permission。

# pip install django-cors-headers

INSTALLED_APPS = [
    # ...
    "corsheaders",
]

MIDDLEWARE = [
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    # ...
]

CORS_ALLOWED_ORIGINS = [
    "https://dashboard.example.org",
]
CORS_URLS_REGEX = r"^/api/.*$"

# 僅Cookie跨站情境才評估開啟,並搭配CSRF與Cookie策略:
CORS_ALLOW_CREDENTIALS = False

正式環境避免CORS_ALLOW_ALL_ORIGINS = True應列出真正需要的Origin;CORS與CSRF_TRUSTED_ORIGINS是兩套不同設定,不要以為允許CORS就自動通過CSRF。

六、Token:回答「你是誰」,Permission回答「你能做什麼」

DRF內建TokenAuthentication適合教學與簡單Client–Server情境。Client以Header送出Authorization: Token <key>。官方文件也提醒,內建Token是較簡單的實作;正式系統若需要每裝置多Token、到期、輪替或細緻權限,應評估更完整的方案。

# settings.py
INSTALLED_APPS = [
    # ...
    "rest_framework",
    "rest_framework.authtoken",
]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.TokenAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

# 設定後執行:python manage.py migrate
# views.py
from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

class DeviceEventView(APIView):
    authentication_classes = [TokenAuthentication]
    permission_classes = [IsAuthenticated]

    def post(self, request):
        event_uuid = request.data.get("event_uuid")
        if not event_uuid:
            return Response({"error": "event_uuid required"}, status=400)

        # 還要驗證:此Token可否代表這台device_id、欄位型別與資料範圍
        return Response({"accepted": True, "event_uuid": event_uuid}, status=201)
curl -X POST "https://api.example.org/api/events/" \
  -H "Authorization: Token YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"event_uuid":"DEVICE-001-0001","event_type":"STORY_START"}'
一台裝置一個身分:不要把同一個管理員Token燒錄到所有ESP32。每台裝置使用獨立憑證,伺服器限制其可操作的device_id;外洩時才能只撤銷單一裝置。

七、常見迷思:看起來能用,實際上仍不安全

迷思問題正確觀念
用了HTTPS就安全任何持有Token者仍可能越權HTTPS之外還要驗證、權限、輸入驗證與日誌
開放CORS讓ESP32能連ESP32不受瀏覽器CORS限制查DNS、TLS、Token、API路徑與防火牆
CSRF錯誤就加csrf_exempt移除重要防線且混淆驗證模式Session走CSRF;裝置API走Header Token與Permission
Token放在URL比較方便可能出現在日誌、瀏覽紀錄與Referer放在Authorization Header,且避免輸出到log
前端藏起按鈕就代表沒權限攻擊者可直接呼叫API每個API在伺服器端檢查Permission與物件所有權
所有裝置共用一把Token一台外洩等於整個場域失守個別憑證、最小權限、可撤銷及輪替

八、從ESP32到Django的安全檢查順序

  1. TLS:網址是否為HTTPS?憑證鏈、主機名稱與ESP32時間是否正確?
  2. Authentication:Header格式是否正確?Token是否有效、遭撤銷或誤寫到log?
  3. Permission:此裝置能否寫入指定device_id與API?
  4. Validation:JSON欄位、型別、長度、數值範圍與時間是否合理?
  5. Idempotency:QoS或重試造成重複請求時,event_uuid是否能去重?
  6. Audit:記錄結果、裝置ID、時間與Request ID,但不得記錄完整Token。
HTTP結果判讀下一步
401 Unauthorized沒有提供或無法驗證身分檢查Authorization Header與Token狀態
403 Forbidden身分已知但不允許,或Session情境的CSRF失敗查Permission、物件所有權及CSRF日誌
400 Bad Request資料格式或欄位驗證失敗查看安全且不洩密的錯誤內容
瀏覽器顯示CORS錯誤瀏覽器無法讀取回應;伺服器仍可能已收到請求看Browser Network與Server log,確認Origin、OPTIONS及Header

九、場域最低安全基線

傳輸層

全程HTTPS、有效憑證、ESP32驗證CA、停用明文API。

身分與權限

每台裝置獨立Token、最小權限、可撤銷、定期輪替。

應用與營運

Serializer驗證、節流、UUID去重、安全日誌、備份與更新。

祕密不進Git:使用環境變數或平台祕密管理保存Django SECRET_KEY、資料庫密碼與API憑證;Token不可寫進公開程式碼、截圖或教學log。

十、學生實作:安全不是抄設定,而是建立威脅模型

No-AI|四機制分類

針對「封包被偷看、惡意網站借Cookie、前端跨網域、ESP32身分冒用」四個情境,分別判斷HTTPS、CSRF、CORS、Token誰負責,並說明誰不能解決。

AI Pair|安全審查

提供一份去除真實密鑰的settings.py與API View,請AI列出風險;學生必須逐條對照官方文件,標示接受、修正或駁回及理由。

Challenge AI|紅藍隊驗證

測試無Token、錯Token、越權device_id、重複event_uuid、錯誤Origin與無CSRF請求;完成預期狀態碼、Server log與修補證據。

十一、上線前驗收清單

檢核通過證據
HTTPS與憑證驗證HTTP會安全導向HTTPS;ESP32拒絕錯誤憑證
Session與CSRF合法表單可操作;缺少或錯誤CSRF的修改請求被拒絕
CORS最小開放只有指定Origin可由瀏覽器讀取API,且僅套用需要的路徑
Token與Permission每台裝置獨立;不可寫入別台設備資料
不洩漏祕密程式庫、log、錯誤頁與截圖均無Token或密碼
異常與撤銷演練外洩Token可單獨停用,服務不中斷且留下稽核紀錄

十二、延伸閱讀(官方文件)

安全設定會隨框架版本與部署平台調整;實作時應以目前使用版本的官方文件為準。

封包旅行記 EP05|真正的API安全,不是把錯誤訊息消掉,而是讓每條連線都能回答:誰在呼叫、能做什麼、資料是否安全、失敗後如何追查。

一個封包如何從ESP32走到Django?

封包旅行記 EP01|大學部網際網路應用

一個封包如何從
ESP32走到Django?

按下按鈕、讀到水溫或播放一段故事之後,資料如何穿越Wi-Fi、路由器、DNS、Internet與HTTPS,最後被Django接收並寫進Database?

ESP32Wi-FiDNSTCP/TLSHTTP/JSONDjango

一個按鈕按下後,真正發生了什麼?

在智慧生活系統中,我們常看到ESP32序列監控視窗顯示「POST成功」,Django Dashboard也出現一筆新資料。看起來只是幾行程式,但在這短短幾秒內,資料其實經過多個設備、位址與協定。

本篇的重點不是再做一個能上傳資料的Demo,而是能清楚回答:資料從哪裡產生、每一層加了什麼資訊、封包如何找到伺服器,以及失敗時應該檢查哪一層。

學習目標

說得清架構

說明ESP32、AP、Router、DNS、Internet、Django與Database的責任。

看得懂封包

分辨MAC、IP、TCP、TLS、HTTP與JSON各自負責的工作。

找得到故障

使用分層診斷,不再把所有問題都歸因於「Wi-Fi不好」。

先看完整路線

感測器/按鈕
ESP32
Wi-Fi AP
Router/NAT
DNS
Internet
Django
Database

回應則沿著已建立的連線反方向返回:Database → Django → HTTPS Response → Internet → Router → ESP32。

五層看懂同一筆資料

應用層HTTP+JSON+REST API定義要送什麼資料、送到哪一個功能入口
安全/傳輸層TLS+TCP加密、建立可靠連線、排序、重送與確認
網路層IP+Routing決定封包從來源IP送往目的IP的路徑
資料連結層Wi-Fi+MAC在目前區域網路的一跳內傳送Frame
實體層2.4GHz無線電波把位元轉成無線訊號,在空氣中傳遞
重要:日常口語常把所有傳輸資料都叫「封包」,但精確來說,應用層是資料、TCP是Segment、IP是Packet、Wi-Fi/Ethernet是Frame。本篇在描述整體旅程時沿用「封包」作為易懂稱呼。

封包出發前:先定義這次事件

以水井三寶智慧互動展覽為例,使用者按下「烏龜故事」按鈕後,ESP32可以把事件整理成JSON。這份JSON是應用層要傳遞的內容,不包含MAC、IP或TCP資訊。

JSON Payload
{
  "event_uuid": "SHUIJING-001-0000000062",
  "event_type": "STORY_START",
  "story": "002",
  "source": "button",
  "network_status": "online",
  "metadata": {
    "firmware": "Layer4-V1.4",
    "jq_confirmed": true
  }
}

為什麼需要event_uuid?

網路逾時後ESP32可能重送同一事件。Django可用唯一識別碼判斷是否重複,避免同一筆資料寫入兩次。

為什麼要有metadata?

保留韌體版本、確認狀態及診斷資訊,方便日後比較不同設備與版本的行為。

八站完成一次封包旅行

01

ESP32加入Wi-Fi,取得網路身分

Association → Authentication → DHCP

ESP32先以STA模式加入無線基地台。連線成功後,通常透過DHCP取得四項重要資訊:

資訊範例用途
本機IP192.168.1.119ESP32在目前LAN中的位址
Subnet Mask255.255.255.0判斷目的地是否位於同一區域網路
Default Gateway192.168.1.1前往其他網路與Internet的出口
DNS Server192.168.1.1或ISP DNS將網域名稱解析成IP位址
檢查證據:序列監控應輸出SSID、本機IP、Gateway、DNS與RSSI。只有顯示 WiFi.status()==WL_CONNECTED,還不能證明Internet或Django已正常。
02

DNS把網域名稱翻譯成IP

Domain Name → DNS Query → Server IP

程式使用的網址可能是:

https://shuijingtreasures.pythonanywhere.com/api/events/

ESP32並不知道這個字串位於世界哪裡,因此會向DNS Server詢問:shuijingtreasures.pythonanywhere.com對應哪一個IP?DNS回覆後,ESP32才能建立後續連線。

診斷觀念:STA=ONLINE只代表取得區域網路連線。若DNS失敗,ESP32仍然無法用網域名稱連到Django。測試時可分別檢查「能否到Gateway」、「能否解析Domain」與「能否連上Server」。
03

ARP找到Gateway的MAC位址

下一跳不是遠端Django,而是本地Router

Django不在同一個Subnet,所以ESP32不會直接找Django的MAC。它先透過ARP找出Default Gateway的MAC位址,再把Wi-Fi Frame交給Router。

Wi-Fi來源MACESP32的MAC
Wi-Fi目的MACAP/Gateway在這一跳使用的MAC
IP來源位址192.168.1.119
IP目的位址DNS解析得到的Django主機IP

每經過一個路由節點,Frame的MAC資訊可能改變;但IP Packet的目的IP仍指向遠端伺服器。

04

Router執行NAT並選擇路徑

Private IP → Public IP → Internet

192.168.1.119是私有IP,不能直接在全球Internet上被路由。Router會執行NAT/PAT,把ESP32的私有IP與來源Port轉換成路由器的公網IP與暫時Port,並記錄對應關係。

位置來源目的
LAN內192.168.1.119:隨機來源PortServer-IP:443
Internet側Public-IP:轉換後PortServer-IP:443

伺服器回應抵達Router後,Router依NAT表把資料交回原本的ESP32。

05

TCP建立可靠連線

SYN → SYN-ACK → ACK

HTTPS一般建立在TCP之上。ESP32與伺服器先進行三向交握,確認雙方都能收發資料。TCP負責:

可靠性

透過Sequence Number、ACK、逾時與重送,降低資料遺失造成的錯誤。

有序傳輸

即使Segment經過不同路徑或抵達順序不同,也能重新排列成正確資料。

Timeout不一定是Django錯誤:TCP連線建立失敗,可能來自DNS錯誤、Port被阻擋、Server未回應、路由問題或訊號品質不穩。
06

TLS確認伺服器並加密資料

Certificate → Key Exchange → Encrypted Channel

因為網址使用HTTPS,TCP建立後還要進行TLS Handshake。ESP32檢查憑證是否可信、網域是否相符、憑證是否在有效期限內,再協商加密金鑰。

不要把正式系統設成「不驗證憑證」。使用不安全Client或略過憑證驗證,雖可能暫時連線成功,卻失去確認伺服器身分的能力。教學測試與正式部署應清楚區分。
07

HTTP把JSON送進Django API

POST+Header+Body

安全通道建立後,ESP32送出HTTP Request。概念上包含以下內容:

Request LinePOST /api/events/ HTTP/1.1
Hostshuijingtreasures.pythonanywhere.com
Content-Typeapplication/json
AuthorizationBearer DEVICE_TOKEN(範例,依系統設計)
Body{"event_uuid":"...","event_type":"STORY_START",...}

HTTP定義「怎麼送」,JSON定義「送什麼」,REST API定義「送到哪一個功能入口」。

08

Django驗證、處理並寫入Database

URL → View → Validation → Model → Response

Django收到Request後,通常依序進行:

  1. URL Router將 /api/events/交給對應View。
  2. 驗證Method、Content-Type、Token與JSON格式。
  3. 檢查必要欄位、資料型別及event_uuid是否重複。
  4. 使用Model寫入Database。
  5. 回傳JSON與適當HTTP Status Code。
Django View示意
import json
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .models import DeviceEvent

@require_POST
def create_event(request):
    try:
        data = json.loads(request.body)
        event_uuid = data["event_uuid"]

        event, created = DeviceEvent.objects.get_or_create(
            event_uuid=event_uuid,
            defaults={
                "event_type": data["event_type"],
                "story": data.get("story", ""),
                "source": data.get("source", "unknown"),
                "metadata": data.get("metadata", {}),
            },
        )

        return JsonResponse(
            {"ok": True, "created": created, "id": event.id},
            status=201 if created else 200,
        )
    except (KeyError, json.JSONDecodeError) as exc:
        return JsonResponse(
            {"ok": False, "error": str(exc)}, status=400
        )
成功不只看200:新增資源通常可回傳201 Created;重複事件可回200並標示created:false;格式錯誤用400;未授權用401;伺服器錯誤用500

ESP32送出HTTPS POST的示意程式

以下程式聚焦資料旅程,憑證、Token、逾時、重送與離線Queue仍需依正式系統補齊。不得把真實密鑰直接寫進公開文章或GitHub。

Arduino/ESP32示意
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>

const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* API_URL =
  "https://shuijingtreasures.pythonanywhere.com/api/events/";

void postEvent(const String& payload) {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("NETWORK ERROR: Wi-Fi disconnected");
    return;  // 正式版應排入離線Queue
  }

  WiFiClientSecure client;
  // 正式版:設定並驗證正確CA憑證
  // client.setCACert(ROOT_CA);

  HTTPClient https;
  https.setTimeout(8000);

  if (!https.begin(client, API_URL)) {
    Serial.println("HTTPS BEGIN FAILED");
    return;
  }

  https.addHeader("Content-Type", "application/json");
  https.addHeader("Authorization", "Bearer DEVICE_TOKEN");

  int statusCode = https.POST(payload);
  String response = https.getString();

  Serial.printf("HTTP STATUS: %d\n", statusCode);
  Serial.println("RESPONSE: " + response);

  https.end();
}

封包裡到底包了什麼?

「封裝」可以想像成一層一層加上信封。最裡面是JSON,外面依序包上HTTP、TLS、TCP、IP與Wi-Fi Frame。

由內到外主要資訊在哪裡被處理
JSON資料事件類型、故事編號、裝置資訊ESP32程式與Django View
HTTPMethod、Path、Header、Body、Status CodeHTTPClient、Web Server、Django
TLS憑證、加密參數、加密後內容ESP32安全Client與HTTPS伺服器
TCP來源/目的Port、Sequence、ACK兩端作業系統或網路堆疊
IP來源IP、目的IP、TTLESP32、Router及Internet路由器
Wi-Fi Frame本地一跳使用的MAC與錯誤檢查ESP32、AP與區域網路

出錯時,從哪裡開始查?

PowerDeviceWi-FiIPGatewayDNSTCP/TLSHTTPAPIDatabase
現象可能層級應取得的證據
無法取得IPWi-Fi/DHCPSSID、密碼、WiFi狀態、DHCP紀錄
有IP但Domain解析失敗DNSDNS Server、解析結果、其他Domain測試
連線逾時Routing/TCP/FirewallGateway、目的IP、Port 443、Timeout位置
TLS失敗憑證/時間/網域憑證錯誤、系統時間、CA與Host名稱
HTTP 400JSON/API驗證Request Body、Content-Type、Django錯誤回應
HTTP 401/403身分與權限Token、Header、裝置權限與ACL
HTTP 500Django/DatabaseServer Log、Exception、Migration與資料庫狀態
ESP32顯示成功但Dashboard沒資料API/Database/Dashboard QueryResponse、資料表紀錄、查詢條件與時間範圍
工程判斷要用證據:不要只說「網路不穩」或「伺服器壞了」。應記錄失敗在哪一層、看到什麼狀態碼、哪一個測試成功、哪一個測試失敗。

三項學生任務

No-AI|畫出封包旅行圖

標示ESP32、AP、Router、DNS、Internet、Django與Database,並為每段寫出主要協定。

AI Pair|解釋封裝

請AI協助比較Frame、Packet、Segment與HTTP資料,再以自己的話修正與重畫。

Challenge|故障闖關

教師設定錯誤DNS、錯誤API Path、無效Token或重複event_uuid,學生用證據定位與修復。

學習檢核

問題學生應能回答的重點
ESP32已取得IP,為何仍可能無法連到Django?Gateway、DNS、Routing、TCP、TLS、HTTP與API仍可能失敗。
為什麼目的MAC不是Django伺服器的MAC?MAC只負責本地一跳;跨網路時先交給Gateway。
NAT做了什麼?把私有IP與Port轉換為公網IP與Port,並保存回程對應。
HTTP、JSON與REST API如何分工?HTTP定義傳送方式,JSON定義資料格式,REST API定義資源入口與操作。
為何需要event_uuid?讓伺服器辨識重送與重複事件,支援冪等處理。
如何證明事件已成功?同時檢查ESP32 Log、HTTP Status、Django Response、Database與Dashboard。

一個封包的旅程,就是一套系統的縮影

ESP32負責把實體事件轉成資料;Wi-Fi與Router把資料送出場域;DNS找到服務;TCP與TLS提供可靠且安全的通道;HTTP與JSON定義溝通;Django與Database把事件保存成可分析的資訊。

真正學會網際網路,不是只讓資料送成功,而是能解釋每一站、驗證每一層、修復每一種失敗。

系列定位:《封包旅行記》EP01。適用於大學部網際網路應用、物聯網與智慧生活、ESP32聯網、Django雲端平台及USR場域型專題。範例中的網址、Token、IP與資料內容均為教學示意,正式系統應採安全憑證、環境變數、裝置身分、權限控管與完整異常處理。

2026年8月18日 星期二

從接線施工到手機控制: 水井三寶 ESP32 擴充底板 × GPIO19 WS2812B × Web 音量控制實作

從接線施工到手機控制:
水井三寶 ESP32 擴充底板 × GPIO19 WS2812B × Web 音量控制實作

水井三寶智慧互動展覽系統|Layer 2~Layer 3 實作紀錄

ESP32 × WS2812B × JQ6500 × Edge Web × Realtime Status × Mobile Volume Control

「水井三寶智慧互動展覽系統」在實作過程中,除了要考慮程式能不能執行,更重要的是: 設備真正裝進展覽箱之後,好不好接線?好不好維修?現場人員能不能不用重新燒錄程式,就完成基本設定?

因此,本次系統在原有功能穩定之後,又進行兩項很重要的實務調整: 第一項是為了配合 ESP32 擴充底板與 WS2812B 三線式插頭,重新調整 LED DATA GPIO; 第二項則是在 V1.8 Realtime Web Status 基礎上加入手機音量控制,形成 Layer 4 V1.8.1「Realtime Status+Mobile Volume Control」

一、為什麼已經可以動了,還要修改GPIO?

早期測試 WS2812B 時,DATA 使用 GPIO13。從程式的角度來看完全沒有問題, 但當系統開始由麵包板實驗走向正式展覽箱施工時,問題就不再只是「GPIO能不能輸出」。

真正的問題變成:哪一支GPIO最適合施工?

  • 能不能直接配合擴充底板的三針插座?
  • 能不能使用現有 WS2812B 三線插頭?
  • 是否會和三顆實體按鈕衝突?
  • 是否會影響 JQ6500 UART?
  • 日後拆裝與維修是否方便?

二、從 GPIO13 改成 GPIO19

經過重新檢查擴充底板可使用的腳位後, GPIO19 可以直接配合板上的插座,而且不需要搬動目前已經測試穩定的按鈕與 JQ6500 接腳, 因此最後決定:

WS2812B DATA:GPIO13 → GPIO19
功能 GPIO 說明
白馬按鈕 GPIO32 維持原接線
烏龜按鈕 GPIO33 維持原接線
姻緣花按鈕 GPIO25 維持原接線
JQ6500 UART GPIO26 / GPIO27 已完成穩定測試,不再更動
ToF VL53L0X GPIO21 / GPIO22 I2C SDA / SCL
WS2812B DATA GPIO19 配合擴充底板三針插座

三、程式其實只需要改一個地方

這正是軟硬體模組化的好處。 LED控制邏輯沒有改變,只是將資料輸出的GPIO重新指定。

原來版本

#define LED_PIN 13

新版

#define LED_PIN 19

Adafruit NeoPixel 的初始化方式仍然相同:

#define LED_PIN    19
#define LED_COUNT  61

Adafruit_NeoPixel strip(
  LED_COUNT,
  LED_PIN,
  NEO_GRB + NEO_KHZ800
);
這次修改的重要經驗:
GPIO選擇不能只從「程式能不能跑」來思考。 當作品進入正式施工階段,還要把接頭、底板、線材、維修方式與模組位置一起納入設計。

四、WS2812B 如何接到 ESP32 擴充底板?

WS2812B 基本上需要三條線:

ESP32 擴充底板 GPIO19 / Signal │ ├────────────→ WS2812B DIN │ 5V / VCC ├────────────→ WS2812B +5V │ GND └────────────→ WS2812B GND

如果 LED 數量較多,正式展覽系統建議讓 WS2812B 使用獨立 5V 電源, ESP32只提供 DATA,但兩邊的 GND 必須共地。

ESP32 GPIO19 ─────────→ WS2812B DIN 外接 5V + ───────────→ WS2812B +5V 外接 5V GND ─────┬───→ WS2812B GND │ ESP32 GND ────────┘ 必須共地
施工注意: 三針插頭的線色不能直接當成腳位依據, 一定要確認擴充板的 Signal / VCC / GND, 以及 WS2812B 的 DIN / 5V / GND 順序。

五、61顆WS2812B不是只拿來「發亮」

目前系統將 61 顆 WS2812B 分成不同區域:

#define BASE_START       0
#define BASE_COUNT       9

#define HORSE_START      9
#define HORSE_COUNT     16

#define TURTLE_START    25
#define TURTLE_COUNT    12

#define FLOWER_START    37
#define FLOWER_COUNT    24
區域 LED數量 燈光意義
底座 9 系統基礎氛圍
白馬 16 行動、力量、向前
烏龜 12 水、生態、守護
姻緣花 24 祝福、緣分、生命與地方情感

尤其姻緣花已由原本的固定色彩改成 HSV 彩虹色, 讓不同 LED 呈現五顏六色的效果,再搭配逐漸綻放與收縮的動畫。

uint16_t hue =
  colorOffset +
  (65535UL * i / FLOWER_COUNT);

strip.setPixelColor(
  FLOWER_START + i,
  strip.gamma32(
    strip.ColorHSV(
      hue,
      255,
      100
    )
  )
);

因此燈光不只是裝飾,而是成為展覽系統的一種 視覺回饋介面

六、第二個問題:展場音量不能每次都重新燒錄

JQ6500 原本的音量是在 Arduino 程式中設定:

uint8_t currentVolume = 20;

JQ6500 的控制範圍設定為:

0 ~ 30

如果要從 20 改成 25 或 30,最簡單的方法當然是修改程式再重新燒錄。 但是到了真正的展覽現場,這種方式非常不方便。

不同場域需要不同音量:
  • 安靜教室可能只需要 15~20。
  • 兒童館可能需要 20~25。
  • 大型展覽空間可能需要更高音量。
  • 閉館測試時可能希望立即靜音。

因此 V1.8.1 的設計目標很直接:

讓手機成為水井三寶的音量遙控器。

七、V1.8.1:手機直接控制 JQ6500 音量

手機連上 ESP32 的:

Wi-Fi SSID: Shuijing-Treasures ↓ 瀏覽器 ↓ http://192.168.4.1

就可以在 Edge Web 控制頁看到:

🔊 音量控制

0 ─────────── ● ─────────── 30

目前音量:20 / 30

【靜音】 【15】 【20】 【25】 【最大30】

八、HTML使用 Range Slider

Web介面使用 HTML 的 range 元件:

<input
  type="range"
  id="volumeSlider"
  min="0"
  max="30"
  value="20"
>

這讓手機可以直接拖曳調整:

0 ↓ 5 ↓ 10 ↓ 15 ↓ 20 ↓ 25 ↓ 30

九、手機不是直接控制JQ6500

這一點非常適合拿來作為《網際網路應用》課程教材。

手機調整音量時,真正的資料流程是:

手機瀏覽器 │ │ HTTP Request ▼ ESP32 Edge Web │ │ /volume?v=25 ▼ Web Handler │ ▼ jqSetVolume(25) │ │ UART Command ▼ JQ6500 │ ▼ 喇叭音量改變

也就是:

Web介面 → HTTP → ESP32 → UART → JQ6500 → 喇叭

十、Web端如何送出音量設定?

JavaScript 可以使用 fetch()

async function setVolume(v)
{
  const r = await fetch(
    '/volume?v=' + encodeURIComponent(v),
    {
      cache:'no-store'
    }
  );

  const d = await r.json();

  document.getElementById(
    'volumeValue'
  ).textContent = d.volume;
}

例如使用者選擇 25,瀏覽器送出:

GET /volume?v=25

十一、ESP32如何接收?

ESP32 Web Server 收到 /volume Request 後, 讀取參數並限制在 0~30:

int volume =
  server.arg("v").toInt();

if (volume < 0)
{
  volume = 0;
}

if (volume > 30)
{
  volume = 30;
}

jqSetVolume(
  (uint8_t)volume
);

完成後再回傳 JSON:

{
  "ok": true,
  "volume": 25
}

十二、為什麼回傳JSON,而不是重新整理網頁?

早期 Web 控制很容易採用:

按按鈕 ↓ ESP32執行 ↓ Redirect ↓ 整個網頁重新載入

V1.8.1 則改成:

拖曳音量 ↓ fetch() ↓ /volume?v=25 ↓ ESP32設定JQ6500 ↓ 回傳JSON ↓ 只更新音量數字

因此操作更接近現代 Web App, 也不會因為每次調整音量就讓整個手機畫面閃動。

十三、V1.8的重要改進:Realtime Web Status

在早期版本中曾經出現一個很有意思的問題:

故事已經播放完畢,ESP32 Serial Monitor 也顯示 PLAY COMPLETE, 但是手機網頁仍然顯示:

PLAYING

原因不是 JQ6500,也不是 ESP32 狀態機, 而是瀏覽器看到的是載入網頁那一刻的狀態。

因此 V1.8 新增:

/api/status

手機每秒讀取一次最新狀態:

setInterval(
  updateStatus,
  1000
);

十四、Realtime Status與音量控制整合

V1.8.1 再把音量加入 Status JSON。 概念上可以得到:

{
  "state":"PLAYING",
  "story":"白馬故事",
  "jq_busy":true,
  "volume":25,
  "cloud_ok":18,
  "cloud_fail":0
}

因此手機控制頁可以同時知道:

  • 現在是不是正在播放。
  • 目前播放哪一個故事。
  • JQ6500 BUSY 是否有效。
  • 目前音量是多少。
  • ESP32目前的即時狀態。

十五、播放完成後,手機會自己變化

現在操作流程變成:

手機按下「白馬」 ↓ PLAYING 白馬故事 JQ BUSY:ACTIVE ↓ 語音播放完成 ↓ COOLDOWN 白馬故事 JQ BUSY:IDLE ↓ 約2秒 ↓ READY / IDLE 待機

使用者不再需要手動按「重新整理」。

十六、為什麼Status Polling不能算成「使用者操作」?

這是 V1.8 開發過程中另一個很重要的系統設計問題。

水井三寶採用:

Local First, Cloud Later
現場互動優先,雲端同步延後。

如果瀏覽器每秒查詢 /api/status, ESP32每次都把它當成「使用者正在操作」, Cloud Sync 就可能永遠等不到真正的 Idle 時間。

因此:

void handleStatus()
{
  // 不呼叫 markLocalActivity()

  ...
}

也就是把:

「查詢狀態」 和 「真正操作設備」 分開處理。

十七、V1.8.1的完整控制關係

手機 │ │ Wi-Fi ▼ ESP32 Edge Web 192.168.4.1 │ ┌─────────┼─────────┐ │ │ │ ▼ ▼ ▼ 故事控制 音量控制 即時狀態 /play /volume /api/status │ │ ▲ ▼ ▼ │ ESP32 ────────────────┘ │ ├──── UART ───→ JQ6500 ───→ 喇叭 │ ├──── GPIO19 ─→ WS2812B │ ├──── I2C ────→ VL53L0X │ └──── Wi-Fi STA │ ▼ Internet │ ▼ Django / Cloud

十八、從「接腳修改」看工程設計

GPIO13改成GPIO19,看起來只修改了一行程式:

#define LED_PIN 19

但背後其實代表系統已經從:

「麵包板能動」 進入 「正式作品能施工」 再進入 「現場人員能維護」

這也是 IoT 專題很重要的一課: 好的系統不只是功能正確,也要考慮安裝、操作與維護。

十九、從「音量控制」看Edge Web的價值

同樣地,音量原本只是 Arduino 裡的一個變數:

uint8_t currentVolume = 20;

但是加入 Edge Web 之後, 這個變數就成為使用者可以透過手機操作的系統參數。

Arduino Variable currentVolume ↓ ESP32 Web API /volume?v=25 ↓ HTML / JavaScript Volume Slider ↓ 手機操作介面

這就是 Edge Web 很重要的價值:

把嵌入式系統內部的控制功能, 轉換成一般使用者也能操作的 Web 服務。

二十、從水井三寶學「網際網路應用」

這次修改雖然從「換一支GPIO」和「增加音量滑桿」開始, 但其實可以串起很多《網際網路應用》的重要概念。

實作 可以學到的概念
GPIO19控制WS2812B Embedded I/O、硬體介面、模組化設計
手機連ESP32 SoftAP、Wi-Fi、IP、Client / Server
192.168.4.1 Private IP、Edge Web Server
/volume?v=25 URL、Path、Query Parameter、HTTP GET
fetch() 非同步Web Request
JSON Response Web資料交換格式
/api/status API、Realtime Status、Polling
JQ6500 UART、裝置控制
WS2812B 數位燈光與視覺回饋
Django同步 Edge → Internet → Cloud

二十一、版本演進

版本 主要功能
早期版本 ESP32+JQ6500基本故事播放
Layer 3 ESP32 Edge Web手機控制
Layer 4 V1.7 AP+STA+Django+Queue+Cloud Sync
Layer 4 V1.8 Realtime Web Status
Layer 4 V1.8.1 Realtime Status+Mobile Volume Control+GPIO19 WS2812B施工配置

二十二、結語:真正的IoT,是讓實體作品、網路與使用者連在一起

水井三寶智慧互動展覽系統的開發不是一次完成, 而是在實際接線、播放、展示、手機操作與雲端同步的過程中不斷修正。

從 GPIO13 改成 GPIO19, 是為了讓硬體更適合正式施工; 從固定音量改成手機控制, 是為了讓系統更適合真實展覽環境; 從靜態網頁改成 Realtime Status, 則是讓使用者真正看見設備當下的狀態。

文化作品是起點,ESP32負責互動,Edge Web負責控制, Internet負責連結,Django負責資料,而未來的AI/RAG則負責讓地方知識持續被理解與使用。

這也正是「水井三寶智慧互動展覽系統」作為 大學《網際網路應用》與USR實作教材最重要的價值: 不是只教學生把程式寫出來,而是讓學生從一個真實地方文化作品出發, 一路理解硬體、網路、Web、資料與使用者之間如何形成一個完整系統。

水井三寶 ESP32 GPIO19 WS2812B JQ6500 Edge Web Realtime Status Mobile Volume Control HTTP JSON IoT Django USR

2026年8月15日 星期六

[水井村USR] ESP32 AP+STA 到 Django 雲端:水井三寶智慧互動展覽系統 Layer 4 V1.7 實作紀錄

ESP32 AP+STA 到 Django 雲端:水井三寶智慧互動展覽系統 Layer 4 V1.7 實作紀錄

從 JQ6500 故事播放、Edge Web、NVS Wi‑Fi 設定,到 Public DNS 修正與匿名事件雲端同步

實作版本:Layer 4 V1.7|Public DNS Fix

這次測試最重要的成果,不只是「ESP32 終於可以把資料送到雲端」,而是把一個展覽原型逐步整理成具有離線可操作、現場可診斷、網路可設定、事件可暫存、恢復連線可同步的 Edge+Cloud 架構。最後的 V1.7 已能讓 ESP32 同時維持本地 AP 與 Internet STA,透過 192.168.4.1 控制故事,並將匿名互動事件送往 Django 儀表板。

一、系統從「會播放」走向「真正可展覽」

整體可理解成四層:第二層負責 ESP32、ToF、三按鈕、JQ6500 與 RGB 的智慧互動;第三層加入 ESP32 內嵌 Edge Web,讓工作人員可用手機控制與診斷;第四層再把匿名事件送到 Django,形成展覽統計與設備健康資訊。

觀眾/工作人員 ↓ 實體按鈕 / ESP32 Edge Web ↓ JQ6500 故事播放 + 狀態判斷 ↓ LittleFS Event Queue ↓ STA → DNS → HTTPS ↓ PythonAnywhere / Django API ↓ 匿名事件儀表板

二、AP+STA 共存是展場架構的關鍵

ESP32 的 AP 並不是拿來取代 Internet,而是保留一條不依賴展場網路的本地控制通道。手機連上 Shuijing-Treasures 後,可直接進入 192.168.4.1;同一時間 STA 連上可上網的 Wi‑Fi,負責把事件送往 Django。即使外網失效,AP 與本地故事控制仍可工作。

Layer 4 V1.7 系統狀態:STA ONLINE、DNS 8.8.8.8、Queue 與故事播放狀態
圖 1 Layer 4 V1.7 系統狀態:STA ONLINE、DNS 8.8.8.8、Queue 與故事播放狀態

V1.7 畫面可直接看到 AP 位址、STA 是否 ONLINE、STA IP、RSSI、DNS 與 Queue 大小。這些資訊對展場維護非常重要,因為「手機能控制」與「雲端能同步」其實是兩條不同的網路路徑。

三、測試中最關鍵的轉折:HTTP -1 的根因其實是 DNS

前期曾反覆看到 Cloud HTTP = -1。一開始容易懷疑 Django API、TLS、API Key 或 PythonAnywhere,但加入逐層診斷後,真正有決定性的紀錄是:

WiFi status = 3
STA IP = 192.168.1.119
Gateway = 192.168.1.1
DNS = 192.168.1.1
RSSI = -20
Resolving: shuijingtreasures.pythonanywhere.com
DNS result = -54
ERROR: DNS FAILED
重要經驗:「STA=ONLINE」只代表 ESP32 已加入 Wi‑Fi 並取得 IP,不代表 DNS、TLS、HTTPS 與 Django 都已經正常。除錯時必須拆成 Wi‑Fi → Gateway → DNS → TLS → HTTP → API 六個階段。

因此 V1.7 不再只相信 DHCP 配發的 DNS,而是在 STA 連線成功後保留 DHCP IP,另外指定 Public DNS:

IPAddress DNS1(8, 8, 8, 8);
IPAddress DNS2(1, 1, 1, 1);

WiFi.setDNS(DNS1, DNS2);

修正後 Edge Web 已顯示 DNS:8.8.8.8,而 Cloud Test 也正式成功。

Cloud Test OK:Django 已接受 ESP32 上傳事件
圖 4 Cloud Test OK:Django 已接受 ESP32 上傳事件
驗證結果:Cloud Test OK,畫面顯示「Django 已接受事件」。這代表 DNS 解析、HTTPS 傳輸、裝置驗證與 Django API 接收這條路徑已經打通。

四、JQ6500:送出播放命令不等於真的播放

另一個重要經驗來自 JQ6500。早期程式只要送出 UART 命令就把狀態設成 PLAYING,但實測曾出現「程式顯示已播放、喇叭卻沒有聲音」。因此後續版本把 JQ6500 的 BUSY/播放確認與 Recovery 納入狀態判斷,不再把「已送命令」當成「播放成功」。

Layer 4 V1.7 故事控制與設備診斷:JQ6500 BUSY、Cloud OK / Fail
圖 2 Layer 4 V1.7 故事控制與設備診斷:JQ6500 BUSY、Cloud OK / Fail

目前設備診斷畫面可看到 JQ BUSY、Start Confirmed、Retry、Fail,以及 Cloud OK / Fail。這使現場人員不用接電腦,也能從手機快速判斷問題是在音訊端還是網路端。

五、Web 狀態不能只靠重新整理

測試過程也發現:故事播完後,如果 Edge Web 還停留在 PLAYING,下一次操作會讓人誤以為系統延遲。這說明嵌入式 Web 不應只是「控制按鈕頁」,還應該是設備狀態的即時視窗。V1.7 延續前面的播放狀態管理,讓 PLAYING、IDLE、目前故事與 JQ 狀態有一致的資料來源。

早期 Edge Web Control V1.0 畫面,作為 Layer 3 到 Layer 4 演進比較
圖 6 早期 Edge Web Control V1.0 畫面,作為 Layer 3 到 Layer 4 演進比較

六、匿名事件 Queue:網路斷線也不能讓展覽失憶

展場網路不可能永遠穩定,因此事件不是「有網路才記錄」。V1.7 先將 STORY_START、STORY_COMPLETE 等匿名事件排入 LittleFS Queue,再由 Cloud Sync 在適當時機送出。畫面中的 Queue: 14096 bytes 就是尚待同步的本地事件資料。

這個設計的價值是:互動優先、上雲其次。播放故事時避免 HTTPS 與 Wi‑Fi reconnect 搶資源;空閒後再同步,才符合實體展覽設備的可靠性需求。

七、從 ESP32 即時統計到 Django 儀表板

Edge Web 提供的是「這一台設備現在發生什麼事」,Django 則負責「今天整體發生多少互動」。兩者角色不同但互補。

ESP32 即時匿名統計:故事、Web、實體按鈕、展區喚醒與完整播放
圖 3 ESP32 即時匿名統計:故事、Web、實體按鈕、展區喚醒與完整播放

ESP32 本地頁可看到白馬、烏龜、姻緣花、004、005、Web、實體按鈕、展區喚醒與完整播放等即時匿名統計。這些資料不需要辨識個人身分,而是記錄設備互動事件。

PythonAnywhere Django 雲端儀表板:故事啟動、完整播放、互動來源與裝置健康
圖 5 PythonAnywhere Django 雲端儀表板:故事啟動、完整播放、互動來源與裝置健康

雲端儀表板已收到 Layer4‑V1.7 資料,可顯示故事啟動、完整播放、播放完成率、三寶與延伸故事、互動來源,以及 SHUIJING‑001 裝置健康。測試畫面中已有 9 次故事啟動、2 次完整播放,Edge Web 為主要互動來源,裝置狀態為 ONLINE。

八、這次除錯得到的工程經驗

現象容易誤判最後得到的經驗
STA ONLINE以為 Internet 一定正常還要分別驗證 Gateway、DNS、TLS、HTTP
Cloud HTTP = -1先懷疑 Django本次真正根因是 DNS 解析失敗
JQ TX 已送出以為故事一定播放必須用 BUSY/確認機制判斷實際播放
Web 顯示 PLAYING以為狀態一定同步播放完成後狀態也要回到 IDLE
Cloud 暫時離線事件可能直接遺失先進 LittleFS Queue,恢復網路後再同步
展場有 Internet認為不需要 APAP 是維護與現場控制的獨立生命線

九、V1.7 的設計原則

AP+STA 共存Public DNSNVS Wi‑Fi 設定JQ6500 RecoveryLittleFS QueueHTTPSDjango API匿名統計Edge Web

如果只把 ESP32 當成「播放 MP3 的控制板」,這套系統並不複雜;但當目標變成真正可長時間展出的作品,問題就會轉向可恢復性、可診斷性、離線能力與資料一致性。V1.7 的價值正是在這裡:不是增加更多炫目的功能,而是讓每一層失效時,其他層仍能盡量維持工作。

十、Layer 4 V1.7 完整 Arduino 程式

以下為本次實測版本的完整程式。實際部署前,請依自己的 Django 裝置資料設定 API Key,並避免在公開文章中放入正式金鑰或 Wi‑Fi 密碼。

Shuijing_Treasures_Layer4_V1_7_PublicDNSFix.inoLayer 4 V1.7
/*
====================================================================
 水井三寶智慧互動展覽系統
 Shuijing Treasures Smart Interactive Exhibition System

 Layer 4|Django Data Layer V1.7
 Cloud Diagnostic + Local First / Cloud Later
--------------------------------------------------------------------
 ESP32 NodeMCU-32S
 + VL53L0X ToF
 + 3 Buttons
 + JQ6500
 + WS2812
 + SoftAP
 + STA Internet
 + Embedded Web
 + NVS Wi-Fi Setting
 + LittleFS Offline Event Queue
 + Django Event API / Heartbeat
 + JQ6500 播放確認 / Recovery
 + Cloud DNS / TLS / HTTP 診斷

 本版重點:
 1. 播放故事優先,播放中完全不做 Django HTTPS。
 2. READY / IDLE 都允許同步,只要最近 10 秒沒有本地操作。
 3. /cloudtest 可直接測試 Django API。
 4. postJsonToCloud() 會依序檢查:
      WiFi status
      STA IP
      Gateway
      DNS
      hostByName()
      TCP/TLS 443
      HTTP begin
      HTTP POST
      response body
 5. HTTPS connect/response timeout = 10 秒。
 6. 每次 Queue 同步最多 1 筆,避免長時間阻塞。
 7. Wi-Fi / Cloud 診斷每 30 秒一次。

 AP:
   SSID     = Shuijing-Treasures
   Password = 12345678
   URL      = http://192.168.4.1

 Wi-Fi Setup:
   http://192.168.4.1/wifi

 Cloud Test:
   http://192.168.4.1/cloudtest

 Track:
   001 白馬故事
   002 烏龜故事
   003 姻緣花故事
   004 水井三寶總故事
   005 創作者資訊
====================================================================
*/

#include <Arduino.h>
#include <WiFi.h>
#include <WiFiAP.h>
#include <WebServer.h>
#include <Preferences.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <LittleFS.h>
#include <Wire.h>
#include <HardwareSerial.h>
#include <Adafruit_VL53L0X.h>
#include <Adafruit_NeoPixel.h>

// ==========================================================
// Version
// ==========================================================

const char* FIRMWARE_VERSION = "Layer4-V1.7";

// ==========================================================
// SoftAP
// ==========================================================

const char* AP_SSID = "Shuijing-Treasures";
const char* AP_PASSWORD = "12345678";

WebServer server(80);
Preferences prefs;

// ==========================================================
// STA Wi-Fi
// ==========================================================

String staSSID = "";
String staPassword = "";

bool restartScheduled = false;
unsigned long restartAt = 0;
unsigned long lastReconnectAttempt = 0;

// ==========================================================
// Public DNS
// ==========================================================

// 保留 DHCP IP,只覆寫 DNS server
IPAddress DNS1(8, 8, 8, 8);
IPAddress DNS2(1, 1, 1, 1);


// ==========================================================
// Django Cloud
// ==========================================================

const char* DEVICE_ID = "SHUIJING-001";

// 請改成 Django 中 SHUIJING-001 的 api_key
const char* DEVICE_KEY = "CHANGE-ME";

const char* CLOUD_HOST =
  "shuijingtreasures.pythonanywhere.com";

const char* CLOUD_EVENT_URL =
  "https://shuijingtreasures.pythonanywhere.com/api/exhibition/events/";

const char* CLOUD_HEARTBEAT_URL =
  "https://shuijingtreasures.pythonanywhere.com/api/exhibition/heartbeat/";

const char* EVENT_QUEUE_FILE = "/events.queue";
const char* EVENT_TEMP_FILE  = "/events.tmp";

// Cloud 策略
const unsigned long CLOUD_SYNC_INTERVAL_MS = 30000;    // 30 sec
const unsigned long HEARTBEAT_INTERVAL_MS = 120000;    // 120 sec
const unsigned long CLOUD_IDLE_GRACE_MS = 10000;       // 10 sec

const uint8_t MAX_EVENTS_PER_SYNC = 1;

unsigned long lastCloudSync = 0;
unsigned long lastHeartbeat = 0;
unsigned long lastLocalActivity = 0;
unsigned long eventSequence = 0;

// ==========================================================
// GPIO
// ==========================================================

#define JQ_TX_PIN       26
#define JQ_RX_PIN       27
#define JQ_BUSY_PIN     34

#define BTN_HORSE       32
#define BTN_TURTLE      33
#define BTN_FLOWER      25

#define TOF_SDA         21
#define TOF_SCL         22

#define LED_PIN         13
#define LED_COUNT       62

// ==========================================================
// LED Zones
// ==========================================================

#define BASE_START       0
#define BASE_COUNT      24

#define HORSE_START     24
#define HORSE_COUNT     10

#define TURTLE_START    34
#define TURTLE_COUNT    12

#define FLOWER_START    46
#define FLOWER_COUNT    16

// ==========================================================
// Parameters
// ==========================================================

const unsigned long DEBOUNCE_MS = 50;
const unsigned long COOLDOWN_MS = 2000;
const unsigned long BUSY_GUARD_MS = 300;
const unsigned long MAX_PLAY_MS = 180000;

const unsigned long TOF_INTERVAL_MS = 100;
const unsigned long PERSON_DWELL_MS = 1200;
const unsigned long ATTRACT_MS = 1500;

const int PERSON_DISTANCE_MM = 1200;

// 若實測播放時 BUSY=LOW,改成 false
const bool BUSY_ACTIVE_HIGH = true;

const bool USE_BUSY_CONFIRM = true;
const unsigned long JQ_START_CONFIRM_MS = 1000;
const unsigned long JQ_RETRY_CONFIRM_MS = 1500;

const unsigned long DIAG_INTERVAL_MS = 30000;

// ==========================================================
// Hardware
// ==========================================================

HardwareSerial JQ(2);
Adafruit_VL53L0X lox;

Adafruit_NeoPixel strip(
  LED_COUNT,
  LED_PIN,
  NEO_GRB + NEO_KHZ800
);

// ==========================================================
// State
// ==========================================================

enum SystemState
{
  STATE_IDLE,
  STATE_ATTRACT,
  STATE_READY,
  STATE_PLAYING,
  STATE_COOLDOWN,
  STATE_ERROR
};

enum Story
{
  STORY_NONE    = 0,
  STORY_HORSE   = 1,
  STORY_TURTLE  = 2,
  STORY_FLOWER  = 3,
  STORY_ALL     = 4,
  STORY_CREATOR = 5
};

SystemState state = STATE_IDLE;
Story currentStory = STORY_NONE;

// ==========================================================
// Button
// ==========================================================

struct Button
{
  uint8_t pin;
  bool stableState;
  bool lastReading;
  unsigned long lastChange;
  bool pressedEvent;
};

Button horseButton;
Button turtleButton;
Button flowerButton;

// ==========================================================
// Runtime
// ==========================================================

unsigned long stateStartTime = 0;
unsigned long playStartTime = 0;
unsigned long cooldownStartTime = 0;
unsigned long lastToFTime = 0;
unsigned long personStartTime = 0;

bool personDetected = false;
bool personTiming = false;
bool tofOK = false;
bool busySeenPlaying = false;
bool jqStartConfirmed = false;

int lastDistanceMM = -1;
uint8_t currentVolume = 20;

// ==========================================================
// Stats
// ==========================================================

struct Statistics
{
  unsigned long wakeCount = 0;

  unsigned long horseCount = 0;
  unsigned long turtleCount = 0;
  unsigned long flowerCount = 0;

  unsigned long totalCount = 0;
  unsigned long creatorCount = 0;

  unsigned long buttonCount = 0;
  unsigned long webCount = 0;

  unsigned long completedCount = 0;
  unsigned long stopCount = 0;

  unsigned long jqRetryCount = 0;
  unsigned long jqFailCount = 0;

  unsigned long cloudSuccessCount = 0;
  unsigned long cloudFailCount = 0;
};

Statistics stats;

// ==========================================================
// Names
// ==========================================================

String stateName()
{
  switch (state)
  {
    case STATE_IDLE:     return "IDLE";
    case STATE_ATTRACT:  return "ATTRACT";
    case STATE_READY:    return "READY";
    case STATE_PLAYING:  return "PLAYING";
    case STATE_COOLDOWN: return "COOLDOWN";
    case STATE_ERROR:    return "ERROR";
  }

  return "UNKNOWN";
}

String storyName(Story story)
{
  switch (story)
  {
    case STORY_HORSE:   return "白馬故事";
    case STORY_TURTLE:  return "烏龜故事";
    case STORY_FLOWER:  return "姻緣花故事";
    case STORY_ALL:     return "水井三寶總故事";
    case STORY_CREATOR: return "創作者資訊";
    default:            return "待機";
  }
}

String storyCode(Story story)
{
  switch (story)
  {
    case STORY_HORSE:   return "001";
    case STORY_TURTLE:  return "002";
    case STORY_FLOWER:  return "003";
    case STORY_ALL:     return "004";
    case STORY_CREATOR: return "005";
    default:            return "";
  }
}

// ==========================================================
// Local Activity
// ==========================================================

void markLocalActivity()
{
  lastLocalActivity = millis();
}

// ==========================================================
// Buttons
// ==========================================================

void initButton(Button &btn, uint8_t pin)
{
  btn.pin = pin;
  pinMode(pin, INPUT_PULLUP);

  btn.stableState = HIGH;
  btn.lastReading = HIGH;
  btn.lastChange = 0;
  btn.pressedEvent = false;
}

void updateButton(Button &btn)
{
  bool reading = digitalRead(btn.pin);

  if (reading != btn.lastReading)
  {
    btn.lastReading = reading;
    btn.lastChange = millis();
  }

  if (millis() - btn.lastChange >= DEBOUNCE_MS)
  {
    if (reading != btn.stableState)
    {
      btn.stableState = reading;

      if (btn.stableState == LOW)
      {
        btn.pressedEvent = true;
      }
    }
  }
}

void updateButtons()
{
  updateButton(horseButton);
  updateButton(turtleButton);
  updateButton(flowerButton);
}

void clearButtonEvents()
{
  horseButton.pressedEvent = false;
  turtleButton.pressedEvent = false;
  flowerButton.pressedEvent = false;
}

bool allButtonsReleased()
{
  return
    horseButton.stableState == HIGH &&
    turtleButton.stableState == HIGH &&
    flowerButton.stableState == HIGH;
}

// ==========================================================
// JQ6500
// ==========================================================

void sendJQ(const uint8_t *data, size_t length)
{
  JQ.write(data, length);
  JQ.flush();

  Serial.print("JQ TX: ");

  for (size_t i = 0; i < length; i++)
  {
    if (data[i] < 0x10)
    {
      Serial.print("0");
    }

    Serial.print(data[i], HEX);
    Serial.print(" ");
  }

  Serial.println();
}

void jqSelectFlash()
{
  uint8_t cmd[] =
  {
    0x7E,
    0x03,
    0x09,
    0x04,
    0xEF
  };

  sendJQ(cmd, sizeof(cmd));
}

void jqSetVolume(uint8_t volume)
{
  if (volume > 30)
  {
    volume = 30;
  }

  currentVolume = volume;

  uint8_t cmd[] =
  {
    0x7E,
    0x03,
    0x06,
    volume,
    0xEF
  };

  sendJQ(cmd, sizeof(cmd));
}

void jqPlayTrack(uint16_t track)
{
  uint8_t cmd[] =
  {
    0x7E,
    0x04,
    0x03,
    (uint8_t)(track >> 8),
    (uint8_t)(track & 0xFF),
    0xEF
  };

  sendJQ(cmd, sizeof(cmd));
}

void jqStop()
{
  uint8_t cmd[] =
  {
    0x7E,
    0x02,
    0x0E,
    0xEF
  };

  sendJQ(cmd, sizeof(cmd));
}

bool jqIsBusy()
{
  bool level = digitalRead(JQ_BUSY_PIN);

  return BUSY_ACTIVE_HIGH
    ? (level == HIGH)
    : (level == LOW);
}

bool waitJQStart(unsigned long timeoutMs)
{
  unsigned long start = millis();

  while (millis() - start < timeoutMs)
  {
    server.handleClient();

    if (jqIsBusy())
    {
      Serial.println(
        "JQ6500 PLAY CONFIRMED"
      );

      return true;
    }

    delay(10);
  }

  Serial.println(
    "JQ6500 PLAY NOT CONFIRMED"
  );

  return false;
}

bool startJQTrackWithRecovery(uint16_t track)
{
  Serial.println();
  Serial.println(
    "===== JQ6500 PLAY START ====="
  );

  Serial.print(
    "Track = "
  );

  Serial.println(track);

  Serial.println(
    "[1] First PLAY"
  );

  jqPlayTrack(track);

  delay(100);

  if (!USE_BUSY_CONFIRM)
  {
    return true;
  }

  if (waitJQStart(JQ_START_CONFIRM_MS))
  {
    Serial.println(
      "JQ6500 FIRST PLAY OK"
    );

    return true;
  }

  stats.jqRetryCount++;

  Serial.println();
  Serial.println(
    "[2] JQ6500 RECOVERY"
  );

  jqStop();
  delay(300);

  Serial.println(
    "Select Flash..."
  );

  jqSelectFlash();
  delay(700);

  Serial.print(
    "Set Volume = "
  );

  Serial.println(
    currentVolume
  );

  jqSetVolume(
    currentVolume
  );

  delay(500);

  Serial.println();
  Serial.println(
    "[3] Retry PLAY"
  );

  jqPlayTrack(track);

  delay(100);

  if (waitJQStart(JQ_RETRY_CONFIRM_MS))
  {
    Serial.println(
      "JQ6500 RETRY PLAY OK"
    );

    return true;
  }

  stats.jqFailCount++;

  Serial.println(
    "ERROR: JQ6500 PLAY FAILED"
  );

  return false;
}

// ==========================================================
// LED
// ==========================================================

void setZone(int start, int count, uint32_t color)
{
  for (int i = 0; i < count; i++)
  {
    strip.setPixelColor(
      start + i,
      color
    );
  }
}

void ledIdle()
{
  static unsigned long last = 0;
  static int b = 4;
  static int direction = 1;

  if (millis() - last < 80)
  {
    return;
  }

  last = millis();

  b += direction;

  if (b >= 14) direction = -1;
  if (b <= 4) direction = 1;

  strip.clear();

  setZone(
    BASE_START,
    BASE_COUNT,
    strip.Color(
      b,
      b / 2,
      0
    )
  );

  strip.show();
}

void ledAttract()
{
  static unsigned long last = 0;
  static int b = 8;
  static int direction = 1;

  if (millis() - last < 60)
  {
    return;
  }

  last = millis();

  b += direction;

  if (b >= 35) direction = -1;
  if (b <= 8) direction = 1;

  strip.clear();

  setZone(
    HORSE_START,
    HORSE_COUNT,
    strip.Color(
      b,
      b / 2,
      0
    )
  );

  setZone(
    TURTLE_START,
    TURTLE_COUNT,
    strip.Color(
      0,
      b / 2,
      b
    )
  );

  setZone(
    FLOWER_START,
    FLOWER_COUNT,
    strip.Color(
      b,
      0,
      b / 3
    )
  );

  strip.show();
}

void ledReady()
{
  strip.clear();

  setZone(
    HORSE_START,
    HORSE_COUNT,
    strip.Color(
      20,
      8,
      0
    )
  );

  setZone(
    TURTLE_START,
    TURTLE_COUNT,
    strip.Color(
      0,
      8,
      18
    )
  );

  setZone(
    FLOWER_START,
    FLOWER_COUNT,
    strip.Color(
      18,
      0,
      7
    )
  );

  strip.show();
}

void ledHorse()
{
  static unsigned long last = 0;
  static int pos = 0;

  if (millis() - last < 80)
  {
    return;
  }

  last = millis();

  strip.clear();

  for (int i = 0; i < HORSE_COUNT; i++)
  {
    int dist =
      (i - pos + HORSE_COUNT)
      % HORSE_COUNT;

    if (dist == 0)
    {
      strip.setPixelColor(
        HORSE_START + i,
        strip.Color(
          100,
          45,
          0
        )
      );
    }
    else if (dist <= 2)
    {
      strip.setPixelColor(
        HORSE_START + i,
        strip.Color(
          30,
          10,
          0
        )
      );
    }
  }

  pos++;

  if (pos >= HORSE_COUNT)
  {
    pos = 0;
  }

  strip.show();
}

void ledTurtle()
{
  static unsigned long last = 0;
  static int b = 10;
  static int direction = 1;

  if (millis() - last < 70)
  {
    return;
  }

  last = millis();

  b += direction;

  if (b >= 80) direction = -1;
  if (b <= 10) direction = 1;

  strip.clear();

  setZone(
    TURTLE_START,
    TURTLE_COUNT,
    strip.Color(
      0,
      b / 2,
      b
    )
  );

  strip.show();
}

void ledFlower()
{
  static unsigned long last = 0;
  static int count = 1;
  static bool growing = true;

  if (millis() - last < 120)
  {
    return;
  }

  last = millis();

  strip.clear();

  for (int i = 0; i < count; i++)
  {
    strip.setPixelColor(
      FLOWER_START + i,
      strip.Color(
        80,
        5,
        25
      )
    );
  }

  strip.show();

  if (growing)
  {
    count++;

    if (count >= FLOWER_COUNT)
    {
      count = FLOWER_COUNT;
      growing = false;
    }
  }
  else
  {
    count--;

    if (count <= 2)
    {
      count = 2;
      growing = true;
    }
  }
}

void ledAllStory()
{
  static unsigned long last = 0;
  static int phase = 0;

  if (millis() - last < 900)
  {
    return;
  }

  last = millis();

  phase++;

  if (phase > 2)
  {
    phase = 0;
  }

  strip.clear();

  if (phase == 0)
  {
    setZone(
      HORSE_START,
      HORSE_COUNT,
      strip.Color(
        70,
        30,
        0
      )
    );
  }
  else if (phase == 1)
  {
    setZone(
      TURTLE_START,
      TURTLE_COUNT,
      strip.Color(
        0,
        25,
        70
      )
    );
  }
  else
  {
    setZone(
      FLOWER_START,
      FLOWER_COUNT,
      strip.Color(
        70,
        5,
        25
      )
    );
  }

  strip.show();
}

void ledCreator()
{
  strip.clear();

  uint32_t warm =
    strip.Color(
      45,
      28,
      10
    );

  setZone(
    HORSE_START,
    HORSE_COUNT,
    warm
  );

  setZone(
    TURTLE_START,
    TURTLE_COUNT,
    warm
  );

  setZone(
    FLOWER_START,
    FLOWER_COUNT,
    warm
  );

  strip.show();
}

void updateStoryLED()
{
  switch (currentStory)
  {
    case STORY_HORSE:
      ledHorse();
      break;

    case STORY_TURTLE:
      ledTurtle();
      break;

    case STORY_FLOWER:
      ledFlower();
      break;

    case STORY_ALL:
      ledAllStory();
      break;

    case STORY_CREATOR:
      ledCreator();
      break;

    default:
      ledReady();
      break;
  }
}

// ==========================================================
// ToF
// ==========================================================

void updateToF()
{
  if (!tofOK)
  {
    return;
  }

  if (
    millis() - lastToFTime
    < TOF_INTERVAL_MS
  )
  {
    return;
  }

  lastToFTime = millis();

  VL53L0X_RangingMeasurementData_t
    measure;

  lox.rangingTest(
    &measure,
    false
  );

  bool nowDetected = false;

  if (measure.RangeStatus != 4)
  {
    lastDistanceMM =
      measure.RangeMilliMeter;

    if (
      lastDistanceMM > 50
      &&
      lastDistanceMM <= PERSON_DISTANCE_MM
    )
    {
      nowDetected = true;
    }
  }
  else
  {
    lastDistanceMM = -1;
  }

  if (nowDetected)
  {
    if (!personTiming)
    {
      personTiming = true;
      personStartTime = millis();
    }

    if (
      millis() - personStartTime
      >= PERSON_DWELL_MS
    )
    {
      if (!personDetected)
      {
        personDetected = true;
        stats.wakeCount++;
      }
    }
  }
  else
  {
    personTiming = false;
    personDetected = false;
  }
}

// ==========================================================
// Event Sequence
// ==========================================================

void loadEventSequence()
{
  prefs.begin(
    "event-seq",
    true
  );

  eventSequence =
    prefs.getULong(
      "seq",
      0
    );

  prefs.end();
}

unsigned long nextEventSequence()
{
  eventSequence++;

  prefs.begin(
    "event-seq",
    false
  );

  prefs.putULong(
    "seq",
    eventSequence
  );

  prefs.end();

  return eventSequence;
}

String makeEventUUID()
{
  char buf[80];

  snprintf(
    buf,
    sizeof(buf),
    "%s-%010lu",
    DEVICE_ID,
    nextEventSequence()
  );

  return String(buf);
}

// ==========================================================
// Event Queue
// ==========================================================

String buildEventJson(
  const String& eventType,
  Story story,
  const String& source,
  unsigned long durationMs,
  int completedValue
)
{
  String json;

  json.reserve(440);

  json += "{";

  json += "\"event_uuid\":\"";
  json += makeEventUUID();
  json += "\",";

  json += "\"event_type\":\"";
  json += eventType;
  json += "\",";

  json += "\"story\":\"";
  json += storyCode(story);
  json += "\",";

  json += "\"source\":\"";
  json += source;
  json += "\",";

  json += "\"duration_ms\":";
  json += String(durationMs);
  json += ",";

  json += "\"completed\":";

  if (completedValue < 0)
  {
    json += "null";
  }
  else
  {
    json +=
      completedValue
      ? "true"
      : "false";
  }

  json += ",";

  json += "\"network_status\":\"";

  json +=
    WiFi.status() == WL_CONNECTED
    ? "online"
    : "offline";

  json += "\",";

  json += "\"metadata\":{";

  json += "\"firmware\":\"";
  json += FIRMWARE_VERSION;
  json += "\",";

  json += "\"jq_confirmed\":";
  json +=
    jqStartConfirmed
    ? "true"
    : "false";

  json += "}";

  json += "}";

  return json;
}

bool appendQueuedEvent(
  const String& json
)
{
  File file =
    LittleFS.open(
      EVENT_QUEUE_FILE,
      FILE_APPEND
    );

  if (!file)
  {
    Serial.println(
      "ERROR: event queue open failed"
    );

    return false;
  }

  file.println(json);
  file.close();

  Serial.print(
    "EVENT QUEUED: "
  );

  Serial.println(json);

  return true;
}

void emitLocalEvent(
  const String& eventType,
  Story story,
  const String& source,
  unsigned long durationMs = 0,
  int completedValue = -1
)
{
  String json =
    buildEventJson(
      eventType,
      story,
      source,
      durationMs,
      completedValue
    );

  appendQueuedEvent(json);
}

size_t queueFileSize()
{
  if (
    !LittleFS.exists(
      EVENT_QUEUE_FILE
    )
  )
  {
    return 0;
  }

  File file =
    LittleFS.open(
      EVENT_QUEUE_FILE,
      FILE_READ
    );

  if (!file)
  {
    return 0;
  }

  size_t size =
    file.size();

  file.close();

  return size;
}

// ==========================================================
// Cloud Diagnostic V1.6
// DNS -> single TLS/HTTPS connection -> POST
// ==========================================================

bool postJsonToCloud(
  const char* url,
  const String& body
)
{
  Serial.println();
  Serial.println(
    "========== CLOUD POST =========="
  );

  // ------------------------------------------------------
  // 1. STA status
  // ------------------------------------------------------

  Serial.print(
    "WiFi status = "
  );

  Serial.println(
    WiFi.status()
  );

  if (
    WiFi.status() != WL_CONNECTED
  )
  {
    Serial.println(
      "ERROR: STA OFFLINE"
    );

    stats.cloudFailCount++;

    return false;
  }

  Serial.print(
    "STA IP = "
  );

  Serial.println(
    WiFi.localIP()
  );

  Serial.print(
    "Gateway = "
  );

  Serial.println(
    WiFi.gatewayIP()
  );

  Serial.print(
    "DNS = "
  );

  Serial.println(
    WiFi.dnsIP()
  );

  Serial.print(
    "RSSI = "
  );

  Serial.println(
    WiFi.RSSI()
  );

  // ------------------------------------------------------
  // 2. DNS
  // ------------------------------------------------------

  IPAddress serverIP;

  Serial.print(
    "Resolving: "
  );

  Serial.println(
    CLOUD_HOST
  );

  int dnsResult =
    WiFi.hostByName(
      CLOUD_HOST,
      serverIP
    );

  Serial.print(
    "DNS result = "
  );

  Serial.println(
    dnsResult
  );

  if (
    dnsResult != 1
  )
  {
    Serial.println(
      "ERROR: DNS FAILED"
    );

    stats.cloudFailCount++;

    return false;
  }

  Serial.print(
    "Resolved IP = "
  );

  Serial.println(
    serverIP
  );

  // ------------------------------------------------------
  // 3. Heap before TLS
  // ------------------------------------------------------

  Serial.print(
    "Free Heap before TLS = "
  );

  Serial.println(
    ESP.getFreeHeap()
  );

  // ------------------------------------------------------
  // 4. Single TLS client
  //    不再先做額外 testClient.connect()
  // ------------------------------------------------------

  WiFiClientSecure client;

  // 測試階段先略過 CA 驗證
  client.setInsecure();

  // 秒
  client.setHandshakeTimeout(
    20
  );

  // 毫秒
  client.setTimeout(
    15000
  );

  HTTPClient https;

  https.setConnectTimeout(
    15000
  );

  https.setTimeout(
    15000
  );

  https.setReuse(
    false
  );

  Serial.print(
    "POST URL = "
  );

  Serial.println(
    url
  );

  if (
    !https.begin(
      client,
      url
    )
  )
  {
    Serial.println(
      "ERROR: https.begin FAILED"
    );

    stats.cloudFailCount++;

    return false;
  }

  // ------------------------------------------------------
  // 5. Headers
  // ------------------------------------------------------

  https.addHeader(
    "Content-Type",
    "application/json"
  );

  https.addHeader(
    "X-Device-ID",
    DEVICE_ID
  );

  https.addHeader(
    "X-Device-Key",
    DEVICE_KEY
  );

  https.addHeader(
    "Connection",
    "close"
  );

  Serial.println(
    "POST body:"
  );

  Serial.println(
    body
  );

  // ------------------------------------------------------
  // 6. POST
  // ------------------------------------------------------

  unsigned long postStart =
    millis();

  int code =
    https.POST(
      body
    );

  unsigned long elapsed =
    millis() -
    postStart;

  Serial.print(
    "Cloud HTTP = "
  );

  Serial.println(
    code
  );

  Serial.print(
    "Elapsed = "
  );

  Serial.print(
    elapsed
  );

  Serial.println(
    " ms"
  );

  // ------------------------------------------------------
  // 7. Negative HTTPClient result
  // ------------------------------------------------------

  if (
    code < 0
  )
  {
    Serial.print(
      "HTTP error = "
    );

    Serial.println(
      HTTPClient::errorToString(
        code
      )
    );

    Serial.print(
      "Free Heap after fail = "
    );

    Serial.println(
      ESP.getFreeHeap()
    );

    char sslError[192] = {0};

    int tlsError =
      client.lastError(
        sslError,
        sizeof(sslError)
      );

    Serial.print(
      "TLS last error = "
    );

    Serial.println(
      tlsError
    );

    Serial.print(
      "TLS message = "
    );

    Serial.println(
      sslError
    );

    https.end();
    client.stop();

    stats.cloudFailCount++;

    return false;
  }

  // ------------------------------------------------------
  // 8. Server response
  // ------------------------------------------------------

  String response =
    https.getString();

  Serial.print(
    "Response = "
  );

  Serial.println(
    response
  );

  https.end();
  client.stop();

  Serial.print(
    "Free Heap after POST = "
  );

  Serial.println(
    ESP.getFreeHeap()
  );

  // ------------------------------------------------------
  // 9. Result
  // ------------------------------------------------------

  if (
    code >= 200
    &&
    code < 300
  )
  {
    stats.cloudSuccessCount++;

    Serial.println(
      "CLOUD POST OK"
    );

    return true;
  }

  stats.cloudFailCount++;

  Serial.println(
    "CLOUD POST FAILED"
  );

  return false;
}

// ==========================================================
// Cloud Sync Rule
// ==========================================================

bool cloudSyncAllowed()
{
  if (
    state == STATE_PLAYING
  )
  {
    return false;
  }

  if (
    state == STATE_COOLDOWN
  )
  {
    return false;
  }

  if (
    state == STATE_ATTRACT
  )
  {
    return false;
  }

  // READY / IDLE 都可同步
  if (
    state != STATE_IDLE
    &&
    state != STATE_READY
  )
  {
    return false;
  }

  if (
    WiFi.status() != WL_CONNECTED
  )
  {
    return false;
  }

  if (
    millis() - lastLocalActivity
    < CLOUD_IDLE_GRACE_MS
  )
  {
    return false;
  }

  return true;
}

void printCloudStatus()
{
  Serial.print(
    "CLOUD | "
  );

  if (
    WiFi.status() != WL_CONNECTED
  )
  {
    Serial.println(
      "BLOCKED: STA OFFLINE"
    );

    return;
  }

  if (
    state == STATE_PLAYING
  )
  {
    Serial.println(
      "BLOCKED: PLAYING"
    );

    return;
  }

  if (
    state == STATE_COOLDOWN
  )
  {
    Serial.println(
      "BLOCKED: COOLDOWN"
    );

    return;
  }

  if (
    state == STATE_ATTRACT
  )
  {
    Serial.println(
      "BLOCKED: ATTRACT"
    );

    return;
  }

  unsigned long elapsed =
    millis() - lastLocalActivity;

  if (
    elapsed < CLOUD_IDLE_GRACE_MS
  )
  {
    Serial.print(
      "BLOCKED: LOCAL ACTIVITY, wait "
    );

    Serial.print(
      (CLOUD_IDLE_GRACE_MS - elapsed)
      / 1000
    );

    Serial.println(
      " sec"
    );

    return;
  }

  Serial.print(
    "READY | Queue="
  );

  Serial.print(
    queueFileSize()
  );

  Serial.println(
    " bytes"
  );
}

// ==========================================================
// Queue Sync
// ==========================================================

void copyRemainingLines(
  File &input,
  File &output
)
{
  while (input.available())
  {
    String line =
      input.readStringUntil('\n');

    line.trim();

    if (line.length() > 0)
    {
      output.println(line);
    }
  }
}

void syncQueuedEvents()
{
  if (!cloudSyncAllowed())
  {
    return;
  }

  if (
    !LittleFS.exists(
      EVENT_QUEUE_FILE
    )
  )
  {
    return;
  }

  File input =
    LittleFS.open(
      EVENT_QUEUE_FILE,
      FILE_READ
    );

  if (!input)
  {
    return;
  }

  LittleFS.remove(
    EVENT_TEMP_FILE
  );

  File output =
    LittleFS.open(
      EVENT_TEMP_FILE,
      FILE_WRITE
    );

  if (!output)
  {
    input.close();
    return;
  }

  uint8_t sentThisPass = 0;

  while (input.available())
  {
    String line =
      input.readStringUntil('\n');

    line.trim();

    if (line.length() == 0)
    {
      continue;
    }

    if (
      sentThisPass >=
      MAX_EVENTS_PER_SYNC
    )
    {
      output.println(line);

      copyRemainingLines(
        input,
        output
      );

      break;
    }

    if (!cloudSyncAllowed())
    {
      output.println(line);

      copyRemainingLines(
        input,
        output
      );

      break;
    }

    Serial.println(
      "Cloud sync event..."
    );

    if (
      postJsonToCloud(
        CLOUD_EVENT_URL,
        line
      )
    )
    {
      sentThisPass++;

      Serial.println(
        "Cloud sync OK"
      );
    }
    else
    {
      Serial.println(
        "Cloud sync FAILED"
      );

      output.println(line);

      copyRemainingLines(
        input,
        output
      );

      break;
    }

    delay(20);
  }

  input.close();
  output.close();

  LittleFS.remove(
    EVENT_QUEUE_FILE
  );

  File check =
    LittleFS.open(
      EVENT_TEMP_FILE,
      FILE_READ
    );

  size_t remainingSize = 0;

  if (check)
  {
    remainingSize =
      check.size();

    check.close();
  }

  if (remainingSize > 0)
  {
    LittleFS.rename(
      EVENT_TEMP_FILE,
      EVENT_QUEUE_FILE
    );
  }
  else
  {
    LittleFS.remove(
      EVENT_TEMP_FILE
    );
  }
}

// ==========================================================
// Heartbeat
// ==========================================================

void sendHeartbeat()
{
  if (!cloudSyncAllowed())
  {
    return;
  }

  String body;

  body.reserve(240);

  body += "{";

  body += "\"state\":\"";
  body += stateName();
  body += "\",";

  body += "\"firmware\":\"";
  body += FIRMWARE_VERSION;
  body += "\",";

  body += "\"rssi\":";
  body += String(WiFi.RSSI());

  body += "}";

  Serial.println(
    "Sending heartbeat..."
  );

  postJsonToCloud(
    CLOUD_HEARTBEAT_URL,
    body
  );
}

// ==========================================================
// Start Story
// ==========================================================

void startStory(
  Story story,
  bool fromWeb
)
{
  Serial.println();
  Serial.println(
    "========== START STORY =========="
  );

  Serial.print(
    "story = "
  );

  Serial.println(
    (int)story
  );

  Serial.print(
    "source = "
  );

  Serial.println(
    fromWeb
    ? "WEB"
    : "BUTTON"
  );

  Serial.print(
    "state = "
  );

  Serial.println(
    stateName()
  );

  if (
    state == STATE_PLAYING
    ||
    state == STATE_COOLDOWN
  )
  {
    Serial.println(
      "START STORY rejected"
    );

    return;
  }

  markLocalActivity();

  currentStory = story;
  busySeenPlaying = false;
  jqStartConfirmed = false;

  clearButtonEvents();

  uint16_t track = 0;

  switch (story)
  {
    case STORY_HORSE:

      track = 1;
      stats.horseCount++;

      Serial.println(
        "PLAY 001 HORSE"
      );

      break;

    case STORY_TURTLE:

      track = 2;
      stats.turtleCount++;

      Serial.println(
        "PLAY 002 TURTLE"
      );

      break;

    case STORY_FLOWER:

      track = 3;
      stats.flowerCount++;

      Serial.println(
        "PLAY 003 FLOWER"
      );

      break;

    case STORY_ALL:

      track = 4;
      stats.totalCount++;

      Serial.println(
        "PLAY 004 ALL"
      );

      break;

    case STORY_CREATOR:

      track = 5;
      stats.creatorCount++;

      Serial.println(
        "PLAY 005 CREATOR"
      );

      break;

    default:

      Serial.println(
        "INVALID STORY"
      );

      return;
  }

  jqStartConfirmed =
    startJQTrackWithRecovery(
      track
    );

  if (!jqStartConfirmed)
  {
    Serial.println(
      "STORY ABORTED: JQ6500 NOT PLAYING"
    );

    emitLocalEvent(
      "DEVICE_ERROR",
      story,
      "system",
      0,
      0
    );

    currentStory =
      STORY_NONE;

    state =
      personDetected
      ? STATE_READY
      : STATE_IDLE;

    return;
  }

  busySeenPlaying = true;

  if (fromWeb)
  {
    stats.webCount++;
  }
  else
  {
    stats.buttonCount++;
  }

  playStartTime =
    millis();

  state =
    STATE_PLAYING;

  emitLocalEvent(
    "STORY_START",
    story,
    fromWeb
      ? "web"
      : "button",
    0,
    -1
  );

  Serial.println(
    "STATE -> PLAYING"
  );

  Serial.println(
    "================================="
  );
}

// ==========================================================
// Buttons
// ==========================================================

void handleButtons()
{
  if (horseButton.pressedEvent)
  {
    startStory(
      STORY_HORSE,
      false
    );

    return;
  }

  if (turtleButton.pressedEvent)
  {
    startStory(
      STORY_TURTLE,
      false
    );

    return;
  }

  if (flowerButton.pressedEvent)
  {
    startStory(
      STORY_FLOWER,
      false
    );

    return;
  }
}

// ==========================================================
// State Machine
// ==========================================================

void runStateMachine()
{
  switch (state)
  {
    case STATE_IDLE:

      ledIdle();
      handleButtons();

      if (
        state != STATE_PLAYING
        &&
        personDetected
      )
      {
        state =
          STATE_ATTRACT;

        stateStartTime =
          millis();

        markLocalActivity();

        emitLocalEvent(
          "EXHIBIT_WAKE",
          STORY_NONE,
          "system",
          0,
          -1
        );
      }

      break;

    case STATE_ATTRACT:

      ledAttract();
      handleButtons();

      if (
        state != STATE_PLAYING
        &&
        millis() - stateStartTime
        >= ATTRACT_MS
      )
      {
        state =
          STATE_READY;
      }

      break;

    case STATE_READY:

      ledReady();
      handleButtons();

      if (
        state != STATE_PLAYING
        &&
        !personDetected
      )
      {
        state =
          STATE_IDLE;
      }

      break;

    case STATE_PLAYING:

      clearButtonEvents();

      updateStoryLED();

      if (
        millis() - playStartTime
        < BUSY_GUARD_MS
      )
      {
        break;
      }

      if (jqIsBusy())
      {
        busySeenPlaying = true;
      }

      if (
        busySeenPlaying
        &&
        !jqIsBusy()
      )
      {
        Serial.println(
          "PLAY COMPLETE"
        );

        unsigned long duration =
          millis() - playStartTime;

        stats.completedCount++;

        markLocalActivity();

        emitLocalEvent(
          "STORY_COMPLETE",
          currentStory,
          "system",
          duration,
          1
        );

        cooldownStartTime =
          millis();

        state =
          STATE_COOLDOWN;
      }

      if (
        millis() - playStartTime
        > MAX_PLAY_MS
      )
      {
        Serial.println(
          "PLAY TIMEOUT"
        );

        jqStop();

        unsigned long duration =
          millis() - playStartTime;

        markLocalActivity();

        emitLocalEvent(
          "PLAY_TIMEOUT",
          currentStory,
          "system",
          duration,
          0
        );

        cooldownStartTime =
          millis();

        state =
          STATE_COOLDOWN;
      }

      break;

    case STATE_COOLDOWN:

      clearButtonEvents();
      ledReady();

      if (
        millis() - cooldownStartTime
        >= COOLDOWN_MS
        &&
        allButtonsReleased()
      )
      {
        currentStory =
          STORY_NONE;

        state =
          personDetected
          ? STATE_READY
          : STATE_IDLE;
      }

      break;

    case STATE_ERROR:

      strip.clear();
      strip.show();

      break;
  }
}

// ==========================================================
// Wi-Fi NVS
// ==========================================================

void loadWiFiSettings()
{
  prefs.begin(
    "wifi-config",
    true
  );

  staSSID =
    prefs.getString(
      "ssid",
      ""
    );

  staPassword =
    prefs.getString(
      "password",
      ""
    );

  prefs.end();

  Serial.println();
  Serial.println(
    "========== NVS Wi-Fi =========="
  );

  Serial.print(
    "SSID: "
  );

  if (staSSID.length())
  {
    Serial.println(
      staSSID
    );
  }
  else
  {
    Serial.println(
      "(not configured)"
    );
  }

  Serial.println(
    "================================"
  );
}

void connectSTA()
{
  if (
    staSSID.length() == 0
  )
  {
    Serial.println(
      "STA: no saved Wi-Fi"
    );

    return;
  }

  Serial.println();
  Serial.println(
    "========== STA CONNECT =========="
  );

  Serial.print(
    "Connecting STA: "
  );

  Serial.println(
    staSSID
  );

  WiFi.begin(
    staSSID.c_str(),
    staPassword.c_str()
  );

  unsigned long start =
    millis();

  while (
    WiFi.status() != WL_CONNECTED
    &&
    millis() - start < 15000
  )
  {
    Serial.print(".");
    delay(500);
  }

  Serial.println();

  if (
    WiFi.status() != WL_CONNECTED
  )
  {
    Serial.println(
      "STA CONNECT FAILED"
    );

    Serial.println(
      "SoftAP remains available."
    );

    return;
  }

  Serial.println(
    "STA CONNECTED"
  );

  Serial.print(
    "STA IP = "
  );

  Serial.println(
    WiFi.localIP()
  );

  Serial.print(
    "Gateway = "
  );

  Serial.println(
    WiFi.gatewayIP()
  );

  Serial.print(
    "DHCP DNS = "
  );

  Serial.println(
    WiFi.dnsIP(0)
  );

  // ------------------------------------------------------
  // Public DNS Fix
  // ------------------------------------------------------

  bool dnsOK =
    WiFi.setDNS(
      DNS1,
      DNS2
    );

  Serial.print(
    "Set Public DNS = "
  );

  Serial.println(
    dnsOK
    ? "OK"
    : "FAILED"
  );

  delay(200);

  Serial.print(
    "DNS1 = "
  );

  Serial.println(
    WiFi.dnsIP(0)
  );

  Serial.print(
    "DNS2 = "
  );

  Serial.println(
    WiFi.dnsIP(1)
  );

  // ------------------------------------------------------
  // DNS self-test
  // ------------------------------------------------------

  IPAddress testIP;

  int dnsResult =
    WiFi.hostByName(
      CLOUD_HOST,
      testIP
    );

  Serial.print(
    "DNS self-test = "
  );

  Serial.println(
    dnsResult
  );

  if (dnsResult == 1)
  {
    Serial.print(
      "Resolved = "
    );

    Serial.println(
      testIP
    );
  }
  else
  {
    Serial.println(
      "WARNING: DNS still failed."
    );
  }

  Serial.println(
    "================================="
  );
}

void maintainSTA()
{
  if (
    staSSID.length() == 0
  )
  {
    return;
  }

  if (
    WiFi.status() != WL_CONNECTED
    &&
    millis() - lastReconnectAttempt > 15000
  )
  {
    // 播放期間不主動重連
    if (
      state == STATE_PLAYING
    )
    {
      return;
    }

    lastReconnectAttempt =
      millis();

    Serial.println(
      "STA reconnect..."
    );

    WiFi.begin(
      staSSID.c_str(),
      staPassword.c_str()
    );

    unsigned long start =
      millis();

    while (
      WiFi.status() != WL_CONNECTED
      &&
      millis() - start < 10000
    )
    {
      server.handleClient();
      delay(250);
    }

    if (
      WiFi.status() == WL_CONNECTED
    )
    {
      WiFi.setDNS(
        DNS1,
        DNS2
      );

      Serial.print(
        "STA reconnected | IP="
      );

      Serial.print(
        WiFi.localIP()
      );

      Serial.print(
        " | DNS="
      );

      Serial.println(
        WiFi.dnsIP(0)
      );
    }
    else
    {
      Serial.println(
        "STA reconnect failed"
      );
    }
  }
}

// ==========================================================
// HTML
// ==========================================================

String htmlHeader(String title)
{
  String s;

  s.reserve(3800);

  s +=
    "<!DOCTYPE html>"
    "<html lang='zh-TW'>"
    "<head>"
    "<meta charset='UTF-8'>"
    "<meta name='viewport' content='width=device-width,initial-scale=1'>";

  s +=
    "<title>" +
    title +
    "</title>";

  s +=
    "<style>"

    "body{margin:0;background:#f4f0e6;"
    "font-family:Arial,'Microsoft JhengHei',sans-serif;color:#333}"

    ".wrap{max-width:760px;margin:auto;padding:16px}"

    ".hero{background:#654321;color:white;padding:20px;border-radius:16px}"

    ".card{background:white;margin-top:14px;padding:16px;border-radius:14px;"
    "box-shadow:0 3px 12px #0002}"

    ".grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}"

    ".btn,button{display:block;width:100%;box-sizing:border-box;"
    "text-align:center;border:0;border-radius:10px;padding:14px 8px;"
    "font-size:16px;text-decoration:none;color:#222;background:#ddd}"

    ".horse{background:#f5c96b}"
    ".turtle{background:#81d4fa}"
    ".flower{background:#f48fb1}"
    ".all{background:#a5d6a7}"
    ".creator{background:#d7ccc8}"
    ".stop{background:#c62828;color:white}"
    ".cloud{background:#90caf9}"

    "input{width:100%;box-sizing:border-box;padding:12px;"
    "margin:6px 0 14px;border:1px solid #aaa;border-radius:8px}"

    ".ok{color:#2e7d32;font-weight:bold}"
    ".bad{color:#c62828;font-weight:bold}"
    ".note{font-size:13px;color:#666}"

    "</style>"
    "</head>"
    "<body>"
    "<div class='wrap'>"
    "<div class='hero'>"
    "<h1>🌳 水井三寶智慧互動展覽系統</h1>"
    "<div>Layer 4 V1.7|Public DNS Fix</div>"
    "</div>";

  return s;
}

// ==========================================================
// Root
// ==========================================================

void handleRoot()
{
  markLocalActivity();

  String s =
    htmlHeader(
      "水井三寶"
    );

  s +=
    "<div class='card'>"
    "<h2>系統狀態</h2>";

  s +=
    "<p>State:<b>" +
    stateName() +
    "</b></p>";

  s +=
    "<p>Story:<b>" +
    storyName(currentStory) +
    "</b></p>";

  s +=
    "<p>ToF:" +
    String(lastDistanceMM) +
    " mm</p>";

  s +=
    "<p>AP:<b>192.168.4.1</b></p>";

  s += "<p>STA:";

  if (
    WiFi.status() ==
    WL_CONNECTED
  )
  {
    s +=
      "<span class='ok'>ONLINE</span> / ";

    s +=
      WiFi.localIP().toString();

    s +=
      " / RSSI ";

    s +=
      String(WiFi.RSSI());

    s +=
      " dBm";
  }
  else
  {
    s +=
      "<span class='bad'>OFFLINE</span>";
  }

  s += "</p>";

  s +=
    "<p>DNS:<b>" +
    WiFi.dnsIP(0).toString() +
    "</b></p>";

  s +=
    "<p>Queue:<b>" +
    String(queueFileSize()) +
    " bytes</b></p>";

  s +=
    "<div class='grid'>"
    "<a class='btn' href='http://192.168.4.1/wifi'>⚙ Wi-Fi 設定</a>"
    "<a class='btn cloud' href='http://192.168.4.1/cloudtest'>☁ Cloud Test</a>"
    "</div>";

  s += "</div>";

  // Stories
  s +=
    "<div class='card'>"
    "<h2>🎵 故事控制</h2>"
    "<div class='grid'>";

  s +=
    "<a class='btn horse' href='http://192.168.4.1/play?track=1'>"
    "🐴 001 白馬故事</a>";

  s +=
    "<a class='btn turtle' href='http://192.168.4.1/play?track=2'>"
    "🐢 002 烏龜故事</a>";

  s +=
    "<a class='btn flower' href='http://192.168.4.1/play?track=3'>"
    "🌸 003 姻緣花故事</a>";

  s +=
    "<a class='btn all' href='http://192.168.4.1/play?track=4'>"
    "🌳 004 水井三寶總故事</a>";

  s +=
    "<a class='btn creator' href='http://192.168.4.1/play?track=5'>"
    "👨‍🎨 005 創作者資訊</a>";

  s +=
    "<a class='btn stop' href='http://192.168.4.1/stop'>"
    "■ STOP</a>";

  s +=
    "</div>"
    "</div>";

  // Diagnostics
  s +=
    "<div class='card'>"
    "<h2>🔧 設備診斷</h2>";

  s +=
    "<p>JQ BUSY:<b>";

  s +=
    jqIsBusy()
    ? "ACTIVE"
    : "IDLE";

  s += "</b></p>";

  s +=
    "<p>JQ Start Confirmed:<b>";

  s +=
    jqStartConfirmed
    ? "YES"
    : "NO";

  s += "</b></p>";

  s +=
    "<p>JQ Retry:" +
    String(stats.jqRetryCount) +
    "</p>";

  s +=
    "<p>JQ Fail:" +
    String(stats.jqFailCount) +
    "</p>";

  s +=
    "<p>Cloud OK:" +
    String(stats.cloudSuccessCount) +
    "</p>";

  s +=
    "<p>Cloud Fail:" +
    String(stats.cloudFailCount) +
    "</p>";

  s += "</div>";

  // Stats
  s +=
    "<div class='card'>"
    "<h2>📊 即時匿名統計</h2>";

  s += "<p>白馬:" + String(stats.horseCount) + "</p>";
  s += "<p>烏龜:" + String(stats.turtleCount) + "</p>";
  s += "<p>姻緣花:" + String(stats.flowerCount) + "</p>";
  s += "<p>004:" + String(stats.totalCount) + "</p>";
  s += "<p>005:" + String(stats.creatorCount) + "</p>";
  s += "<p>Web:" + String(stats.webCount) + "</p>";
  s += "<p>實體按鈕:" + String(stats.buttonCount) + "</p>";
  s += "<p>展區喚醒:" + String(stats.wakeCount) + "</p>";
  s += "<p>完整播放:" + String(stats.completedCount) + "</p>";

  s += "</div>";

  s +=
    "</div>"
    "</body>"
    "</html>";

  server.send(
    200,
    "text/html; charset=utf-8",
    s
  );
}

// ==========================================================
// Wi-Fi Page
// ==========================================================

void handleWiFiPage()
{
  markLocalActivity();

  String s =
    htmlHeader(
      "Wi-Fi 設定"
    );

  s +=
    "<div class='card'>"
    "<h2>Internet STA 設定</h2>";

  s +=
    "<p>本地 AP 永遠維持:"
    "<b>Shuijing-Treasures</b> / 192.168.4.1</p>";

  s +=
    "<p>目前 SSID:<b>";

  if (staSSID.length())
  {
    s += staSSID;
  }
  else
  {
    s += "尚未設定";
  }

  s += "</b></p>";

  s +=
    "<form method='GET' action='http://192.168.4.1/save'>";

  s +=
    "<label>Wi-Fi SSID</label>"
    "<input name='ssid' maxlength='32' required value='";

  s += staSSID;

  s += "'>";

  s +=
    "<label>Wi-Fi Password</label>"
    "<input type='password' name='password' maxlength='64'>";

  s +=
    "<button type='submit'>儲存並重新啟動</button>"
    "</form>";

  s +=
    "<p><a class='btn' href='http://192.168.4.1/clearwifi'>"
    "清除 STA 設定</a></p>";

  s +=
    "<p><a class='btn' href='http://192.168.4.1/'>"
    "回控制頁</a></p>";

  s +=
    "</div>"
    "</div>"
    "</body>"
    "</html>";

  server.send(
    200,
    "text/html; charset=utf-8",
    s
  );
}

// ==========================================================
// Save Wi-Fi
// ==========================================================

void handleConfigSave()
{
  markLocalActivity();

  if (!server.hasArg("ssid"))
  {
    server.send(
      400,
      "text/plain",
      "SSID missing"
    );

    return;
  }

  String newSSID =
    server.arg("ssid");

  String newPassword =
    server.arg("password");

  if (
    newSSID.length() == 0
  )
  {
    server.send(
      400,
      "text/plain",
      "SSID empty"
    );

    return;
  }

  if (
    newSSID == staSSID
    &&
    newPassword.length() == 0
  )
  {
    newPassword =
      staPassword;
  }

  prefs.begin(
    "wifi-config",
    false
  );

  prefs.putString(
    "ssid",
    newSSID
  );

  prefs.putString(
    "password",
    newPassword
  );

  prefs.end();

  prefs.begin(
    "wifi-config",
    true
  );

  String verifySSID =
    prefs.getString(
      "ssid",
      ""
    );

  String verifyPassword =
    prefs.getString(
      "password",
      ""
    );

  prefs.end();

  bool ok =
    verifySSID == newSSID
    &&
    verifyPassword == newPassword;

  if (!ok)
  {
    server.send(
      500,
      "text/plain",
      "NVS ERROR"
    );

    return;
  }

  staSSID = verifySSID;
  staPassword = verifyPassword;

  String s =
    htmlHeader(
      "Wi-Fi 設定完成"
    );

  s +=
    "<div class='card'>"
    "<h2>✅ Wi-Fi 設定完成</h2>"
    "<p>SSID:<b>";

  s += staSSID;

  s +=
    "</b></p>"
    "<p>設定已寫入 NVS。</p>"
    "<p>ESP32 將於 5 秒後重新啟動。</p>"
    "</div>"
    "</div>"
    "</body>"
    "</html>";

  server.send(
    200,
    "text/html; charset=utf-8",
    s
  );

  restartScheduled = true;
  restartAt = millis() + 5000;
}

// ==========================================================
// Clear Wi-Fi
// ==========================================================

void handleClearWiFi()
{
  markLocalActivity();

  prefs.begin(
    "wifi-config",
    false
  );

  prefs.clear();
  prefs.end();

  staSSID = "";
  staPassword = "";

  WiFi.disconnect();

  server.send(
    200,
    "text/html; charset=utf-8",
    "<html><meta charset='UTF-8'><body>"
    "<h2>STA Wi-Fi 已清除</h2>"
    "<p><a href='http://192.168.4.1/wifi'>重新設定</a></p>"
    "</body></html>"
  );
}

// ==========================================================
// Cloud Test
// ==========================================================

void handleCloudTest()
{
  markLocalActivity();

  String json =
    buildEventJson(
      "DEVICE_BOOT",
      STORY_NONE,
      "system",
      0,
      -1
    );

  Serial.println();
  Serial.println(
    "========== CLOUD TEST =========="
  );

  bool ok =
    postJsonToCloud(
      CLOUD_EVENT_URL,
      json
    );

  String s =
    htmlHeader(
      "Cloud Test"
    );

  s +=
    "<div class='card'>";

  if (ok)
  {
    s +=
      "<h2>✅ Cloud Test OK</h2>"
      "<p>Django 已接受事件。</p>";
  }
  else
  {
    s +=
      "<h2>❌ Cloud Test FAILED</h2>"
      "<p>請查看 Serial Monitor 的 DNS / TLS / HTTP 診斷資訊。</p>";
  }

  s +=
    "<p><a class='btn' href='http://192.168.4.1/'>回控制頁</a></p>"
    "</div>"
    "</div>"
    "</body>"
    "</html>";

  server.send(
    200,
    "text/html; charset=utf-8",
    s
  );
}

// ==========================================================
// Web Play
// ==========================================================

void handlePlay()
{
  markLocalActivity();

  if (!server.hasArg("track"))
  {
    server.send(
      400,
      "text/plain",
      "track missing"
    );

    return;
  }

  int track =
    server.arg("track")
      .toInt();

  if (
    track < 1
    ||
    track > 5
  )
  {
    server.send(
      400,
      "text/plain",
      "invalid track"
    );

    return;
  }

  if (
    state == STATE_PLAYING
    ||
    state == STATE_COOLDOWN
  )
  {
    server.sendHeader(
      "Location",
      "http://192.168.4.1/"
    );

    server.send(
      303,
      "text/plain",
      ""
    );

    return;
  }

  startStory(
    (Story)track,
    true
  );

  server.sendHeader(
    "Location",
    "http://192.168.4.1/"
  );

  server.send(
    303,
    "text/plain",
    ""
  );
}

// ==========================================================
// Stop
// ==========================================================

void handleStop()
{
  markLocalActivity();

  unsigned long duration = 0;

  if (
    state == STATE_PLAYING
  )
  {
    duration =
      millis() - playStartTime;
  }

  jqStop();

  stats.stopCount++;

  emitLocalEvent(
    "STOP",
    currentStory,
    "web",
    duration,
    0
  );

  currentStory =
    STORY_NONE;

  cooldownStartTime =
    millis();

  state =
    STATE_COOLDOWN;

  server.sendHeader(
    "Location",
    "http://192.168.4.1/"
  );

  server.send(
    303,
    "text/plain",
    ""
  );
}

// ==========================================================
// Volume
// ==========================================================

void handleVolume()
{
  markLocalActivity();

  if (!server.hasArg("v"))
  {
    server.send(
      400,
      "text/plain",
      "volume missing"
    );

    return;
  }

  int volume =
    server.arg("v")
      .toInt();

  if (volume < 0) volume = 0;
  if (volume > 30) volume = 30;

  jqSetVolume(
    (uint8_t)volume
  );

  server.sendHeader(
    "Location",
    "http://192.168.4.1/"
  );

  server.send(
    303,
    "text/plain",
    ""
  );
}

// ==========================================================
// 404
// ==========================================================

void handleNotFound()
{
  markLocalActivity();

  server.send(
    404,
    "text/plain",
    "Not Found"
  );
}

// ==========================================================
// Web Setup
// ==========================================================

void setupWeb()
{
  server.on(
    "/",
    HTTP_GET,
    handleRoot
  );

  server.on(
    "/wifi",
    HTTP_GET,
    handleWiFiPage
  );

  server.on(
    "/save",
    HTTP_ANY,
    handleConfigSave
  );

  server.on(
    "/clearwifi",
    HTTP_GET,
    handleClearWiFi
  );

  server.on(
    "/cloudtest",
    HTTP_GET,
    handleCloudTest
  );

  server.on(
    "/play",
    HTTP_GET,
    handlePlay
  );

  server.on(
    "/stop",
    HTTP_GET,
    handleStop
  );

  server.on(
    "/volume",
    HTTP_GET,
    handleVolume
  );

  server.onNotFound(
    handleNotFound
  );

  server.begin();
}

// ==========================================================
// Setup
// ==========================================================

void setup()
{
  Serial.begin(115200);

  delay(1000);

  Serial.println();
  Serial.println(
    "=============================================="
  );
  Serial.println(
    "Shuijing Treasures Layer 4 V1.7"
  );
  Serial.println(
    "CLOUD DIAGNOSTIC"
  );
  Serial.println(
    "=============================================="
  );

  initButton(
    horseButton,
    BTN_HORSE
  );

  initButton(
    turtleButton,
    BTN_TURTLE
  );

  initButton(
    flowerButton,
    BTN_FLOWER
  );

  pinMode(
    JQ_BUSY_PIN,
    INPUT
  );

  strip.begin();
  strip.setBrightness(100);
  strip.clear();
  strip.show();

  JQ.begin(
    9600,
    SERIAL_8N1,
    JQ_RX_PIN,
    JQ_TX_PIN
  );

  delay(3000);

  jqSelectFlash();

  delay(2500);

  jqSetVolume(
    currentVolume
  );

  Wire.begin(
    TOF_SDA,
    TOF_SCL
  );

  tofOK =
    lox.begin();

  Serial.println(
    tofOK
    ? "VL53L0X OK"
    : "VL53L0X ERROR"
  );

  if (
    LittleFS.begin(true)
  )
  {
    Serial.println(
      "LittleFS OK"
    );
  }
  else
  {
    Serial.println(
      "LittleFS ERROR"
    );
  }

  loadEventSequence();

  Serial.println(
    "Starting SoftAP..."
  );

  if (
    !WiFi.softAP(
      AP_SSID,
      AP_PASSWORD
    )
  )
  {
    Serial.println(
      "SoftAP FAILED"
    );

    while (true)
    {
      delay(1000);
    }
  }

  Serial.print(
    "AP IP = "
  );

  Serial.println(
    WiFi.softAPIP()
  );

  loadWiFiSettings();

  connectSTA();

  setupWeb();

  state =
    STATE_IDLE;

  markLocalActivity();

  emitLocalEvent(
    "DEVICE_BOOT",
    STORY_NONE,
    "system",
    0,
    -1
  );

  Serial.println();
  Serial.println(
    "SYSTEM READY"
  );

  Serial.println(
    "Control: http://192.168.4.1"
  );

  Serial.println(
    "Wi-Fi Setup: http://192.168.4.1/wifi"
  );

  Serial.println(
    "Cloud Test: http://192.168.4.1/cloudtest"
  );

  Serial.print(
    "Active DNS: "
  );

  Serial.println(
    WiFi.dnsIP(0)
  );
}

// ==========================================================
// Loop
// ==========================================================

void loop()
{
  server.handleClient();

  updateButtons();

  updateToF();

  runStateMachine();

  maintainSTA();

  if (
    restartScheduled
    &&
    (long)(
      millis() -
      restartAt
    ) >= 0
  )
  {
    server.stop();
    delay(300);
    ESP.restart();
  }

  if (JQ.available())
  {
    Serial.print(
      "JQ RX: "
    );

    while (JQ.available())
    {
      uint8_t b =
        JQ.read();

      if (b < 0x10)
      {
        Serial.print("0");
      }

      Serial.print(
        b,
        HEX
      );

      Serial.print(" ");
    }

    Serial.println();
  }

  // Cloud sync
  if (
    millis() - lastCloudSync
    >= CLOUD_SYNC_INTERVAL_MS
  )
  {
    lastCloudSync =
      millis();

    if (cloudSyncAllowed())
    {
      syncQueuedEvents();
    }
  }

  // Heartbeat
  if (
    millis() - lastHeartbeat
    >= HEARTBEAT_INTERVAL_MS
  )
  {
    lastHeartbeat =
      millis();

    if (cloudSyncAllowed())
    {
      sendHeartbeat();
    }
  }

  // Diagnostics
  static unsigned long
    lastDiag = 0;

  if (
    millis() - lastDiag
    >= DIAG_INTERVAL_MS
  )
  {
    lastDiag =
      millis();

    Serial.print(
      "DIAG | AP="
    );

    Serial.print(
      WiFi.softAPgetStationNum()
    );

    Serial.print(
      " | STA="
    );

    if (
      WiFi.status() ==
      WL_CONNECTED
    )
    {
      Serial.print(
        "ONLINE"
      );

      Serial.print(
        " | IP="
      );

      Serial.print(
        WiFi.localIP()
      );

      Serial.print(
        " | RSSI="
      );

      Serial.print(
        WiFi.RSSI()
      );
    }
    else
    {
      Serial.print(
        "OFFLINE"
      );
    }

    Serial.print(
      " | STATE="
    );

    Serial.print(
      stateName()
    );

    Serial.print(
      " | STORY="
    );

    Serial.print(
      storyName(
        currentStory
      )
    );

    Serial.print(
      " | QUEUE="
    );

    Serial.print(
      queueFileSize()
    );

    Serial.println(
      " bytes"
    );

    printCloudStatus();
  }
}

結語:真正的 Edge AIoT 教材,是把失敗也留下來

這次從 AP、STA、JQ6500、Web 狀態、Queue,到最後找出 DNS 問題的過程,很適合作為物聯網與智慧生活課程案例。學生真正需要學的不只是「把程式燒進 ESP32」,而是如何從 Serial Log 與系統狀態逐層定位問題:硬體有沒有動?本地服務有沒有動?Wi‑Fi 有沒有連?DNS 能不能解析?HTTPS 能不能建立?API 有沒有接受?資料最後有沒有出現在儀表板?

當這條診斷鏈建立起來,「水井三寶」就不只是一件會說故事的地方工藝作品,而成為一套把地方文化、嵌入式系統、Edge Web、IoT 與雲端資料服務串在一起的智慧互動展覽實作。