ESP32 × Django × 雲端資料:水井三寶智慧互動展覽系統進入 Layer 4
因此 Layer 4 是從「單一智慧展品」走向可管理、可分析、可擴充的智慧展覽平台的重要一步。
一、Layer 4 的「今日水井三寶」儀表板
圖中的網址已經不再是 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:系統開始真正「分層」
↓
ESP32 智慧互動
↓
手機 Edge Web
↓
Internet / HTTPS
↓
Django API
↓
資料庫
↓
今日水井三寶 Dashboard
三、使用者還是怎麼用手機播放故事?
到了 Layer 4,觀眾的操作並沒有變複雜。 Layer 4 是在後端增加資料能力,前端使用者仍然可以沿用 Layer 3 的簡單方式。
Shuijing-Treasures
Wi-Fi。
http://192.168.4.1。
使用者仍然只是「點故事」,但系統背後可以同步記錄:
故事何時開始?
是哪一個故事?
是實體按鈕還是手機 Web 啟動?
有沒有完整播放?
播放多久?
ESP32 現在是否在線?
這就是從「操作」走向「資料化」。
四、手機按下白馬故事之後,Layer 4 多做了什麼?
假設觀眾在手機上按下:
🐴 001 白馬故事
Layer 3 原本的流程是:
↓
GET /api/play?track=1
↓
ESP32
↓
UART
↓
JQ6500
↓
播放 001 白馬故事
到了 Layer 4,可以再增加一條資料路徑:
↓
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,
)
九、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。
十三、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 可以回答:
這種資訊可以再回過頭改善下一版的互動介面。
十五、五個故事也有自己的 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"
),
]
十八、這裡有一個非常重要的網路觀念
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 最值得教學生的是什麼?
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 後, 下一步就可以把這些結構化資料再提供給更高層應用。
↓
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
沒有留言:
張貼留言