2026年8月25日 星期二

[水井村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_INTERRUPTEDSTOP以STOP表示中止,利用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佇列停住。

沒有留言:

張貼留言