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

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

2026年8月2日 星期日

[水井村USR] 從 No Data 到成功讀取四種感測器:Atlas Scientific Aquaponics Kit V1.8 × ESP32 除錯紀錄



在建置水井村智慧養殖系統時,我們選用 Atlas Scientific Aquaponics Kit V1.8, 搭配 Adafruit HUZZAH32 ESP32 Feather,希望讀取水溫、pH、溶氧與導電度, 並為後續上傳 Django REST API 做準備。原本以為只要修改 Wi-Fi 與 API 即可完成整合, 沒想到真正花最多時間的,是硬體腳位、Enable 控制與 I²C 位址的確認。

一、系統架構

Atlas Aquaponics Kit V1.8
        │
        ├── RTD(水溫)
        ├── pH
        ├── Dissolved Oxygen(DO)
        └── Conductivity(EC)
                 │
                 ▼
      Adafruit HUZZAH32 ESP32 Feather
                 │
              Arduino IDE
                 │
         Django REST API(下一階段)

二、第一個問題:全部都是 No Data

最初程式執行時,四個感測器全部無法取得有效資料:

RTD:無有效資料
pH:無有效資料
DO:無有效資料
EC:無有效資料

因此,除錯並沒有先從 API 開始,而是先確認 ESP32 是否正常、I²C 是否正確、 Atlas 模組是否有供電,以及每個感測器是否真的出現在匯流排上。

三、第二個問題:I²C 腳位誤判

一開始依照 PCB 絲印誤判為:

SDA = GPIO14
SCL = GPIO32

重新對照 Adafruit HUZZAH32 Feather 的實際腳位後,修正為:

SDA = GPIO23
SCL = GPIO22

修正之後,ESP32 才開始穩定掃描到 Atlas EZO 模組。

四、第三個問題:Enable 腳位

依照 Atlas Aquaponics V1.8 PCB 與實際測試,確認 Enable 腳位如下:

功能GPIO
pH ENGPIO13
DO ENGPIO12
EC ENGPIO27
RTD ENGPIO33
CO₂ ENGPIO15
Humidity ENGPIO32

程式初始化如下:

digitalWrite(EN_PH, LOW);
digitalWrite(EN_DO, LOW);
digitalWrite(EN_EC, LOW);

digitalWrite(EN_RTD, HIGH);
digitalWrite(EN_CO2, HIGH);
digitalWrite(EN_HUM, HIGH);

五、第四個問題:只有 EC 一直 No Data

修正腳位與 Enable 後,RTD、pH 與 DO 已能讀取,但 EC 仍顯示:

EC: No Data

甚至在單獨測試 EC 時,出現:

Wire.endTransmission = 4

一開始看起來像是 EC 模組故障、插槽異常、Enable 設定錯誤, 甚至懷疑探棒、供電或主機板本身有問題。

六、第五個問題:EC LED 與插槽交換測試

觀察 EC 模組時,發現剛通電時藍燈約亮一秒,隨後熄滅。 我們也進行了模組與插槽交換測試,但現象一致,因此逐漸排除 Conductivity 插槽本身故障的可能。

除錯過程中最重要的原則,是不要只看 LED 或假設模組一定使用原廠預設位址, 而是要實際掃描 I²C,並逐一確認每一個位址對應的模組。

七、最後找到真正原因:EC 位址被修改

I²C 掃描結果如下:

找到 0x61 ( 97) -> EZO DO
找到 0x63 ( 99) -> EZO pH
找到 0x66 (102) -> EZO RTD
找到 0x69 (105) -> 其他 I2C 裝置

原本一直把 0x69 當成未知裝置, 後來才發現它其實就是 EC 模組,只是 I²C 位址已被修改。

原先以為 EC 位址是:

constexpr uint8_t ADDR_EC = 100;  // 0x64

實際上應改成:

constexpr uint8_t ADDR_EC = 105;  // 0x69,本機 EC 已改址

掃描名稱也同步修正:

case 0x69:
  return "EZO EC(已改址)";

八、成功讀取四種感測器

最後測試結果如下:
水溫 RTD:30.01 °C
pH:6.80
溶氧 DO:31.92 mg/L
導電度 EC:441 µS/cm

目前確認的 I²C 位址如下:

感測器十進位位址十六進位位址
DO970x61
pH990x63
RTD1020x66
EC1050x69

九、本次最大的收穫

不要假設所有模組都維持原廠預設值。

設備在出廠、維修、校正或前一個專案中,都可能修改 I²C 位址、輸出格式或 LED 設定。 最可靠的方法是先掃描全部位址,再逐一確認每個裝置的實際身分。

這次除錯流程可以整理成:

  1. 確認開發板型號與 PSRAM 設定。
  2. 確認正確的 SDA、SCL 腳位。
  3. 確認各感測器 Enable 腳位與有效電位。
  4. 掃描完整 I²C 位址。
  5. 逐顆單獨讀取。
  6. 不要假設模組位址仍為原廠預設值。

十、下一步:上傳 Django REST API

完成硬體驗證後,下一階段將把四項水質資料上傳至水井 USR Django API。

{
  "token": "abc123",
  "farm_name": "湖虎戰隊",
  "pond_code": "1",
  "water_temperature": 30.01,
  "salinity": 0.22,
  "ph": 6.80,
  "dissolved_oxygen": 31.92,
  "water_source": "直流變頻水車運轉中",
  "recorded_at": "2026-08-02T14:30:00+08:00"
}

後續可延伸:

  • Django Dashboard 即時儀表板
  • 歷史趨勢分析
  • MQTT 與 LoRa
  • 異常警示
  • AI 水質判讀與養殖決策建議

結語

這次除錯的價值,不只是讓程式成功讀到資料,而是更深入理解 Atlas Scientific EZO 模組、 ESP32、Enable 控制與 I²C 通訊的實際運作。對智慧養殖系統而言, 穩定的硬體資料來源是所有雲端分析、AI 判讀與自動控制的基礎。

本文由作者主導內容規劃,並使用生成式 AI 協助文字整理、程式說明與版面設計; 所有內容均依實際硬體測試結果進行修訂與確認。

附件:程式碼

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
/*
  Atlas Scientific Aquaponics V1.8
  修正版:硬體驗證版

  實體板:
  - Adafruit HUZZAH32 ESP32 Feather
  - Atlas Aquaponics V1.8

  依 PCB 絲印與 HUZZAH32 實體排針重新確認:
  pH EN   -> GPIO13
  DO EN   -> GPIO12
  EC EN   -> GPIO27
  RTD EN  -> GPIO33
  CO2 EN  -> GPIO15
  HUM EN  -> GPIO32

  I2C 使用 Feather 板上標示的 SDA / SCL 腳:
  SDA -> GPIO23
  SCL -> GPIO22

  Arduino IDE:
  Board: Adafruit ESP32 Feather
  PSRAM: Disabled
  Serial Monitor: 115200
*/

#include <Wire.h>
#include <Ezo_i2c.h>
#include <Ezo_i2c_util.h>

constexpr uint8_t I2C_SDA = 23;
constexpr uint8_t I2C_SCL = 22;

constexpr uint8_t EN_PH  = 13;
constexpr uint8_t EN_DO  = 12;
constexpr uint8_t EN_EC  = 27;
constexpr uint8_t EN_RTD = 33;
constexpr uint8_t EN_CO2 = 15;
constexpr uint8_t EN_HUM = 32;

constexpr uint8_t ADDR_DO  = 97;   // 0x61
constexpr uint8_t ADDR_PH  = 99;   // 0x63
constexpr uint8_t ADDR_EC  = 105;  // 0x64
constexpr uint8_t ADDR_RTD = 102;  // 0x66;

Ezo_board PH  = Ezo_board(ADDR_PH,  "PH");
Ezo_board DO  = Ezo_board(ADDR_DO,  "DO");
Ezo_board EC  = Ezo_board(ADDR_EC,  "EC");
Ezo_board RTD = Ezo_board(ADDR_RTD, "RTD");

constexpr unsigned long READ_DELAY_MS = 1100;
constexpr unsigned long LOOP_DELAY_MS = 5000;

void enableCircuits() {
  pinMode(EN_PH, OUTPUT);
  pinMode(EN_DO, OUTPUT);
  pinMode(EN_EC, OUTPUT);
  pinMode(EN_RTD, OUTPUT);
  pinMode(EN_CO2, OUTPUT);
  pinMode(EN_HUM, OUTPUT);

  // 沿用 Atlas 官方範例的極性邏輯
  digitalWrite(EN_PH, LOW);
  digitalWrite(EN_DO, LOW);
  digitalWrite(EN_EC, LOW);

  digitalWrite(EN_RTD, HIGH);
  digitalWrite(EN_CO2, HIGH);
  digitalWrite(EN_HUM, HIGH);

  delay(2000);
}

const char* deviceName(uint8_t address) {
  switch (address) {
    case 0x61: return "EZO DO";
    case 0x63: return "EZO pH";
    case 0x64: return "EZO EC";
    case 0x66: return "EZO RTD";
    case 0x67: return "EZO Pump";
    case 0x6F: return "Humidity";
    case 0x71: return "CO2";
    default:   return "其他 I2C 裝置";
  }
}

uint8_t scanI2C() {
  uint8_t count = 0;

  Serial.println();
  Serial.println("========== I2C 掃描 ==========");

  for (uint8_t address = 1; address < 127; address++) {
    Wire.beginTransmission(address);
    uint8_t error = Wire.endTransmission();

    if (error == 0) {
      Serial.printf(
        "找到 0x%02X (%3u) -> %s\n",
        address,
        address,
        deviceName(address)
      );
      count++;
    }
  }

  Serial.printf("共找到 %u 個 I2C 裝置\n", count);
  Serial.println("==============================");

  return count;
}

bool receiveValue(Ezo_board& sensor, float& value) {
  receive_and_print_reading(sensor);

  if (sensor.get_error() != Ezo_board::SUCCESS) {
    return false;
  }

  value = sensor.get_last_received_reading();
  return true;
}

void readSensors() {
  Serial.println();
  Serial.println("========== 感測器讀值 ==========");

  RTD.send_read_cmd();
  delay(READ_DELAY_MS);

  float temperature = NAN;
  bool rtdOK = receiveValue(RTD, temperature);

  if (rtdOK && temperature > -1000.0f) {
    Serial.printf("水溫 RTD:%.2f C\n", temperature);

    PH.send_cmd_with_num("T,", temperature);
    DO.send_cmd_with_num("T,", temperature);
    EC.send_cmd_with_num("T,", temperature);
  } else {
    Serial.println("水溫 RTD:無有效資料");

    PH.send_cmd_with_num("T,", 25.0);
    DO.send_cmd_with_num("T,", 25.0);
    EC.send_cmd_with_num("T,", 25.0);
  }

  delay(350);

  PH.send_read_cmd();
  DO.send_read_cmd();
  EC.send_read_cmd();

  delay(READ_DELAY_MS);

  float phValue = NAN;
  float doValue = NAN;
  float ecValue = NAN;

  bool phOK = receiveValue(PH, phValue);
  bool doOK = receiveValue(DO, doValue);
  bool ecOK = receiveValue(EC, ecValue);

  Serial.println();
  Serial.println("---------- 結果 ----------");

  if (phOK) {
    Serial.printf("pH:%.2f\n", phValue);
  } else {
    Serial.println("pH:無有效資料");
  }

  if (doOK) {
    Serial.printf("溶氧 DO:%.2f mg/L\n", doValue);
  } else {
    Serial.println("溶氧 DO:無有效資料");
  }

  if (ecOK) {
    Serial.printf("導電度 EC:%.0f uS/cm\n", ecValue);
  } else {
    Serial.println("導電度 EC:無有效資料");
  }

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

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

  Serial.println();
  Serial.println("Atlas Aquaponics V1.8 修正版硬體驗證啟動");
  Serial.printf("SDA=GPIO%u, SCL=GPIO%u\n", I2C_SDA, I2C_SCL);

  enableCircuits();

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000);
  Wire.setTimeOut(1000);

  delay(500);

  scanI2C();
  readSensors();
}

void loop() {
  delay(LOOP_DELAY_MS);
  scanI2C();
  readSensors();
}