ESP32 Edge Web × 手機:讓水井三寶智慧互動展覽進入第三層
一、手機就是水井三寶的新互動入口
從手機畫面可以看到,ESP32 已經不只是控制器, 還同時扮演一台小型 Web Server。
使用者可以直接看到目前狀態、正在播放的故事、ToF 距離、 JQ6500 BUSY、AP IP、目前連線裝置數, 並透過按鈕播放:
| 編號 | 內容 |
|---|---|
| 001 | 白馬故事 |
| 002 | 烏龜故事 |
| 003 | 姻緣花故事 |
| 004 | 水井三寶總故事 |
| 005 | 創作者資訊 |
二、使用者如何用手機播放故事?
Shuijing-Treasures。
192.168.4.1。
Wi-Fi SSID:
Shuijing-TreasuresPassword:
12345678網頁網址:
http://192.168.4.1
三、為什麼不需要 Internet?
關鍵在於 ESP32 使用:
WiFi.softAP(AP_SSID,AP_PASSWORD);
也就是由 ESP32 自己建立一個 Wi-Fi Access Point。
↓
Shuijing-Treasures Wi-Fi
↓
ESP32 SoftAP
↓
192.168.4.1
↓
ESP32 Web Server
所以整個互動不需要經過外部路由器,也不需要 Internet。
四、ESP32 如何建立 Edge Web Server?
Layer 3 程式首先設定:
const char *AP_SSID="Shuijing-Treasures";
const char *AP_PASSWORD="12345678";
NetworkServer server(80);
Port 80 就是一般 HTTP Web Server 常用的連接埠。
接著:
void setupAP(){
Serial.println("\nStarting Shuijing SoftAP");
if(!WiFi.softAP(AP_SSID,AP_PASSWORD)){
Serial.println("Soft AP creation failed.");
while(true)
delay(1000);
}
IPAddress ip=WiFi.softAPIP();
Serial.print("AP SSID : ");
Serial.println(AP_SSID);
Serial.print("AP IP : ");
Serial.println(ip);
Serial.println("URL : http://192.168.4.1");
server.begin();
Serial.println("Web Server started");
}
ESP32 開機後就會同時具備:
+
HTTP Web Server
+
實體裝置控制器
五、這就是 Edge Web
傳統雲端 Web 控制可能是:
手機
↓
Internet
↓
Cloud Server
↓
ESP32
Layer 3 則是:
手機
↓
Wi-Fi
↓
ESP32
Web Server 就直接執行在展品旁邊的 ESP32 上, 所以這裡可以稱為:
六、手機按下「001 白馬故事」時發生什麼?
手機頁面中的按鈕:
<button
class="horse"
onclick="playTrack(1)"
>
🐴 001 白馬故事
</button>
點下後 JavaScript 執行:
async function playTrack(n){
try{
await api(
"/api/play?track="+n
);
refreshAll();
}catch(e){
alert(e.message);
}
}
如果使用者按白馬,手機就會送出:
GET /api/play?track=1
整個控制流程為:
↓
JavaScript playTrack(1)
↓
HTTP /api/play?track=1
↓
ESP32 Web Server
↓
startStory(STORY_HORSE,true)
↓
jqPlayTrack(1)
↓
UART
↓
JQ6500
↓
喇叭播放白馬故事
七、Layer 3 已經具有 API 架構
| API | 功能 |
|---|---|
/api/play?track=1 |
播放指定故事 |
/api/stop |
停止播放 |
/api/volume?value=20 |
設定 JQ6500 音量 |
/api/status |
取得目前系統狀態 |
/api/stats |
取得匿名互動統計 |
/api/diag |
取得設備診斷資料 |
這代表 Layer 3 已經不只是「手機網頁」, 而是具備一個簡單的 IoT Device API。
八、五個故事如何在程式中定義?
enum Story{
STORY_NONE=0,
STORY_HORSE=1,
STORY_TURTLE=2,
STORY_FLOWER=3,
STORY_ALL=4,
STORY_CREATOR=5
};
Layer 2 原本只有三顆實體按鈕,因此主要對應白馬、烏龜與姻緣花。 Layer 3 加入手機 Web 後,就可以突破實體按鈕數量限制, 再加入:
004 水井三寶總故事
005 創作者資訊
九、Web 最後仍然透過 UART 控制 JQ6500
手機只是新增一種輸入來源,真正播放 MP3 的核心仍然是 JQ6500。
void jqPlayTrack(uint16_t track){
uint8_t cmd[]={
0x7E,
0x04,
0x03,
(uint8_t)(track>>8),
(uint8_t)(track&0xFF),
0xEF
};
sendJQ(
cmd,
sizeof(cmd)
);
}
硬體通訊仍然是:
ESP32 GPIO26 TX → JQ6500 RX
ESP32 GPIO27 RX ← JQ6500 TX
JQ6500 BUSY → ESP32 GPIO34
所以 Layer 3 並不是取代 Layer 2, 而是在 Layer 2 上方再加入 Web 與手機互動。
↓
Layer 2:ESP32 + UART + JQ6500 + ToF + LED + Button
十、實體按鈕與手機 Web 可以共存
原本的三顆按鈕仍然保留:
void handlePhysicalButtons(){
if(horseButton.pressedEvent){
startStory(STORY_HORSE,false);
return;
}
if(turtleButton.pressedEvent){
startStory(STORY_TURTLE,false);
return;
}
if(flowerButton.pressedEvent){
startStory(STORY_FLOWER,false);
return;
}
}
手機則會使用:
startStory((Story)track,true);
其中:
false = 實體按鈕
true = Web
因此還可以統計不同互動來源:
if(fromWeb)
stats.webCount++;
else
stats.buttonCount++;
十一、手機可以即時看到系統狀態
手機畫面上的:
PLAYING
目前故事:白馬故事
ToF距離:-1 mm
有人停留:false
JQ6500 BUSY:false
AP IP:192.168.4.1
已連線手機/平板:1
主要來自:
/api/status
ESP32 會產生 JSON:
String jsonStatus(){
String j="{";
j+="\"state\":\""+
String(stateName())+"\",";
j+="\"story\":\""+
String(storyName(currentStory))+"\",";
j+="\"distance_mm\":"+
String(lastDistanceMM)+",";
j+="\"person\":"+
String(personDetected ? "true":"false")+",";
j+="\"busy\":"+
String(jqIsBusy() ? "true":"false")+",";
j+="\"ap_ip\":\""+
WiFi.softAPIP().toString()+"\",";
j+="\"clients\":"+
String(WiFi.softAPgetStationNum());
j+="}";
return j;
}
十二、手機網頁會定時更新,不需要整頁重新載入
setInterval(
loadStatus,
1000
);
setInterval(
loadStats,
2000
);
setInterval(
loadDiag,
5000
);
也就是:
| 週期 | 更新內容 |
|---|---|
| 每 1 秒 | 系統狀態 |
| 每 2 秒 | 匿名互動統計 |
| 每 5 秒 | 設備診斷資訊 |
十三、Layer 3 還加入匿名互動統計
struct Statistics{
unsigned long wakeCount=0;
unsigned long horseCount=0;
unsigned long turtleCount=0;
unsigned long flowerCount=0;
unsigned long allStoryCount=0;
unsigned long creatorCount=0;
unsigned long buttonCount=0;
unsigned long webCount=0;
unsigned long completedCount=0;
unsigned long stopCount=0;
};
可以統計:
| 統計項目 | 用途 |
|---|---|
| 白馬/烏龜/姻緣花 | 了解故事熱門程度 |
| 總故事/創作者資訊 | 了解延伸內容使用情況 |
| 實體按鈕播放 | 了解現場面板互動情形 |
| Web 播放 | 了解手機互動情形 |
| 展區喚醒 | 觀察 ToF 偵測到的互動次數 |
| 完整播放 | 觀察故事是否聽完 |
| STOP | 了解中途停止情形 |
十四、手機也能直接調整 JQ6500 音量
Web 頁面中的 Slider:
<input
id="vol"
type="range"
min="0"
max="30"
value="20"
onchange="setVolume(this.value)"
>
手機會送出:
/api/volume?value=20
ESP32 再執行:
jqSetVolume((uint8_t)vol);
↓
HTTP API
↓
ESP32
↓
UART
↓
JQ6500
十五、Layer 3 V1.0 完整程式
/*
水井三寶智慧互動展覽系統
第三層|Edge Web 層 V1.0
ESP32 NodeMCU-32S + VL53L0X + 3 Buttons
+ JQ6500 + WS2812 + SoftAP + Web
AP:
SSID = Shuijing-Treasures
Password = 12345678
URL = http://192.168.4.1
Track:
001 白馬
002 烏龜
003 姻緣花
004 水井三寶總故事
005 創作者資訊
*/
#include <Arduino.h>
#include <Wire.h>
#include <HardwareSerial.h>
#include <WiFi.h>
#include <NetworkClient.h>
#include <WiFiAP.h>
#include <Adafruit_VL53L0X.h>
#include <Adafruit_NeoPixel.h>
const char *AP_SSID="Shuijing-Treasures";
const char *AP_PASSWORD="12345678";
NetworkServer server(80);
#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
#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
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 int PERSON_DISTANCE_MM=1200;
const unsigned long PERSON_DWELL_MS=1200;
const unsigned long TOF_INTERVAL_MS=100;
const unsigned long ATTRACT_MS=1500;
const bool BUSY_ACTIVE_HIGH=true;
HardwareSerial JQ(2);
Adafruit_VL53L0X lox;
Adafruit_NeoPixel strip(
LED_COUNT,
LED_PIN,
NEO_GRB+NEO_KHZ800
);
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;
struct Button{
uint8_t pin;
bool stableState;
bool lastReading;
unsigned long lastChange;
bool pressedEvent;
};
Button horseButton;
Button turtleButton;
Button flowerButton;
struct Statistics{
unsigned long wakeCount=0;
unsigned long horseCount=0;
unsigned long turtleCount=0;
unsigned long flowerCount=0;
unsigned long allStoryCount=0;
unsigned long creatorCount=0;
unsigned long buttonCount=0;
unsigned long webCount=0;
unsigned long completedCount=0;
unsigned long stopCount=0;
};
Statistics stats;
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;
int lastDistanceMM=-1;
uint8_t currentVolume=20;
/* ================================
手機 Web 頁面
================================ */
const char WEB_PAGE[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width,initial-scale=1"
>
<title>
水井三寶 Edge Control
</title>
<style>
body{
margin:0;
background:#f4f0e6;
color:#333;
font-family:
Arial,
"Microsoft JhengHei",
sans-serif;
}
.wrap{
max-width:760px;
margin:auto;
padding:16px;
}
.hero{
background:#6b4d2e;
color:white;
padding:22px;
border-radius:18px;
text-align:center;
}
.hero h1{
font-size:25px;
margin:0;
}
.hero p{
margin:6px 0 0;
opacity:.85;
}
.card{
background:white;
margin-top:14px;
padding:17px;
border-radius:16px;
box-shadow:
0 3px 12px rgba(0,0,0,.08);
}
.status{
font-size:28px;
font-weight:bold;
color:#2e7d32;
}
.grid{
display:grid;
grid-template-columns:1fr 1fr;
gap:10px;
}
button{
border:0;
border-radius:12px;
padding:15px 8px;
font-size:16px;
font-weight:bold;
cursor:pointer;
}
.horse{background:#f6c96e;}
.turtle{background:#81d4fa;}
.flower{background:#f5a0bd;}
.total{background:#a5d6a7;}
.creator{background:#d7ccc8;}
.stop{
background:#c62828;
color:white;
}
.row{
display:flex;
justify-content:space-between;
border-bottom:1px solid #eee;
padding:7px 0;
}
.note{
font-size:13px;
color:#666;
}
</style>
</head>
<body>
<div class="wrap">
<div class="hero">
<h1>
🌳 水井三寶智慧互動展覽系統
</h1>
<p>
Edge Web Control V1.0
</p>
</div>
<div class="card">
<h2>
系統狀態
</h2>
<div
id="state"
class="status"
>
Loading...
</div>
<div id="statusDetail">
</div>
</div>
<div class="card">
<h2>
🎵 故事控制
</h2>
<div class="grid">
<button
class="horse"
onclick="playTrack(1)"
>
🐴 001 白馬故事
</button>
<button
class="turtle"
onclick="playTrack(2)"
>
🐢 002 烏龜故事
</button>
<button
class="flower"
onclick="playTrack(3)"
>
🌸 003 姻緣花故事
</button>
<button
class="total"
onclick="playTrack(4)"
>
🌳 004 水井三寶總故事
</button>
<button
class="creator"
onclick="playTrack(5)"
>
👨🎨 005 創作者資訊
</button>
<button
class="stop"
onclick="stopTrack()"
>
■ STOP
</button>
</div>
</div>
<div class="card">
<h2>
🔊 音量
</h2>
<input
id="vol"
type="range"
min="0"
max="30"
value="20"
style="width:100%"
onchange="setVolume(this.value)"
>
<div>
目前:
<b id="volText">
20
</b>
</div>
</div>
<div class="card">
<h2>
📊 即時匿名統計
</h2>
<div id="stats">
Loading...
</div>
<p class="note">
只記錄互動事件,
不記錄姓名、人臉、電話
或其他個人識別資料。
本版統計存在 RAM,
ESP32 重啟後歸零。
</p>
</div>
<div class="card">
<h2>
🔧 設備診斷
</h2>
<div id="diag">
Loading...
</div>
</div>
</div>
<script>
async function api(url){
const r=
await fetch(url);
const d=
await r.json();
if(!r.ok)
throw new Error(
d.reason ||
"Request failed"
);
return d;
}
async function playTrack(n){
try{
await api(
"/api/play?track="+n
);
refreshAll();
}catch(e){
alert(e.message);
}
}
async function stopTrack(){
try{
await api(
"/api/stop"
);
refreshAll();
}catch(e){
alert(e.message);
}
}
async function setVolume(v){
document
.getElementById(
"volText"
)
.textContent=v;
try{
await api(
"/api/volume?value="+v
);
}catch(e){}
}
async function loadStatus(){
try{
const d=
await api(
"/api/status"
);
document
.getElementById("state")
.textContent=d.state;
}catch(e){}
}
function refreshAll(){
loadStatus();
}
setInterval(
loadStatus,
1000
);
refreshAll();
</script>
</body>
</html>
)rawliteral";
const char* 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";
}
const char* storyName(Story s){
switch(s){
case STORY_HORSE:
return "白馬故事";
case STORY_TURTLE:
return "烏龜故事";
case STORY_FLOWER:
return "姻緣花故事";
case STORY_ALL:
return "水井三寶總故事";
case STORY_CREATOR:
return "創作者資訊";
default:
return "待機";
}
}
void sendJQ(
const uint8_t *data,
size_t length
){
JQ.write(
data,
length
);
JQ.flush();
}
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;
}
void startStory(
Story story,
bool fromWeb
){
if(
state==STATE_PLAYING
||
state==STATE_COOLDOWN
)
return;
currentStory=story;
busySeenPlaying=false;
switch(story){
case STORY_HORSE:
jqPlayTrack(1);
stats.horseCount++;
break;
case STORY_TURTLE:
jqPlayTrack(2);
stats.turtleCount++;
break;
case STORY_FLOWER:
jqPlayTrack(3);
stats.flowerCount++;
break;
case STORY_ALL:
jqPlayTrack(4);
stats.allStoryCount++;
break;
case STORY_CREATOR:
jqPlayTrack(5);
stats.creatorCount++;
break;
default:
return;
}
if(fromWeb)
stats.webCount++;
else
stats.buttonCount++;
playStartTime=
millis();
state=
STATE_PLAYING;
}
/* ================================
HTTP
================================ */
String getTarget(
const String &requestLine
){
int a=
requestLine.indexOf(' ');
int b=
requestLine.indexOf(
' ',
a+1
);
if(
a<0 ||
b<0
)
return "/";
return requestLine.substring(
a+1,
b
);
}
String getPathOnly(
const String &target
){
int q=
target.indexOf('?');
if(q<0)
return target;
return target.substring(
0,
q
);
}
String getQueryValue(
const String &target,
const String &key
){
int q=
target.indexOf('?');
if(q<0)
return "";
String query=
target.substring(
q+1
);
String token=
key+"=";
int s=
query.indexOf(
token
);
if(s<0)
return "";
s+=
token.length();
int e=
query.indexOf(
'&',
s
);
if(e<0)
e=query.length();
return query.substring(
s,
e
);
}
void routeRequest(
NetworkClient &client,
const String &requestLine
){
String target=
getTarget(
requestLine
);
String path=
getPathOnly(
target
);
if(path=="/"){
sendHeader(
client,
"text/html; charset=utf-8"
);
client.print(
WEB_PAGE
);
return;
}
if(
path==
"/api/play"
){
int track=
getQueryValue(
target,
"track"
).toInt();
if(
track<1 ||
track>5
){
sendJson(
client,
"{\"ok\":false,\"reason\":\"INVALID_TRACK\"}",
400,
"Bad Request"
);
return;
}
if(
state==STATE_PLAYING
||
state==STATE_COOLDOWN
){
sendJson(
client,
"{\"ok\":false,\"reason\":\"目前故事播放中\"}",
409,
"Conflict"
);
return;
}
startStory(
(Story)track,
true
);
sendJson(
client,
"{\"ok\":true}"
);
return;
}
}
/* ================================
ESP32 SoftAP
================================ */
void setupAP(){
Serial.println(
"\nStarting Shuijing SoftAP"
);
if(
!WiFi.softAP(
AP_SSID,
AP_PASSWORD
)
){
Serial.println(
"Soft AP creation failed."
);
while(true)
delay(1000);
}
IPAddress ip=
WiFi.softAPIP();
Serial.print(
"AP SSID : "
);
Serial.println(
AP_SSID
);
Serial.print(
"AP IP : "
);
Serial.println(ip);
Serial.println(
"URL : http://192.168.4.1"
);
server.begin();
Serial.println(
"Web Server started"
);
}
void setup(){
Serial.begin(
115200
);
delay(1000);
Serial.println(
"\nShuijing Treasures Layer 3 Edge Web V1.0"
);
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
);
Wire.begin(
TOF_SDA,
TOF_SCL
);
tofOK=
lox.begin();
setupAP();
state=
STATE_IDLE;
Serial.println(
"\nSYSTEM READY"
);
Serial.println(
"WiFi : Shuijing-Treasures"
);
Serial.println(
"Password: 12345678"
);
Serial.println(
"Open : http://192.168.4.1"
);
}
void loop(){
updateButtons();
updateToF();
runStateMachine();
handleWebClient();
}
十六、這個案例最值得教學生的是什麼?
2. Web Server: 一塊微控制器如何直接提供 HTML 網頁?
3. HTTP: 手機按一個按鈕,如何變成
GET /api/play?track=1?4. API: Web 前端如何透過 API 控制實體裝置?
5. UART: ESP32 收到 Web 命令後,如何再控制 JQ6500?
6. Edge Computing: 為什麼沒有 Internet 仍然能提供完整互動?
7. 多介面整合: 實體按鈕與手機 Web 如何共享同一套故事播放核心?
十七、從 Layer 2 到 Layer 3,是一次很重要的升級
Layer 2:
人
↓
實體按鈕
↓
ESP32
↓
UART
↓
JQ6500
Layer 3:
人
├── 實體按鈕
└── 手機瀏覽器
↓
HTTP API
↓
ESP32
↓
UART
↓
JQ6500
這表示同一套展品已經同時擁有「實體介面」與「數位介面」。
十八、下一步可以走向 QR Code × AI 導覽
當 Layer 3 已經具備手機 Web 後, 下一步可以進一步加入 QR Code。
↓
手機 Web
↓
作品識別
↓
Django / API
↓
LLM + RAG
↓
AI 地方故事導覽
到那時,手機就不只是播放固定 MP3, 而可以進一步詢問:
「白馬故事是怎麼來的?」
「為什麼水井村有烏龜的故事?」
「姻緣花代表什麼?」
「這件作品是誰創作的?」
結語:手機不是遙控器,而是智慧展品的新入口
Layer 3 V1.0 最重要的意義,不只是增加一個漂亮的手機畫面。
真正的改變是:
使用者不需要下載 App,也不需要依賴 Internet,
只要加入 Shuijing-Treasures,
再開啟 192.168.4.1,
就可以直接選擇故事。
從 GPIO、UART 到 HTTP API, 從實體按鈕到手機 Edge Web, 這正好形成一條完整的智慧生活科技學習路徑:
↓
控制
↓
通訊
↓
Web
↓
Edge
↓
AI
沒有留言:
張貼留言