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

2026年8月22日 星期六

Wi-Fi連上了, 為何資料還是送不到?

封包旅行記 EP03|分層找故障

Wi-Fi連上了,
為何資料還是送不到?

ESP32 顯示 WL_CONNECTED,只代表它已經加入無線基地台,不代表資料已抵達 Django。真正的連線,要連續通過「Wi-Fi → IP → Gateway → DNS → TCP/TLS → HTTP → 應用程式」七道關卡。

一、先破解最常見的誤會

Wi-Fi connected ≠ Internet available ≠ Server reachable ≠ Data accepted
「連上 Wi-Fi」是第一關成功;只要後面任何一層失敗,Django 儀表板仍然看不到資料。
1Wi-Fi連上 AP
2IP取得位址
3Gateway走出區網
4DNS找到伺服器
5TCP/TLS建立通道
6HTTP/App接受資料

診斷原則:不要一看到「資料沒上傳」就立刻改程式。由下往上逐層驗證,先找到第一個失敗點。

二、Wi-Fi連線只證明了什麼?

已經證明

SSID、密碼與無線訊號大致可用;ESP32 已與基地台完成關聯。

還沒證明

是否取得正確 IP、Subnet、Gateway、DNS,網路是否能通往外部。

更沒證明

Django 網址、Port、憑證、API 路徑、權杖、JSON 格式與資料庫都正確。

先把 ESP32 的網路身分印出來

#include <WiFi.h>

void printNetworkInfo() {
  Serial.println("=== NETWORK INFO ===");
  Serial.print("SSID: ");    Serial.println(WiFi.SSID());
  Serial.print("IP: ");      Serial.println(WiFi.localIP());
  Serial.print("Subnet: ");  Serial.println(WiFi.subnetMask());
  Serial.print("Gateway: "); Serial.println(WiFi.gatewayIP());
  Serial.print("DNS: ");     Serial.println(WiFi.dnsIP());
  Serial.print("RSSI: ");    Serial.println(WiFi.RSSI());
}

若 IP 是 0.0.0.0,表示尚未完成網路配置;若是 169.254.x.x,通常代表 DHCP 沒有成功提供位址。RSSI 約 -30 dBm 很強、-67 dBm 尚可,低於 -75 dBm 時傳輸容易不穩。

三、從區域網路到 Django:封包實際走哪裡?

ESP32→Wi-Fi AP/Router→NAT/Internet→Web Server→Django URL→View/Database
關卡封包需要知道常見失敗
Wi-FiSSID、密碼、訊號密碼錯、2.4/5 GHz 不相容、訊號弱
IP/Subnet本機 IP 與同網段判斷DHCP 失敗、固定 IP 衝突、Subnet 設錯
Gateway/NAT跨網段的下一跳Gateway 錯、訪客網路隔離、路由器無外網
DNS網域名稱對應的 IPDNS 未配置、解析失敗、網址拼錯
TCP/TLS目標 Port 與安全連線Port 關閉、防火牆阻擋、時間或憑證錯誤
HTTPMethod、URL、Header、BodyPOST/GET 用錯、路徑錯、JSON 或 Content-Type 錯
Django路由、驗證、資料模型404、403、CSRF、500、欄位驗證失敗

四、七層診斷法:先找第一個失敗點

① 無線層

狀態WiFi.status() == WL_CONNECTED
訊號觀察 RSSI 是否持續過低或大幅波動。

② IP 配置層

確認 IP、Subnet、Gateway、DNS 不是空值;檢查固定 IP 是否與其他設備重複。

③ 本地網路層

用同一 Wi-Fi 的電腦或手機測試 Router 與區網伺服器。若訪客 Wi-Fi 開啟 Client Isolation,設備彼此可能不能連線。

④ DNS 層

先用 IP 測,再用網域測。IP 可通、網域不通,問題多半在 DNS;不要把 https:// 一起當作主機名稱解析。

⑤ TCP/TLS 層

確認 Port:HTTP 常用 80,HTTPS 常用 443。HTTPS 還要檢查 ESP32 時間、CA 憑證與 TLS 相容性。

⑥ HTTP 層

序列埠必須印出完整 URL、HTTP 回應碼與回應內容。只有「send failed」不足以定位問題。

⑦ Django 應用層

同步查看伺服器 log。請求有進來但回 4xx/5xx,代表網路大致已通,問題已經移到 API 或程式端。

⑧ 儀表板與資料庫

API 回 200/201 仍未顯示時,檢查資料是否真正寫入、查詢條件、時區、快取與前端刷新。

五、HTTP回應碼就是伺服器留下的線索

回應代表意義優先檢查
200 OK/201 Created請求成功或資料已建立若畫面仍沒資料,檢查資料庫與前端查詢
301/302網址被重新導向HTTP→HTTPS、尾端斜線、網域設定
400 Bad Request請求內容無法解析JSON、必填欄位、資料型別
401/403身分驗證或權限失敗API Key、Token、CSRF、權限
404 Not Found找不到 API 路徑Django urls.py、版本路徑、尾斜線
405 Method Not AllowedHTTP 方法不被接受伺服器要 POST,裝置卻送 GET
415 Unsupported Media Type資料格式宣告不符Content-Type: application/json
500/502/503應用程式或伺服器端異常Django log、反向代理、服務狀態
-1 或連線逾時常是 HTTP 之前就失敗DNS、TCP、TLS、Port、防火牆

六、ESP32上傳時,至少留下這些診斷資訊

#include <HTTPClient.h>

void postEvent(const String& json) {
  const char* url = "https://example.org/api/events/";
  HTTPClient http;

  Serial.print("POST URL: "); Serial.println(url);
  Serial.print("WiFi RSSI: "); Serial.println(WiFi.RSSI());

  if (!http.begin(url)) {
    Serial.println("HTTP begin failed");
    return;
  }

  http.addHeader("Content-Type", "application/json");
  int code = http.POST(json);
  Serial.print("HTTP code: "); Serial.println(code);

  if (code > 0) {
    Serial.print("Response: "); Serial.println(http.getString());
  } else {
    Serial.print("Transport error: ");
    Serial.println(http.errorToString(code));
  }
  http.end();
}

上例用 example.org 作教材占位網址。實際部署 HTTPS 時,應依函式庫版本正確設定 CA 憑證與系統時間;不要為了省事而長期停用憑證驗證。

離線佇列很重要:IoT 裝置不應假設網路永遠在線。每筆事件加入唯一 event_uuid,失敗時先保存,恢復連線後再重送;伺服器依 UUID 去除重複資料。

七、最有效率的交叉測試

測試結果可推論的範圍
手機使用同一 Wi-Fi 也打不開 API先查 Router、DNS、伺服器或 API,不急著改 ESP32
手機可用,ESP32 不行查 ESP32 的 DNS、TLS、時間、憑證、記憶體與程式
用 IP 可連,用網域不行DNS 解析問題
HTTP 可用,HTTPS 不行TLS、CA 憑證、系統時間或 SNI
API 收得到,但回 400/403/404網路已走到應用層,查 Header、Token、路徑與資料格式
API 回 201,但儀表板沒顯示查資料庫、查詢條件、時區與前端刷新

八、場域版故障排除順序

  1. 記錄現象:時間、裝置 ID、IP、RSSI、目標網址、HTTP code。
  2. 確認影響範圍:一台裝置、同一基地台,還是所有場域都失敗?
  3. 由下往上測:Wi-Fi → DHCP → Gateway → DNS → TCP/TLS → HTTP → Django。
  4. 做單一變因測試:不要同時換 Wi-Fi、改網址又重寫程式。
  5. 恢復後驗證:即時資料與離線佇列是否都完成補送,是否產生重複事件。

好系統不只是「正常時會動」:還要在斷網時保留資料、恢復時自動重送、出錯時留下足以診斷的證據。

九、學生實作:同一問題,三種學習層次

No-AI|人工診斷

抄錄 ESP32 的 IP、Subnet、Gateway、DNS、RSSI與 HTTP code;依七層表判斷第一個失敗點,畫出封包停在哪裡。

AI Pair|協作除錯

把「去識別化後」的序列埠紀錄與 Django log 交給 AI,要求它提出三個假設、每個假設的驗證方法,以及不可直接下結論的原因。

Challenge AI|場域韌性

設計斷網佇列、指數退避重試、event_uuid 去重與狀態儀表板;實測關閉 Wi-Fi 5 分鐘後能否完整補送。

十、學習檢核

我能做到完成證據
說明 Wi-Fi connected 與資料送達的差異能指出封包必須通過的七個關卡
讀懂 ESP32 的網路配置能解釋 IP、Subnet、Gateway、DNS與RSSI
利用 HTTP code 定位問題能區分傳輸錯誤、用戶端錯誤與伺服器錯誤
完成可重現的故障報告包含時間、環境、步驟、log、假設、測試與結果
設計斷線不漏資料的 IoT 裝置具備佇列、重試、去重與恢復驗證
封包旅行記 EP03|「連上」只是狀態;能逐層驗證、找到第一個失敗點,才是真正的網路能力。

192.168.x.x是什麼? DHCP、Subnet、Gateway與NAT

封包旅行記 EP02|區域網路配置

192.168.x.x是什麼?
DHCP、Subnet、Gateway與NAT

ESP32顯示「Wi-Fi connected,IP=192.168.1.119」之後,這串數字代表什麼?它如何知道誰在同一個網路、誰必須交給Gateway,又如何透過NAT走向Internet?

Private IPDHCPSubnetGatewayARPNAT/PAT

192.168.1.119不是Internet上的地址嗎?

當ESP32、手機或電腦連上家中、學校或展場Wi-Fi後,常會得到192.168.x.x。它是區域網路中的「私有IPv4位址」,可以在內部重複使用,但不會直接在全球Internet上被路由。

最重要的觀念:192.168.1.119只說明ESP32在目前LAN中的位置;要前往Django網站,仍需知道Subnet、Gateway、DNS,並由Router執行NAT。

學習目標

看懂位址

區分私有IP、公網IP、Network Address、Host Address與Broadcast。

判斷路徑

使用Subnet Mask判斷目的地在同一LAN,還是必須交給Gateway。

解釋出網

說明DHCP給了什麼,以及NAT/PAT如何讓多台裝置共用一個公網IP。

先看一個智慧場域網路

ESP32-A192.168.1.119
ESP32-B192.168.1.120
手機192.168.1.50
RouterLAN:192.168.1.1
InternetDjango Cloud

在這個例子中,三台終端裝置都位於192.168.1.0/24。它們彼此通訊時留在LAN內;要連Django、DNS或其他外部服務時,則把封包交給192.168.1.1。

一、192.168.x.x是私有IPv4位址

IPv4位址由32個bit組成,通常用四個0~255的十進位數字表示。IANA保留三段IPv4範圍供私有網路使用:

私有範圍CIDR常見場景
10.0.0.0 ~ 10.255.255.25510.0.0.0/8大型組織、雲端VPC、校園網路
172.16.0.0 ~ 172.31.255.255172.16.0.0/12企業、容器與內部服務
192.168.0.0 ~ 192.168.255.255192.168.0.0/16家庭、教室、小型場域與IoT

私有IP

只在內部網路使用,可由不同家庭或場域重複使用。例如很多人的Router都可能是192.168.1.1。

公網IP

在Internet上具有全球可路由性,通常由ISP提供給Router或雲端伺服器。

私有IP不等於安全。即使裝置不能直接從Internet被路由,區域網路內仍可能有未授權存取;Router設定、Wi-Fi密碼、服務驗證、防火牆與更新仍然必要。

二、DHCP:自動發放網路設定

ESP32加入Wi-Fi後,通常不必手動輸入IP。DHCP Server(多半位於Router)透過DORA流程協助裝置取得租約。

Discover有人可以給我IP嗎?
Offer可以使用這組設定
Request我要接受這個租約
Acknowledge租約成立

DHCP不只給IP

DHCP提供範例功能
IP Address192.168.1.119ESP32在目前LAN中的位址
Subnet Mask255.255.255.0分辨Network與Host部分
Default Gateway192.168.1.1通往其他網路的預設出口
DNS Server192.168.1.1或外部DNS把Domain解析成IP
Lease Time例如24小時這組設定可使用多久
ESP32輸出網路設定
#include <WiFi.h>

void printNetworkInfo() {
  Serial.println("===== NETWORK INFO =====");
  Serial.print("IP      : "); Serial.println(WiFi.localIP());
  Serial.print("MASK    : "); Serial.println(WiFi.subnetMask());
  Serial.print("GATEWAY : "); Serial.println(WiFi.gatewayIP());
  Serial.print("DNS     : "); Serial.println(WiFi.dnsIP());
  Serial.print("RSSI    : "); Serial.println(WiFi.RSSI());
}
教學建議:每一篇ESP32連網文章都應固定輸出IP、Mask、Gateway、DNS與RSSI。這五項資訊是分層診斷的起點。

三、Subnet:誰和我在同一個網路?

IPv4位址同時包含「Network部分」與「Host部分」。Subnet Mask用1標示Network bit,用0標示Host bit。最常見的:

255.255.255.0 = /24 = 前24個bit是Network,後8個bit是Host
Network:192.168.1
Host:119
項目192.168.1.119/24的結果用途
Network Address192.168.1.0代表整個Subnet,不分配給一般Host
可用Host範圍192.168.1.1 ~ 192.168.1.254Router、ESP32、手機與電腦可使用
Broadcast Address192.168.1.255傳送給該Subnet內的所有Host
總位址數2562^(32-24)
一般可用Host數254扣除Network與Broadcast

用AND運算找Network Address

設備會把自己的IP或目的IP與Subnet Mask做bitwise AND:

IP:    192.168.1.119
Mask:  255.255.255.0
AND:   192.168.1.0   ← Network Address

若ESP32與目的設備計算出的Network Address相同,就在同一個Subnet;不同則交給Gateway。

ESP32目的位址/24判斷下一步
192.168.1.119192.168.1.50同為192.168.1.0留在LAN,先用ARP找目的MAC
192.168.1.119192.168.2.50Network不同交給Default Gateway
192.168.1.119Django公網IPNetwork不同交給Default Gateway

四、Gateway:通往其他網路的出口

取得目的IP→套用Subnet Mask→比較Network→同Subnet:直接傳/不同Subnet:交給Gateway

Default Gateway通常是Router的LAN位址,例如192.168.1.1。ESP32要連Django時,IP Packet的目的IP仍是Django Server;但目前這一跳的Wi-Fi/Ethernet Frame會先送到Gateway的MAC。

常見誤解:ESP32不是把「目的IP改成Gateway」。IP目的地仍是遠端伺服器;Gateway只是下一跳的設備。

ARP在這裡做什麼?

ESP32知道Gateway的IP,卻還不知道它在目前LAN中的MAC,因此發出ARP Request詢問:「誰是192.168.1.1?」Router回覆MAC後,ESP32才能建立這一跳的Frame。

五、NAT/PAT:讓私有IP走向Internet

私有IP不能直接在Internet上被路由。Router執行Source NAT,並常搭配Port Address Translation,讓多台內部裝置共用一個公網IP。

192.168.1.119:49152→ NAT/PAT →203.0.113.20:62001
192.168.1.120:49153→ NAT/PAT →203.0.113.20:62002
192.168.1.50:51000→ NAT/PAT →203.0.113.20:62003

上例的203.0.113.20是文件示意用公網IP。Router建立一張暫時對照表,讓Django回應到達後,可以交回正確的ESP32或手機。

位置來源目的
ESP32送往Router前192.168.1.119:49152Django-IP:443
Router送往Internet後Public-IP:62001Django-IP:443
Django回應Django-IP:443Public-IP:62001
Router交回LANDjango-IP:443192.168.1.119:49152

NAT不等於Port Forwarding

一般出站NAT讓內部設備主動連出去;Port Forwarding則把外部進站連線轉交給指定內部設備,風險與用途不同。

NAT不等於防火牆

NAT改寫位址;防火牆依規則允許或阻擋流量。兩者常在同一台Router上,但功能不同。

六、DHCP與固定IP如何選?

方式優點風險/注意適合情境
一般DHCP設定簡單、自動避免多數衝突租約更新後IP可能改變手機、筆電、一般ESP32 Client
DHCP Reservation由Router依MAC固定發同一IP需管理MAC與Router設定場域Gateway、Dashboard、Home Assistant
裝置手動Static IP不依賴DHCP租約容易設定錯Mask/Gateway/DNS或造成IP衝突封閉、受管理且有完整清冊的網路
場域建議:ESP32主動向Django送資料時,多半不需要固定IP;若其他設備要主動連回ESP32或本地Server,優先考慮DHCP Reservation,較方便集中管理。

七、常見錯誤與分層診斷

Wi-Fi→DHCP→IP→Mask→Gateway→DNS→NAT/Internet
現象可能原因取得證據
IP顯示0.0.0.0尚未連線、DHCP失敗或狀態讀取太早WiFi.status、連線時間、DHCP Server
IP是169.254.x.x未取得正常DHCP租約,設備使用Link-localRouter DHCP服務、租約數量與網路阻擋
同LAN裝置無法互通Mask錯誤、AP Isolation、防火牆或IP衝突IP/Mask比較、ARP、Router無線隔離設定
能連Gateway,不能用DomainDNS設定或解析失敗DNS IP、Domain解析結果
能解析Domain但無法連DjangoInternet、Routing、Port、TLS或Server問題目的IP、TCP連線、TLS錯誤與HTTP狀態
偶爾換一個IP後系統失效程式依賴動態IP或租約改變DHCP Lease、Router紀錄、是否應用Reservation
兩台設備時好時壞IP衝突裝置清冊、ARP表、Router租約與MAC
不要只寫「網路不通」。診斷紀錄至少要包含:本機IP、Mask、Gateway、DNS、RSSI、目的Domain、解析IP、失敗時間與錯誤碼。

八、場域網路規劃範例

水井三寶展覽、智慧養殖或慢食平台若逐步增加設備,建議先建立網路與裝置清冊。

類型建議位址方式範例管理重點
Router/Gateway固定192.168.10.1管理密碼、韌體、防火牆、備份
本地Server/Home AssistantDHCP Reservation192.168.10.10服務Port、備份、健康檢查
ESP32感測節點DHCP192.168.10.101~150Device ID、MAC、場域位置、韌體版本
管理者手機/筆電DHCP192.168.10.51~100帳號、權限與訪客網路分離
訪客獨立Guest Network192.168.20.0/24禁止直接存取IoT與管理設備
網路規劃不是只分IP:還要處理SSID、頻段、訊號覆蓋、訪客隔離、裝置身分、更新、金鑰、資料回傳、離線機制與維運責任。

三項學生任務

No-AI|找出我的網路

記錄電腦或ESP32的IP、Mask、Gateway、DNS,計算Network、Broadcast與可用Host範圍。

AI Pair|比較三個目的IP

讓AI協助判斷哪些位於同Subnet,再由學生用AND運算驗證並修正AI解釋。

Challenge|網路故障闖關

教師設定錯誤Mask、錯誤Gateway、錯誤DNS或重複IP,學生依證據定位並完成修復報告。

學習檢核

問題應回答的核心
為什麼不同家庭都能使用192.168.1.1?它是私有IP,只在各自LAN內有意義,可重複使用。
DHCP只負責分配IP嗎?還提供Mask、Gateway、DNS、Lease等設定。
192.168.1.119/24的Network與Broadcast是什麼?192.168.1.0與192.168.1.255。
為什麼連Django要交給Gateway?Django的目的IP不在本機Subnet。
NAT改變什麼?出站封包的私有來源IP/Port被轉成公網IP/Port,並建立回程對照。
NAT是否等於防火牆?不是;NAT做位址轉換,防火牆依安全規則管控流量。

192.168.x.x,是智慧場域走向Internet的起點

DHCP讓裝置取得設定;Subnet決定目的地是否在同一個LAN;Gateway提供通往其他網路的出口;NAT/PAT則把私有位址轉換成可在Internet上回應的連線。

看懂IP,不是記住四個數字,而是能判斷「我是誰、誰和我同網、下一跳要交給誰、如何走到雲端」。

系列定位:《封包旅行記》EP02。建議先閱讀EP01《一個封包如何從ESP32走到Django?》。本文適用於大學部網際網路應用、物聯網與智慧生活、ESP32聯網、Django雲端平台及USR場域專題。文中的公網IP 203.0.113.20屬文件示意用途,不代表實際服務位址。

一個封包如何從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與區域網路

出錯時,從哪裡開始查?

Power→Device→Wi-Fi→IP→Gateway→DNS→TCP/TLS→HTTP→API→Database
現象可能層級應取得的證據
無法取得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月20日 星期四

JQ6500 STOP 爆音除錯實錄

JQ6500 STOP 爆音除錯實錄

從「按 STOP 後無法續播」到 V1.8.8 Reset Only+Deferred Re-init

水井三寶智慧互動展覽系統|ESP32 × JQ6500 × Web Control × Debugging

在「水井三寶智慧互動展覽系統」中,JQ6500 負責播放白馬、烏龜、姻緣花等故事。系統原本在故事自然播放完畢後,可以正常切換到下一個故事;但只要使用手機 Web 按下 STOP,中途停止播放,再選另一個故事,就會出現「無法再次播放」的問題。更麻煩的是,後續雖然成功解決續播問題,STOP 時卻又出現明顯的「啵、啵」兩聲爆音。這篇文章完整記錄這次除錯過程,以及最後得到的工程經驗。

一、最初的問題:自然播完正常,STOP 後卻無法續播

最早觀察到的現象非常明確:

情況 A 白馬播放 ↓ 自然播放完畢 ↓ 再按烏龜 ↓ 正常播放 情況 B 白馬播放 ↓ 手機按 STOP ↓ 故事停止 ↓ 再按烏龜 ↓ 沒有正常播放 ↓ 必須斷電重開

這表示問題不在一般播放流程,而是在「人工中止」之後,JQ6500 的內部狀態沒有回到可重新播放的狀態。

二、第一個錯誤認知:把 0x0E 當成 STOP

早期程式使用:

0x7E, 0x02, 0x0E, 0xEF

原本把它命名為:

jqStop();

後來重新檢查指令語意後發現,0x0E 實際上是 PAUSE,不是真正的 STOP。這正好解釋為什麼人工停止後,播放器可能停留在 PAUSED 狀態,下一次使用 Play-by-index 指令時不一定能正常重新起播。

第一個重要經驗:
裝置通訊最怕「指令名稱寫得像真的」。如果函式叫 jqStop(),開發者很容易直接相信它就是 Stop,而忽略真正協定中的語意。

三、V1.8.5:改用 RESET 解決無法續播

後來改用 JQ6500 的 RESET 指令:

0x7E, 0x02, 0x0C, 0xEF

流程改成:

PAUSE ↓ RESET ↓ Select Flash ↓ Restore Volume ↓ READY

這一版最大的成果是:STOP 後終於可以正常再播放下一個故事。

但新的問題出現了:

按下 STOP 時,喇叭會明顯聽到「啵、啵」兩聲。

四、V1.8.6:先把音量設成 0,仍然沒有解決

第一個直覺是:會不會是 JQ6500 音量太大,所以 RESET 的瞬態被放大?

因此嘗試:

Volume → 0 ↓ Pause ↓ Reset ↓ Select Flash ↓ Restore Volume

程式另外設計了 Raw Volume Control,讓 STOP 過程中的暫時音量 0 不會改動手機設定,也不會寫入 NVS。

void jqSendVolumeRaw(uint8_t volume)
{
  // 只送命令
  // 不修改 currentVolume
  // 不寫入 NVS
}

但實測結果仍然是:

「啵、啵」兩聲依舊明顯。

這個結果非常重要,因為它告訴我們:問題不是數位音量參數本身。

五、V1.8.7:拿掉 PAUSE,只保留 RESET

接下來進一步做變因隔離。

如果兩聲分別是:

PAUSE → 啵 RESET → 啵

那拿掉 PAUSE 後,理論上應該只剩一聲。

因此 STOP 改成:

RESET ↓ Select Flash ↓ Volume = 0 ↓ Restore Volume

結果卻仍然是:

「啵、啵」兩聲。

這表示「第一聲=Pause、第二聲=Reset」這個推論並不成立。

六、V1.8.8:STOP 時真的只做 RESET

為了徹底排除後續初始化造成的影響,V1.8.8 採用最乾淨的實驗方式:

STOP 階段: RESET 0x0C ↓ jqNeedsRearm = true ↓ READY ★ 不 Select Flash ★ 不送 Volume=0 ★ 不 Restore Volume

真正的初始化全部延後到下一次使用者按 PLAY 時:

下一次 PLAY: 偵測 jqNeedsRearm ↓ Select Flash ↓ Restore Volume ↓ PLAY

這個版本有兩個重要結果:

測試項目結果
STOP 後再選下一個故事✅ 正常播放
STOP 當下爆音⚠️ 仍然是「啵、啵」兩聲

七、最後可以得到什麼結論?

經過 V1.8.5 → V1.8.8 的逐步排除,可以相當有把握地判斷:

這兩聲爆音不是 Web 控制、狀態機、NVS 音量、Select Flash 或 Restore Volume 造成,而是 JQ6500 在 RESET 過程中,內建 DAC/功放輸出級工作點改變所產生的硬體瞬態。

目前系統是 JQ6500 直接推喇叭,沒有使用外部功放:

JQ6500 │ SPK+ / SPK- │ ▼ Speaker

因此 RESET 時內部音訊級的關閉與重新建立偏壓,都直接反映到 Speaker 上。

八、為什麼會出現兩聲?

雖然 STOP 程式只有送一次 RESET,但 JQ6500 內部在 Reset 過程可能經歷:

內部功放 / DAC 關閉 ↓ 輸出工作點改變 ↓ 第一聲「啵」 ↓ 內部模組重新啟動 ↓ 偏壓重新建立 ↓ 第二聲「啵」

也就是說,兩聲未必需要兩個外部命令;一個 RESET 本身就可能包含兩個類比輸出瞬態。

九、這次除錯最重要的方法:一次只改一個變因

這次能真正找到原因,不是因為一次改很多程式,而是把可能因素逐步拆開:

版本STOP策略結果
V1.8.5Pause → Reset → Init可續播,但啵、啵
V1.8.6Volume 0 → Pause → Reset仍啵、啵
V1.8.7Reset → Init仍啵、啵
V1.8.8STOP 只 Reset;Init 延後到 PLAY仍啵、啵
第二個重要經驗:
除錯不是一直「加功能」,而是要有意識地做變因隔離。每一版只改一個關鍵行為,才能知道真正是哪一個因素造成結果。

十、什麼問題已經解決?什麼問題還沒解決?

項目目前狀態
自然播放完畢後再播下一首✅ 正常
手機中途 STOP✅ 正常停止
STOP 後再播下一首✅ V1.8.8 已正常
手機 Realtime Status✅ 正常
手機音量控制✅ 正常
音量寫入 NVS✅ 關機重開可保留
姻緣花 24 顆柔和彩虹旋轉✅ 正常
STOP 爆音⚠️ 還有「啵、啵」兩聲

十一、目前最合理的工程判斷

在功能已穩定的前提下,繼續修改 delay 或音量命令,效益已經很低。

目前最合理的判斷是:

V1.8.8 可視為目前穩定軟體版。

STOP 爆音屬於 JQ6500 直接驅動 Speaker 時,RESET 造成的類比輸出瞬態。若要完全消除,下一步應從硬體音訊架構處理,而不是持續修改 Web 或狀態機。

十二、未來真正要消除爆音,應該怎麼做?

如果正式展覽版要完全消除爆音,可以把音訊路徑改成:

JQ6500 DAC / Line Out ↓ 具有 MUTE / SHDN 的外部功放 ↓ Speaker

STOP 時先:

AMP MUTE ↓ JQ6500 RESET ↓ 等待穩定 ↓ AMP UNMUTE

這樣 JQ6500 即使仍然產生 RESET transient,也不會直接送到 Speaker。

目前尚未接外部功放,因此現階段不要誤以為「再加一個 delay」就能根治爆音。真正的解法應該是建立硬體 MUTE 點。

十三、這個案例對《網際網路應用》課程有什麼價值?

表面上看,這只是「STOP 後有爆音」的硬體問題;但實際上,它很適合讓學生理解完整系統除錯。

層級這次檢查的內容
Web手機是否真的送出 STOP Request
ESP32 ApplicationhandleStop() 與 State Machine 是否正確
UARTJQ6500 收到的是 Pause 還是 Reset
Device StateReset 後是否可以重新播放
Audio Hardware爆音是否來自 DAC/功放輸出瞬態

也就是說,一個看似簡單的 STOP 按鈕,其實橫跨:

Browser ↓ HTTP ↓ ESP32 Web Handler ↓ State Machine ↓ UART Command ↓ JQ6500 ↓ Audio Output ↓ Speaker

十四、從這次經驗學到的五件事

1. 指令語意一定要回到協定本身。
函式名稱叫 Stop,不代表實際送出的命令真的是 Stop。
2. 自然結束和人工中止,是兩條不同狀態路徑。
不能因為自然播放正常,就假設 STOP 後也會進入相同狀態。
3. 音量 0 不等於類比輸出真正靜音。
RESET 時 DAC/功放的工作點仍可能產生 transient。
4. 除錯要一次只改一個變因。
Pause、Reset、Volume、Select Flash、Restore Volume 必須逐項隔離。
5. 軟體問題解到最後,可能會證明其實是硬體特性。
這時應該停止一直改程式,改從系統架構處理。

十五、結語:失敗的版本也是教材

從 V1.8.5 到 V1.8.8,表面上像是在「反覆修改 STOP」,但真正累積的是一套非常有價值的工程判斷過程。

我們先解決「STOP 後不能再播放」,再逐步確認爆音不是音量、不是 Pause、不是 Select Flash、也不是重新初始化造成,最後把問題定位到 JQ6500 本身 RESET 時的音訊輸出瞬態。

真正有價值的不是某一版程式,而是知道:為什麼要改、改了什麼、結果如何、下一步該往哪裡查。

這也是「水井三寶智慧互動展覽系統」作為大學《網際網路應用》與 IoT 實作教材的重要價值:學生看到的不只是一個成功作品,而是一個真實系統如何一步一步從問題、測試、假設、修正走向穩定。

JQ6500 ESP32 STOP RESET UART Pop Noise Debugging Realtime Web IoT 水井三寶 USR

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月16日 星期日

[水井村USR]WS2812B讓水井三寶會發光:ESP32燈光控制原理、接線與程式實作

WS2812B讓水井三寶會發光:ESP32燈光控制原理、接線與程式實作

水井三寶智慧互動展覽系統|智慧互動層燈光控制專題

在水井三寶智慧互動展覽系統中,聲音負責說故事,燈光則讓觀眾「看見故事正在發生」。這篇把WS2812B燈光控制獨立整理,從工作原理、接線、LED分區,到白馬追光、烏龜呼吸與姻緣花綻放的程式邏輯,完整做成可直接上課與實作的教材。

一、WS2812B是什麼?

WS2812B是一種可定址RGB LED,每顆LED內部整合驅動控制電路。多顆串接時,只需要一條DATA線,就能逐顆指定顏色與亮度。Arduino端使用Adafruit_NeoPixel函式庫,水井三寶目前採 NEO_GRB + NEO_KHZ800,由ESP32 GPIO13控制62顆LED。

ESP32 DATA ↓ LED #0 → LED #1 → LED #2 → … → LED #61 每一顆取走自己的顏色資料,再把後續資料傳給下一顆

二、正式接線方式

ESP32 × WS2812B 正式接線圖|水井三寶 V1.7 外接 5V 電源 +5VGND ESP32 GPIO13 GND 邏輯電平轉換 74AHCT125 等 330–470Ω WS2812B 5V DIN GND 500–1000µF 62顆 LED 分區 0–23:底座 24顆 24–33:白馬 10顆 34–45:烏龜 12顆 46–61:姻緣花 16顆
展覽版電源原則:LED燈條使用獨立5V電源;ESP32與燈條必須共地。DATA進入第一顆LED前建議串接300~500Ω電阻,電源端可加500~1000µF電容。ESP32是3.3V邏輯,5V燈條的長期展覽版建議加入適當的邏輯電平轉換器。

三、62顆LED不是一整條,而是四個燈區

燈區LED編號數量角色
底座0–2324待機與環境柔光
白馬24–3310暖金追光
烏龜34–4512水藍呼吸
姻緣花46–6116粉紅漸次綻放

四、燈光控制不是裝飾,而是故事的第二語言

故事燈效意義
001 白馬暖金追光向前、行動、奔跑感
002 烏龜藍色呼吸沉穩、守水、守護土地
003 姻緣花粉紅逐顆點亮花朵慢慢綻放
004 水井三寶總故事三角色輪替白馬→烏龜→姻緣花依序出場
005 創作者資訊三區共同暖光溫暖、穩定、不搶作品主體

五、為什麼白馬用追光?

白馬的主光點會沿著10顆LED移動,後方保留較暗的尾光,因此不是整區同時亮,而是形成「奔跑」的速度感。

六、為什麼烏龜用呼吸燈?

烏龜燈區使用藍色,亮度從低到高、再由高到低反覆變化,形成穩定呼吸感。這比快速閃爍更適合「守水、沉穩、長久陪伴土地」的角色設定。

七、姻緣花如何「開花」?

姻緣花不是整區一起閃,而是控制目前亮起的LED數量,由1顆慢慢增加到16顆,再逐步縮回。這種漸次點亮的動畫更接近「花開」而不是一般警示燈。

八、燈效如何跟故事狀態機連動?

IDLE ↓ 底座暖色微呼吸 READY ↓ 三角色低亮提示 選擇故事 ↓ JQ6500 PLAYING + 對應角色燈效 ↓ 故事播放完成 ↓ COOLDOWN → READY / IDLE

正式系統由 currentStory 決定燈效,因此聲音與燈光會同步對應故事。燈光也成為「狀態可視化」介面:即使觀眾沒有看手機,也能知道目前正在播放哪個角色。

九、程式設計重點:不要用長時間delay()

水井三寶還要同時處理ToF、按鈕、JQ6500、Edge Web與Wi-Fi,因此燈效大量使用 millis() 控制更新節奏,避免動畫阻塞其他任務。

教學重點:Arduino入門常用 delay() 做動畫;真正的IoT系統要進一步學會非阻塞式時間控制。燈光再漂亮,也不能讓感測器、Web或故事播放停住。

十、完整獨立測試程式

下面程式只保留WS2812B燈光模組,適合先測接線、燈區與動畫。正式系統再把這些函式整合回Layer 2~Layer 4。


#include <Arduino.h>
#include <Adafruit_NeoPixel.h>

#define LED_PIN 13
#define LED_COUNT 62
#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

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

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

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, dir=1;
  if(millis()-last<80) return;
  last=millis();
  b+=dir;
  if(b>=14) dir=-1;
  if(b<=4) dir=1;
  strip.clear();
  setZone(BASE_START,BASE_COUNT,strip.Color(b,b/2,0));
  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, dir=1;
  if(millis()-last<70) return;
  last=millis();
  b+=dir;
  if(b>=80) dir=-1;
  if(b<=10) dir=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;
  }
}

void setup(){
  Serial.begin(115200);
  strip.begin();
  strip.setBrightness(100);
  strip.clear();
  strip.show();
}

void loop(){
  unsigned long section=(millis()/10000UL)%6;

  switch(section){
    case 0: ledIdle(); break;
    case 1: currentStory=STORY_HORSE; updateStoryLED(); break;
    case 2: currentStory=STORY_TURTLE; updateStoryLED(); break;
    case 3: currentStory=STORY_FLOWER; updateStoryLED(); break;
    case 4: currentStory=STORY_ALL; updateStoryLED(); break;
    case 5: currentStory=STORY_CREATOR; updateStoryLED(); break;
  }
}

十一、從RGB燈條到文化互動語言

WS2812B真正有價值的不是「顏色很多」,而是每顆LED都可以被程式個別控制。當白馬用追光、烏龜用呼吸、姻緣花用綻放來呈現各自故事時,LED就不再只是裝飾,而成為文化敘事的一部分。

設計理念:聲音說故事,燈光表達故事情緒,作品本體仍然是主角。科技不取代文化,而是讓觀眾多一種理解文化的方式。

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 與雲端資料服務串在一起的智慧互動展覽實作。