從HTTP 400到HTTP 201:HUB 8735 Ultra設備身分、Django同步與離線佇列實測
這次測試讓「水井三寶故事機」跨出重要一步:故事不只在現場播放,還能以設備身分將匿名互動事件送到Django;網路或API暫時異常時,資料先留在Flash,待問題排除後再依序補送。
一、V1.0-11要解決什麼問題?
前一版已完成實體按鈕、手機Web控制、JQ6500播放、STA主要連線、AP故障備援及NTP校時。但展場系統若要長期營運,還必須回答三個問題:
- 是哪一台故事機送出的資料?每台設備必須有獨立Device ID與API Key。
- 斷網時互動紀錄會不會消失?事件必須先寫入本機Flash。
- 恢復連線後如何補送?佇列必須按照事件順序逐筆送往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
當時韌體只顯示狀態碼,無法知道伺服器拒絕哪個欄位。因此加入「安全截取Django回應本文」診斷,並限制最大長度,避免錯誤頁占用過多記憶體。
[CLOUD] Failed code=400
[CLOUD] Django response: {"ok": false, "error": "invalid event_type"}
這一行就是整次除錯的關鍵證據:網路、TLS、網址、設備驗證都已經通過,真正問題是Arduino與Django使用了不同的事件名稱。
四、找出兩端的事件名稱差異
| 原Arduino事件 | Django模型事件 | 最後處理 |
|---|---|---|
SYSTEM | DEVICE_BOOT | Arduino改送DEVICE_BOOT |
STORY_START | STORY_START | 保持不變 |
STORY_END | STORY_COMPLETE | Arduino改送STORY_COMPLETE |
STORY_INTERRUPTED | STOP | 以STOP表示中止,利用story與completed區分 |
STOP | STOP | 保持不變 |
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
八、補送期間仍可正常播放故事
雲端補送不能犧牲展場體驗。測試過程中,使用者仍可從手機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 400 | API收到請求但內容不合規 | Django回應本文、JSON欄位、事件名稱 |
| HTTP 201 | 事件建立成功 | 確認佇列remaining持續下降 |
| HTTP 409 | UUID可能已存在 | 視為冪等成功或檢查重複事件 |
十、展場驗收清單
- 每台設備使用獨立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佇列停住。
沒有留言:
張貼留言