2026年8月26日 星期三

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

水井三寶故事機|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] 設備心跳+線上狀態+雲端健康監控版:讓故事機自己回報「我還正常運作」

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

設備心跳+線上狀態+雲端健康監控版:讓故事機自己回報「我還正常運作」

V1.0-11已完成設備身分、Django事件同步與Flash離線佇列;V1.0-12再向前一步,加入低優先權設備心跳,讓管理者從雲端儀表板確認每台故事機是ONLINE或OFFLINE,以及目前狀態與韌體版本。

60秒自動心跳週期
HTTP 200Django更新成功
ONLINE雲端即時判定

一、為什麼故事機需要「心跳」?

故事播放正常,不代表管理者隨時知道設備是否健康。故事機部署在社區、展場或兒童館後,可能遇到斷電、分享器失效、Wi-Fi中斷、程式重啟或設備長時間沒有回報等狀況。

若沒有心跳,雲端只能看到「最後一筆故事事件」,卻無法判斷設備是安靜待機,還是已經離線。V1.0-12因此讓HUB 8735 Ultra每隔60秒主動向Django報到。

1故事機待機
2檢查STA與時間
3確認事件佇列為0
4送出設備心跳
5Django更新ONLINE

二、V1.0-12心跳包含哪些資訊?

心跳不是故事事件,不需要永久保存在Flash。它是一份設備當下的健康摘要:

{
  "device_id": "SHUIJING-002",
  "state": "IDLE",
  "firmware": "V1.0-12",
  "rssi": -24,
  "uptime": 3600,
  "volume": 16,
  "queue_count": 0,
  "current_track": 0,
  "jq_recoveries": 0,
  "last_cloud_http": 201,
  "last_error": ""
}
欄位用途管理意義
device_id設備唯一編號區分不同展場與故事機
stateIDLE、PLAYING或ERROR知道設備正在待機、播放或異常
firmware韌體版本確認設備是否完成升級
rssiWi-Fi訊號強度找出連線不穩的場域
queue_count尚未同步事件數判斷Django或網路是否阻塞
jq_recoveries播放器復原次數觀察JQ6500長期穩定性

三、心跳不能影響故事播放

展場系統最重要的仍是使用者操作。因此V1.0-12把心跳放在最低優先層級:

  1. 實體按鈕與停止操作。
  2. JQ6500播放與BUSY狀態機。
  3. 手機Web控制。
  4. Flash離線事件補送。
  5. 設備心跳。
設計原則:只要播放器不是IDLE、BUSY仍在播放、離線事件尚未補完,或使用者突然按下按鈕,心跳就延後或取消。心跳失敗也不寫入Flash,避免累積大量沒有長期保存價值的資料。

四、開機測試:先送重要事件,再送心跳

V1.0-12沿用V1.0-11的Flash資料結構,開機後成功恢復音量、STA設定、事件序號與佇列:

[FLASH] V1.0-11 data restored
[FLASH] Volume = 16
[FLASH] Boot count = 54
[CLOUD] Device ID: SHUIJING-002
[CLOUD] Offline queue: 0

接著建立本次開機事件:

[CLOUD QUEUE] + SHUIJING-002-0000000016 | DEVICE_BOOT | pending=1

STA與NTP完成後,系統先把重要的DEVICE_BOOT送到Django:

[TIME] Synced by NTP | 2026-08-25 10:44:06
[MODE] STA online service ready
[CLOUD] POST SHUIJING-002-0000000016 | pending=1
[CLOUD] Accepted HTTP 201 | remaining=0

只有在事件佇列回到0以後,才開始傳送心跳:

[HEARTBEAT] POST | state=IDLE | queue=0
[HEARTBEAT] Accepted HTTP 200
這個順序非常重要:HTTP 201代表開機事件已建立;HTTP 200代表設備健康資料已更新。事件資料不會被頻繁心跳搶在前面。

五、連續心跳實測

序列監控顯示,故事機約每分鐘傳送一次心跳,而且每次均獲得Django的HTTP 200:

HUB 8735 Ultra V1.0-12每分鐘設備心跳均獲得HTTP 200
圖1:10:45至10:49連續心跳測試,每分鐘均成功回傳HTTP 200。
10:46:07 [HEARTBEAT] POST | state=IDLE | queue=0
10:46:09 [HEARTBEAT] Accepted HTTP 200
10:47:07 [HEARTBEAT] POST | state=IDLE | queue=0
10:47:09 [HEARTBEAT] Accepted HTTP 200
10:48:07 [HEARTBEAT] POST | state=IDLE | queue=0
10:48:09 [HEARTBEAT] Accepted HTTP 200
10:49:07 [HEARTBEAT] POST | state=IDLE | queue=0
10:49:09 [HEARTBEAT] Accepted HTTP 200

從紀錄可觀察到每次HTTPS請求約在兩秒內完成;下一次心跳不會緊接著大量重複送出,而是依照週期執行。

六、Django儀表板成功判斷ONLINE

Django以設備最後收到心跳的時間判斷線上狀態。實測畫面中,舊設備SHUIJING-001因長時間沒有心跳而顯示OFFLINE;目前測試設備SHUIJING-002則顯示ONLINE,並正確呈現IDLE / V1.0-12

Django水井三寶儀表板顯示SHUIJING-002為ONLINE且韌體為V1.0-12
圖2:雲端儀表板的裝置健康區,能同時比較不同設備的ONLINE/OFFLINE狀態。

儀表板實測結果

  • SHUIJING-001:OFFLINE,顯示舊韌體資訊。
  • SHUIJING-002:ONLINE,狀態IDLE,韌體V1.0-12。
  • 故事啟動:6次。
  • 完整播放:6次。
  • 播放完成率:100%。
  • 互動來源:實體按鈕5次、Edge Web 1次。

七、Django如何判斷設備是否在線?

API收到心跳後更新last_seenlast_statefirmware_versionlast_rssi。儀表板再依最後回報時間判定設備狀態:

online_cutoff = timezone.now() - timedelta(minutes=10)

online = bool(
    device.last_seen and
    device.last_seen >= online_cutoff
)

正式展場可依網路品質調整門檻。若心跳每60秒一次,常見做法是:

最後心跳距今建議狀態管理判讀
2分鐘內ONLINE設備正常
2至5分鐘WARNING可能網路不穩
超過5分鐘OFFLINE需要遠端或現場檢查

八、HTTP狀態碼如何判讀?

紀錄意義處理方式
HTTP 200心跳更新成功設備維持ONLINE
HTTP 201事件建立成功從Flash佇列移除該事件
HTTP 400JSON或欄位不合API規則查看Django回應本文
HTTP 401設備驗證失敗檢查Device ID、API Key與啟用狀態
-1/-2連線或等待逾時檢查STA、DNS、TLS與伺服器
-3心跳被現場操作取消正常設計,稍後自動再試

九、V1.0-12驗收結果

  • V1.0-11的Flash設定與統計成功延續。
  • STA連線與NTP自動校時正常。
  • DEVICE_BOOT取得HTTP 201並從佇列移除。
  • 事件佇列為0後才開始傳送心跳。
  • 連續多次心跳均取得HTTP 200。
  • Django正確顯示SHUIJING-002為ONLINE。
  • Django正確顯示IDLE與V1.0-12。
  • 不同設備可以同時顯示ONLINE與OFFLINE。
  • 心跳不寫入Flash,不增加Flash耗損。
  • 按鈕、播放、停止、Web與事件補送仍具有較高優先權。

十、從「會播放」走向「可維護」

早期的故事機只要按下按鈕會播放,就可以稱為功能完成。但真正進入社區與展場後,管理者需要知道:設備是否在線、使用哪一版韌體、Wi-Fi訊號是否穩定、離線事件是否堆積,以及播放器是否經常復原。

V1.0-12加入的不是一個華麗功能,而是一項非常重要的營運能力:讓設備主動說明自己的健康狀況。這也是IoT系統從作品原型走向可管理、可維護及可擴充服務的重要分界。

[水井村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月24日 星期一

[水井村USR] 長按進入AP+自動重啟+網路診斷:展場維護版實測成功

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

長按進入AP+自動重啟+網路診斷:展場維護版實測成功

這一版讓 HUB 8735 Ultra 故事機即使沒有電腦、沒有 Arduino IDE,也能透過一顆實體停止按鈕完成網路救援。短按用來停止播放;長按5秒進入AP設定;長按10秒則清除舊Wi-Fi、自動重新啟動並開放備援設定入口。

5秒進入AP設定
10秒清除STA設定
自動重新啟動
192.168.4.1備援入口

一、為什麼需要V1.0-10?

故事機進入展場、社區或教學現場後,最大的維護問題往往不是故事能不能播放,而是現場Wi-Fi環境改變時,設備要如何重新設定。如果每次都要接上電腦、重新編譯並燒錄程式,維護成本會非常高。

因此,V1.0-10將原本的GPIO20停止按鈕擴充成「維護入口」,保留日常操作,同時提供不需電腦的網路救援功能。

設計原則

  • STA為主要連線:平時連接現場分享器並由DHCP取得IP。
  • AP為故障備援:只有STA設定不存在、連線失敗或使用者主動要求時才啟動。
  • 按鈕優先:Web、網路校時與播放程序都不能阻塞實體按鈕掃描。
  • 可現場復原:不連電腦也能清除舊Wi-Fi並重新設定。

二、GPIO20一顆按鈕,三種操作

操作方式系統動作使用情境
短按GPIO20立即停止目前播放一般使用
長按5~9秒後放開切換至AP設定模式臨時開啟維護入口,但保留原STA資料
長按10秒以上後放開清除STA的SSID、密碼與頻道,寫入Flash後自動重啟更換分享器、密碼錯誤或完全無法連線
關鍵設計:系統在「放開按鈕」時才決定執行5秒或10秒功能。如此一來,使用者長按超過5秒後仍可繼續按到10秒,不會在第5秒就被迫切換模式。

三、這次除錯最重要的發現

第一次測試時,按住停止按鈕只看到:

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

當時容易懷疑GPIO20接線、按鈕彈跳或長按計時程式有問題。但進一步檢查開機標題後,發現設備實際執行的仍是:

HUB 8735 Ultra + JQ6500 Story Player V1.0-09h
STA Primary + AP Fallback Configuration Portal

也就是說,硬體沒有故障,真正原因是板子仍在執行舊版韌體。重新燒錄V1.0-10後,開機標題正確顯示:

HUB 8735 Ultra + JQ6500 Story Player V1.0-10
Long-Press AP + Auto Restart + Network Diagnostics
經驗結論:測試新功能前,第一步不是先拆接線,而是先核對序列監控視窗中的版本標題。這個小動作可以節省大量除錯時間。

四、如何確認長按真的被持續掃描?

管理後台開啟測試模式後,序列監控會定期顯示六顆按鈕的原始電位:

[BUTTON RAW] G10=1 G11=1 G12=1 G20=1 G7=1 G6=1

GPIO按鈕採用上拉輸入並接到GND,因此:

數值按鈕狀態
1未按下
0已按下

實測長按期間,GPIO20持續保持為0:

[BUTTON RAW] G10=1 G11=1 G12=1 G20=0 G7=1 G6=1
[BUTTON] Stop held 5s: release to enter AP setup mode
[BUTTON RAW] G10=1 G11=1 G12=1 G20=0 G7=1 G6=1
[BUTTON] Stop held 10s: release to clear Wi-Fi and restart

這證明按鈕、接線、上拉邏輯與非阻塞掃描均正常,主迴圈沒有因STA、NTP、Web或音訊狀態機而停止更新。

五、10秒清除Wi-Fi的完整實測流程

GPIO20
持續按住10秒
放開後
清除STA資料並存入Flash
自動重啟
開放AP備援入口

放開按鈕後,系統依序完成:

[MAINTENANCE] Clearing STA credentials
[FLASH] Batch saved: STA credentials cleared
[SYSTEM] Restart scheduled: STA credentials cleared
[SYSTEM] Restarting: STA credentials cleared

重新啟動後,Flash資料區仍可正常讀取音量、開機次數與統計資料,但STA憑證已被清除:

[FLASH] Volume = 16
[FLASH] Boot count = 48
[STA CONFIG] Not configured
[STA CONFIG] Missing; AP fallback queued

這也驗證了程式並非清空整個Flash,而是只清除指定的STA網路設定。

六、重新啟動後自動進入AP備援

系統完成開機語音006、準備完成語音007及JQ6500狀態判定後,自動啟動純AP模式:

[AP] Starting pure fallback AP mode
[AP NET] IP: 192.168.4.1
[AP NET] DHCP: 192.168.4.x
[AP NET] Configuration OK
[AP] SSID: Shuijing-Story
[WEB] http://192.168.4.1
[ADMIN] http://192.168.4.1/admin
[MODE] AP fallback configuration portal

手機維護資訊

AP名稱Shuijing-Story
AP密碼shuijing8735
故事控制首頁http://192.168.4.1/
管理後台http://192.168.4.1/admin
管理PIN8735

七、展場人員的標準維護程序

  1. 確認故事機已正常供電,避免使用供電不穩定的PC USB孔。
  2. 按住停止按鈕至少10秒,看到燈號快速同步閃爍後再放開。
  3. 等待設備自動重新啟動。
  4. 手機連上 Shuijing-Story
  5. 在瀏覽器輸入 http://192.168.4.1/admin
  6. 輸入管理PIN,重新設定現場分享器的SSID、密碼與頻道。
  7. 儲存後等待設備自動重啟,再由分享器查詢故事機取得的新STA IP。

八、V1.0-10完成的展場可靠性

項目實測結果
實體故事按鈕正常
Web故事控制正常
GPIO20短按停止正常
GPIO20長按5秒可辨識AP維護動作
GPIO20長按10秒可清除STA設定
Flash延遲保存正常,其他統計資料保留
自動重新啟動正常
純AP備援正常,固定為192.168.4.1
STA主要連線與NTP有憑證時自動連線與校時

九、這次測試留下的寶貴經驗

  • 先看韌體版本,再查硬體:序列監控的版本標題是除錯起點。
  • 原始GPIO紀錄很重要:[BUTTON RAW]能區分接線問題與程式狀態問題。
  • 長按功能要在放開時決策:才能同時支援5秒與10秒兩種命令。
  • 網路程序必須非阻塞:即使STA連線、NTP校時或無手機連入,按鈕仍應持續掃描。
  • STA與AP不要勉強同時運作:RTL8735B在本案採「STA主要、AP故障備援」比並行模式穩定。
  • Flash只清必要欄位:清除網路憑證時仍保留音量、開機次數及故事統計。
  • 穩定供電是系統的一部分:改用可靠市電電源後,可避免USB供電不足造成反覆重置與開機語音重播。

測試平台:HUB 8735 Ultra(RTL8735B)+JQ6500|韌體版本:V1.0-10|測試日期:2026年8月24日

[水井村USR] 從AP/STA衝突走到穩定上線:HUB 8735 Ultra故事機的網路與時間校正實戰

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

從AP/STA衝突走到穩定上線:HUB 8735 Ultra故事機的網路與時間校正實戰

這次測試不是單純把Wi-Fi連上,而是一步步找出RTL8735B在AP+STA共存時的限制,最後確立「STA為主、AP為故障備援」的穩定架構。實測結果顯示,故事機能透過分享器取得IP、自動完成NTP校時,手機控制頁、管理後台、播放統計與實體按鈕皆能正常運作。

一、這一版完成了什麼?

  • 以STA模式連接既有Wi-Fi,IP由分享器DHCP自動分配。
  • STA連線成功後自動執行NTP,不需由手機按鈕觸發。
  • NTP失敗時自動嘗試HTTP Date;手機時間只作最後備援。
  • STA連線、上網或校時失敗時,才啟動192.168.4.1備援AP設定入口。
  • STA與AP互斥,不再使用不穩定的Concurrent Mode。
  • 首頁可播放五組內容、停止播放、調整音量並查看即時狀態。
  • 管理後台可查看時間來源、STA狀態、統計、最近事件及下載CSV。
  • 保留JQ6500非阻塞播放、中途切歌、停止及提示音寬容Watchdog。

二、最終網路架構:STA為主,AP為輔

開機讀取Flash
連接STA
DHCP取得IP
自動NTP/HTTP校時
STA Web服務

若沒有STA設定、Wi-Fi連線逾時、連線中斷,或NTP與HTTP都無法驗證外網,系統才切換到:

關閉STA
啟動備援AP
SSID:Shuijing-Story
192.168.4.1
重新設定Wi-Fi
設計原則:正常展場使用STA,讓手機與故事機同在既有區域網路;只有網路故障時才開AP。如此可避開RTL8735B Concurrent Mode造成的socket、路由及UDP不穩定問題。

三、為什麼不再同時開啟AP與STA?

早期版本曾讓AP與STA同時運作。手機剛連上192.168.4.1時,Web短暫正常;但STA連上分享器後,即使把預設路由切回AP,Web仍然失效。這表示問題不只在IP路由,而是STA加入時可能重建底層網路介面,使原本的TCP監聽socket失效。

另一個現象是Concurrent Mode執行NTP UDP請求時,曾直接重新開機。反覆修正路由並不能真正解決底層模式切換問題,因此最後不再勉強共存,而是採用互斥模式。

架構測試結果決策
AP固定192.168.1.1+STAAP與分享器Gateway同網段,路由衝突淘汰
AP固定192.168.4.1+STA ConcurrentSTA加入後Web只能短暫使用,NTP亦可能不穩淘汰
STA主要+AP故障備援STA Web、NTP、播放與統計均正常採用

四、NTP自動校時測試成功

RTL8735B STA取得IP並成功完成NTP校時的序列監控畫面
STA取得192.168.1.122,NTP自動校時成功,系統進入STA online service ready。
[STA] IP: 192.168.1.122
[STA] Gateway: 192.168.1.1
[TIME] STA connected; automatic NTP queued
[NTP] Attempting UDP time sync
[TIME] Synced by NTP | 2026-08-24 21:02:08
[MODE] STA online service ready

這段紀錄證明NTP不是由手機按鈕決定,而是STA取得DHCP位址後由狀態機自動排程。分享器IP仍維持原設定,故事機不會要求使用者修改Gateway。

五、手機故事控制首頁

手機與故事機連接同一部分享器後,以序列監控器顯示的STA IP進入首頁。本次測試網址為http://192.168.1.122/。這個IP由DHCP分配,實際部署時可能不同。

六、管理後台:從播放控制走向可維護系統

後台不只是遠端按鈕,而是展場維運工具:可以確認現在是否CONNECTED、時間是否來自NTP、RSSI是否足夠、各故事被播放幾次,以及播放器是否曾進行自動復原。

七、後台畫面的JavaScript除錯經驗

V1.0-09h初版曾出現:

null is not an object
(evaluating $('channel').textContent=s.staChannel||1)

原因是新版不再要求AP與STA同頻道,因此畫面移除了「頻道」欄位,但JavaScript仍更新channel元素。API其實已正常回傳,卻因前端找不到DOM元素而中斷。

修正方式:保留隱藏的相容欄位,讓舊JavaScript仍可安全更新;使用者不必再設定頻道。這提醒我們:後端欄位、HTML元素與JavaScript選擇器必須同步修改。

八、JQ6500播放與Watchdog經驗

JQ6500的BUSY腳位實測定義為:

BUSY狀態
LOW(0)待機
HIGH(1)播放中

故事曲目001~005採嚴格Watchdog;若沒有進入播放狀態,程式會復原播放器。但006~009屬短提示音,部分JQ6500不一定可靠回報BUSY,因此採「寬容Watchdog」:提示音未回報BUSY時只推進狀態,不再重複Pause、Reset與播放008,以免提示音造成系統鎖定。

檔案內容用途
001.mp3白馬故事故事
002.mp3烏龜故事故事
003.mp3姻緣花故事故事
004.mp3水井三寶總故事故事
005.mp3創作者資訊故事
006.mp3開機提示系統提示
007.mp3準備完成系統提示
008.mp3錯誤提示系統提示
009.mp3停止提示系統提示

九、主要接線

HUB 8735 Ultra連接裝置功能
GPIO8/Serial2 TXJQ6500 RX播放命令
GPIO15/Serial2 RXJQ6500 TX播放器回傳
GPIO4JQ6500 BUSY播放狀態
GPIO10白馬按鈕→GND001.mp3
GPIO11烏龜按鈕→GND002.mp3
GPIO12姻緣花按鈕→GND003.mp3
GPIO20停止按鈕→GND中斷播放
GPIO7音量+→GND增加音量
GPIO6音量-→GND降低音量
供電經驗:PC USB供電曾造成反覆重置與重播開機提示;改用穩定、合格的市電轉低壓電源後,系統才真正穩定。市電不可直接接入開發板。

十、測試結論

  1. 故事機成功連接STA並取得192.168.1.122
  2. NTP由STA連線後自動啟動並校時成功。
  3. 手機控制首頁可正常播放、停止、切歌及調整音量。
  4. 管理後台可正確顯示CONNECTED、NTP、RSSI、統計及事件。
  5. 實體按鈕與Web操作皆維持非阻塞。
  6. STA與AP互斥後,不再發生Concurrent Mode導致的Web短暫可用問題。

這次最寶貴的經驗,不是找到一個能編譯的Wi-Fi函式,而是從真實紀錄判斷:能連線不代表架構穩定;展場系統應優先選擇可預測、可復原、可維護的模式。

測試平台:HUB 8735 Ultra(RTL8735B)+JQ6500;測試日期:2026年8月24日。

[水井村USR] 從故事播放器走向可管理的展場系統

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

從故事播放器走向可管理的展場系統

HUB 8735 Ultra+JQ6500本機統計、Web管理後台與Flash延遲保存實作紀錄

實機測試完成:故事播放、六個實體按鈕、手機Web、管理後台、PIN驗證、統計、事件紀錄、Flash保存、LED與JQ6500控制全部正常。
5類故事人氣統計
3來源按鈕/Web/管理
20筆最近操作事件
1次Flash批次寫入

一、為什麼需要V1.0-08?

V1.0-07已經讓水井三寶故事機具備穩定的實體按鈕、AP網頁、播放中切歌、停止、音量記憶、BUSY逾時保護與LED狀態。下一個問題不再是「能不能播放」,而是展出之後能不能知道:

  • 哪個故事最受歡迎?
  • 參觀者偏好使用實體按鈕還是手機?
  • 系統曾經停止、切歌或自動復原幾次?
  • 管理者能否不用重新燒錄,就完成曲目測試與播放器重設?

因此V1.0-08的核心是:讓設備可以被觀察、被管理,同時不犧牲V1.0-07已驗證的穩定性。

二、系統架構

實體按鈕
最高優先
非阻塞狀態機
JQ6500
本機統計
RAM累計
待機時批次寫入
Flash
手機控制首頁PIN管理後台同一個非阻塞
WiFiServer

三、操作與接線維持不變

GPIO/介面設備或按鈕功能
GPIO8/Serial2 TXJQ6500 RX傳送播放、停止、音量與重設命令
GPIO15/Serial2 RXJQ6500 TX保留播放器回傳通道
GPIO4JQ6500 BUSY判斷播放開始及完成
GPIO10白馬按鈕,另一端接GND001.mp3
GPIO11烏龜按鈕,另一端接GND002.mp3
GPIO12姻緣花按鈕,另一端接GND003.mp3
GPIO20停止按鈕,另一端接GND中斷並播放009提示
GPIO7/GPIO6音量+/音量-,另一端接GND每次調整2級
LED_G/LED_B板載綠/藍LEDHIGH點亮、LOW熄滅
供電仍是穩定運作的前提:請使用合格的AC轉DC 5V電源。先前使用電腦USB供電曾造成重置與006開機語音反覆播放;市電不可直接接入開發板。

四、故事與系統提示音

曲目內容是否列入故事人氣
001白馬故事民眾按鈕或Web操作時計入
002烏龜故事民眾按鈕或Web操作時計入
003姻緣花故事民眾按鈕或Web操作時計入
004水井三寶總故事Web操作時計入
005創作者資訊Web操作時計入
006開機提示不列入
007準備完成不列入
008錯誤提示記入播放器復原
009停止提示記入停止次數
統計不灌水:管理後台測試001~009時,只增加「管理操作」次數,不會增加民眾故事人氣與切歌統計。

五、手機首頁與管理後台

頁面網址用途
故事控制首頁http://192.168.1.1/民眾播放故事、停止及調整音量
管理後台http://192.168.1.1/admin統計、事件、曲目測試及維護
  1. 手機連線AP:Shuijing-Story
  2. 輸入Wi-Fi密碼:shuijing8735
  3. 一般操作開啟首頁;維護人員開啟/admin
  4. 管理命令輸入PIN:8735

正式展出前可修改:

const char ADMIN_PIN[] = "8735";
管理PIN透過展場本機AP傳送,功能是避免一般參觀者誤觸管理命令,並非網際網路等級的安全機制。若未來開放外網,必須改用HTTPS與正式身分驗證。

六、管理後台可以做什麼?

  • 查看目前狀態、曲目、音量、運作秒數及開機次數。
  • 查看白馬、烏龜、姻緣花、總故事及創作者資訊的播放次數。
  • 區分實體按鈕、一般Web與管理操作。
  • 查看停止、切歌及JQ6500復原次數。
  • 查看最近20筆事件及最後一次操作。
  • 測試001~009全部曲目。
  • 遠端停止、調整音量、重設JQ6500及立即保存。
  • 切換測試模式,開啟每3秒一次的GPIO診斷。
  • 經確認後清除使用統計;音量與開機次數保留。

七、Flash不是每次操作都寫入

若每按一次按鈕就把十多個計數器逐一寫入Flash,不只可能影響按鈕反應,也會快速增加擦寫次數。V1.0-08採用兩層延遲:

#define VOLUME_SAVE_DELAY_MS  3000
#define STATS_SAVE_DELAY_MS   60000UL
  • 音量停止調整3秒後才保存。
  • 一般統計累積60秒後才保存。
  • 所有永久資料打包成一個PersistentData結構,一次寫回。
  • 自動保存只在播放器真正待機時執行,避免影響播放中切歌與停止。
  • 管理員仍可按「立即保存」。
bool safeToWrite = playerState == STATE_IDLE &&
                   digitalRead(JQ_BUSY_PIN) == busyIdleLevel;

if ((volumeDue || statsDue) && safeToWrite) {
    savePersistentData(...);
}

八、資料完整性與V1.0-07遷移

永久資料包含Magic、Schema版本與Checksum。重新開機時只有三項都正確才採用;第一次從V1.0-07升級時,程式會辨識舊格式,自動承接原本音量與開機次數,再轉成V1.0-08新結構。

[FLASH] Migrated V1.0-07 volume and boot count

看到這行訊息代表遷移成功,不是錯誤。最近20筆事件則只放在RAM,重新開機後重新開始,避免Flash承受高頻事件紀錄。

九、最重要的穩定原則仍然保留

V1.0-08沒有改變已成功的主迴圈優先順序:

handleButtons();              // 實體按鈕最高優先
updateBusy();                 // JQ6500 BUSY
updatePlayerStateMachine();   // 非阻塞播放器狀態機
handleWeb();                  // Web放在後面
updateLed();
updatePersistentSettings();

而AmebaPro2 Web能與按鈕共存的關鍵依然是:

server.setNonBlockingMode();
server.begin();

十、實機測試結果

測試項目結果說明
V1.0-07資料遷移通過音量與開機次數可承接
三個實體故事按鈕通過播放與統計同步
停止及播放中切歌通過非阻塞狀態機正常
手機故事首頁通過播放、停止、音量正常
管理PIN通過錯誤PIN拒絕管理API
管理曲目測試通過001~009均可測試
本機統計通過來源與故事次數正確
最近20筆事件通過以新到舊顯示
Flash延遲保存通過待機時單次批次寫入
LED燈號通過HIGH點亮,狀態一致
沒有手機連線通過實體按鈕仍可持續操作

十一、完整Arduino程式

開發板套件:ideasHatch AmebaPro2 4.1.1-Release。使用WiFi.hFlashMemory.h

展開/收合 V1.0-08完整程式
/*
 * =====================================================================
 * 水井三寶故事機|V1.0-08
 * HUB 8735 Ultra + JQ6500 本機統計/Web管理後台/Flash延遲保存版
 * =====================================================================
 * AP:Shuijing-Story / shuijing8735
 * 首頁:http://192.168.1.1/
 * 管理:http://192.168.1.1/admin(PIN:8735,請於正式展出前自行修改)
 * 保存:音量停止調整3秒後、一般統計累積60秒後,批次寫入Flash。
 *
 * 曲目:
 *  001 白馬、002 烏龜、003 姻緣花、004 總故事、005 創作者
 *  006 開機、007 準備完成、008 錯誤、009 停止提示
 *
 * 接線:
 *  GPIO8  Serial2 TX -> JQ6500 RX
 *  GPIO15 Serial2 RX <- JQ6500 TX
 *  GPIO4  <- JQ6500 BUSY(開機時自動校正待機電位)
 *  GPIO10 白馬、GPIO11 烏龜、GPIO12 姻緣花
 *  GPIO20 停止、GPIO7 音量+、GPIO6 音量-;按鈕另一端均接GND
 *
 * 電源:使用合格AC轉DC 5V電源;不可將市電直接接入開發板。
 * =====================================================================
 */

#include <WiFi.h>
#include <FlashMemory.h>
#include <string.h>

char AP_SSID[] = "Shuijing-Story";
char AP_PASS[] = "shuijing8735";
char AP_CHANNEL[] = "1";
int AP_HIDDEN = 0;
const char ADMIN_PIN[] = "8735";

WiFiServer server(80);

#define BTN_HORSE       10
#define BTN_TURTLE      11
#define BTN_FLOWER      12
#define BTN_STOP        20
#define BTN_VOL_UP       7
#define BTN_VOL_DOWN     6
#define JQ_BUSY_PIN      4

#ifndef LED_G
#define LED_G 25
#endif
#ifndef LED_B
#define LED_B 26
#endif
#define LED_ON  HIGH
#define LED_OFF LOW

#define TRACK_HORSE      1
#define TRACK_TURTLE     2
#define TRACK_FLOWER     3
#define TRACK_ALL        4
#define TRACK_CREATOR    5
#define TRACK_BOOT       6
#define TRACK_READY      7
#define TRACK_ERROR      8
#define TRACK_STOP       9

#define VOL_MIN          0
#define VOL_MAX         30
#define VOL_STEP         2

#define FIRMWARE_VERSION "V1.0-08"

// AmebaPro2官方FlashMemory保留區;只用第一個4KB sector。
#define SETTINGS_FLASH_BASE       0xFD0000
#define SETTINGS_FLASH_SIZE       0x1000
#define PERSIST_MAGIC             0x534A3038UL
#define PERSIST_SCHEMA            1UL
#define LEGACY_SETTINGS_MAGIC     0x53560700UL
#define LEGACY_SETTINGS_MASK      0xFFFFFF00UL
#define VOLUME_SAVE_DELAY_MS      3000
#define STATS_SAVE_DELAY_MS       60000UL
#define EVENT_CAPACITY            20

// JQ6500異常保護:未進入播放、BUSY卡住或故事異常過長都會自動復原。
#define AUDIO_BUSY_START_TIMEOUT_MS 1800
#define STORY_MAX_PLAY_MS           1800000UL
#define PROMPT_MAX_PLAY_MS          30000UL

// 009停止提示的BUSY保護時間
#define STOP_BUSY_START_TIMEOUT_MS  600
#define STOP_PROMPT_MAX_MS          8000

enum PlayerState {
    STATE_AP_STARTING,
    STATE_BOOT_PROMPT,
    STATE_READY_PROMPT,
    STATE_IDLE,
    STATE_STORY_PLAYING,
    STATE_PAUSE_WAIT,
    STATE_RESET_WAIT,
    STATE_STOP_PROMPT,
    STATE_ERROR
};

// 所有永久資料一次寫入同一個結構,避免每個計數器各自擦寫Flash。
struct PersistentData {
    uint32_t magic;
    uint32_t schema;
    uint32_t volume;
    uint32_t bootCount;
    uint32_t storyCount[5];
    uint32_t buttonCommands;
    uint32_t webCommands;
    uint32_t adminCommands;
    uint32_t stopCount;
    uint32_t switchCount;
    uint32_t volumeUpCount;
    uint32_t volumeDownCount;
    uint32_t jqRecoveries;
    uint32_t checksum;
};
static_assert(sizeof(PersistentData) <= SETTINGS_FLASH_SIZE,
              "PersistentData exceeds Flash buffer");

struct RuntimeEvent {
    unsigned long seconds;
    String source;
    String action;
    String result;
};

PersistentData stats;
RuntimeEvent recentEvents[EVENT_CAPACITY];
int eventHead = 0;
int eventCount = 0;

PlayerState playerState = STATE_AP_STARTING;

int currentVolume = 20;
int currentTrack = 0;
String currentTitle = "AP啟動中";
bool wifiReady = false;
bool busySeenPlaying = false;
int lastBusy = LOW;
int busyIdleLevel = LOW;

// 中斷完成後要播放的目標;0代表只停止、不播放下一首
int pendingTrack = 0;
String pendingTitle = "";
bool pendingIsStopPrompt = false;
unsigned long stateStartedAt = 0;
unsigned long lastStopAt = 0;
unsigned long lastApRetryAt = 0;
unsigned long lastErrorBlinkAt = 0;
unsigned long lastButtonDiagAt = 0;
bool errorBlink = false;
bool volumeDirty = false;
bool statsDirty = false;
bool errorRecoveryActive = false;
unsigned long volumeChangedAt = 0;
unsigned long statsChangedAt = 0;
unsigned long bootCount = 0;
unsigned long playerErrorCount = 0;
bool testMode = false;
String lastSource = "system";
String lastAction = "開機";
unsigned long lastActionAt = 0;

const int BUTTON_COUNT = 6;
const int buttonPins[BUTTON_COUNT] = {
    BTN_HORSE, BTN_TURTLE, BTN_FLOWER,
    BTN_STOP, BTN_VOL_UP, BTN_VOL_DOWN
};
int rawButton[BUTTON_COUNT] = {HIGH, HIGH, HIGH, HIGH, HIGH, HIGH};
int stableButton[BUTTON_COUNT] = {HIGH, HIGH, HIGH, HIGH, HIGH, HIGH};
unsigned long buttonChangedAt[BUTTON_COUNT] = {0, 0, 0, 0, 0, 0};

const char *stateName(PlayerState state)
{
    switch (state) {
        case STATE_AP_STARTING:   return "AP_STARTING";
        case STATE_BOOT_PROMPT:   return "BOOT_PROMPT";
        case STATE_READY_PROMPT:  return "READY_PROMPT";
        case STATE_IDLE:          return "IDLE";
        case STATE_STORY_PLAYING: return "STORY_PLAYING";
        case STATE_PAUSE_WAIT:    return "PAUSE_WAIT";
        case STATE_RESET_WAIT:    return "RESET_WAIT";
        case STATE_STOP_PROMPT:   return "STOP_PROMPT";
        default:                  return "ERROR";
    }
}

void enterState(PlayerState nextState, const String &title)
{
    playerState = nextState;
    currentTitle = title;
    stateStartedAt = millis();

    Serial.print("[STATE] ");
    Serial.print(stateName(playerState));
    Serial.print(" | ");
    Serial.println(currentTitle);
}

// ---------------------------------------------------------------------
// JQ6500底層命令
// ---------------------------------------------------------------------
void jqSend(uint8_t command, const uint8_t *data, uint8_t dataLength)
{
    Serial2.write(0x7E);
    Serial2.write((uint8_t)(dataLength + 2));
    Serial2.write(command);
    for (uint8_t i = 0; i < dataLength; i++) {
        Serial2.write(data[i]);
    }
    Serial2.write(0xEF);
    Serial2.flush();
}

void jqSetVolume(int value, bool remember = true)
{
    int newVolume = constrain(value, VOL_MIN, VOL_MAX);
    bool changed = newVolume != currentVolume;
    currentVolume = newVolume;
    uint8_t data[1] = {(uint8_t)currentVolume};
    jqSend(0x06, data, 1);
    Serial.print("[VOLUME] ");
    Serial.println(currentVolume);

    // 延後合併寫入,避免連按音量鍵造成Flash過度擦寫。
    if (remember && changed) {
        volumeDirty = true;
        volumeChangedAt = millis();
    }
}

uint32_t persistentChecksum(const PersistentData &data)
{
    const uint32_t *words = (const uint32_t *)&data;
    int count = (sizeof(PersistentData) - sizeof(uint32_t)) / sizeof(uint32_t);
    uint32_t hash = 2166136261UL;
    for (int i = 0; i < count; i++) {
        hash ^= words[i];
        hash *= 16777619UL;
    }
    return hash;
}

void initializeStats()
{
    memset(&stats, 0, sizeof(stats));
    stats.magic = PERSIST_MAGIC;
    stats.schema = PERSIST_SCHEMA;
    stats.volume = 20;
}

void savePersistentData(const char *reason)
{
    stats.magic = PERSIST_MAGIC;
    stats.schema = PERSIST_SCHEMA;
    stats.volume = currentVolume;
    stats.bootCount = bootCount;
    stats.jqRecoveries = playerErrorCount;
    stats.checksum = persistentChecksum(stats);

    // 先讀取整個sector,再只覆蓋本程式的結構,最後一次批次寫回。
    FlashMemory.read(0);
    memcpy(FlashMemory.buf, &stats, sizeof(stats));
    FlashMemory.write(0);
    volumeDirty = false;
    statsDirty = false;

    Serial.print("[FLASH] Batch saved: ");
    Serial.println(reason);
}

void loadPersistentSettings()
{
    FlashMemory.begin(SETTINGS_FLASH_BASE, SETTINGS_FLASH_SIZE);
    FlashMemory.read(0);

    PersistentData loaded;
    memcpy(&loaded, FlashMemory.buf, sizeof(loaded));
    bool valid = loaded.magic == PERSIST_MAGIC &&
                 loaded.schema == PERSIST_SCHEMA &&
                 loaded.checksum == persistentChecksum(loaded);

    if (valid) {
        stats = loaded;
        currentVolume = constrain((int)stats.volume, VOL_MIN, VOL_MAX);
        Serial.println("[FLASH] V1.0-08 statistics restored");
    } else {
        // 自動承接V1.0-07的前兩個word:音量與開機次數。
        uint32_t legacySettings = 0;
        uint32_t legacyBoots = 0;
        memcpy(&legacySettings, FlashMemory.buf, sizeof(uint32_t));
        memcpy(&legacyBoots, FlashMemory.buf + sizeof(uint32_t), sizeof(uint32_t));
        initializeStats();
        if ((legacySettings & LEGACY_SETTINGS_MASK) == LEGACY_SETTINGS_MAGIC) {
            int oldVolume = legacySettings & 0xFF;
            if (oldVolume >= VOL_MIN && oldVolume <= VOL_MAX) {
                currentVolume = oldVolume;
                stats.volume = oldVolume;
            }
            if (legacyBoots != 0xFFFFFFFFUL) stats.bootCount = legacyBoots;
            Serial.println("[FLASH] Migrated V1.0-07 volume and boot count");
        } else {
            currentVolume = 20;
            Serial.println("[FLASH] New statistics initialized");
        }
    }

    bootCount = stats.bootCount + 1;
    playerErrorCount = stats.jqRecoveries;
    savePersistentData("boot count");
    Serial.print("[FLASH] Volume = "); Serial.println(currentVolume);
    Serial.print("[FLASH] Boot count = "); Serial.println(bootCount);
}

void markStatsDirty()
{
    if (!statsDirty) statsChangedAt = millis();
    statsDirty = true;
}

void addEvent(const String &source, const String &action, const String &result)
{
    recentEvents[eventHead].seconds = millis() / 1000;
    recentEvents[eventHead].source = source;
    recentEvents[eventHead].action = action;
    recentEvents[eventHead].result = result;
    eventHead = (eventHead + 1) % EVENT_CAPACITY;
    if (eventCount < EVENT_CAPACITY) eventCount++;
    lastSource = source;
    lastAction = action;
    lastActionAt = millis() / 1000;
}

void recordCommand(const char *source, const String &action)
{
    if (String(source) == "button") stats.buttonCommands++;
    else if (String(source) == "admin") stats.adminCommands++;
    else stats.webCommands++;
    addEvent(source, action, "accepted");
    markStatsDirty();
}

void updatePersistentSettings()
{
    bool volumeDue = volumeDirty &&
                     millis() - volumeChangedAt >= VOLUME_SAVE_DELAY_MS;
    bool statsDue = statsDirty &&
                    millis() - statsChangedAt >= STATS_SAVE_DELAY_MS;
    // 自動寫入只在真正待機時執行,避免Flash操作影響播放中切歌與停止。
    bool safeToWrite = playerState == STATE_IDLE &&
                       digitalRead(JQ_BUSY_PIN) == busyIdleLevel;
    if ((volumeDue || statsDue) && safeToWrite) {
        savePersistentData(volumeDue ? "volume delay" : "statistics delay");
    }
}

void jqSetOneStopMode()
{
    uint8_t mode[1] = {0x04};
    jqSend(0x11, mode, 1);
    Serial.println("[JQ] Mode ONE_STOP");
}

void jqPlay(int track)
{
    uint8_t data[2] = {
        (uint8_t)((track >> 8) & 0xFF),
        (uint8_t)(track & 0xFF)
    };
    jqSend(0x03, data, 2);
    currentTrack = track;
    busySeenPlaying = false;
    Serial.print("[JQ] Play track ");
    Serial.println(track);
}

void playDirect(int track, const String &title, PlayerState playingState)
{
    jqPlay(track);
    enterState(playingState, title);
}

// ---------------------------------------------------------------------
// 非阻塞中斷流程
// Pause立即中斷 -> 等150ms -> Reset -> 等700ms -> 恢復模式與音量
// ---------------------------------------------------------------------
void beginInterrupt(int nextTrack, const String &nextTitle, bool isStopPrompt)
{
    if (playerState == STATE_PAUSE_WAIT || playerState == STATE_RESET_WAIT) {
        // 中斷進行中只更新最終目標,不重複送Pause/Reset。
        pendingTrack = nextTrack;
        pendingTitle = nextTitle;
        pendingIsStopPrompt = isStopPrompt;
        Serial.println("[QUEUE] Interrupt target updated");
        return;
    }

    pendingTrack = nextTrack;
    pendingTitle = nextTitle;
    pendingIsStopPrompt = isStopPrompt;
    jqSend(0x0E, NULL, 0);
    currentTrack = 0;
    busySeenPlaying = false;
    enterState(STATE_PAUSE_WAIT, "正在中斷播放");
}

void finishInterrupt()
{
    jqSetVolume(currentVolume, false);
    jqSetOneStopMode();

    if (pendingTrack > 0) {
        int track = pendingTrack;
        String title = pendingTitle;
        bool stopPrompt = pendingIsStopPrompt;
        pendingTrack = 0;
        pendingTitle = "";
        pendingIsStopPrompt = false;

        PlayerState targetState = stopPrompt ? STATE_STOP_PROMPT : STATE_STORY_PLAYING;
        if (errorRecoveryActive) {
            targetState = STATE_ERROR;
            errorRecoveryActive = false;
        }
        playDirect(track, title, targetState);
    } else {
        enterState(STATE_IDLE, "待機中");
    }
}

void recoverPlayer(const char *reason)
{
    playerErrorCount++;
    stats.jqRecoveries = playerErrorCount;
    addEvent("system", "JQ6500自動復原", reason);
    markStatsDirty();
    Serial.print("[WATCHDOG] JQ6500 recovery: ");
    Serial.println(reason);

    // 008只提示一次;若008本身也失敗,STATE_ERROR逾時後直接回待機。
    errorRecoveryActive = true;
    beginInterrupt(TRACK_ERROR, "播放器異常,正在復原", false);
}

void updatePlayerStateMachine()
{
    if (playerState == STATE_PAUSE_WAIT && millis() - stateStartedAt >= 150) {
        jqSend(0x0C, NULL, 0);
        enterState(STATE_RESET_WAIT, "播放器重置中");
        return;
    }

    if (playerState == STATE_RESET_WAIT && millis() - stateStartedAt >= 700) {
        finishInterrupt();
        return;
    }

    if (playerState == STATE_STOP_PROMPT) {
        unsigned long elapsed = millis() - stateStartedAt;

        // 009送出後沒有偵測到BUSY啟動:不讓STOP_PROMPT鎖住系統。
        if (!busySeenPlaying && elapsed >= STOP_BUSY_START_TIMEOUT_MS) {
            Serial.println("[WATCHDOG] Track 009 BUSY did not start; release to IDLE");
            currentTrack = 0;
            enterState(STATE_IDLE, "待機中");
            return;
        }

        // 即使BUSY訊號卡住,最長8秒後仍會釋放控制權。
        if (elapsed >= STOP_PROMPT_MAX_MS) {
            Serial.println("[WATCHDOG] Track 009 timeout; release to IDLE");
            currentTrack = 0;
            busySeenPlaying = false;
            enterState(STATE_IDLE, "待機中");
        }
    }

    // 所有提示與故事共用的BUSY啟動逾時保護。
    bool audioState = playerState == STATE_BOOT_PROMPT ||
                      playerState == STATE_READY_PROMPT ||
                      playerState == STATE_STORY_PLAYING ||
                      playerState == STATE_ERROR;
    if (audioState && currentTrack > 0 && !busySeenPlaying &&
        millis() - stateStartedAt >= AUDIO_BUSY_START_TIMEOUT_MS) {
        if (playerState == STATE_ERROR) {
            Serial.println("[WATCHDOG] Track 008 did not start; return to IDLE");
            currentTrack = 0;
            enterState(STATE_IDLE, "待機中(播放器提示失敗)");
        } else {
            recoverPlayer("BUSY did not start");
        }
        return;
    }

    if (audioState && busySeenPlaying) {
        unsigned long limit = playerState == STATE_STORY_PLAYING ?
                              STORY_MAX_PLAY_MS : PROMPT_MAX_PLAY_MS;
        if (millis() - stateStartedAt >= limit) {
            if (playerState == STATE_ERROR) {
                beginInterrupt(0, "", false);
            } else {
                recoverPlayer("BUSY playback timeout");
            }
        }
    }
}

// ---------------------------------------------------------------------
// BUSY與自然播放完成
// ---------------------------------------------------------------------
void updateBusy()
{
    int busy = digitalRead(JQ_BUSY_PIN);

    if (busy != lastBusy) {
        Serial.print("[BUSY] ");
        Serial.print(lastBusy);
        Serial.print(" -> ");
        Serial.print(busy);
        Serial.print(" | idle=");
        Serial.println(busyIdleLevel);
    }

    // 開機播放前先記住待機電位;離開待機電位即代表開始播放。
    if (currentTrack > 0 && busy != busyIdleLevel) {
        busySeenPlaying = true;
    }

    bool returnedToIdle =
        lastBusy != busyIdleLevel && busy == busyIdleLevel;
    lastBusy = busy;

    if (!returnedToIdle || !busySeenPlaying) {
        return;
    }

    busySeenPlaying = false;
    currentTrack = 0;

    if (playerState == STATE_BOOT_PROMPT) {
        delay(20);
        playDirect(TRACK_READY, "系統準備完成", STATE_READY_PROMPT);
    } else if (playerState == STATE_READY_PROMPT ||
               playerState == STATE_STORY_PLAYING ||
               playerState == STATE_STOP_PROMPT ||
               playerState == STATE_ERROR) {
        enterState(STATE_IDLE, "待機中");
    }
}

// ---------------------------------------------------------------------
// 使用者命令
// ---------------------------------------------------------------------
void requestStory(int track, const String &title, const char *source)
{
    // 即使狀態已被009 Watchdog釋放,只要BUSY仍在播放,仍先中斷再切歌。
    bool hardwareBusy = digitalRead(JQ_BUSY_PIN) != busyIdleLevel;

    if (playerState == STATE_AP_STARTING || playerState == STATE_ERROR) {
        Serial.println("[COMMAND] Rejected: system not ready");
        addEvent(source, title, "rejected");
        return;
    }

    bool switching = playerState != STATE_IDLE || hardwareBusy;
    recordCommand(source, title);
    if (String(source) != "admin" &&
        track >= TRACK_HORSE && track <= TRACK_CREATOR) {
        stats.storyCount[track - 1]++;
    }
    if (switching && String(source) != "admin") stats.switchCount++;
    markStatsDirty();

    if (!switching) {
        playDirect(track, title, STATE_STORY_PLAYING);
    } else {
        beginInterrupt(track, title, false);
    }
}

void requestStop(const char *source)
{
    if (playerState == STATE_AP_STARTING || playerState == STATE_ERROR) {
        Serial.println("[STOP] Ignored in current state");
        addEvent(source, "停止", "rejected");
        return;
    }

    // 009播放期間再按停止:中斷009並回到待機,不再重播009。
    if (playerState == STATE_STOP_PROMPT) {
        Serial.println("[STOP] Stop prompt cancelled by user");
        recordCommand(source, "取消停止提示");
        if (String(source) != "admin") stats.stopCount++;
        markStatsDirty();
        beginInterrupt(0, "", false);
        return;
    }

    // Watchdog已回到IDLE,但009可能仍在硬體播放;允許停止它。
    if (playerState == STATE_IDLE) {
        bool hardwareBusy = digitalRead(JQ_BUSY_PIN) != busyIdleLevel;
        if (hardwareBusy) {
            Serial.println("[STOP] Hardware still busy; stop without prompt");
            recordCommand(source, "停止硬體播放");
            if (String(source) != "admin") stats.stopCount++;
            markStatsDirty();
            beginInterrupt(0, "", false);
        } else {
            Serial.println("[STOP] Ignored: already idle");
        }
        return;
    }

    if (millis() - lastStopAt < 1000) {
        Serial.println("[STOP] Duplicate ignored");
        return;
    }
    lastStopAt = millis();
    recordCommand(source, "停止播放");
    if (String(source) != "admin") stats.stopCount++;
    markStatsDirty();
    beginInterrupt(TRACK_STOP, "停止提示", true);
}

void requestVolume(int delta, const char *source)
{
    int before = currentVolume;
    jqSetVolume(currentVolume + delta);
    if (currentVolume == before) {
        addEvent(source, delta > 0 ? "音量+" : "音量-", "limit");
        return;
    }
    recordCommand(source, delta > 0 ? "音量+" : "音量-");
    if (String(source) != "admin") {
        if (delta > 0) stats.volumeUpCount++;
        else stats.volumeDownCount++;
    }
    markStatsDirty();
}

bool buttonEdge(int index)
{
    int reading = digitalRead(buttonPins[index]);
    if (reading != rawButton[index]) {
        rawButton[index] = reading;
        buttonChangedAt[index] = millis();
    }
    if (millis() - buttonChangedAt[index] >= 35 &&
        reading != stableButton[index]) {
        stableButton[index] = reading;
        if (stableButton[index] == LOW) {
            Serial.print("[BUTTON] Pressed GPIO");
            Serial.println(buttonPins[index]);
            return true;
        }
    }
    return false;
}

void handleButtons()
{
    // 暫時保留展場診斷:每3秒顯示六個GPIO原始電位。
    // 正常未按為1,按下期間應變成0。
    if (testMode && millis() - lastButtonDiagAt >= 3000) {
        lastButtonDiagAt = millis();
        Serial.print("[BUTTON RAW] G10="); Serial.print(digitalRead(BTN_HORSE));
        Serial.print(" G11="); Serial.print(digitalRead(BTN_TURTLE));
        Serial.print(" G12="); Serial.print(digitalRead(BTN_FLOWER));
        Serial.print(" G20="); Serial.print(digitalRead(BTN_STOP));
        Serial.print(" G7="); Serial.print(digitalRead(BTN_VOL_UP));
        Serial.print(" G6="); Serial.println(digitalRead(BTN_VOL_DOWN));
    }

    bool pressed[BUTTON_COUNT];
    for (int i = 0; i < BUTTON_COUNT; i++) {
        pressed[i] = buttonEdge(i);
    }
    if (pressed[0]) requestStory(TRACK_HORSE, "白馬故事", "button");
    else if (pressed[1]) requestStory(TRACK_TURTLE, "烏龜故事", "button");
    else if (pressed[2]) requestStory(TRACK_FLOWER, "姻緣花故事", "button");
    else if (pressed[3]) requestStop("button");
    else if (pressed[4]) requestVolume(VOL_STEP, "button");
    else if (pressed[5]) requestVolume(-VOL_STEP, "button");
}

// ---------------------------------------------------------------------
// LED
// ---------------------------------------------------------------------
void updateLed()
{
    digitalWrite(LED_G, LED_OFF);
    digitalWrite(LED_B, LED_OFF);

    if (playerState == STATE_ERROR) {
        if (millis() - lastErrorBlinkAt >= 350) {
            lastErrorBlinkAt = millis();
            errorBlink = !errorBlink;
        }
        digitalWrite(LED_G, errorBlink ? LED_ON : LED_OFF);
        digitalWrite(LED_B, errorBlink ? LED_OFF : LED_ON);
    } else if (playerState == STATE_IDLE && !wifiReady) {
        // AP尚未成功:綠燈維持待機可用,藍燈每500ms閃爍。
        digitalWrite(LED_G, LED_ON);
        digitalWrite(LED_B, ((millis() / 500) & 1) ? LED_ON : LED_OFF);
    } else if (playerState == STATE_IDLE) {
        digitalWrite(LED_G, LED_ON);
    } else {
        digitalWrite(LED_B, LED_ON);
    }
}

// ---------------------------------------------------------------------
// HTTP
// ---------------------------------------------------------------------
void httpHeader(WiFiClient &client, const char *type, int code = 200)
{
    if (code == 200) client.println("HTTP/1.1 200 OK");
    else if (code == 403) client.println("HTTP/1.1 403 Forbidden");
    else client.println("HTTP/1.1 404 Not Found");
    client.print("Content-Type: "); client.println(type);
    client.println("Cache-Control: no-store");
    client.println("Connection: close");
    client.println();
}

void printJsonString(WiFiClient &client, const String &value)
{
    client.print('"');
    for (unsigned int i = 0; i < value.length(); i++) {
        char c = value[i];
        if (c == '"' || c == '\\') { client.print('\\'); client.print(c); }
        else if (c == '\n') client.print("\\n");
        else if ((unsigned char)c >= 0x20) client.print(c);
    }
    client.print('"');
}

void sendStatus(WiFiClient &client)
{
    httpHeader(client, "application/json; charset=utf-8");
    client.print("{\"state\":\""); client.print(stateName(playerState));
    client.print("\",\"title\":\""); client.print(currentTitle);
    client.print("\",\"track\":"); client.print(currentTrack);
    client.print(",\"volume\":"); client.print(currentVolume);
    client.print(",\"busy\":");
    client.print(digitalRead(JQ_BUSY_PIN) != busyIdleLevel ? "true" : "false");
    client.print(",\"uptime\":"); client.print(millis() / 1000);
    client.print(",\"wifi\":"); client.print(wifiReady ? "true" : "false");
    client.print(",\"bootCount\":"); client.print(bootCount);
    client.print(",\"errors\":"); client.print(playerErrorCount);
    client.print(",\"firmware\":\""); client.print(FIRMWARE_VERSION); client.print("\"");
    client.print(",\"testMode\":"); client.print(testMode ? "true" : "false");
    client.println("}");
}

void sendAdminData(WiFiClient &client)
{
    httpHeader(client, "application/json; charset=utf-8");
    client.print("{\"firmware\":\""); client.print(FIRMWARE_VERSION);
    client.print("\",\"state\":\""); client.print(stateName(playerState));
    client.print("\",\"title\":"); printJsonString(client, currentTitle);
    client.print(",\"track\":"); client.print(currentTrack);
    client.print(",\"volume\":"); client.print(currentVolume);
    client.print(",\"uptime\":"); client.print(millis() / 1000);
    client.print(",\"bootCount\":"); client.print(bootCount);
    client.print(",\"buttonCommands\":"); client.print(stats.buttonCommands);
    client.print(",\"webCommands\":"); client.print(stats.webCommands);
    client.print(",\"adminCommands\":"); client.print(stats.adminCommands);
    client.print(",\"stopCount\":"); client.print(stats.stopCount);
    client.print(",\"switchCount\":"); client.print(stats.switchCount);
    client.print(",\"volumeUpCount\":"); client.print(stats.volumeUpCount);
    client.print(",\"volumeDownCount\":"); client.print(stats.volumeDownCount);
    client.print(",\"jqRecoveries\":"); client.print(playerErrorCount);
    client.print(",\"testMode\":"); client.print(testMode ? "true" : "false");
    client.print(",\"dirty\":"); client.print((statsDirty || volumeDirty) ? "true" : "false");
    client.print(",\"lastSource\":"); printJsonString(client, lastSource);
    client.print(",\"lastAction\":"); printJsonString(client, lastAction);
    client.print(",\"lastActionAt\":"); client.print(lastActionAt);
    client.print(",\"stories\":[");
    for (int i = 0; i < 5; i++) {
        if (i) client.print(',');
        client.print(stats.storyCount[i]);
    }
    client.print("],\"events\":[");
    for (int i = 0; i < eventCount; i++) {
        if (i) client.print(',');
        int index = (eventHead - 1 - i + EVENT_CAPACITY) % EVENT_CAPACITY;
        client.print("{\"seconds\":"); client.print(recentEvents[index].seconds);
        client.print(",\"source\":"); printJsonString(client, recentEvents[index].source);
        client.print(",\"action\":"); printJsonString(client, recentEvents[index].action);
        client.print(",\"result\":"); printJsonString(client, recentEvents[index].result);
        client.print('}');
    }
    client.println("]}");
}

void sendPage(WiFiClient &client)
{
    httpHeader(client, "text/html; charset=utf-8");
    client.print(R"HTML(<!doctype html><html lang="zh-Hant"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>水井三寶故事機</title><style>
*{box-sizing:border-box}body{margin:0;background:linear-gradient(135deg,#e5f7f4,#fff2c9);font-family:Arial,"Noto Sans TC",sans-serif;color:#174349}.wrap{width:min(760px,94%);margin:22px auto}.panel{padding:24px;border:1px solid #b9d9d4;border-radius:24px;background:#ffffffed;box-shadow:0 10px 30px #17434922}h1{margin:0;color:#006b65;font-size:clamp(28px,7vw,43px)}.sub{margin:4px 0 18px;color:#668084}.status{padding:15px;border-radius:14px;background:#ebf8f6;font-size:18px;font-weight:bold}.dot{display:inline-block;width:13px;height:13px;margin-right:8px;border-radius:50%;background:#28a745}.stories{display:grid;grid-template-columns:repeat(3,1fr);gap:11px;margin-top:15px}.more,.controls{display:grid;grid-template-columns:repeat(2,1fr);gap:11px;margin-top:11px}.controls{grid-template-columns:1.2fr 1fr 1fr}.btn{min-height:72px;border:0;border-radius:15px;color:#fff;font-size:19px;font-weight:bold;cursor:pointer;box-shadow:0 4px 11px #0002}.horse{background:#ad6829}.turtle{background:#268149}.flower{background:#c14c78}.all{background:#7151a1}.creator{background:#96651f}.stop{background:#d43b3b}.vol{background:#187f9e}.meter{margin-top:17px;padding:13px;border-radius:12px;background:#f1f6f5}.small{text-align:center;color:#667b7e;font-size:13px}@media(max-width:560px){.stories,.more{grid-template-columns:1fr}.controls{grid-template-columns:1fr 1fr}.stop{grid-column:1/-1}.panel{padding:18px}}
</style></head><body><main class="wrap"><section class="panel">
<h1>水井三寶故事機</h1><p class="sub">V1.0-08|本機統計與管理後台版</p>
<div class="status"><span class="dot" id="dot"></span><span id="title">讀取中</span><br><small id="detail"></small></div>
<div class="stories"><button class="btn horse" onclick="cmd('/api/play/horse')">白馬故事</button><button class="btn turtle" onclick="cmd('/api/play/turtle')">烏龜故事</button><button class="btn flower" onclick="cmd('/api/play/flower')">姻緣花故事</button></div>
<div class="more"><button class="btn all" onclick="cmd('/api/play/all')">水井三寶總故事</button><button class="btn creator" onclick="cmd('/api/play/creator')">創作者資訊</button></div>
<div class="controls"><button class="btn stop" onclick="cmd('/api/stop')">停止播放</button><button class="btn vol" onclick="cmd('/api/volume/down')">音量 −</button><button class="btn vol" onclick="cmd('/api/volume/up')">音量 +</button></div>
<div class="meter">音量:<b id="vol">20</b>/30 運作時間:<span id="uptime">0</span>秒<br>開機次數:<span id="boots">0</span> 播放器復原:<span id="errors">0</span></div><p class="small">Wi-Fi:Shuijing-Story|<a href="/admin">管理後台</a></p>
</section></main><script>
const uiTitle=document.getElementById('title'),uiDetail=document.getElementById('detail'),uiVol=document.getElementById('vol'),uiUptime=document.getElementById('uptime'),uiDot=document.getElementById('dot'),uiBoots=document.getElementById('boots'),uiErrors=document.getElementById('errors');let locked=false,refreshing=false;async function cmd(u){if(locked)return;locked=true;try{await fetch(u,{cache:'no-store'});await refresh()}catch(e){}setTimeout(()=>locked=false,500)}
async function refresh(){if(refreshing)return;refreshing=true;try{let r=await fetch('/api/status',{cache:'no-store'}),s=await r.json();uiTitle.textContent=s.title;uiDetail.textContent=s.state+'|曲目 '+s.track+'|BUSY '+(s.busy?'1':'0')+'|'+s.firmware;uiVol.textContent=s.volume;uiUptime.textContent=s.uptime;uiBoots.textContent=s.bootCount;uiErrors.textContent=s.errors;uiDot.style.background=s.state==='ERROR'?'#dc3545':(s.state==='IDLE'?'#28a745':'#1680a1')}catch(e){uiTitle.textContent='連線中斷';uiDot.style.background='#dc3545'}finally{refreshing=false}}refresh();setInterval(refresh,3000);
</script></body></html>)HTML");
}

void sendAdminPage(WiFiClient &client)
{
    httpHeader(client, "text/html; charset=utf-8");
    client.print(R"ADMIN(<!doctype html><html lang="zh-Hant"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>故事機管理後台</title>
<style>*{box-sizing:border-box}body{margin:0;background:#edf5f3;color:#173f43;font-family:Arial,"Noto Sans TC",sans-serif}.wrap{width:min(980px,94%);margin:20px auto}.head,.card{background:#fff;border:1px solid #c9deda;border-radius:18px;padding:20px;margin-bottom:14px;box-shadow:0 5px 18px #173f4312}h1{margin:0;color:#006b65}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}.metric{padding:14px;border-radius:12px;background:#eef8f6;text-align:center}.metric b{display:block;font-size:26px;color:#087d76}.stories,.tools{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}.tools{grid-template-columns:repeat(4,1fr)}button,input{min-height:48px;border-radius:10px;border:1px solid #9bbfba;font-size:16px;padding:8px}button{border:0;background:#087d76;color:#fff;font-weight:bold;cursor:pointer}.danger{background:#c33b3b}.gold{background:#a46e13}.events{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{padding:9px;border-bottom:1px solid #dce8e6;text-align:left}.auth{display:flex;gap:8px}.auth input{flex:1}.msg{min-height:28px;color:#b33;font-weight:bold}@media(max-width:700px){.grid{grid-template-columns:repeat(2,1fr)}.stories,.tools{grid-template-columns:1fr 1fr}}</style></head><body><main class="wrap">
<section class="head"><h1>水井三寶故事機管理後台</h1><p>V1.0-08|本機統計+Flash延遲保存</p><div class="auth"><input id="pin" type="password" inputmode="numeric" placeholder="輸入管理PIN"><button onclick="loadData()">登入/更新</button></div><div id="msg" class="msg"></div></section>
<section class="card"><h2>即時狀態</h2><div class="grid"><div class="metric"><b id="state">-</b>狀態</div><div class="metric"><b id="track">0</b>曲目</div><div class="metric"><b id="volume">0</b>音量</div><div class="metric"><b id="uptime">0</b>運作秒數</div></div><p id="title">讀取中</p></section>
<section class="card"><h2>故事統計</h2><div class="grid"><div class="metric"><b id="s1">0</b>白馬</div><div class="metric"><b id="s2">0</b>烏龜</div><div class="metric"><b id="s3">0</b>姻緣花</div><div class="metric"><b id="s4">0</b>總故事</div><div class="metric"><b id="s5">0</b>創作者</div><div class="metric"><b id="buttons">0</b>實體按鈕</div><div class="metric"><b id="web">0</b>Web</div><div class="metric"><b id="admin">0</b>管理操作</div><div class="metric"><b id="switches">0</b>切歌</div></div></section>
<section class="card"><h2>曲目測試</h2><div class="stories"><button onclick="act('/api/admin/play?track=1')">001 白馬</button><button onclick="act('/api/admin/play?track=2')">002 烏龜</button><button onclick="act('/api/admin/play?track=3')">003 姻緣花</button><button onclick="act('/api/admin/play?track=4')">004 總故事</button><button onclick="act('/api/admin/play?track=5')">005 創作者</button><button onclick="act('/api/admin/play?track=6')">006 開機提示</button><button onclick="act('/api/admin/play?track=7')">007 準備完成</button><button onclick="act('/api/admin/play?track=8')">008 錯誤提示</button><button onclick="act('/api/admin/play?track=9')">009 停止提示</button></div></section>
<section class="card"><h2>管理工具</h2><div class="tools"><button class="danger" onclick="act('/api/admin/stop')">立即停止</button><button onclick="act('/api/admin/volume-down')">音量-</button><button onclick="act('/api/admin/volume-up')">音量+</button><button onclick="act('/api/admin/reset-player')">重設播放器</button><button onclick="act('/api/admin/save')">立即保存</button><button class="gold" onclick="act('/api/admin/toggle-test')">切換測試模式</button><button class="danger" onclick="clearStats()">清除統計</button></div><p>開機:<b id="boots">0</b>|停止:<b id="stops">0</b>|播放器復原:<b id="errors">0</b>|待保存:<b id="dirty">否</b>|測試模式:<b id="test">關</b></p></section>
<section class="card events"><h2>最近20筆事件(重新開機後清空)</h2><table><thead><tr><th>秒</th><th>來源</th><th>動作</th><th>結果</th></tr></thead><tbody id="events"></tbody></table></section>
<p><a href="/">回故事控制首頁</a></p></main><script>
const $=id=>document.getElementById(id);function pin(){return $('pin').value}async function api(path){let join=path.includes('?')?'&':'?';let r=await fetch(path+join+'pin='+encodeURIComponent(pin()),{cache:'no-store'});if(!r.ok)throw Error('PIN錯誤或命令失敗');return r}
async function loadData(){try{let r=await api('/api/admin/data'),s=await r.json();$('msg').textContent='';$('state').textContent=s.state;$('track').textContent=s.track;$('volume').textContent=s.volume;$('uptime').textContent=s.uptime;$('title').textContent=s.title;for(let i=0;i<5;i++)$('s'+(i+1)).textContent=s.stories[i];$('buttons').textContent=s.buttonCommands;$('web').textContent=s.webCommands;$('admin').textContent=s.adminCommands;$('switches').textContent=s.switchCount;$('boots').textContent=s.bootCount;$('stops').textContent=s.stopCount;$('errors').textContent=s.jqRecoveries;$('dirty').textContent=s.dirty?'是':'否';$('test').textContent=s.testMode?'開':'關';$('events').innerHTML=s.events.map(e=>`<tr><td>${e.seconds}</td><td>${e.source}</td><td>${e.action}</td><td>${e.result}</td></tr>`).join('')}catch(e){$('msg').textContent=e.message}}
async function act(path){try{await api(path);setTimeout(loadData,250)}catch(e){$('msg').textContent=e.message}}function clearStats(){if(confirm('確定清除故事與操作統計?開機次數與音量會保留。'))act('/api/admin/clear?confirm=YES')}setInterval(()=>{if(pin())loadData()},5000);
</script></body></html>)ADMIN");
}

int queryValue(const String &request, const String &key, int fallback)
{
    int start = request.indexOf(key + "=");
    if (start < 0) return fallback;
    start += key.length() + 1;
    int end = request.indexOf(' ', start);
    return request.substring(start, end).toInt();
}

bool adminAuthorized(const String &request)
{
    return request.indexOf(String("pin=") + ADMIN_PIN) >= 0;
}

String trackTitle(int track)
{
    switch (track) {
        case 1: return "白馬故事";
        case 2: return "烏龜故事";
        case 3: return "姻緣花故事";
        case 4: return "水井三寶總故事";
        case 5: return "創作者資訊";
        case 6: return "開機提示測試";
        case 7: return "準備完成測試";
        case 8: return "錯誤提示測試";
        case 9: return "停止提示測試";
        default: return "";
    }
}

void clearUsageStatistics()
{
    memset(stats.storyCount, 0, sizeof(stats.storyCount));
    stats.buttonCommands = 0;
    stats.webCommands = 0;
    stats.adminCommands = 0;
    stats.stopCount = 0;
    stats.switchCount = 0;
    stats.volumeUpCount = 0;
    stats.volumeDownCount = 0;
    eventHead = 0;
    eventCount = 0;
    addEvent("admin", "清除使用統計", "success");
    savePersistentData("admin clear statistics");
}

void handleWeb()
{
    WiFiClient client = server.available();
    if (!client) return;
    client.setTimeout(100);
    String req = client.readStringUntil('\n');
    req.trim();
    // 高頻status輪詢不輸出Serial,避免塞滿底層訊息佇列。
    if (!req.startsWith("GET /api/status ") &&
        !req.startsWith("GET /api/admin/data?")) {
        Serial.print("[WEB] "); Serial.println(req);
    }

    unsigned long until = millis() + 60;
    while (client.connected() && millis() < until) {
        if (client.available()) {
            String line = client.readStringUntil('\n');
            if (line == "\r" || line.length() == 0) break;
        }
    }

    bool adminApi = req.startsWith("GET /api/admin/");
    if (adminApi && !adminAuthorized(req)) {
        httpHeader(client, "application/json; charset=utf-8", 403);
        client.println("{\"error\":\"invalid admin PIN\"}");
        client.stop();
        delay(10);
        return;
    }

    if (req.startsWith("GET /api/play/horse ")) requestStory(TRACK_HORSE, "白馬故事", "web");
    else if (req.startsWith("GET /api/play/turtle ")) requestStory(TRACK_TURTLE, "烏龜故事", "web");
    else if (req.startsWith("GET /api/play/flower ")) requestStory(TRACK_FLOWER, "姻緣花故事", "web");
    else if (req.startsWith("GET /api/play/all ")) requestStory(TRACK_ALL, "水井三寶總故事", "web");
    else if (req.startsWith("GET /api/play/creator ")) requestStory(TRACK_CREATOR, "創作者資訊", "web");
    else if (req.startsWith("GET /api/stop ")) requestStop("web");
    else if (req.startsWith("GET /api/volume/up ")) requestVolume(VOL_STEP, "web");
    else if (req.startsWith("GET /api/volume/down ")) requestVolume(-VOL_STEP, "web");
    else if (req.startsWith("GET /api/volume/set?")) {
        int target = constrain(queryValue(req, "value", currentVolume), VOL_MIN, VOL_MAX);
        int delta = target - currentVolume;
        if (delta != 0) requestVolume(delta, "web");
    }
    else if (req.startsWith("GET /api/admin/play?")) {
        int track = queryValue(req, "track", 0);
        String title = trackTitle(track);
        if (track >= 1 && track <= 9) requestStory(track, title, "admin");
    }
    else if (req.startsWith("GET /api/admin/stop?")) requestStop("admin");
    else if (req.startsWith("GET /api/admin/volume-up?")) requestVolume(VOL_STEP, "admin");
    else if (req.startsWith("GET /api/admin/volume-down?")) requestVolume(-VOL_STEP, "admin");
    else if (req.startsWith("GET /api/admin/reset-player?")) {
        recordCommand("admin", "重設JQ6500");
        beginInterrupt(0, "", false);
    }
    else if (req.startsWith("GET /api/admin/save?")) {
        addEvent("admin", "立即保存", "success");
        savePersistentData("admin manual save");
    }
    else if (req.startsWith("GET /api/admin/toggle-test?")) {
        testMode = !testMode;
        addEvent("admin", testMode ? "開啟測試模式" : "關閉測試模式", "success");
    }
    else if (req.startsWith("GET /api/admin/clear?") &&
             req.indexOf("confirm=YES") >= 0) clearUsageStatistics();

    if (adminApi) sendAdminData(client);
    else if (req.startsWith("GET /api/")) sendStatus(client);
    else if (req.startsWith("GET /admin ")) sendAdminPage(client);
    else if (req.startsWith("GET / ")) sendPage(client);
    else { httpHeader(client, "text/plain", 404); client.println("Not found"); }
    client.stop();
    // Ameba WiFi底層需要短暫時間釋放socket與RTOS queue資源。
    delay(10);
}

// ---------------------------------------------------------------------
// AP:單次嘗試、立即返回。網路失敗不改變播放器狀態。
// ---------------------------------------------------------------------
bool tryStartAPOnce()
{
    Serial.println("[AP] Non-blocking single attempt");
    int status = WiFi.apbegin(AP_SSID, AP_PASS, AP_CHANNEL, AP_HIDDEN);

    if (status != WL_CONNECTED) {
        wifiReady = false;
        Serial.print("[AP] Not ready, status=");
        Serial.println(status);
        Serial.println("[AP] Buttons and audio remain available");
        return false;
    }

    wifiReady = true;
    // AmebaPro2 WiFiServer預設為BLOCKING_MODE。
    // 若沒有手機連線,server.available()會阻塞整個loop;
    // 必須在begin前切換為NON_BLOCKING_MODE,確保按鈕與BUSY持續更新。
    server.setNonBlockingMode();
    server.begin();
    Serial.print("[AP] SSID: "); Serial.println(AP_SSID);
    Serial.print("[WEB] http://"); Serial.println(WiFi.localIP());
    Serial.print("[ADMIN] http://"); Serial.print(WiFi.localIP()); Serial.println("/admin");
    return true;
}

void setup()
{
    Serial.begin(115200);
    Serial2.begin(9600);
    delay(1000);

    for (int i = 0; i < BUTTON_COUNT; i++) {
        pinMode(buttonPins[i], INPUT_PULLUP);
        rawButton[i] = stableButton[i] = digitalRead(buttonPins[i]);
        buttonChangedAt[i] = millis();
    }
    pinMode(JQ_BUSY_PIN, INPUT_PULLUP);
    lastBusy = digitalRead(JQ_BUSY_PIN);
    pinMode(LED_G, OUTPUT); pinMode(LED_B, OUTPUT);

    Serial.println("================================================");
    Serial.println("HUB 8735 Ultra + JQ6500 Story Player V1.0-08");
    Serial.println("Local Statistics + Web Admin + Flash Batch Save");
    Serial.println("================================================");

    loadPersistentSettings();
    addEvent("system", "系統開機", "success");

    // AP只嘗試一次;無論成功或失敗,音訊與實體按鈕都必須啟動。
    tryStartAPOnce();

    jqSetVolume(currentVolume, false);
    jqSetOneStopMode();
    busyIdleLevel = digitalRead(JQ_BUSY_PIN);
    lastBusy = busyIdleLevel;
    Serial.print("[BUSY] Calibrated idle level = ");
    Serial.println(busyIdleLevel);
    playDirect(TRACK_BOOT, "系統啟動中", STATE_BOOT_PROMPT);

    // 從本次嘗試完成後重新計算15秒重試週期。
    lastApRetryAt = millis();
}

void loop()
{
    // 最高優先:實體按鈕。即使AP完全失效仍持續掃描。
    handleButtons();

    // 第二、第三優先:JQ6500 BUSY與播放器狀態機。
    updateBusy();
    updatePlayerStateMachine();

    // 最低優先:Web與AP恢復。
    if (wifiReady) {
        handleWeb();
    } else if (millis() - lastApRetryAt >= 15000) {
        lastApRetryAt = millis();
        tryStartAPOnce();
    }

    updateLed();
    updatePersistentSettings();
    delay(3);
}

十二、結語

V1.0-08讓水井三寶故事機從「可以互動」再前進到「可以營運」。文化故事不只被播放,也開始留下匿名的使用趨勢;管理者不必接上Arduino IDE,就能用手機確認設備狀態、測試曲目與處理播放器。

更重要的是,所有新增功能都遵守同一原則:網路不能阻塞按鈕、統計不能干擾聲音、Flash不能破壞即時操作。這是故事機能從桌面實驗走向地方展場的真正關鍵。