2026年8月15日 星期六

[水井村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

沒有留言:

張貼留言