2026年8月15日 星期六

[水井村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, 科技不再只是放在作品旁邊的設備, 而是開始成為地方故事與參觀者之間的互動媒介。

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

[水井村USR] ESP32 × Django × 雲端資料:水井三寶智慧互動展覽系統進入 Layer 4

ESP32 × Django × 雲端資料:水井三寶智慧互動展覽系統進入 Layer 4

從手機播放故事,到匿名事件上雲、設備 Heartbeat 與「今日水井三寶」即時儀表板
ESP32 Django PythonAnywhere HTTP API Edge Web Cloud Bridge 智慧展覽 匿名數據
「水井三寶智慧互動展覽系統」到了第四層 Layer 4,重點已經不再只是 「手機能不能控制 ESP32 播放故事」,而是進一步思考: 故事被播放之後,系統能不能把播放事件、互動來源、完成狀態與設備健康資訊 安全地傳送到 Django 伺服器長期保存,再轉換成可閱讀的展覽數據?

因此 Layer 4 是從「單一智慧展品」走向可管理、可分析、可擴充的智慧展覽平台的重要一步。

一、Layer 4 的「今日水井三寶」儀表板


圖1 Layer 4 Django「今日水井三寶」匿名事件儀表板

圖中的網址已經不再是 Layer 3 ESP32 本機的 192.168.4.1, 而是位於 PythonAnywhere 上的 Django 網站:

shuijingtreasures.pythonanywhere.com/exhibition/dashboard/

這表示系統已經從「手機直接連 ESP32」的 Edge Web, 再延伸到 Internet 上的 Django Data Layer。

儀表板目前可以呈現:

指標 意義
展區喚醒 EXHIBIT_WAKE 有效接近事件數
故事啟動 STORY_START 次數
完整播放 STORY_COMPLETE 次數
播放完成率 完整播放 ÷ 故事啟動
三寶與延伸故事 001~005 各故事播放統計
實體按鈕 source = button
Edge Web source = web
裝置健康 ESP32 Online / Offline、狀態、Firmware、RSSI

二、從 Layer 1 到 Layer 4:系統開始真正「分層」

Layer 1 文化內容與實體作品
Layer 2 ESP32、ToF、JQ6500、按鈕、LED
Layer 3 手機 Edge Web 與 HTTP API
Layer 4 Django、資料庫、雲端 API、Dashboard
實體展品

ESP32 智慧互動

手機 Edge Web

Internet / HTTPS

Django API

資料庫

今日水井三寶 Dashboard

三、使用者還是怎麼用手機播放故事?

到了 Layer 4,觀眾的操作並沒有變複雜。 Layer 4 是在後端增加資料能力,前端使用者仍然可以沿用 Layer 3 的簡單方式。

Step 1|手機連 ESP32 連接 ESP32 所建立的 Shuijing-Treasures Wi-Fi。
Step 2|開啟 Edge Web 在 Safari 或 Chrome 輸入 http://192.168.4.1
Step 3|點選故事 選擇 001~005, ESP32 控制 JQ6500 播放對應 MP3。
Layer 4 的關鍵:

使用者仍然只是「點故事」,但系統背後可以同步記錄:

故事何時開始?
是哪一個故事?
是實體按鈕還是手機 Web 啟動?
有沒有完整播放?
播放多久?
ESP32 現在是否在線?

這就是從「操作」走向「資料化」。

四、手機按下白馬故事之後,Layer 4 多做了什麼?

假設觀眾在手機上按下:

🐴 001 白馬故事

Layer 3 原本的流程是:

手機

GET /api/play?track=1

ESP32

UART

JQ6500

播放 001 白馬故事

到了 Layer 4,可以再增加一條資料路徑:

ESP32 STORY_START

HTTPS POST

Django API

ExhibitionEvent

資料庫

Dashboard 統計 +1

五、Layer 4 ESP32 Cloud Bridge 程式

第四層提供的 ESP32 雲端橋接程式, 主要工作是將互動事件透過 HTTPS 傳送到 PythonAnywhere。

/*
  第四層 ESP32 雲端橋接示例
  注意:只有 ESP32 已經有 Internet (STA) 時才可用。
  SoftAP 192.168.4.1 本身不代表能連 PythonAnywhere。

  需要:
    #include <WiFiClientSecure.h>
    #include <HTTPClient.h>
*/

#include <WiFiClientSecure.h>
#include <HTTPClient.h>

const char* CLOUD_BASE =
  "https://shuijingtreasures.pythonanywhere.com";

const char* DEVICE_ID =
  "SHUIJING-001";

const char* DEVICE_KEY =
  "CHANGE-ME";


bool postCloudEvent(
  const String& eventUUID,
  const String& eventType,
  const String& storyCode,
  const String& source,
  unsigned long durationMs = 0,
  bool completed = false
){

  if(WiFi.status() != WL_CONNECTED){
    return false;
  }

  WiFiClientSecure client;

  // 原型測試可先使用 setInsecure()
  // 正式展覽建議改用 CA 憑證驗證
  client.setInsecure();

  HTTPClient https;

  String url =
    String(CLOUD_BASE) +
    "/api/exhibition/events/";

  if(!https.begin(client,url)){
    return false;
  }

  https.addHeader(
    "Content-Type",
    "application/json"
  );

  https.addHeader(
    "X-Device-ID",
    DEVICE_ID
  );

  https.addHeader(
    "X-Device-Key",
    DEVICE_KEY
  );

  String body = "{";

  body +=
    "\"event_uuid\":\"" +
    eventUUID + "\",";

  body +=
    "\"event_type\":\"" +
    eventType + "\",";

  body +=
    "\"story\":\"" +
    storyCode + "\",";

  body +=
    "\"source\":\"" +
    source + "\",";

  body +=
    "\"duration_ms\":" +
    String(durationMs) + ",";

  body +=
    "\"completed\":" +
    String(
      completed ? "true" : "false"
    ) + ",";

  body +=
    "\"network_status\":\"online\"";

  body += "}";

  int code =
    https.POST(body);

  String response =
    https.getString();

  Serial.print(
    "Cloud HTTP = "
  );

  Serial.println(code);

  Serial.println(response);

  https.end();

  return
    code >= 200 &&
    code < 300;
}

六、ESP32 傳給 Django 的不是「人」,而是「事件」

例如白馬故事被手機啟動時,可以傳送:

{
  "event_uuid":
    "SHUIJING-001-000001",

  "event_type":
    "STORY_START",

  "story":
    "001",

  "source":
    "web",

  "network_status":
    "online",

  "metadata":
    {}
}

其中最值得注意的是:

欄位 內容
event_uuid 每筆事件的唯一編號
event_type STORY_START、STORY_COMPLETE、STOP 等
story 001~005
source button、web、system、qr
duration_ms 播放持續時間
completed 是否完整播放
network_status 事件同步時的網路狀態
本系統第四層的設計方向是: 記錄展品事件,而不是追蹤個人。

不記錄姓名、人臉、手機 MAC、IP 或 visitor_id, 而是分析「展品被怎麼使用」。

七、Django 的第一個核心:ExhibitionEvent 資料模型

Layer 4 使用 Django Model 將展覽事件正式資料化:

class ExhibitionEvent(models.Model):

    EVENT_TYPES = [
        ("DEVICE_BOOT", "設備啟動"),
        ("EXHIBIT_WAKE", "展區喚醒"),
        ("STORY_START", "故事開始"),
        ("STORY_COMPLETE", "故事完成"),
        ("PLAY_TIMEOUT", "播放逾時"),
        ("STOP", "停止播放"),
        ("DEVICE_ERROR", "設備錯誤"),
        ("QR_OPEN", "QR延伸閱讀"),
    ]

    SOURCE_CHOICES = [
        ("button", "實體按鈕"),
        ("web", "Edge Web"),
        ("system", "系統"),
        ("qr", "QR"),
    ]

    event_uuid = models.CharField(
        max_length=80,
        unique=True
    )

    device = models.ForeignKey(
        Device,
        on_delete=models.CASCADE,
        related_name="events"
    )

    exhibition = models.ForeignKey(
        Exhibition,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="events"
    )

    event_type = models.CharField(
        max_length=30,
        choices=EVENT_TYPES,
        db_index=True
    )

    story = models.ForeignKey(
        Story,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="events"
    )

    source = models.CharField(
        max_length=20,
        choices=SOURCE_CHOICES,
        default="system"
    )

    duration_ms = models.PositiveIntegerField(
        null=True,
        blank=True
    )

    completed = models.BooleanField(
        null=True,
        blank=True
    )

    network_status = models.CharField(
        max_length=20,
        blank=True
    )

    metadata = models.JSONField(
        default=dict,
        blank=True
    )

    created_at = models.DateTimeField(
        auto_now_add=True,
        db_index=True
    )

八、event_uuid:避免同一事件被算兩次

IoT 系統很常遇到一個問題:

ESP32 POST
↓
伺服器其實已收到
↓
網路瞬間中斷
↓
ESP32 以為失敗
↓
重新 POST
↓
同一事件被記錄兩次?

Layer 4 使用:

event_uuid = models.CharField(
    max_length=80,
    unique=True
)

伺服器端則使用:

obj, created =
    ExhibitionEvent.objects.get_or_create(
        event_uuid=event_uuid,
        defaults=defaults,
    )
相同 event_uuid 不會重複新增。 這就是 IoT 資料同步中非常重要的「冪等性 Idempotency」概念。

九、Django 如何確認是哪一台 ESP32?

ESP32 發送 HTTP Request 時會帶兩個 Header:

X-Device-ID: SHUIJING-001
X-Device-Key: <Device API Key>

Django 端:

def _get_device(request):

    device_id = request.headers.get(
        "X-Device-ID",
        ""
    ).strip()

    api_key = request.headers.get(
        "X-Device-Key",
        ""
    ).strip()

    if not device_id or not api_key:
        return None

    try:

        device = Device.objects.get(
            device_id=device_id,
            is_active=True
        )

    except Device.DoesNotExist:

        return None

    if not secrets.compare_digest(
        device.api_key,
        api_key
    ):
        return None

    return device

所以不是任何人向 API POST 一筆 JSON, 都可以被系統接受。

十、接收單一事件的 Django API

@csrf_exempt
@require_POST
def api_event(request):

    device =
        _get_device(request)

    if not device:

        return JsonResponse(
            {
                "ok": False,
                "error": "unauthorized"
            },
            status=401
        )

    payload =
        _json_body(request)

    if payload is None:

        return JsonResponse(
            {
                "ok": False,
                "error": "invalid json"
            },
            status=400
        )

    event, error =
        _create_event(
            device,
            payload
        )

    if error:

        return JsonResponse(
            {
                "ok": False,
                "error": error
            },
            status=400
        )

    return JsonResponse(
        {
            "ok": True,
            "event_uuid":
                event.event_uuid
        },
        status=201
    )

對應網址:

POST /api/exhibition/events/

十一、如果斷線,Layer 4 還準備了 Batch 補傳 API

展場 IoT 裝置不能假設 Internet 永遠在線。 因此 Layer 4 另外設計:

POST /api/exhibition/events/batch/

讓 ESP32 或閘道可以把離線期間暫存的多筆事件, 恢復網路後一次補傳。

@csrf_exempt
@require_POST
def api_event_batch(request):

    device =
        _get_device(request)

    if not device:

        return JsonResponse(
            {
                "ok": False,
                "error": "unauthorized"
            },
            status=401
        )

    payload =
        _json_body(request)

    items =
        payload.get(
            "events",
            []
        )

    accepted = []
    rejected = []

    for item in items[:200]:

        event, error =
            _create_event(
                device,
                item
            )

        if error:

            rejected.append({
                "event_uuid":
                    item.get("event_uuid"),
                "error":
                    error,
            })

        else:

            accepted.append(
                event.event_uuid
            )

    return JsonResponse({
        "ok": True,
        "accepted": accepted,
        "rejected": rejected,
    })

十二、Heartbeat:Dashboard 為什麼知道 ESP32 是 OFFLINE?

附圖右下方顯示:

SHUIJING-001     OFFLINE

這不是人工設定,而是利用 Device Heartbeat。

ESP32 定期向:

POST /api/exhibition/heartbeat/

傳送自己的狀態。 Django 記錄:

device.last_seen =
    timezone.now()

device.last_state =
    str(
        payload.get(
            "state",
            ""
        )
    )[:30]

device.firmware_version =
    str(
        payload.get(
            "firmware",
            ""
        )
    )[:30]

device.last_rssi =
    rssi

Dashboard 則設定:

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

如果 ESP32 超過約 10 分鐘沒有 Heartbeat, 就會被判定為 Offline。

這使 Layer 4 不只是在分析「觀眾」, 也開始管理設備健康 Device Health

十三、Dashboard 如何算今天的故事播放數?

Django 先取得今天的事件:

today = timezone.localdate()

start = timezone.make_aware(
    timezone.datetime.combine(
        today,
        timezone.datetime.min.time()
    )
)

qs =
    ExhibitionEvent.objects.filter(
        created_at__gte=start
    )

再計算:

wake =
    qs.filter(
        event_type="EXHIBIT_WAKE"
    ).count()

starts =
    qs.filter(
        event_type="STORY_START"
    ).count()

completes =
    qs.filter(
        event_type="STORY_COMPLETE"
    ).count()

完成率:

completion_rate =
    round(
        (
            completes
            /
            starts
            *
            100
        ),
        1
    )
    if starts
    else 0

因此附圖最上方的四個數值:

展區喚醒
故事啟動
完整播放
播放完成率

全部都是由真實 ExhibitionEvent 計算出來。

十四、「互動來源」可以比較實體按鈕和手機 Web

這是 Layer 4 很有價值的一個指標。

web_count =
    qs.filter(
        event_type="STORY_START",
        source="web"
    ).count()

button_count =
    qs.filter(
        event_type="STORY_START",
        source="button"
    ).count()

因此 Dashboard 可以回答:

觀眾比較喜歡直接按作品上的實體按鈕, 還是拿手機操作 Edge Web?

這種資訊可以再回過頭改善下一版的互動介面。

十五、五個故事也有自己的 Django Story Model

class Story(models.Model):

    code = models.CharField(
        max_length=3,
        unique=True
    )

    slug = models.CharField(
        max_length=40,
        unique=True
    )

    title = models.CharField(
        max_length=100
    )

    public_url = models.URLField(
        max_length=300
    )

    is_active = models.BooleanField(
        default=True
    )

目前 Layer 4 規劃的五個故事網站為:

代碼 內容 網站路徑
001 白馬 /treasures/white-horse/
002 烏龜 /treasures/turtle/
003 姻緣花 /treasures/marriage-flower/
004 水井三寶總故事 /
005 品牌/創作者資訊 /about/

十六、Story Map API 為未來 QR Code 與手機導覽準備

@require_GET
def api_story_map(request):

    stories =
        Story.objects.filter(
            is_active=True
        ).order_by("code")

    return JsonResponse({
        "stories": [
            {
                "code": s.code,
                "title": s.title,
                "slug": s.slug,
                "url": s.public_url,
            }
            for s in stories
        ]
    })

對應:

GET /api/exhibition/story-map/

這代表未來手機、QR Code 或其他導覽裝置, 不需要把 001~005 的網站網址寫死在程式裡, 而可以從 Django 動態取得。

十七、Layer 4 的 Django URL 設計

from django.urls import path
from . import views

app_name = "exhibition"

urlpatterns = [

    path(
        "api/exhibition/events/",
        views.api_event,
        name="api_event"
    ),

    path(
        "api/exhibition/events/batch/",
        views.api_event_batch,
        name="api_event_batch"
    ),

    path(
        "api/exhibition/heartbeat/",
        views.api_heartbeat,
        name="api_heartbeat"
    ),

    path(
        "api/exhibition/story-map/",
        views.api_story_map,
        name="api_story_map"
    ),

    path(
        "api/exhibition/dashboard-data/",
        views.api_dashboard_data,
        name="api_dashboard_data"
    ),

    path(
        "exhibition/dashboard/",
        views.dashboard,
        name="dashboard"
    ),
]

十八、這裡有一個非常重要的網路觀念

ESP32 SoftAP 不等於 Internet。

Layer 3 的 192.168.4.1, 只是讓手機連到 ESP32 本機。

如果 ESP32 要主動把事件 POST 到:

https://shuijingtreasures.pythonanywhere.com

ESP32 還必須另外取得 Internet 連線。

所以真正完整的 Layer 4 架構應該是:

                    ┌── 手機
                    │
                    │ SoftAP
                    ▼
                ESP32 Layer 3
                    │
                    │ STA / Internet
                    ▼
             Django Layer 4
                    │
                    ▼
                 Database

也就是可以考慮:

AP + STA 同時運作

其中:

模式 用途
AP 讓觀眾手機連到 ESP32
STA 讓 ESP32 連上場域 Wi-Fi 與 Internet

十九、從手機播放一次故事,看完整 Layer 4 資料生命週期

① 使用者拿出手機

② 連接 Shuijing-Treasures

③ 開啟 192.168.4.1

④ 點「001 白馬故事」

⑤ ESP32 接收到 Web API

⑥ JQ6500 播放白馬 MP3

⑦ ESP32 產生 STORY_START

⑧ source = web、story = 001

⑨ HTTPS POST 到 Django

⑩ Django 驗證 Device ID / API Key

⑪ ExhibitionEvent 寫入資料庫

⑫ Dashboard「故事啟動」+1

⑬ 「Edge Web」+1

⑭ 白馬故事 +1

等到 JQ6500 BUSY 顯示播放完成, ESP32 還可以再產生:

STORY_COMPLETE

讓 Dashboard 進一步計算完成率。

二十、Layer 4 最值得教學生的是什麼?

1. Edge 與 Cloud 的差異
192.168.4.1 是 Edge,PythonAnywhere 是 Cloud。

2. HTTP API
ESP32 不只是接收 Web 指令,也可以主動 POST JSON 到 Django。

3. Django Model
互動行為如何從瞬間事件變成可長期保存的結構化資料。

4. Device Authentication
為什麼 ESP32 要有 Device ID 與 API Key。

5. Idempotency
event_uuid 如何避免網路重傳造成重複計算。

6. Offline First
為什麼真實 IoT 系統要考慮斷線與 Batch 補傳。

7. Heartbeat
伺服器如何知道遠端 ESP32 是否仍正常工作。

8. Data Visualization
如何把原始事件轉變成展區喚醒、故事啟動、完成率與互動來源。

二十一、從「播放故事」進化成「知道故事如何被使用」

Layer 2 解決的是:

作品如何播放故事?

Layer 3 解決的是:

手機如何控制作品播放故事?

而 Layer 4 開始回答:

今天有多少人觸發展品?

哪一個故事最常被播放?

手機 Web 和實體按鈕,
哪一種互動方式比較常被使用?

有多少故事真正播放完成?

設備現在是不是正常在線?
這正是從「智慧裝置」走向「智慧場域」的重要差異。

二十二、下一步:從 Layer 4 走向 AI 智慧導覽

當 Django 已經掌握 Story、Device、Event 與 Dashboard 後, 下一步就可以把這些結構化資料再提供給更高層應用。

Layer 4 Django Data

Story API + Event Data

RAG Knowledge Base

LLM

手機 AI 導覽

「我想知道白馬的完整故事」
「姻緣花為什麼叫姻緣花?」
「這件作品是誰創作的?」

到這個階段,「水井三寶」就會從固定播放 MP3 的智慧展品, 逐步走向可以查詢、推薦與對話的 AI 文化導覽系統。

結語:Layer 4 讓每一次互動都成為改善展覽的資料

對使用者來說,操作依然很簡單:

拿出手機

連接 ESP32

選擇故事

聽故事

但是在系統背後,Layer 4 已經可以進一步完成:

播放事件

匿名資料

HTTPS

Django

Database

Dashboard

分析與改善
智慧展覽真正的價值,不只是讓展品「會說故事」, 而是讓系統知道故事如何被使用,並利用資料持續改善下一次的互動。

從 ESP32、JQ6500、ToF 與 RGB LED, 到手機 Edge Web,再到 Django 與 PythonAnywhere, 「水井三寶智慧互動展覽系統」已經形成一條相當完整的智慧生活科技學習路徑:

文化故事

感測與控制

UART

手機 Web

HTTP API

Edge Computing

Cloud / Django

Data

AI
案例: 水井三寶智慧互動展覽系統
系統層級: Layer 4 Data / Django Layer
邊緣設備: ESP32 NodeMCU-32S
語音模組: JQ6500
手機互動: Layer 3 Edge Web
雲端平台: Django × PythonAnywhere
裝置: SHUIJING-001
主要 API: events、events/batch、heartbeat、story-map、dashboard-data
核心技術: ESP32、HTTPS、JSON、Django Model、REST-like API、Device Authentication、Idempotency、Heartbeat、Dashboard

[水井村USR] ESP32 Edge Web × 手機:讓水井三寶智慧互動展覽進入第三層

ESP32 Edge Web × 手機:讓水井三寶智慧互動展覽進入第三層

Layer 3 V1.0:不需要外部網路,手機連上 ESP32 就能播放白馬、烏龜、姻緣花與水井三寶故事
ESP32 Edge Web SoftAP HTTP API JQ6500 手機互動 智慧展覽
「水井三寶智慧互動展覽系統」在 Layer 2 已經完成 ESP32、JQ6500、 VL53L0X、實體按鈕與 WS2812B 的軟硬體整合;到了 Layer 3, 則進一步加入 ESP32 SoftAP 與 Web Server, 讓手機不需要下載 App,也不需要連上 Internet, 只要加入 ESP32 建立的 Wi-Fi,就能透過瀏覽器直接控制故事播放。

一、手機就是水井三寶的新互動入口


圖1 水井三寶智慧互動展覽系統 Layer 3 Edge Web Control V1.0 手機操作畫面

從手機畫面可以看到,ESP32 已經不只是控制器, 還同時扮演一台小型 Web Server。

使用者可以直接看到目前狀態、正在播放的故事、ToF 距離、 JQ6500 BUSY、AP IP、目前連線裝置數, 並透過按鈕播放:

編號 內容
001 白馬故事
002 烏龜故事
003 姻緣花故事
004 水井三寶總故事
005 創作者資訊

二、使用者如何用手機播放故事?

Step 1|連接 ESP32 Wi-Fi 手機進入 Wi-Fi 設定,選擇 Shuijing-Treasures
Step 2|打開瀏覽器 使用 Safari、Chrome 或其他瀏覽器, 開啟 192.168.4.1
Step 3|選擇故事 點選白馬、烏龜、姻緣花、總故事或創作者資訊, ESP32 就會控制 JQ6500 播放。
使用者操作資訊:

Wi-Fi SSID:Shuijing-Treasures
Password: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。

這種架構特別適合展覽、社區場域、戶外活動或網路品質不穩定的地方, 因為即使現場沒有 Internet,手機仍然可以直接與 ESP32 溝通。

四、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 開機後就會同時具備:

Wi-Fi Access Point

HTTP Web Server

實體裝置控制器

五、這就是 Edge Web

傳統雲端 Web 控制可能是:

手機
 ↓
Internet
 ↓
Cloud Server
 ↓
ESP32

Layer 3 則是:

手機
 ↓
Wi-Fi
 ↓
ESP32

Web Server 就直接執行在展品旁邊的 ESP32 上, 所以這裡可以稱為:

Edge Web Control:把網頁控制服務直接放在邊緣裝置上。

六、手機按下「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

整個控制流程為:

手機點「001 白馬故事」

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 3:手機 + Web + HTTP API

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 了解中途停止情形
本版只記錄互動事件,不記錄姓名、人臉、電話或其他個人識別資料; 統計資料存在 ESP32 RAM 中,重新開機後歸零。

十四、手機也能直接調整 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);
手機音量 Slider

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();

}

十六、這個案例最值得教學生的是什麼?

1. SoftAP: ESP32 如何自己成為 Wi-Fi 基地台?

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。

QR Code

手機 Web

作品識別

Django / API

LLM + RAG

AI 地方故事導覽

到那時,手機就不只是播放固定 MP3, 而可以進一步詢問:

「白馬故事是怎麼來的?」

「為什麼水井村有烏龜的故事?」

「姻緣花代表什麼?」

「這件作品是誰創作的?」

結語:手機不是遙控器,而是智慧展品的新入口

Layer 3 V1.0 最重要的意義,不只是增加一個漂亮的手機畫面。

真正的改變是:

ESP32 開始提供 Web Service,而實體文化展品開始擁有自己的數位入口。

使用者不需要下載 App,也不需要依賴 Internet, 只要加入 Shuijing-Treasures, 再開啟 192.168.4.1, 就可以直接選擇故事。

從 GPIO、UART 到 HTTP API, 從實體按鈕到手機 Edge Web, 這正好形成一條完整的智慧生活科技學習路徑:

感測

控制

通訊

Web

Edge

AI
案例:水井三寶智慧互動展覽系統
系統層級:Layer 3 Edge Web V1.0
控制核心:ESP32 NodeMCU-32S
手機連線:ESP32 SoftAP
Web Server:HTTP Port 80
語音控制:JQ6500 UART
互動方式:實體按鈕+手機 Web
核心技術:Wi-Fi SoftAP、HTTP、API、UART、Edge Computing

[水井村USR] ESP32 × JQ6500:打造會感應、會發光、會說故事的「水井三寶」智慧互動展覽系統

ESP32 × JQ6500:打造會感應、會發光、會說故事的「水井三寶」智慧互動展覽系統

從 UART 的 TX/RX 交叉接線,理解 ESP32 如何控制語音模組,並進一步整合 ToF、按鈕、WS2812 與狀態機
ESP32 JQ6500 UART VL53L0X WS2812B 智慧展覽 IoT實作
「水井三寶智慧互動展覽系統」第二層 Layer 2 的任務,是讓原本靜態的白馬、烏龜與姻緣花作品, 進一步具有感應、選擇、語音與燈光互動能力。 系統以 ESP32 NodeMCU-32S 為控制核心,利用 VL53L0X 偵測觀眾靠近, 再以三顆實體按鈕選擇故事,透過 UART 控制 JQ6500 播放語音, 並同步驅動 62 顆 WS2812B RGB LED 呈現不同故事燈效。

一、Layer 2:從靜態展品走向智慧互動

智慧展覽的核心並不只是「放一塊 ESP32」,而是要思考: 當觀眾走近作品之後,系統如何感知?如何判斷?如何回應?

觀眾靠近

VL53L0X ToF 距離感測

ESP32 判斷互動狀態

白馬/烏龜/姻緣花三按鈕選擇

ESP32 透過 UART 命令 JQ6500 播放故事

WS2812B 同步產生故事燈效

整套 Layer 2 因此包含四種重要能力: 感知、判斷、通訊與回饋。 其中 ESP32 與 JQ6500 之間的 UART 通訊,是整個「讓作品開口說故事」的關鍵。

二、水井三寶智慧互動層 V1.1 接線圖

圖1 水井三寶智慧互動展覽系統|第二層 智慧互動層 接線圖 V1.1(修正版)

V1.1 接線圖中特別重新整理了 JQ6500 UART 部分。 原因是 UART 最常見的錯誤,就是把 TX 接到 TX、RX 接到 RX。

三、ESP32 與 JQ6500 最重要的觀念:TX 要接 RX

正確 UART 接線:

ESP32 GPIO26(TX) → JQ6500 RX
ESP32 GPIO27(RX) ← JQ6500 TX
JQ6500 BUSY → ESP32 GPIO34
ESP32 GND ↔ JQ6500 GND
ESP32                         JQ6500
──────────────────────────────────────
GPIO26 / TX  ───────────────→ RX
GPIO27 / RX  ←─────────────── TX
GPIO34       ←─────────────── BUSY
GND          ──────────────── GND

UART 是 Universal Asynchronous Receiver/Transmitter, 中文通常稱為通用非同步收發傳輸

TX:Transmit

負責把資料「送出去」,就像裝置的嘴巴。

RX:Receive

負責把資料「收進來」,就像裝置的耳朵。

因此兩個設備互相溝通時,必須是:

TX → RX
RX ← TX
可以讓學生記住一句很簡單的 UART 口訣: 「TX 找 RX,RX 找 TX。」

如果接成:

TX → TX
RX → RX

就像兩個人的「嘴巴接嘴巴、耳朵接耳朵」, 當然無法形成正常的資料傳遞。

四、為什麼程式寫 JQ_TX_PIN = 26?

Layer 2 V1.0 程式一開始定義:

#define JQ_TX_PIN 26
#define JQ_RX_PIN 27
#define JQ_BUSY_PIN 34

這裡的名稱很容易讓初學者混淆。

JQ_TX_PIN 所代表的 GPIO26,是 ESP32 用來把指令送給 JQ6500 的 TX 腳

因此 GPIO26 必須接到:

ESP32 GPIO26 TX → JQ6500 RX

同樣地,GPIO27 是 ESP32 的接收腳:

ESP32 GPIO27 RX ← JQ6500 TX

五、ESP32 使用 UART2 與 JQ6500 通訊

程式建立一個硬體序列埠:

HardwareSerial JQ(2);

這代表我們使用 ESP32 的 Hardware UART2 專門負責和 JQ6500 溝通。

setup() 中:

JQ.begin(9600,SERIAL_8N1,JQ_RX_PIN,JQ_TX_PIN);

這一行的參數概念可以理解成:

JQ.begin(
    傳輸速率,
    資料格式,
    ESP32 RX腳,
    ESP32 TX腳
);

代入目前設定,就是:

JQ.begin(
    9600,
    SERIAL_8N1,
    27,
    26
);
ESP32 功能 JQ6500
GPIO26 ESP32 TX RX
GPIO27 ESP32 RX TX
GPIO34 讀取播放狀態 BUSY

六、ESP32 如何命令 JQ6500 播放聲音?

ESP32 不會直接把「播放白馬故事」這句文字傳給 JQ6500, 而是依照 JQ6500 的通訊格式,送出一串位元組命令。

Layer 2 程式把 UART 傳送功能集中在:

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();
}

真正把資料傳到 JQ6500 的核心就是:

JQ.write(data,length);

這些資料會經由:

ESP32 UART2

GPIO26 TX

JQ6500 RX

七、播放第 1、2、3 首故事

播放指定音軌的函式為:

void jqPlayTrack(uint16_t track){
  uint8_t cmd[]={
    0x7E,
    0x04,
    0x03,
    (uint8_t)(track>>8),
    (uint8_t)(track&0xFF),
    0xEF
  };

  sendJQ(cmd,sizeof(cmd));
}

當白馬按鈕被按下:

jqPlayTrack(1);

烏龜則是:

jqPlayTrack(2);

姻緣花則是:

jqPlayTrack(3);
故事 按鈕 GPIO 播放音軌
白馬故事 GPIO32 Track 1
烏龜故事 GPIO33 Track 2
姻緣花故事 GPIO25 Track 3

因此形成了一條完整的事件鏈:

使用者按下實體按鈕

ESP32 GPIO 偵測 LOW

程式判斷故事類別

jqPlayTrack()

UART 傳送命令

JQ6500 播放 MP3

喇叭播放水井三寶故事

八、只有 TX/RX 還不夠:BUSY 是重要的回饋訊號

如果 ESP32 只是把播放命令送出去,它並不知道 JQ6500 到底何時播放完成。

因此 Layer 2 又加入一條非常重要的訊號:

JQ6500 BUSY → ESP32 GPIO34

程式設定:

#define JQ_BUSY_PIN 34

初始化:

pinMode(JQ_BUSY_PIN,INPUT);

接著利用:

bool jqIsBusy(){
  bool level=digitalRead(JQ_BUSY_PIN);
  return BUSY_ACTIVE_HIGH ? level==HIGH : level==LOW;
}

讓 ESP32 知道 JQ6500 是否正在播放。

這裡呈現一個非常重要的智慧系統觀念:
UART TX 是「ESP32 命令 JQ6500」,BUSY 則是「JQ6500 回報自己的工作狀態」。 因此系統不再只是單純的單向控制,而具有基本的回饋機制。

九、BUSY_ACTIVE_HIGH 為什麼要能調整?

程式中有:

// 若實測播放時 BUSY=LOW,改成 false
const bool BUSY_ACTIVE_HIGH=true;

不同版本的 JQ6500 模組或板子,其 BUSY 邏輯可能需要依實機確認。

如果實測發現「播放時 BUSY 是 LOW」,只要改成:

const bool BUSY_ACTIVE_HIGH=false;

這種設計比把 HIGH/LOW 直接寫死在各處更好, 因為只需要修改一個參數,就能適應不同硬體。

十、為什麼播放開始後要等待 300ms?

程式設定:

const unsigned long BUSY_GUARD_MS=300;

播放狀態中:

if(millis()-playStartTime<BUSY_GUARD_MS)break;

原因是 ESP32 執行速度非常快。 剛把 UART 播放命令送出去時,JQ6500 可能還需要一小段時間開啟音檔、開始解碼並改變 BUSY 狀態。

如果 ESP32 太早讀 BUSY,就可能把「尚未開始播放」誤認為「已經播放完畢」。

這也是實作 IoT 與嵌入式系統時很值得教學生的觀念: 程式執行完成,不等於外部硬體已經完成動作。

十一、播放結束後還有 2 秒 Cooldown

程式另外設定:

const unsigned long COOLDOWN_MS=2000;

播放完成後,不會立即重新接受下一次按鍵,而是進入 STATE_COOLDOWN

這可以避免使用者手指還沒有離開按鈕, 就再度觸發同一段語音。

十二、JQ6500 只是 Layer 2 的其中一部分

Layer 2 其實整合了多種不同的通訊與控制方式:

模組 ESP32 接腳 技術 用途
JQ6500 GPIO26、27 UART MP3 故事語音控制
JQ6500 BUSY GPIO34 Digital Input 確認語音播放狀態
VL53L0X GPIO21、22 I²C 觀眾距離感測
白馬按鈕 GPIO32 Digital Input 選擇白馬故事
烏龜按鈕 GPIO33 Digital Input 選擇烏龜故事
姻緣花按鈕 GPIO25 Digital Input 選擇姻緣花故事
WS2812B × 62 GPIO13 單線數位控制 故事燈光效果

十三、62 顆 WS2812B 如何分區?

程式把 62 顆 LED 分成四個區域:

#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
燈區 LED 編號 數量
底座環境燈 0~23 24
白馬 24~33 10
烏龜 34~45 12
姻緣花 46~61 16

不同故事播放時,就可以只控制對應區域, 讓「聲音」和「燈光」建立關聯。

十四、真正讓智慧展品穩定運作的是「狀態機」

程式不是只寫:

if(按鈕按下){
  播放MP3;
}

而是定義:

enum SystemState{
  STATE_IDLE,
  STATE_ATTRACT,
  STATE_READY,
  STATE_PLAYING,
  STATE_COOLDOWN,
  STATE_ERROR
};
IDLE 等待

ATTRACT 吸引觀眾

READY 等待選擇

PLAYING 播放故事

COOLDOWN 防止連續誤觸

回到 READY 或 IDLE

這就是有限狀態機(Finite State Machine, FSM)的概念。

一旦系統從「單一 if 判斷」提升成狀態機, 程式就比較容易管理真正展場中可能出現的各種情況。

十五、Layer 2 V1.0 完整程式

以下為目前「水井三寶智慧互動展覽系統」第二層智慧互動層 Layer 2 V1.0 的完整 Arduino/ESP32 程式。

/*
 水井三寶智慧互動展覽系統
 第二層|智慧互動層 V1.0
 ESP32 NodeMCU-32S + VL53L0X + 3 Buttons + JQ6500 + WS2812
*/

#include <Arduino.h>
#include <Wire.h>
#include <HardwareSerial.h>
#include <Adafruit_VL53L0X.h>
#include <Adafruit_NeoPixel.h>

#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=120000;
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;

// 若實測播放時 BUSY=LOW,改成 false
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
};

SystemState state=STATE_IDLE;
Story currentStory=STORY_NONE;

struct Button{
  uint8_t pin;
  bool stableState;
  bool lastReading;
  unsigned long lastChange;
  bool pressedEvent;
};

Button horseButton,turtleButton,flowerButton;

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;

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";
}

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;
}

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;

  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;
}

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,d=1;

  if(millis()-last<80)
    return;

  last=millis();
  b+=d;

  if(b>=14)d=-1;
  if(b<=4)d=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,d=1;

  if(millis()-last<60)
    return;

  last=millis();
  b+=d;

  if(b>=35)d=-1;
  if(b<=8)d=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=(pos+1)%HORSE_COUNT;
  strip.show();
}

void ledTurtle(){
  static unsigned long last=0;
  static int b=10,d=1;

  if(millis()-last<70)
    return;

  last=millis();
  b+=d;

  if(b>=80)d=-1;
  if(b<=10)d=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 updateStoryLED(){
  switch(currentStory){
    case STORY_HORSE:
      ledHorse();
      break;

    case STORY_TURTLE:
      ledTurtle();
      break;

    case STORY_FLOWER:
      ledFlower();
      break;

    default:
      ledReady();
      break;
  }
}

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)
      personDetected=true;

  }else{
    personTiming=false;
    personDetected=false;
  }
}

void startStory(Story story){

  if(state==STATE_PLAYING ||
     state==STATE_COOLDOWN)
    return;

  currentStory=story;
  busySeenPlaying=false;
  clearButtonEvents();

  if(story==STORY_HORSE)
    jqPlayTrack(1);

  else if(story==STORY_TURTLE)
    jqPlayTrack(2);

  else if(story==STORY_FLOWER)
    jqPlayTrack(3);

  playStartTime=millis();
  state=STATE_PLAYING;
}

void handleButtons(){

  if(horseButton.pressedEvent){
    startStory(STORY_HORSE);
    return;
  }

  if(turtleButton.pressedEvent){
    startStory(STORY_TURTLE);
    return;
  }

  if(flowerButton.pressedEvent){
    startStory(STORY_FLOWER);
    return;
  }
}

void runStateMachine(){

  switch(state){

    case STATE_IDLE:

      ledIdle();
      handleButtons();

      if(state!=STATE_PLAYING &&
         personDetected){

        state=STATE_ATTRACT;
        stateStartTime=millis();
      }

      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()){

        cooldownStartTime=millis();
        state=STATE_COOLDOWN;
      }

      if(millis()-playStartTime>MAX_PLAY_MS){

        jqStop();

        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;
  }
}

void setup(){

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

  Serial.println(
    "\nShuijing Treasures Layer 2 V1.0"
  );

  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(20);

  Wire.begin(TOF_SDA,TOF_SCL);

  tofOK=lox.begin();

  Serial.println(
    tofOK
    ? "VL53L0X OK"
    : "VL53L0X ERROR -> button-only mode"
  );

  Serial.println("SYSTEM READY");
}

void loop(){

  updateButtons();
  updateToF();
  runStateMachine();

  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();
  }
}

十六、從 Arduino 範例走向真正的智慧互動系統

如果只看單一元件,JQ6500 是 MP3 模組、VL53L0X 是距離感測器、 WS2812B 是 RGB LED,而 ESP32 只是一塊微控制器。

但當這些元件透過程式整合之後,就形成了一個完整的智慧互動系統:

感知 Perception

判斷 Decision

通訊 Communication

行動 Action

回饋 Feedback

ToF 負責「感知」有人靠近; ESP32 負責「判斷」目前應該進入什麼狀態; UART 負責與 JQ6500「通訊」; JQ6500 與 WS2812B 負責產生聲音與燈光「行動」; BUSY 訊號再把 JQ6500 的狀態「回饋」給 ESP32。

十七、最值得拿來教學生的,不只是 GPIO 腳位

這個作品若只當成「接線實習」,學生可能只記住:

GPIO26
GPIO27
GPIO34

但真正應該學會的是:

1. UART:為什麼 TX 要接 RX?

2. 通訊協定:ESP32 如何把命令變成位元組送給 JQ6500?

3. Feedback:為什麼還需要 BUSY?

4. 非同步思維:為什麼送出命令後不能立刻假設硬體完成?

5. 狀態機:如何避免互動流程互相干擾?

6. 系統整合:如何讓感測器、聲音、燈光與地方故事形成完整體驗?

十八、結語:從兩條 UART 線,讓地方故事真正「活起來」

在整套水井三寶智慧互動展覽系統裡, ESP32 與 JQ6500 之間只有幾條線, 其中最不起眼的可能就是 TX 與 RX。

但正是:

ESP32 TX → JQ6500 RX
ESP32 RX ← JQ6500 TX

讓一塊微控制器可以命令另一個語音裝置工作。

再加入 BUSY、ToF、按鈕、RGB LED 與狀態機後, 原本只被觀看的地方工藝作品, 就開始具有感知環境、理解操作、播放故事與回應觀眾的能力。

這也是智慧生活科技很重要的核心精神:

科技不是目的,而是讓人、地方、文化與故事產生新的互動方式。

從 ESP32 與 JQ6500 的 UART 通訊開始, 學生學到的不只是「如何播放 MP3」, 而是如何讓不同裝置彼此溝通, 最後形成一套真正可以放進場域中持續運作的智慧系統。

案例:水井三寶智慧互動展覽系統
系統層級:Layer 2 智慧互動層 V1.0
接線版本:Wiring Diagram V1.1
控制核心:ESP32 NodeMCU-32S
語音模組:JQ6500
距離感測:VL53L0X ToF
視覺輸出:62 顆 WS2812B RGB LED
核心技術:UART、I²C、GPIO、WS2812B、Finite State Machine

2026年8月14日 星期五

[水井村USR] ESP32 × JQ6500 實作筆記 從「沒有聲音」到 UART 成功播放

智慧生活科技實作筆記

ESP32 × JQ6500 實作筆記
從「沒有聲音」到 UART 成功播放

一次實際除錯,重新認識電源、UART、GPIO 與模組測試的重要性

一、為什麼這次實作很值得記錄?

JQ6500 是一款價格低、體積小,而且能直接播放 MP3 語音的模組。 對於語音導覽、作品故事機、互動裝置、AIoT 展示與地方文化導覽而言, 它有一個很大的優點:即使沒有網路,也可以直接從內建 Flash 播放語音。

這次實作的目標,是利用 ESP32 控制 JQ6500, 讓未來的樹藝作品只要按下按鈕,就可以播放不同的作品故事; 再搭配 QR Code,延伸到手機網站與 AI 問答。

原本以為只是「接 TX、RX,送一段 UART 指令」就可以完成, 實際測試卻經歷了:

燒錄成功但沒有聲音 → K1~K5 測試 → 檢查喇叭 → 檢查 Flash → 更換 ESP32 UART 腳位 → UART 收發測試 → 收到 SPIOK → 最後成功由 ESP32 指令播放 MP3。

這次經驗最值得留下來的,不只是最後成功的程式,而是: 電源與通訊腳位,往往才是嵌入式系統除錯的第一現場。

二、使用硬體

ESP32
NodeMCU-32 / ESP32-WROOM-32
語音模組
JQ6500
喇叭
4Ω~8Ω 小型喇叭

JQ6500 使用 UART TTL 通訊,預設鮑率為 9600 bps。 此次採用 JQ6500 內建 Flash 播放 MP3, 並利用 ESP32 的硬體 UART2 控制播放。

三、這次實測最重要的三個發現

1. 電源真的很重要

一開始很容易把注意力全部放在程式、MP3 格式或 UART 指令, 但 JQ6500 本身包含 MP3 解碼與喇叭功率輸出, 播放瞬間的電源狀態會直接影響模組是否正常工作。

實作建議:
ESP32 與 JQ6500 在除錯初期可以各自使用 USB 供電, 但兩者的 GND 必須相連

「兩塊板都有 USB」不代表 UART 就能直接通訊。 數位訊號需要共同的電位參考,所以:

ESP32 GND ↔ JQ6500 GND

2. GPIO26 / GPIO27 最後成為穩定的 UART 配置

最初曾以 GPIO16、GPIO17 測試 UART, 實作過程中一直無法得到理想結果。 後來改用 NodeMCU-32 板上明確標示的:

P26 / GPIO26
ESP32 TX → JQ6500 RX
P27 / GPIO27
ESP32 RX ← JQ6500 TX

這裡特別容易誤解: GPIO26 是板子上印著「P26/26」的腳位, 不是從排針開始數第 26 隻腳。

需要特別說明的是,這並不表示所有 ESP32 都不能使用 GPIO16、17; 這次紀錄的是本次 NodeMCU-32 實機測試中,GPIO26 / 27 成功完成 JQ6500 UART 通訊。 實際專案仍應依開發板版本與腳位配置選擇。

3. TX 與 RX 一定要交叉

ESP32 TX → JQ6500 RX
ESP32 RX ← JQ6500 TX

TX 是「送出去」,RX 是「接進來」, 所以不能 TX 接 TX、RX 接 RX。

四、ESP32 × JQ6500 實測成功接線圖

ESP32 NodeMCU-32 P26 / GPIO26 TX P27 / GPIO27 RX GND JQ6500 RX TX GND SPK+ SPK− UART 9600 共地 4Ω~8Ω 喇叭 USB 供電 USB 供電

五、JQ6500 UART 指令

成功通訊後,ESP32 可以直接送 HEX 命令控制 JQ6500。 例如播放第一首 MP3:

7E 04 03 00 01 EF

其中:

7E:起始碼
04:資料長度
03:指定曲目命令
00 01:第 1 首
EF:結束碼

設定音量 20:

7E 03 06 14 EF

選擇內建 Flash:

7E 03 09 04 EF

六、最關鍵的一刻:收到 SPIOK

在 UART 除錯過程中,ESP32 最後收到:

53 50 49 4F 4B

轉成 ASCII 即為:

SPIOK

這是一個非常重要的除錯訊號。 因為它代表 JQ6500 已經有資料回到 ESP32, 也證明雙向 UART 通訊路徑已經建立。

ESP32 TX → JQ6500 RX ✓
JQ6500 TX → ESP32 RX ✓
UART 9600 ✓
共地 ✓

七、最後成功的 ESP32 最小播放程式

#include <HardwareSerial.h>

HardwareSerial JQ(2);

#define JQ_TX 26
#define JQ_RX 27

void setup()
{
  Serial.begin(115200);

  JQ.begin(
    9600,
    SERIAL_8N1,
    JQ_RX,
    JQ_TX
  );

  delay(3000);

  // 選擇內建 Flash
  uint8_t flash[] = {
    0x7E, 0x03, 0x09, 0x04, 0xEF
  };

  JQ.write(flash, sizeof(flash));
  JQ.flush();

  delay(2500);

  // 音量 = 20
  uint8_t volume[] = {
    0x7E, 0x03, 0x06, 0x14, 0xEF
  };

  JQ.write(volume, sizeof(volume));
  JQ.flush();

  delay(1000);

  // 播放第一首
  uint8_t play1[] = {
    0x7E,
    0x04,
    0x03,
    0x00,
    0x01,
    0xEF
  };

  JQ.write(play1, sizeof(play1));
  JQ.flush();
}

void loop()
{
}

八、真正值得學生學習的是「怎麼除錯」

這次實作最有價值的地方,其實不是最後那幾行程式, 而是建立一個合理的除錯順序。

Step 1 先不要接 ESP32
JQ6500 燒入 MP3,用 K1~K5 測試能否直接播放。

Step 2 確認 Flash、喇叭與播放核心正常
只要 K1 能播放,就不要再一直懷疑 MP3 或喇叭。

Step 3 再加入 ESP32
先建立 TX、RX、GND 三條 UART 線。

Step 4 確認 GPIO 名稱
GPIO26 是 P26,不是「第 26 隻實體腳」。

Step 5 先送,再收
觀察 Serial Monitor 是否真的送出 HEX 指令。

Step 6 確認回傳
看到 SPIOK,代表 UART 雙向通訊已建立。

Step 7 最後才加入按鈕、QR Code、網路與 AI

九、下一步:把它變成「樹藝故事機」

JQ6500 成功之後,就可以把系統進一步做成三層式架構:

第一層|離線故事

實體按鈕

ESP32

JQ6500

MP3 語音故事
第二層|QR Code

掃描作品 QR Code

手機 Web

作品圖文、影片與導覽
第三層|AI

Django API

LLM + RAG

AI 作品問答

例如三個按鈕可以分別設定:

① 001.mp3:作品介紹
② 002.mp3:創作者故事
③ 003.mp3:地方文化與永續故事

十、結語:AIoT 教育不能只教「成功的接法」

這次 ESP32 × JQ6500 的測試再次說明, 真正的工程學習,不只是取得一份「可以跑的程式」。

接錯、沒聲音、沒有回覆,
其實都是理解系統最好的教材。

從電源、共地、Flash、喇叭,到 UART 的 TX/RX、GPIO 選擇與 HEX 指令, 每一次排除問題,都讓學生更理解: 硬體、軟體與通訊,其實是一個完整的系統。

當這個基礎完成之後, JQ6500 就不再只是一塊「MP3 播放板」, 而可以成為樹藝、工藝、地方文化與 AI 導覽之間, 一個低成本、可靠而有溫度的聲音介面。

關鍵字: ESP32、JQ6500、UART、AIoT、語音模組、樹藝故事機、QR Code、Django、RAG、智慧生活科技