2026年8月16日 星期日

[水井村USR] 一個按鈕按下後,資料去了哪裡?

一個按鈕按下後,資料去了哪裡?

從「水井三寶智慧互動展覽系統」看懂網際網路資料流

Browser × Edge Web × Wi‑Fi × IP × HTTP × JSON × Django × Database

對初學者來說,「按下一個按鈕,故事就播放了」好像只是ESP32的一個控制功能;但從網際網路角度來看,這個動作其實會經過一連串不同層次的資料處理。這篇文章用「按下烏龜故事」作為例子,追蹤一筆事件如何從使用者手指,經過ESP32、Wi‑Fi、HTTP、JSON、Django與資料庫,最後出現在雲端儀表板上。

一、按下的是什麼?

水井三寶有兩種啟動方式:實體按鈕與手機Edge Web。實體按鈕由ESP32直接讀GPIO;手機則先連上ESP32的AP,再開啟 http://192.168.4.1

手機操作時真正發生的第一件事:瀏覽器送出一個HTTP Request,而不是直接播放MP3。

二、一個Web按鈕,其實是一個URL

例如「002 烏龜故事」可對應:

http://192.168.4.1/play?track=2

瀏覽器會送出類似:

GET /play?track=2 HTTP/1.1
Host: 192.168.4.1
概念水井三寶例子作用
IP Address192.168.4.1找到ESP32
HTTPGETBrowser與Web Server溝通
URL Path/play告訴Server要做什麼
Query Stringtrack=2指定播放第2個故事
重點:網頁不是網際網路。網頁只是應用層的一部分,背後還有Wi‑Fi、IP、TCP與HTTP。

三、資料先到ESP32 Edge Web

Browser ↓ HTTP GET ESP32 Edge Web ↓ 解析 track=2 ↓ 啟動 STORY_TURTLE ├─ JQ6500:播放002 ├─ WS2812B:烏龜藍色呼吸 └─ System State:PLAYING

四、到這裡為止,其實還沒有上Internet

手機連的是ESP32自己的AP,因此這是區域網路內的本地通訊。即使展場Internet完全中斷,192.168.4.1仍然可以工作。

能開啟192.168.4.1,不代表Internet一定正常;Internet斷線,也不代表Edge Web一定不能用。

五、故事播放後,ESP32建立事件資料

{
  "event_uuid":"SHUIJING-001-0000000062",
  "event_type":"STORY_START",
  "story":"002",
  "source":"web",
  "duration_ms":0,
  "completed":null,
  "network_status":"online",
  "metadata":{
    "firmware":"Layer4-V1.7",
    "jq_confirmed":true
  }
}

這是一個JSON物件,用來把系統狀態整理成適合網路傳輸的文字格式。

六、為什麼不直接送雲端?

Local First 先完成:JQ6500播放、燈光、按鈕與Web回應 ↓ 事件先寫入 LittleFS Queue ↓ Cloud Later 空閒後再送 Django

這樣即使Wi‑Fi暫時斷線,事件也不會立即遺失。

七、真正上Internet時,資料經過哪些地方?

ESP32 STA ↓ Wi‑Fi Router ↓ Default Gateway ↓ DNS ↓ Internet ↓ shuijingtreasures.pythonanywhere.com ↓ HTTPS / Django API

八、DNS的工作:把名稱變成IP

ESP32知道的是網域名稱,但Internet路由需要IP,因此必須先做DNS解析。

shuijingtreasures.pythonanywhere.com ↓ DNS IP Address
診斷觀念:Wi‑Fi → Gateway → DNS → TCP/TLS → HTTP → API,每一層都可能失敗。

九、ESP32如何把JSON送到Django?

POST /api/exhibition/events/ HTTP/1.1
Host: shuijingtreasures.pythonanywhere.com
Content-Type: application/json
X-Device-ID: SHUIJING-001
X-Device-Key: ********

{
  "event_type":"STORY_START",
  "story":"002",
  "source":"web"
}
項目作用
HTTP POST送資料給Server
Header放Content-Type、Device ID、API Key
JSON Body真正的事件內容

十、Django收到後做什麼?

Django URL Router ↓ api_event() ↓ 檢查 Device ID / API Key ↓ 解析 JSON ↓ 寫入 ExhibitionEvent ↓ Database ↓ Dashboard

十一、一筆「烏龜故事」事件的完整旅程

1. 使用者點選「002 烏龜故事」 2. Browser送出 HTTP GET 3. ESP32收到 /play?track=2 4. JQ6500播放002 5. WS2812B切換烏龜燈效 6. 建立 STORY_START JSON 7. 寫入 LittleFS Queue 8. ESP32 STA連Router 9. DNS解析網域 10. HTTPS POST送到Django 11. Django驗證裝置 12. JSON寫入Database 13. Dashboard統計「002 烏龜故事 +1」

十二、這和水井三寶五層架構有什麼關係?

層級角色
第一層|文化作品白馬、烏龜、姻緣花實體作品
第二層|智慧互動ESP32、按鈕、ToF、JQ6500、RGB
第三層|Edge Web192.168.4.1、HTTP、本地控制與診斷
第四層|Digital Twin / DataJSON、HTTPS、Django、Database、Dashboard
第五層|文化知識品牌故事、影音、教育、工藝、USR與未來AI/RAG

十三、這個案例可以學到哪些《網際網路》概念?

Browser / ServerLANWi‑Fi AP / STAIP AddressGatewayDNSHTTP GETHTTP POSTJSONREST APIHTTPSDatabaseEdge Computing

十四、課堂思考題

問題一:手機能開192.168.4.1,但Django收不到資料,可能是哪一段有問題?
問題二:ESP32顯示STA ONLINE,是否代表Internet一定正常?為什麼?
問題三:為什麼事件要先進LittleFS Queue,而不是每次按下按鈕就立即POST到Django?

十五、結語:真正的網際網路,不是一張網頁

水井三寶案例讓我們看見,網際網路真正有趣的地方,不在於「做出一個網頁」,而是理解一筆資料如何從實體世界產生,再經過本地網路、IP、DNS、HTTP、JSON、API與Database,最後轉化成可被理解的資訊。

當學生按下「002 烏龜故事」時,他不只是啟動一段音檔;他其實啟動了一條從文化作品 → Edge → Internet → Cloud → Data的完整資料鏈。這正是把《網際網路》從課本名詞變成真實系統最有價值的地方。

[水井村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] 水井三寶智慧互動展覽系統

水井三寶智慧互動展覽系統

Shuijing Treasures Smart Interactive Exhibition System

五層架構與設計理念:從文化作品,到互動裝置、Edge Web、Digital Twin,再到文化知識平台

「水井三寶智慧互動展覽系統」的核心,不是把一件文化作品變成單純的科技展示品,而是讓科技成為文化被理解、被互動、被記錄、被延伸的橋梁。因此整個系統刻意採用五層架構,讓每一層都有清楚角色:文化作品負責承載價值,智慧互動負責創造體驗,Edge Web負責現場控制,Digital Twin / Data負責留下匿名資料,文化知識層則負責把故事、教育、工藝與USR持續累積成長。

一、五層架構總覽

第五層|文化知識層 水井三寶網站、品牌故事、繪本、影音、教育、工藝、USR、未來AI/RAG ▲ │ 第四層|Digital Twin / Data層 Django匿名事件、展覽儀表板、跨場次資料 ▲ │ 第三層|Edge Web層 ESP32內嵌網站、現場控制、設備診斷、即時匿名統計 ▲ │ 第二層|智慧互動層 ESP32+ToF+三按鈕+JQ6500+RGB ▲ │ 第一層|文化作品層 水井三寶實體作品,文化展示為主,不直接觸碰

這五層並不是五套彼此獨立的系統,而是一條「由文化出發,再回到文化」的完整鏈結。第一層是核心,第二、三、四層提供互動、控制與資料能力,第五層再將資料與內容重新轉化為教育、品牌與文化傳播的長期價值。

二、第一層|文化作品層

水井三寶實體作品,文化展示為主,不直接觸碰

第一層是整個系統最重要的一層。白馬、烏龜與姻緣花不是互動裝置的配件,而是展覽真正的主角。系統設計的第一原則,就是科技不能破壞作品本體,也不能讓參觀者因為操作而直接觸碰文化作品

因此互動按鈕、燈光、聲音、感測器與控制設備,應盡量整合在木盒、底座或展示台中。參觀者操作的是「展覽介面」,不是作品本身。這樣可以兼顧保存、安全與展示尊嚴。

設計理念:作品是核心,科技退居幕後。不是把文化變成科技,而是用科技讓文化更容易被理解。

三、第二層|智慧互動層

ESP32+ToF+三按鈕+JQ6500+RGB

第二層負責把「觀看」轉化成「參與」。ESP32是互動控制核心;三顆按鈕分別對應白馬、烏龜與姻緣花;JQ6500負責播放故事音檔;RGB燈光則提供視覺回饋;ToF感測器則用來判斷是否有人靠近展區。

這一層的價值不只是「會播聲音」或「會亮燈」,而是建立一個能避免誤動作、能確認播放狀態、能支援展覽現場大量人流的互動邏輯。尤其在人多的展場,感測器與狀態機必須避免因路人經過、連續按鍵或多人同時操作而造成混亂。

第二層可視為「實體互動引擎」,主要任務是快速、穩定、即時,而且即使沒有Internet也必須能正常工作。

四、第三層|Edge Web層

ESP32內嵌網站,現場控制、設備診斷、即時匿名統計

第三層讓ESP32不再只是藏在盒子裡的控制板,而成為一個可被手機直接管理的Edge設備。工作人員只要連上ESP32的本地AP,就可以開啟內嵌網站,不需要額外安裝App。

Edge Web可以提供故事控制、音量設定、設備狀態、JQ6500播放狀態、Wi-Fi狀態、ToF距離、Queue大小與即時匿名統計。它同時也是維護工具:當展場出現「有燈沒聲音」、「有網路但沒同步」等問題時,工作人員可以直接從手機判斷問題在哪一層。

設計理念:現場控制應該留在Edge,不應依賴雲端。即使外網中斷,192.168.4.1仍應可以進入控制頁面。

五、第四層|Digital Twin / Data層

Django接收匿名事件,建立展覽儀表板與跨場次資料

第四層將實體展覽轉化成可觀察、可比較的Digital Twin / Data系統。ESP32不記錄個人姓名、不做人臉辨識,也不追蹤個人身分,而是只記錄匿名事件,例如:故事啟動、完整播放、Web操作、實體按鈕操作、展區喚醒、裝置狀態與錯誤事件。

Django接收這些事件後,可以建立展覽儀表板,顯示白馬、烏龜、姻緣花故事的互動次數、播放完成率、互動來源與設備健康狀態。當展覽移到不同場域、不同日期或不同活動時,也可以累積成跨場次資料。

這一層的重點不是「蒐集越多資料越好」,而是只記錄對展覽改善有用、又不涉及個人識別的資料。資料的價值,在於協助回答:哪個故事最受歡迎?觀眾是否完整聽完?實體按鈕和Web控制哪一種使用較多?哪一場展覽的互動最高?

六、第五層|文化知識層

水井三寶網站承接品牌故事、繪本、影音、教育、工藝、USR,未來再加入AI/RAG

第五層不是單純的「網站資料庫」,而是整個系統最長期的文化知識層。展場中的一段語音只能講幾十秒或幾分鐘,但水井三寶的品牌故事、地方記憶、創作者、工藝技法、繪本、影音、教育課程、USR成果,都需要一個可以持續累積的知識平台。

因此,ESP32內嵌網站負責「現場」,水井三寶網站負責「延伸」。參觀者在現場聽完故事後,可以再透過QR Code進入完整網站,繼續閱讀作品故事、觀看影音、了解地方文化與USR實踐。

未來如果加入AI與RAG,這一層還可以進一步發展成「水井三寶文化知識問答」,讓參觀者用自然語言詢問白馬、烏龜、姻緣花、水井村、工藝、USR與地方故事,讓文化知識從靜態瀏覽進入互動問答。

七、為什麼一定要分成五層?

層級 主要角色 即使其他層失效,仍應保留什麼?
第一層|文化作品 文化核心與展示主體 作品仍能被正常觀看與保存
第二層|智慧互動 按鈕、聲音、燈光、感測 即使沒有Internet,仍能互動與播放
第三層|Edge Web 本地控制、診斷、即時統計 即使雲端離線,仍能從192.168.4.1管理
第四層|Digital Twin / Data 匿名事件、跨場次分析、設備健康 網路恢復後可從Queue補同步
第五層|文化知識 品牌、教育、USR、知識累積 內容可持續成長,不受單一展場限制

八、五層架構背後的四個設計原則

文化優先Local First匿名資料可擴充

第一,文化優先。所有科技都服務於作品,不讓作品變成科技展示的配角。第二,Local First。故事播放與現場控制要在本地完成,雲端不能干擾基本互動。第三,匿名資料。只記錄事件,不記錄個人身分。第四,可擴充。未來可以增加AI、RAG、更多故事、更多展場與更多教學內容,而不必推翻原本架構。

九、這不是五個功能,而是一條文化數位轉譯鏈

文化作品 ↓ 被看見 ↓ 智慧互動 ↓ 被參與 ↓ Edge Web ↓ 被控制與理解 ↓ Digital Twin / Data ↓ 被記錄與分析 ↓ 文化知識平台 ↓ 被延伸、被教學、被再創作

從這個角度來看,「水井三寶智慧互動展覽系統」的真正價值,不只是完成一套ESP32裝置,而是建立一種地方文化數位化的方法:實體作品保留原貌,科技負責提升體驗,資料負責留下足跡,網站負責延伸知識,未來AI再負責讓文化被更自然地提問與理解。

十、結語

「水井三寶智慧互動展覽系統」五層架構,讓文化、硬體、Web、資料與知識不再混在同一層。第一層守住文化,第二層創造互動,第三層保障現場,第四層累積資料,第五層延伸知識。這樣的設計也讓系統更適合真正的展覽情境:某一層暫時失效時,不會讓整個展覽一起失效。

對USR而言,這五層架構還有另一層意義:它把地方故事從「一次性的展示內容」,轉化成可以持續累積、教育、分析、再利用與再傳播的地方文化數位資產。科技因此不只是工具,而是文化保存、教育與地方連結的一座橋。

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

[水井村USR] ESP32 同時當基地台又能上網! 水井三寶智慧互動展覽系統 AP+STA 共存實測

ESP32 同時當基地台又能上網!
水井三寶智慧互動展覽系統 AP+STA 共存實測

ESP32 Arduino SoftAP STA Edge Web IoT USR

在「水井三寶智慧互動展覽系統」的開發過程中,有一個很重要的網路問題: ESP32 能不能一方面提供現場手機連線控制,另一方面又同時連上 Internet?

經過實際測試,答案是:可以。 本次成功讓 ESP32 的 SoftAP 與 STA 同時運作,而且 AP 端的手機控制與 STA 端的 Internet 連線可以共存。

一、為什麼水井三寶故事機需要 AP+STA?

一般 ESP32 IoT 專題常見的做法,是讓 ESP32 連接既有 Wi-Fi,也就是 STA(Station)模式。 但「水井三寶智慧互動展覽系統」的使用情境不同。

展覽現場可能有大量參觀者,而且展場 Internet 並不一定穩定。因此,我們希望故事機即使沒有 Internet, 工作人員仍然可以拿手機直接連上 ESP32,進入內嵌控制網站進行播放、診斷與設備管理。

另一方面,如果展場具有 Internet,ESP32 又應該能將匿名互動事件傳送到遠端 Django 平台, 形成長期的展覽數據。

【參觀者/工作人員手機】

│ Wi-Fi

【ESP32 SoftAP】
Shuijing-Treasures
192.168.4.1

├──── Edge Web 現場控制

└──── ESP32 同時使用 STA
    │
    ▼
【手機熱點/展場 Wi-Fi】
    │
    ▼
   Internet
    │
    ▼
【Django 雲端平台】

二、AP 與 STA 分別做什麼?

模式 用途 水井三寶應用
AP ESP32 自己建立 Wi-Fi 手機直接連入故事機
SoftAP IP ESP32 本地控制網址 192.168.4.1
STA ESP32 連接外部 Wi-Fi 取得 Internet
Internet 連接遠端伺服器 Django 匿名事件平台

三、這次實測成功

本次使用 ESP32 實際測試 AP+STA 共存。 ESP32 一方面建立自己的 Wi-Fi:

SSID:Shuijing-Treasures
Local control:http://192.168.4.1

另一方面,再透過 STA 連接名為 ChiYuan 的外部 Wi-Fi。 實際取得:

STA CONNECTED
STA SSID : ChiYuan
STA IP   : 192.168.1.119
RSSI     : -21 dBm

==============================
SYSTEM READY
==============================

Local control: http://192.168.4.1
Internet: YES

▲ 圖1 ESP32 AP+STA 共存實測。 ESP32 本地 AP 維持 192.168.4.1,同時透過 STA 取得 192.168.1.119,Internet 狀態為 YES。
這張測試畫面證明了一件很重要的事:

ESP32 不需要在「本地控制」與「Internet」之間二選一。
它可以同時提供 192.168.4.1 Edge Web, 又透過另一個 Wi-Fi 介面連上 Internet。

四、連線過程

程式的核心概念其實很簡單:先建立 SoftAP,再使用 WiFi.begin() 建立 STA 連線。

STEP 1|ESP32 建立 SoftAP

if (!WiFi.softAP(AP_SSID, AP_PASSWORD)) {

    Serial.println("SoftAP FAILED");

    while (1) {
        delay(1000);
    }
}

Serial.println("SoftAP OK");

Serial.print("AP IP : ");
Serial.println(WiFi.softAPIP());

成功後,ESP32 本身就是一個 Wi-Fi 基地台。 手機可以搜尋到:

Shuijing-Treasures

連線後即可進入:

http://192.168.4.1

STEP 2|ESP32 再連接 Internet Wi-Fi

WiFi.begin(STA_SSID, STA_PASSWORD);

這時 ESP32 又以 STA 身分連接外部 AP。 程式最多等待 15 秒:

unsigned long start = millis();

while (
    WiFi.status() != WL_CONNECTED &&
    millis() - start < 15000
) {

    Serial.print(".");
    delay(500);
}

STEP 3|確認兩個 IP 同時存在

這是理解 AP+STA 最重要的地方。 ESP32 此時具有兩個不同用途的 IP。

介面 本次實測 IP 功能
SoftAP 192.168.4.1 手機連 ESP32
STA 192.168.1.119 ESP32 連 Internet

五、還能知道有幾台手機連進 ESP32

程式使用:

WiFi.softAPgetStationNum()

取得目前連接 ESP32 AP 的裝置數。 本次測試畫面持續出現:

AP Clients = 1

代表測試當下確實有一台裝置連著 Shuijing-Treasures

六、RSSI 也能成為展覽維護資訊

程式每五秒輸出 STA 狀態:

AP Clients = 1 | STA = ONLINE | IP = 192.168.1.119 | RSSI = -28
AP Clients = 1 | STA = ONLINE | IP = 192.168.1.119 | RSSI = -21
AP Clients = 1 | STA = ONLINE | IP = 192.168.1.119 | RSSI = -29

這些資訊未來可以直接整合進水井三寶 Edge Web 的「設備診斷」頁面, 讓工作人員不必接電腦,就可以從手機知道:

  • AP 是否正常
  • 目前有多少裝置連線
  • STA 是否 ONLINE
  • STA IP
  • Wi-Fi RSSI
  • Internet 是否可用

七、完整 Arduino 測試程式

以下是本次 AP+STA 共存實際測試使用的 Arduino 程式。 正式使用時只需要修改 STA 的 Wi-Fi 名稱與密碼。

#include <Arduino.h>
#include <WiFi.h>
#include <NetworkClient.h>
#include <WiFiAP.h>

// ===============================
// ESP32 AP + Internet STA TEST
// ===============================

// ESP32 本地 AP
const char *AP_SSID = "Shuijing-Treasures";
const char *AP_PASSWORD = "12345678";

// 可上網的 Wi-Fi
// 先用手機熱點測試最容易排除展場網路問題
const char *STA_SSID = "您的手機熱點名稱";
const char *STA_PASSWORD = "您的手機熱點密碼";

NetworkServer server(80);

void setup() {

  Serial.begin(115200);
  delay(1500);

  Serial.println();
  Serial.println("==============================");
  Serial.println("SHUIJING AP + STA TEST");
  Serial.println("==============================");

  // --------------------------------
  // STEP 1
  // 建立 SoftAP
  // --------------------------------

  Serial.println();
  Serial.println("[1] Starting SoftAP...");

  if (!WiFi.softAP(AP_SSID, AP_PASSWORD)) {

    Serial.println("SoftAP FAILED");

    while (1) {
      delay(1000);
    }
  }

  Serial.println("SoftAP OK");

  Serial.print("AP SSID : ");
  Serial.println(AP_SSID);

  Serial.print("AP IP   : ");
  Serial.println(WiFi.softAPIP());


  // --------------------------------
  // STEP 2
  // 建立 STA Internet
  // --------------------------------

  Serial.println();
  Serial.println("[2] Starting STA...");

  WiFi.begin(STA_SSID, STA_PASSWORD);

  unsigned long start = millis();

  while (
    WiFi.status() != WL_CONNECTED &&
    millis() - start < 15000
  ) {

    Serial.print(".");
    delay(500);
  }

  Serial.println();


  // --------------------------------
  // STEP 3
  // 檢查 STA
  // --------------------------------

  if (WiFi.status() == WL_CONNECTED) {

    Serial.println("STA CONNECTED");

    Serial.print("STA SSID : ");
    Serial.println(WiFi.SSID());

    Serial.print("STA IP   : ");
    Serial.println(WiFi.localIP());

    Serial.print("RSSI     : ");
    Serial.print(WiFi.RSSI());
    Serial.println(" dBm");

  } else {

    Serial.println("STA NOT CONNECTED");
    Serial.println("Local AP should still work.");
  }


  // --------------------------------
  // STEP 4
  // 啟動本地 Web Server
  // --------------------------------

  server.begin();

  Serial.println();
  Serial.println("==============================");
  Serial.println("SYSTEM READY");
  Serial.println("==============================");

  Serial.print("Local control: http://");
  Serial.println(WiFi.softAPIP());

  if (WiFi.status() == WL_CONNECTED) {

    Serial.println("Internet: YES");

  } else {

    Serial.println("Internet: NO");
  }
}


void loop() {

  // ===============================
  // 本地 AP Web Server
  // ===============================

  NetworkClient client = server.accept();

  if (client) {

    String currentLine = "";

    while (client.connected()) {

      if (client.available()) {

        char c = client.read();

        if (c == '\n') {

          if (currentLine.length() == 0) {

            client.println("HTTP/1.1 200 OK");
            client.println(
              "Content-Type: text/html; charset=utf-8"
            );
            client.println("Connection: close");
            client.println();

            client.println(
              "<!DOCTYPE html>"
              "<html>"
              "<head>"
              "<meta charset='UTF-8'>"
              "<meta name='viewport' "
              "content='width=device-width,initial-scale=1'>"
              "</head>"
              "<body>"
            );

            client.println(
              "<h1>水井三寶 AP + STA 測試</h1>"
            );

            client.print("<p>AP IP:");
            client.print(WiFi.softAPIP());
            client.println("</p>");

            client.print("<p>STA:");

            if (WiFi.status() == WL_CONNECTED) {

              client.println("CONNECTED</p>");

              client.print("<p>Internet IP:");
              client.print(WiFi.localIP());
              client.println("</p>");

              client.print("<p>RSSI:");
              client.print(WiFi.RSSI());
              client.println(" dBm</p>");

            } else {

              client.println("OFFLINE</p>");
            }

            client.println("</body></html>");

            break;

          } else {

            currentLine = "";
          }

        } else if (c != '\r') {

          currentLine += c;
        }
      }
    }

    client.stop();
  }


  // ===============================
  // STA 斷線診斷
  // ===============================

  static unsigned long lastPrint = 0;

  if (millis() - lastPrint > 5000) {

    lastPrint = millis();

    Serial.print("AP Clients = ");
    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.println(WiFi.RSSI());

    } else {

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

八、為什麼這次測試對「水井三寶」很重要?

這不只是一個 ESP32 Wi-Fi 技術測試,而是決定「水井三寶智慧互動展覽系統」 能不能真正進入展覽現場的一個重要基礎。

第二層|智慧互動層
ESP32+ToF+三按鈕+JQ6500+RGB, 負責故事播放與現場互動。
第三層|Edge Web 層
ESP32 提供 192.168.4.1, 即使 Internet 中斷仍可現場控制。
第四層|Django Data 層
ESP32 透過 STA 與 Internet, 將匿名互動事件送往遠端平台。
內容層|水井三寶網站
白馬、烏龜、姻緣花、三寶故事與創作者資訊, 提供更完整的數位延伸閱讀。

九、最重要的展覽設計原則:Internet 不是故事機的開關

展覽系統不應該因為 Internet 斷線,就讓作品停止說故事。

因此,水井三寶智慧互動展覽系統採取的是 Edge First 的思考方式。

ToF、三個實體按鈕、JQ6500 故事播放、RGB 燈光與 192.168.4.1 本地控制都應該在 ESP32 本機完成。

STA 與 Internet 則是「加值能力」,主要負責匿名事件同步與遠端資料分析。

未來即使展場 Wi-Fi 暫時中斷:

JQ6500        → 繼續播放
三按鈕        → 繼續操作
ToF           → 繼續偵測
RGB           → 繼續互動
192.168.4.1   → 繼續控制

Internet      → 暫時離線
Django Event  → 暫存,恢復後補傳

十、下一步:從「會上網」進入「真正的四層系統」

本次測試已經完成一個重要的技術驗證: ESP32 SoftAP 與 Internet STA 可以穩定共存。

下一階段就可以把這項成果正式整合進「水井三寶智慧互動展覽系統」:

水井三寶作品
      │
      ▼
ESP32 智慧互動層
ToF+Button+JQ6500+WS2812
      │
      ▼
Edge Web
192.168.4.1
      │
      ├──────── 現場手機控制
      │
      ▼
STA / Internet
      │
      ▼
Django
匿名事件資料庫
      │
      ▼
展覽 Dashboard

當地方工藝遇上 ESP32、Edge Web 與 Django, 科技不再只是放在作品旁邊的設備, 而是開始成為地方故事與參觀者之間的互動媒介。

白馬行土地、烏龜守清水、姻緣花牽人情;
三寶同行,水井共生。