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

2026年10月16日 星期五

泡泡牆為什麼能從另一台電腦開啟?IP、連接埠與區域網路入門

網際網路入門・Django 泡泡牆實作

泡泡牆為什麼能從另一台電腦開啟?
認識 IP、連接埠與區域網路

老師在一台電腦啟動 Django 泡泡牆,學生卻能用自己的手機或筆電投稿,投影幕也能即時出現新泡泡。這個過程不只是「打開一個網址」,而是裝置在區域網路中找到伺服器,並透過指定連接埠交換資料。

一、先看懂連線中的角色

角色在泡泡牆中的例子工作
用戶端學生手機、筆電、投影瀏覽器提出網頁請求、顯示畫面、送出資料
伺服器執行 Django 的教師電腦接收請求、驗證資料、查詢或更新資料庫、回應結果
區域網路教室 Wi‑Fi 或有線網路讓同一網路中的裝置彼此傳送資料

本例中,學生瀏覽器是用戶端,教師電腦是伺服器。Django 接到投稿後會儲存泡泡,再通知投影頁更新。

二、IP 位址:在網路中找到那台電腦

IP 位址像是裝置在網路上的地址。教室區域網路常見的私有 IPv4 位址會以 192.168.、10. 或 172.16 至 172.31 開頭。例如 Django 主機可能是 192.168.1.100。

在 Windows 主機開啟 CMD,輸入以下指令查詢網路設定:

ipconfig

找到正在使用的 Wi‑Fi 或乙太網路介面,查看「IPv4 位址」。遠端裝置要連線時,使用的就是這個主機 IP。主機若重新連接網路,IP 可能改變,需要重新查詢。

注意:127.0.0.1 是「本機迴路」位址,代表目前正在使用的這台電腦。遠端學生輸入 127.0.0.1 會連回自己的手機或電腦,因此要輸入 Django 主機的區域網路 IP。

三、連接埠:找到電腦裡正確的服務

一台電腦可能同時提供許多網路服務。連接埠(Port)用來區分資料應該交給哪個服務。泡泡牆開發時使用連接埠 8000,所以網址會寫成:

http://192.168.1.100:8000/

其中 192.168.1.100 是主機 IP,8000 是 Django 服務使用的連接埠,最後的斜線代表網站路徑。完整網址可拆成:

網址部分例子說明
通訊協定http://瀏覽器與網站使用 HTTP 溝通
主機位置192.168.1.100要連線的 Django 電腦
連接埠:8000該電腦上的 Django 服務
路徑/projector/要開啟的網站功能

四、為什麼要用 0.0.0.0 啟動?

Django 的開發伺服器預設只接受本機連線。若要讓同一區域網路的其他裝置連線,要讓它監聽主機的網路介面。進入 Django 專案資料夾後,可在 Windows CMD 執行:

set DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,192.168.1.100
set BUBBLE_TOKENS=class-a-2026
set BUBBLE_ADMIN_TOKEN=teacher-admin-2026
python manage.py runserver 0.0.0.0:8000
  • 0.0.0.0:8000:Django 在所有可用網路介面的 8000 埠接收連線。
  • DJANGO_ALLOWED_HOSTS:列出 Django 接受的主機名稱或 IP,請換成自己的主機位址。
  • BUBBLE_TOKENS:學生投稿時使用的活動 token。
  • BUBBLE_ADMIN_TOKEN:管理頁清空泡泡時使用的管理 token,應與投稿 token 不同。

這些 set 指令設定的值只存在於目前 CMD 視窗。關閉視窗後,需重新設定並啟動伺服器。

五、遠端裝置如何連線

學生手機或筆電連上相同 Wi‑Fi 後,在瀏覽器輸入:

  • 投稿頁:http://192.168.1.100:8000/
  • 投影頁:http://192.168.1.100:8000/projector/
  • 管理頁:http://192.168.1.100:8000/manage/

網址中的 IP 請替換成 Django 主機的實際 IPv4 位址。學生在投稿頁填入活動 token;教師若要清空本場泡泡,則到管理頁輸入管理 token。

六、HTTP 請求如何抵達 Django?

當學生按下送出,瀏覽器會向伺服器傳送 HTTP POST 請求。網路設備依照目標 IP 將資料送到教師電腦,作業系統再依連接埠把請求交給 Django。Django 驗證 token 和欄位後回傳結果;投影頁則透過 SSE 接收新泡泡事件。

動作請求/通訊結果
打開投稿頁瀏覽器向 Django 要求 HTML、CSS、JavaScript顯示投稿表單
送出泡泡POST /api/bubbles,附上 JSON 與 tokenDjango 驗證並寫入資料庫
投影端載入舊資料GET /api/state回傳目前泡泡清單
出現新泡泡/api/events 的 SSE 推播投影頁即時更新,不必重新整理

七、Windows 防火牆與網路限制

即使 IP 和網址正確,防火牆仍可能阻擋其他裝置連入。首次執行 Python 時,若 Windows 顯示網路存取提示,應在可信任的教室私人網路中允許通訊。必要時請管理者設定允許 TCP 8000 埠的私人網路入站規則。

部分校園、訪客 Wi‑Fi 會啟用用戶端隔離,阻止同一 Wi‑Fi 的裝置互相連線。若兩台設備都連上網路但仍無法互通,可請網管確認此設定,或改用允許裝置互通的教學網路。

八、連線檢查清單

  1. 主機上 Django 是否正在執行,且啟動指令包含 0.0.0.0:8000?
  2. 遠端裝置是否使用主機 IPv4,而不是 127.0.0.1?
  3. 網址是否包含 :8000 連接埠?
  4. DJANGO_ALLOWED_HOSTS 是否包含該 IP?
  5. Windows 防火牆是否允許私人網路連線?
  6. 兩台設備所連的 Wi‑Fi 是否允許用戶端彼此通訊?

九、區域網路與公開網際網路

使用主機私有 IP 的方式,只適合可互相連線的同一區域網路。校外的電腦通常不能直接使用 192.168.x.x 連到教室主機。若需要公開服務,應部署到正式伺服器、設定網域與 HTTPS,並妥善管理密鑰。

安全提醒:python manage.py runserver 是開發伺服器,適合本機開發或受控教室網路示範。不要直接把它公開到網際網路,也不要將活動 token 或管理 token 放在公開文章或程式碼中。

十、課堂延伸問題

  • 為什麼同一台電腦上的 127.0.0.1 和 192.168.1.100 指向不同連線情境?
  • 一台電腦開啟多個網站服務時,如何利用連接埠區分它們?
  • 若主機 IP 改變,學生書籤中的網址會發生什麼事?
  • 若教室網路不允許裝置彼此連線,有哪些可行的部署或網路安排?

從泡泡牆的遠端操作開始,學生可以逐步理解 IP 如何定位主機、連接埠如何定位服務、HTTP 如何交換資料,也能認識防火牆和區域網路規則對網站連線的影響。把這些概念與親手操作連在一起,網際網路就不再只是抽象名詞。

從遠端電腦操作 Django 泡泡牆:區域網路設定教學

DJANGO 泡泡牆・網路實作

從遠端電腦操作 Django 泡泡牆
區域網路設定教學

泡泡牆網站只需在一台電腦啟動 Django,其他電腦便可透過瀏覽器連線投稿或投影。本文以 Windows 主機為例,說明如何在教室的同一個 Wi‑Fi/區域網路內設定與操作。

先了解架構:一台電腦擔任 Django 主機,負責執行網站與保存泡泡資料;其他電腦、平板或手機是用戶端,透過瀏覽器連線。遠端裝置不需要另外安裝 Django。

一、適用情境與準備項目

  • 一台已解壓縮 Django 泡泡牆專案、能執行 Python 的主機。
  • 主機和遠端電腦連接同一個 Wi‑Fi 或區域網路。
  • 主機的區域網路 IP 位址,以及活動投稿 token。

以下假設專案已依照泡泡牆安裝教學完成套件安裝和資料庫遷移。若尚未安裝,請先在專案目錄執行 pip install -r requirements.txt 和 python manage.py migrate。

二、在 Django 主機查詢 IP 位址

步驟 1:開啟命令提示字元

在執行 Django 的 Windows 電腦按下「開始」,搜尋並開啟「命令提示字元」(CMD),輸入:

ipconfig

找到目前正在使用的 Wi‑Fi 或乙太網路介面,查看「IPv4 位址」。以下用 192.168.1.100 作為示例,實際操作時請換成您電腦顯示的 IP。

三、允許區域網路連線並啟動 Django

步驟 2:切換到專案資料夾

在 CMD 用 cd 進入包含 manage.py 的資料夾。例如:

cd C:\Users\學生\Desktop\bubblewall-django

請依照您實際解壓縮的位置修改路徑。

步驟 3:啟用虛擬環境(若有建立)

.venv\Scripts\activate

若命令提示字元前面出現 (.venv),表示虛擬環境已啟用。

步驟 4:設定允許的主機 IP、活動 token 並啟動伺服器

在同一個 CMD 視窗依序執行下列指令。請把 IP 換成主機的 IPv4 位址,也可將 token 換成您要提供給學生的活動通行碼。

set DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,192.168.1.100
set BUBBLE_TOKENS=class-a-2026
python manage.py runserver 0.0.0.0:8000

0.0.0.0:8000 表示 Django 開發伺服器接受主機其他網路介面的連線;若只執行 python manage.py runserver,通常只能由主機本身開啟。

請讓這個 CMD 視窗保持開啟,關閉視窗或停止伺服器後,遠端就無法連線。用 set 設定的環境變數只在目前 CMD 視窗有效。

四、從遠端電腦開啟泡泡牆

在遠端電腦確認已連上與主機相同的 Wi‑Fi,接著打開 Chrome、Edge 或其他瀏覽器,輸入主機 IP 和連接埠:

用途範例網址
學生投稿頁http://192.168.1.100:8000/
投影展示頁http://192.168.1.100:8000/projector/

請將範例網址中的 192.168.1.100 改成主機實際的 IPv4 位址。學生在投稿頁輸入活動 token、想法、隊伍名稱並選擇顏色,送出後投影頁會顯示新泡泡。

五、Windows 防火牆設定

若遠端電腦無法開啟網址,可能是 Windows 防火牆阻擋連線。首次啟動 Python 時若出現防火牆提示,允許 Python 在私人網路通訊。若沒有提示,可在 Windows「防火牆與網路保護」設定中,允許 Python 通過私人網路防火牆。

也可以由管理者建立入站規則,允許 TCP 連接埠 8000 在私人網路使用。請只開放教學場域需要的網路範圍。

六、常見問題排查

現象檢查方式
瀏覽器顯示無法連線確認 Django 主機的 CMD 視窗仍在執行;確認網址 IP 正確、連接埠是 8000,且兩台裝置在相同網路。
出現 DisallowedHost確認 DJANGO_ALLOWED_HOSTS 包含主機的 IPv4 位址;修改後重新啟動 Django。
網頁可開啟,但投稿失敗確認 token 與主機設定的 BUBBLE_TOKENS 相同;檢查文字、隊伍名稱與顏色欄位。
同一 Wi‑Fi 還是互相連不到查看防火牆是否允許私人網路;詢問網管 Wi‑Fi 是否啟用用戶端隔離。部分校園或訪客網路會禁止裝置彼此連線。
主機 IP 改變後網址失效重新執行 ipconfig 查詢 IPv4,並用新的 IP 開啟網址;也要更新允許的主機設定。

七、在外網或校外使用

上述方式適合教室內同一個區域網路使用。若要讓校外網路或不同網路的電腦連線,建議將網站部署到正式伺服器或雲端主機,設定網域名稱與 HTTPS,再妥善管理活動 token。

重要:python manage.py runserver 是 Django 開發用伺服器,不適合直接公開到網際網路。不要只為了遠端連線,就在路由器上將 8000 埠公開轉發到網路。

八、課堂操作流程建議

  1. 教師電腦啟動 Django,並將投影頁投放到教室螢幕。
  2. 教師確認主機 IP,將投稿網址和活動 token 提供給學生。
  3. 學生使用自己的電腦或手機開啟投稿頁,送出文字與顏色。
  4. 全班觀察投影頁即時出現泡泡,討論瀏覽器、HTTP 請求、伺服器、資料庫和 SSE 的分工。

透過這個流程,學生能把「網路上的資料如何傳送」與「多媒體內容如何呈現」連結起來,從實際操作理解網站與互動系統的基本架構。

用 Django 打造即時泡泡牆:從網際網路理論到多媒體實作

網際網路 × 多媒體應用 × Django 實作

用 Django 打造即時泡泡牆
從網際網路理論到多媒體實作

一個泡泡牆看起來像是把文字變成彩色圓泡泡,背後其實串起了瀏覽器、HTTP、JSON、伺服器、資料庫與即時推播。本文以 Django 製作一套可在教室展示的泡泡牆,帶大一新生透過完整作品認識網際網路與多媒體應用的基礎。






一、設計動機

大一新生初次接觸網際網路與多媒體應用時,常會分別學到網頁、伺服器、資料格式、資料庫和視覺設計等名詞,卻不容易看見它們如何合作。泡泡牆把這些概念放進一個能親手操作、能立即看見結果的情境:學生輸入一句想法,按下送出,教室投影畫面就多出一顆彩色泡泡。

因此,這個作品的動機是讓大一新生瞭解網際網路以及多媒體應用的理論基礎。學生可以沿著一則資料的旅程,觀察瀏覽器如何發出請求、Django 如何驗證與處理資料、資料如何存進資料庫,以及伺服器如何把新內容即時送到投影畫面。

二、教學目的

  • 認識用戶端與伺服器的分工,理解瀏覽器如何透過 HTTP 與網站溝通。
  • 分辨 GET 與 POST 的用途,讀懂 200、201、400、401 等狀態碼。
  • 理解 JSON 如何承載文字、顏色、隊伍等結構化資料。
  • 了解 Django URL 路由、View、Model 與 SQLite 資料庫的關係。
  • 體驗 Server-Sent Events(SSE)如何將新泡泡由伺服器推送到投影瀏覽器。
  • 連結多媒體的文字、色彩、動態效果與全螢幕展示,思考科技如何支持課堂互動。
學習成果:完成後,學生不只會看到泡泡牆的畫面,也能說明一顆泡泡如何從投稿表單出發,經過網路傳輸、伺服器處理與資料儲存,最後出現在投影幕上。

三、系統如何運作

學生瀏覽器HTTP POSTDjango API寫入SQLite
投影瀏覽器GET /api/state載入既有泡泡
Django SSE推送新事件投影瀏覽器新增泡泡

學生端將文字、顏色、隊伍名稱組成 JSON,並在 HTTP 標頭附上 Bearer token。伺服器檢查 token 和欄位後,建立資料記錄,回傳 201 Created。同時,SSE 連線會收到新泡泡事件,讓投影畫面無須重新整理就能更新。

介面方法與路徑用途成功/錯誤回應
載入泡泡GET /api/state取得既有泡泡200 OK + JSON
新增泡泡POST /api/bubbles驗證並儲存投稿201 Created;格式錯誤 400;token 無效 401
即時事件GET /api/events維持 SSE 連線、接收新泡泡text/event-stream

四、重要程式解析

1. Model:定義泡泡資料

Django Model 描述一筆泡泡要保存哪些欄位。執行資料庫遷移後,Django 會依此建立資料表。

from django.db import models

class Bubble(models.Model):
    text = models.CharField(max_length=160)
    color = models.CharField(max_length=7)
    team = models.CharField(max_length=40)
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)

    class Meta:
        ordering = ['-created_at']

    def as_event(self):
        return {
            'id': self.pk,
            'text': self.text,
            'color': self.color,
            'team': self.team,
            'created_at': self.created_at.isoformat(),
        }

CharField 用來保存文字欄位,auto_now_add=True 會在建立記錄時自動記下時間。as_event() 將 Model 物件轉成適合 JSON 傳輸的 Python 字典。

2. URL 路由:把網址交給對應功能

from django.urls import path
from . import views

urlpatterns = [
    path('', views.home, name='home'),
    path('projector/', views.projector, name='projector'),
    path('api/state', views.state, name='state'),
    path('api/bubbles', views.create_bubble, name='create_bubble'),
    path('api/events', views.events, name='events'),
]

路由可以想成網站的分機表:收到不同路徑,Django 就呼叫不同的 View。一般頁面負責回傳 HTML;以 /api/ 開頭的路徑主要交換資料。

3. View:驗證資料、存入資料庫並回傳狀態碼

以下節錄新增泡泡的關鍵邏輯。實際專案也會檢查 JSON 格式、欄位長度與色碼格式。

@require_POST
def create_bubble(request):
    token = request.headers.get('Authorization', '')
    token = token.removeprefix('Bearer ').strip()

    if not token or token not in settings.BUBBLE_TOKENS:
        return JsonResponse(
            {'error': 'unauthorized'}, status=401
        )

    data = json.loads(request.body)
    text = data.get('text', '').strip()
    color = data.get('color', '')
    team = data.get('team', '').strip()

    # 正式程式在此檢查欄位內容與格式
    bubble = Bubble.objects.create(
        text=text, color=color.upper(), team=team
    )
    return JsonResponse(bubble.as_event(), status=201)

Authorization: Bearer ... 是 HTTP 標頭中的通行憑證。驗證失敗回傳 401;資料欄位無效回傳 400;建立成功回傳 201。這些狀態碼是瀏覽器或其他用戶端判斷結果的依據。

安全提醒:教學原型使用環境變數設定 token;正式活動應將預設示範 token 換掉,避免把 token 寫在公開程式碼中。登入表單送出的 token 會由瀏覽器傳送到網站,因此公開部署須使用 HTTPS。

4. SSE:讓投影畫面收到新泡泡

SSE 是由伺服器單向推送資料到瀏覽器的機制。它適合「學生送出、投影端即時顯示」這種更新方向單純的情境。

def events(request):
    q = Queue()
    with _subscribers_lock:
        _subscribers.add(q)

    def stream():
        try:
            yield 'retry: 3000\n\n'
            while True:
                try:
                    payload = q.get(timeout=15)
                    yield f'event: bubble\ndata: {payload}\n\n'
                except Empty:
                    yield ': keep-alive\n\n'
        finally:
            with _subscribers_lock:
                _subscribers.discard(q)

    response = StreamingHttpResponse(
        stream(), content_type='text/event-stream'
    )
    response['Cache-Control'] = 'no-cache'
    response['X-Accel-Buffering'] = 'no'
    return response

投影瀏覽器先呼叫 /api/state 載入舊泡泡,再使用 EventSource 訂閱新事件:

fetch('/api/state')
  .then(response => response.json())
  .then(data => data.bubbles.forEach(addBubble));

const stream = new EventSource('/api/events');
stream.addEventListener('bubble', event => {
  const bubble = JSON.parse(event.data);
  addBubble(bubble);
});

收到資料後,JavaScript 建立泡泡元素、套用顏色,並將文字放入頁面。CSS 負責圓形外觀、陰影、浮動動畫和手機版排版,構成多媒體呈現的一部分。

五、執行步驟

步驟 1:準備環境

先安裝 Python 3.10 以上版本,將 bubblewall-django.zip 解壓縮到一個工作資料夾。開啟 Windows 命令提示字元(CMD),使用 cd 切換到含有 manage.py 的專案目錄。

cd C:\\Users\\學生\\Desktop\\bubblewall-django

步驟 2:建立虛擬環境並安裝 Django

python -m venv .venv
.venv\\Scripts\\activate
pip install -r requirements.txt

看到命令提示字元前面出現 (.venv),表示虛擬環境已啟用。

步驟 3:設定活動 token

原型預設 token 是 demo-token,直接啟動也能測試。若要指定活動用 token,可在同一個 CMD 視窗執行:

set BUBBLE_TOKENS=class-a-2026

此 token 會用來驗證學生的投稿請求。CMD 視窗關閉後,這項設定會消失;正式部署需改用適當的環境變數管理方式。

步驟 4:建立資料表並啟動伺服器

python manage.py migrate
python manage.py runserver

看到 Django 的開發伺服器已啟動後,在同一個 Wi-Fi 網路中,可先用本機網址測試 http://127.0.0.1:8000/。開發伺服器預設只供本機使用;若要讓教室其他設備連線,需設定主機位址、防火牆及允許的網域。

步驟 5:開啟投稿頁與投影頁

  • 學生投稿頁:http://127.0.0.1:8000/
  • 投影展示頁:http://127.0.0.1:8000/projector/

用兩個瀏覽器視窗開啟這兩個頁面。在投稿頁輸入想法、隊伍、選擇顏色和 token,送出後觀察投影頁是否即時出現泡泡。

步驟 6:用 curl 測試 API

開啟另一個 CMD 視窗,以 curl 直接送出 JSON。若將 token 設定為示範值,命令如下:

curl.exe -X POST http://127.0.0.1:8000/api/bubbles ^
  -H "Authorization: Bearer demo-token" ^
  -H "Content-Type: application/json" ^
  -d "{\"text\":\"讓想法一起發光\",\"color\":\"#61D7C5\",\"team\":\"海洋隊\"}"

成功時會回傳泡泡資料及 HTTP 201。把 token 改錯可觀察 401;把色碼改成不符合格式的內容,則可觀察 400。

六、課堂討論與延伸

  1. 如果投稿資料只有文字、沒有顏色,JSON 欄位與表單要如何調整?
  2. 為什麼投影端要先載入既有泡泡,再訂閱 SSE?如果順序相反,可能發生什麼情況?
  3. GET 和 POST 在這個系統中各自負責什麼?為什麼新增資料不使用 GET?
  4. 除了隊伍名稱與文字,還可以加入圖片、表情符號或音效嗎?前端需要做哪些變更?
  5. 目前資料庫記錄會保留。若每次活動要建立獨立泡泡牆,可加入活動代碼或活動模型來分流資料。

七、原型限制與下一步

目前版本使用 SQLite,適合課堂示範和小型單機活動。SSE 訂閱者清單保存在單一 Django 程序的記憶體中,因此多程序、多台主機部署時,事件不會自動跨程序同步;正式擴充時可改用 Redis Pub/Sub 或 Django Channels。正式上線也應使用 HTTPS、妥善管理 token,並搭配正式的 Django 部署伺服器。

這個作品的價值,在於把抽象網路概念變成可觀察的資料流程。學生每送出一顆泡泡,就能看見用戶端、伺服器、資料庫與多媒體畫面如何協同工作,逐步建立對網際網路與互動應用的整體理解。

2026年8月26日 星期三

[水井村USR] 從遠端控制走向自主維運:異常告警、雲端診斷與安全維護模式實測

水井三寶故事機|V1.0-14

從遠端控制走向自主維運:異常告警、雲端診斷與安全維護模式實測

HUB 8735 Ultra+JQ6500+Django,不只會播放地方故事,更能自行回報健康、接受安全維護命令,並在展場端保留必要的實體停止能力。

100設備健康分數
GOOD健康等級
-27 dBmWi-Fi RSSI
0離線待送事件

前一版 V1.0-13 已完成安全遠端命令、執行結果回報與逾時保護;V1.0-14 再向真正的展場自主維運前進。這次測試不是只確認「雲端按鈕能不能動」,而是驗證設備能否回答三個更重要的問題:設備現在健康嗎?需要維護時能否安全鎖定?維護結束後能否立即恢復服務?

一、V1.0-14增加了什麼?

功能用途展場價值
設備健康分數依網路、播放器、事件佇列、復原次數與告警計算健康狀態管理者不必到現場逐台檢查
異常告警將故障或維護狀態同步到Django儀表板快速辨認需要處理的設備
RUN_DIAGNOSTIC由雲端要求設備產生完整診斷快照遠端取得韌體、RSSI、按鈕、BUSY及JQ6500狀態
MAINTENANCE_ON進入維護模式並阻擋一般播放與音量命令避免維修時被觀眾誤觸啟動
MAINTENANCE_OFF解除維護鎖定,恢復故事播放服務完成維護後不必重新燒錄程式
復原頻率限制限制單位時間內的播放器自動復原次數防止故障設備陷入無限重置

二、雲端儀表板已成為展場維運中心

Django儀表板同時呈現展覽互動成果與設備健康資訊。管理者可以看見故事啟動、完整播放率、設備在線狀態、健康分數、健康等級、告警數、復原層級、最新診斷,以及最近一筆遠端命令結果。


圖:SHUIJING-002在線,健康分數100、等級GOOD、告警0;下方可直接建立V1.0-14自主維運命令。
發表圖片提醒:ChatGPT工作區圖片不是公開網址。請在Blogger編輯器上傳本次儀表板截圖,取得Blogger圖片網址後,把本文唯一的 BLOGGER_IMAGE_URL 換掉,圖片就能穩定顯示。

三、第一階段:遠端完整診斷成功

我們先在Django後台建立 RUN_DIAGNOSTIC 命令。設備透過Heartbeat領取命令後,立即產生診斷快照,再把執行結果送回雲端。

[REMOTE] Heartbeat received RUN_DIAGNOSTIC
[DIAGNOSTIC] fw=V1.0-14,health=100/GOOD,state=IDLE,
track=0,vol=16,rssi=-27,queue=0,jq=0,busy=0,
btn=111111,alert=NONE
[REMOTE] Executed RUN_DIAGNOSTIC | result queued SUCCESS
[REMOTE] Result ACK SUCCESS

診斷資料如何解讀?

health=100/GOOD設備健康,沒有需要立即處理的異常。
state=IDLE故事機處於待機狀態。
vol=16JQ6500目前音量為16。
rssi=-27Wi-Fi訊號非常良好。
queue=0沒有尚未同步的離線事件。
jq=0本次運作尚未發生JQ6500復原。
busy=0播放器沒有正在播放。
btn=111111六個按鈕皆為釋放狀態,沒有腳位異常拉低。
alert=NONE目前沒有作用中的設備告警。

真正關鍵的不是看到 Executed,而是最後收到 Result ACK SUCCESS。這代表「雲端建立命令、設備領取、設備執行、結果回送、伺服器確認」五個環節全部完成。

四、第二階段:從雲端開啟維護模式

Django 建立命令
Heartbeat 領取命令
設備啟用鎖定
結果 ACK 確認
[REMOTE] Heartbeat received MAINTENANCE_ON
[MAINTENANCE] Enabled by cloud
[REMOTE] Executed MAINTENANCE_ON | result queued SUCCESS
[REMOTE] Result ACK SUCCESS

維護模式不是關閉整台設備,而是選擇性鎖定具有風險的操作。故事播放及音量調整會被拒絕,但停止功能仍保留,讓現場人員遇到播放器異常時仍能立即處理。

維護模式中的操作處理結果設計理由
故事按鈕拒絕防止維修時意外啟動音訊
Web故事播放拒絕避免遠端使用者干擾現場維護
音量+/-拒絕維持檢修時的固定條件
停止按鈕保留確保現場仍可立即停止播放器
遠端診斷保留維護期間仍需觀察設備狀態
解除維護保留讓雲端可以安全恢復服務

五、實體按鈕真的被鎖住了嗎?

進入維護模式後,我們分別按下GPIO11烏龜故事與GPIO10白馬故事,系統確實讀到按鍵,但沒有啟動JQ6500:

[BUTTON] Pressed GPIO11
[MAINTENANCE] Story command rejected
[BUTTON] Pressed GPIO10
[MAINTENANCE] Story command rejected

接著按下GPIO20停止鍵,系統仍然接受停止操作;因為當時播放器原本就在待機,所以正確回報:

[BUTTON] Pressed GPIO20
[STOP] Ignored: already idle

這個結果十分重要:維護模式沒有讓整個按鍵掃描停止,而是由命令層判斷哪些動作可以執行。設備仍持續掃描實體輸入,因此不會重演早期版本「網路工作時按鈕失去反應」的問題。

六、解除維護後立即恢復播放

[REMOTE] Heartbeat received MAINTENANCE_OFF
[MAINTENANCE] Disabled by cloud
[REMOTE] Executed MAINTENANCE_OFF | result queued SUCCESS
[REMOTE] Result ACK SUCCESS

解除維護後再次按下GPIO10,白馬故事成功播放,雲端事件也正常寫入本機佇列:

[BUTTON] Pressed GPIO10
[CLOUD QUEUE] + SHUIJING-002-G68F044D7-0000000033
| STORY_START | pending=1
[JQ] Play track 1
[STATE] STORY_PLAYING | 白馬故事
[BUSY] 0 -> 1 | idle=0

這表示解除維護不需要重新開機,也不需要重新燒錄程式;實體按鈕、JQ6500播放、BUSY狀態與雲端事件佇列一起恢復正常。

七、為什麼一直看到NUL filtered=1?

[HEARTBEAT HTTP] NUL filtered=1
[HEARTBEAT] Accepted HTTP 200

這不是錯誤。實測發現Ameba SSL接收的HTTP封包偶爾會夾帶一個 0x00 NUL位元組。早期版本會因此無法解析HTTP狀態列或JSON;現在程式會先過濾NUL,再解析完整回應。

判讀原則:只要NUL過濾後緊接著出現 Accepted HTTP 200,就代表封包已修正並成功處理。這一行是防護機制的工作紀錄,不是通訊失敗。

八、這次完整測試結果

  • STA網路連線與HTTPS Heartbeat正常。
  • Django成功送出RUN_DIAGNOSTIC命令。
  • 設備回傳完整健康診斷快照。
  • 命令執行結果收到雲端ACK。
  • MAINTENANCE_ON成功啟用。
  • 維護期間實體故事按鈕受到阻擋。
  • 維護期間停止按鈕仍可使用。
  • MAINTENANCE_OFF成功解除鎖定。
  • 解除維護後白馬故事成功播放。
  • STORY_START事件成功進入離線保護佇列。
  • JQ6500 BUSY腳位正確由0切換至1。
  • 設備健康分數100、等級GOOD、告警NONE。
實測結論:V1.0-14核心功能全部通過。

水井三寶故事機已從「可以被遠端操作的播放器」,進一步成為「可以自我回報、接受安全維護、保留現場控制並完成雲端稽核」的展場智慧設備。

九、這次最寶貴的工程經驗

展場設備的可靠性,不只取決於功能多不多,而是發生異常時能不能被看見、被限制、被診斷、被恢復。V1.0-14建立的健康分數、異常告警、維護鎖定、遠端診斷與結果ACK,形成一條完整的維運證據鏈。

感知設備健康
雲端派送命令
邊緣安全執行
回報並完成稽核

這也讓地方故事、樹藝作品與智慧展覽不再只是一次性的展示,而是可以長期運作、跨場域部署並由遠端團隊共同維護的數位文化服務。

測試平台:HUB 8735 Ultra、JQ6500、實體按鈕、LED、STA Wi-Fi、HTTPS、Django/PythonAnywhere。版本:V1.0-14「展場自主維運+異常告警+遠端診斷版」。

2026年8月25日 星期二

[水井村USR] 看得見538 Bytes,卻讀不到HTTP:一次珍貴的Ameba SSL封包除錯紀錄

水井三寶故事機|V1.0-13 R3.3

看得見538 Bytes,卻讀不到HTTP:一次珍貴的Ameba SSL封包除錯紀錄

本次測試完成了HUB 8735 Ultra故事機的安全遠端命令、執行結果回報與重新啟動,也找到一個非常隱密的封包問題:SSL回應中只要混入一個NUL(0x00),Arduino的字串解析就可能看似收到資料,實際上卻找不到HTTP狀態列。

最終結果:六種遠端命令全部成功,Django後台均顯示「成功」,每筆命令只領取一次;設備重新啟動後,開機語音也正常播放。

一、這一版要完成什麼?

水井三寶故事機先前已具備實體按鈕、手機Web控制、JQ6500播放、STA主要連線、AP故障備援、NTP校時、Flash離線事件佇列與設備心跳。V1.0-13再向前一步:讓管理者可以從Django雲端安全地下達維護命令,設備執行後還必須回報結果。

Django建立命令
→
心跳領取命令
→
8735執行命令
→
結果ACK後結案
安全領取命令包含Device ID、API Key、UUID與逾期時間。
只執行一次透過命令UUID避免同一指令被重複執行。
結果可追蹤Django必須收到SUCCESS或FAILED,才能將命令結案。

二、R3.1已經成功,為何後面又失敗?

R3.1第一次證明「心跳夾帶命令」的架構可行。設備成功收到並執行狀態回報:

[HEARTBEAT] Accepted HTTP 200
[REMOTE] Heartbeat received REPORT_STATUS | bba846e1-...
[REMOTE] Executed REPORT_STATUS | result queued SUCCESS

但是下一次心跳要把執行結果送回Django時,卻出現:

[HEARTBEAT] POST | state=IDLE | queue=0
[HEARTBEAT] Failed code=0

這不是命令沒有執行,也不是Django拒絕請求,而是RTL8735B端沒有正確解析伺服器回傳的HTTP狀態。

三、最關鍵的線索:538 Bytes與空白Preview

R3.2先改成收完整HTTP Header,並加入原始資料長度診斷。結果出現非常矛盾的訊息:

[HEARTBEAT HTTP] Unparsed bytes=538 | preview=
[HEARTBEAT] Failed code=0
問題焦點:如果完全沒有收到資料,長度應該是0;現在明明收到538 bytes,為何預覽是空白,而且找不到HTTP/1.1 200 OK?

答案是回應內容最前方混入了NUL,也就是數值為0的位元組:

第1 byte00 NUL控制字元
後續文字HTTP/1.1 200 OK
自訂HeaderX-Ameba-Result-Ack: 1
JSON Body{"ok":true,...}

Arduino的String仍可能把這個0x00算入長度,所以看到538 bytes;但許多字串函式會把NUL視為C字串結尾。於是:

  • raw.length()仍顯示收到資料。
  • raw.indexOf("HTTP/")可能找不到後面的HTTP狀態列。
  • Serial.println(raw)從第一個NUL就停止,所以Preview看起來完全空白。
  • HTTP狀態碼最後被判定為0。
這次最重要的除錯觀念:「收到幾個bytes」不等於「收到可直接當成文字處理的bytes」。網路除錯不能只看字串,也要保留位元組、控制字元與十六進位的觀點。

四、R3.3如何修正?

R3.3不再把讀到的0x00直接加入HTTP文字緩衝區,而是在SSL讀取階段排除NUL,同時計算排除數量:

int value = client.read();
if (value == 0) {
    nulCount++;
} else if (value > 0) {
    raw += (char)value;
}

若解析仍失敗,程式還會輸出前32 bytes的HEX資料。這使除錯從「猜測HTTP為何不見」變成「直接觀察真正收到的位元組」。

[HEARTBEAT HTTP] Unparsed bytes=... | NUL filtered=... | preview=...
[HEARTBEAT HTTP] HEX: 48 54 54 50 2F 31 2E 31 ...

其中48 54 54 50就是ASCII的HTTP。這種診斷方式未來也適合用於UART、MQTT、Modbus、WebSocket及其他嵌入式通訊問題。

五、R3.3實機測試結果

更新R3.3後,日誌第一次直接證實問題來源:

[HEARTBEAT HTTP] NUL filtered=1
[HEARTBEAT] Accepted HTTP 200
[REMOTE] Result ACK SUCCESS | d7d05b25-...

只是一個NUL,就足以讓前一版完全讀不到HTTP;排除後,命令領取、執行與ACK全部恢復正常。

六種遠端命令測試

遠端命令設備動作結果
REPORT_STATUS立即回報設備狀態成功
SYNC_EVENTS立即補送Flash離線事件成功
SYNC_TIMENTP校時;失敗時切換HTTP時間成功
SET_VOLUME音量20調整為16並延遲保存成功
PLAYER_RESET重新初始化JQ6500播放器成功
DEVICE_RESTART回報成功並保存資料後重新啟動成功

六、安全重新啟動不是「收到就重開」

DEVICE_RESTART尤其值得記錄。設備不是一收到命令便立刻重新啟動,而是依序完成:

  1. 領取具有UUID的重新啟動命令。
  2. 將執行結果排入下一次心跳。
  3. 等待Django回傳X-Ameba-Result-Ack: 1。
  4. 保存Flash中的統計、音量與佇列資料。
  5. 最後才執行系統重新啟動。
[REMOTE] Executed DEVICE_RESTART | result queued SUCCESS
[HEARTBEAT] Accepted HTTP 200
[REMOTE] Result ACK SUCCESS | 0a815d53-...
[FLASH] Batch saved: before remote restart
[SYSTEM] Restart scheduled: heartbeat command restart
[SYSTEM] Restarting: heartbeat command restart

重新開機後,音量16成功保留,開機提示語音正常播放,STA重新連上Wi-Fi,證明「命令回報、Flash保存、重啟復原」三個環節都通過。

七、Django後台驗證

後台六筆命令全部顯示「成功」,Delivery Attempts均為1,Completed At也有完成時間。這代表:

  • 命令沒有因輪詢或網路延遲而重複執行。
  • 每筆命令都由相同UUID貫穿領取、執行與結果確認。
  • 設備重新啟動前,雲端已收到成功結果。
  • 心跳既是健康監控,也是低負擔的安全命令通道。
命令類型狀態領取次數完成確認
重新啟動設備成功1已記錄
重設播放器成功1已記錄
設定音量成功1已記錄
立即網路校時成功1已記錄
立即補送事件成功1已記錄
立即回報狀態成功1已記錄

八、這次經驗教會我們什麼?

1. HTTP仍是Bytes即使最終看到的是文字協定,底層傳輸仍可能包含控制字元。
2. 長度不能代表內容538 bytes可能以NUL開頭,字串函式看到的卻是空字串。
3. 要有HEX診斷文字預覽失效時,十六進位輸出是最可靠的證據。
4. ACK後才能破壞性操作重啟前先回報與保存,才能避免雲端永遠停在「已領取」。
5. UUID保證冪等同一命令不因重送而執行兩次,對展場設備非常重要。
6. 現場功能優先網路同步與心跳都不能阻塞實體按鈕及故事播放。

九、版本結論

V1.0-13 R3.3已完成從「會播放故事的單機」到「可被雲端安全維護的展場設備」的重要跨越。它不只會執行遠端命令,還能辨識命令、避免重複、回報結果、等待ACK、保存狀態,並在必要時安全重啟。

本次最珍貴的發現:真正讓系統卡住的,不是538 bytes的大問題,而是藏在最前面、肉眼看不見的1 byte——NUL(0x00)。物聯網系統的可靠性,常常就建立在願不願意把「看似空白」繼續追查到底。

[水井村USR] 從HTTP 400到HTTP 201:HUB 8735 Ultra設備身分、Django同步與離線佇列實測

樹藝AI故事機|V1.0-11

從HTTP 400到HTTP 201:HUB 8735 Ultra設備身分、Django同步與離線佇列實測

這次測試讓「水井三寶故事機」跨出重要一步:故事不只在現場播放,還能以設備身分將匿名互動事件送到Django;網路或API暫時異常時,資料先留在Flash,待問題排除後再依序補送。

SHUIJING-002獨立設備身分
Flash Queue離線事件保存
HTTP 201Django接收成功

一、V1.0-11要解決什麼問題?

前一版已完成實體按鈕、手機Web控制、JQ6500播放、STA主要連線、AP故障備援及NTP校時。但展場系統若要長期營運,還必須回答三個問題:

  1. 是哪一台故事機送出的資料?每台設備必須有獨立Device ID與API Key。
  2. 斷網時互動紀錄會不會消失?事件必須先寫入本機Flash。
  3. 恢復連線後如何補送?佇列必須按照事件順序逐筆送往Django。
實體按鈕/手機Web
HUB 8735事件建立
Flash離線佇列
HTTPS+設備金鑰
Django事件資料庫

二、設備身分與事件格式

本次測試設備編號為:

[CLOUD] Device ID: SHUIJING-002

韌體送出請求時,以Header攜帶設備身分與個別金鑰。正式API Key不應出現在部落格、序列紀錄或公開程式庫。

POST /api/exhibition/events/ HTTP/1.1
Content-Type: application/json
X-Device-ID: SHUIJING-002
X-Device-Key: ********

每一筆事件都有唯一的event_uuid,例如:

SHUIJING-002-0000000014

如此即使網路重試,也能由Django利用UUID避免同一筆事件被重複建立。

三、第一次上線:本機正常,雲端卻回傳400

故事機能連上STA、取得IP、完成NTP校時,按鈕與Web也都能播放故事,但離線事件始終停留在佇列:

[TIME] Synced by NTP | 2026-08-25 09:29:21
[MODE] STA online service ready
[CLOUD] POST SHUIJING-002-0000000001 | pending=1
[CLOUD] Failed code=400
重要判斷:HTTP 400不代表Wi-Fi失敗。它表示HUB 8735已成功連到Django,但Django認為送來的內容不符合API規則。

當時韌體只顯示狀態碼,無法知道伺服器拒絕哪個欄位。因此加入「安全截取Django回應本文」診斷,並限制最大長度,避免錯誤頁占用過多記憶體。

[CLOUD] Failed code=400
[CLOUD] Django response: {"ok": false, "error": "invalid event_type"}

這一行就是整次除錯的關鍵證據:網路、TLS、網址、設備驗證都已經通過,真正問題是Arduino與Django使用了不同的事件名稱。

四、找出兩端的事件名稱差異

原Arduino事件Django模型事件最後處理
SYSTEMDEVICE_BOOTArduino改送DEVICE_BOOT
STORY_STARTSTORY_START保持不變
STORY_ENDSTORY_COMPLETEArduino改送STORY_COMPLETE
STORY_INTERRUPTEDSTOP以STOP表示中止,利用story與completed區分
STOPSTOP保持不變

Arduino最後使用的轉換函式如下:

const char *cloudEventTypeName(uint8_t type)
{
    if (type == CLOUD_STORY_START) return "STORY_START";
    if (type == CLOUD_STORY_END) return "STORY_COMPLETE";
    if (type == CLOUD_STORY_INTERRUPTED) return "STOP";
    if (type == CLOUD_STOP) return "STOP";
    if (type == CLOUD_SYSTEM) return "DEVICE_BOOT";
    return "DEVICE_ERROR";
}

五、Django端實際採取的修正

本次正式測試沒有更換views.py,只調整models.py的事件選項,使其與韌體送出的正式名稱一致。原View原本就透過Model動態檢查:

if event_type not in dict(ExhibitionEvent.EVENT_TYPES):
    return None, "invalid event_type"

因此Model選項更新後,View會自動採用新選項,不必再維護第二份事件清單。這也避免Model與View日後再次出現名稱不一致。

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

六、為什麼舊的10筆事件不用清除?

這次設計最有價值的地方,是Flash佇列保存的不是最後的JSON文字,而是精簡的事件型別代碼、故事編號、來源、時間與序號。真正準備上傳時,才由cloudEventTypeName()轉成目前的正式名稱。

因此更新韌體後,原本保存在Flash裡的舊CLOUD_SYSTEM事件,送出時會自動變成DEVICE_BOOT;舊CLOUD_STORY_END則會變成STORY_COMPLETE。資料不必刪除,也不必重新製造。

重新開機後的證據

[FLASH] V1.0-11 data restored
[CLOUD] Offline queue: 10
[CLOUD QUEUE] + SHUIJING-002-0000000011 | DEVICE_BOOT | pending=11

前10筆失敗事件仍然存在;重新開機又新增第11筆設備啟動事件,證明Flash恢復與事件序號持續運作。

七、HTTP 201:離線事件開始依序補送

修正事件名稱後,從管理後台按下「立即補送」,Django開始接受事件:

[CLOUD] POST SHUIJING-002-0000000001 | pending=13
[CLOUD] Accepted HTTP 201 | remaining=12
[CLOUD] POST SHUIJING-002-0000000002 | pending=12
[CLOUD] Accepted HTTP 201 | remaining=11
[CLOUD] POST SHUIJING-002-0000000003 | pending=11
[CLOUD] Accepted HTTP 201 | remaining=10

後續事件繼續補送:

[CLOUD] POST SHUIJING-002-0000000007 | pending=9
[CLOUD] Accepted HTTP 201 | remaining=8
[CLOUD] POST SHUIJING-002-0000000008 | pending=8
[CLOUD] Accepted HTTP 201 | remaining=7
HTTP 201代表建立成功。這證明設備金鑰、Device ID、JSON、事件類型、Story對應與Django資料建立流程均已通過。

八、補送期間仍可正常播放故事

雲端補送不能犧牲展場體驗。測試過程中,使用者仍可從手機Web播放白馬故事,也能按下GPIO11播放烏龜故事:

[WEB] GET /api/play/horse HTTP/1.1
[CLOUD QUEUE] + SHUIJING-002-0000000012 | STORY_START | pending=12
[JQ] Play track 1
[STATE] STORY_PLAYING | 白馬故事

[BUTTON] Pressed GPIO11
[CLOUD QUEUE] + SHUIJING-002-0000000014 | STORY_START | pending=11
[JQ] Play track 2
[STATE] STORY_PLAYING | 烏龜故事

故事播放完成後,再建立STORY_COMPLETE:

[CLOUD QUEUE] + SHUIJING-002-0000000015 | STORY_COMPLETE | pending=12
[STATE] IDLE | 待機中

這表示系統採取的不是「先等網路完成才能播放」,而是現場互動優先、事件同步在後。

九、這次測試建立的除錯方法

看到的現象代表意義優先檢查
無法取得STA IP尚未連上區域網路SSID、密碼、Channel、訊號
NTP失敗可能無Internet或UDP受限Gateway、DNS、HTTP時間備援
HTTP 401設備驗證失敗Device ID、API Key、啟用狀態
HTTP 400API收到請求但內容不合規Django回應本文、JSON欄位、事件名稱
HTTP 201事件建立成功確認佇列remaining持續下降
HTTP 409UUID可能已存在視為冪等成功或檢查重複事件

十、展場驗收清單

  • 每台設備使用獨立Device ID與API Key。
  • API Key不出現在序列監控、部落格與公開GitHub。
  • STA連線後能自動取得DHCP位址並完成NTP校時。
  • 實體按鈕與手機Web均能播放故事。
  • 開始播放建立STORY_START。
  • 正常播完建立STORY_COMPLETE。
  • 斷網或API錯誤時,事件保留於Flash。
  • 恢復正常後,舊事件依UUID順序補送。
  • 佇列最終回到0,Django後台筆數一致。

十一、這次經驗最重要的價值

這次測試不是單純把HTTP 400修成HTTP 201,而是建立了一套更接近真實展場的IoT資料策略:

Local First:按鈕與故事播放永遠優先,事件先安全留在本機。

Cloud Later:網路正常後再送Django,失敗不丟資料。

Evidence-based Debugging:不只看Wi-Fi是否連上,而是沿著IP、NTP、HTTP狀態碼、伺服器回應及資料模型逐層定位。

Shared Contract:Arduino與Django必須共用相同事件語彙;一個名稱不同,就可能讓整條FIFO佇列停住。

2026年8月22日 星期六

網站上線後才是開始: Log、備份、監控與效能

封包旅行記 EP07|從部署走向營運

網站上線後才是開始:
Log、備份、監控與效能

Django網站可以開啟,只代表部署完成;智慧養殖、產銷平台與水井三寶場域真正要長期運作,還必須在出錯時找得到原因、資料遺失時救得回來、異常發生時有人知道、使用量增加時仍然回得動。

一、上線不是終點,而是系統生命週期的起點

Log|留下證據誰在何時做了什麼?在哪一層失敗?可否用Request ID串起整條路徑?
備份|保住成果資料刪錯、主機故障或程式更新失敗時,能恢復到哪個時間點?
監控|提早知道網站掛掉、API變慢、錯誤率上升或ESP32斷線時,誰會收到通知?
效能|量測改善慢在網路、Django、資料庫、檔案系統,還是外部服務?

營運能力=看得見+救得回+叫得到人+持續改善。沒有Log只能猜,沒有還原演練的備份只是希望,沒有告警的監控只是漂亮圖表,沒有量測的效能改善只是碰運氣。

二、先定義「什麼叫系統正常」

智慧養殖系統不能只監看首頁是否回200。即使網站正常,裝置可能已經兩小時沒有上傳,儀表板仍顯示昨天最後一筆水溫。

層級正常條件例失敗時代表
網站存活/health/live/能快速回200Web worker、程式或平台可能無法服務
服務就緒/health/ready/可連資料庫與必要服務網站進程活著,但尚不能完成真正工作
API品質成功率、P95延遲符合門檻部分功能壞掉或逐漸變慢
資料新鮮度各ESP32最近上傳時間未超過預期裝置、Wi-Fi、MQTT/HTTP或排程可能中斷
業務正確性溶氧警報能產生、通知並被確認技術層看似正常,但場域流程失效

三、Log第一課:不要只寫「發生錯誤」

一筆有用的Log至少要回答:時間、嚴重程度、事件、request_id、裝置或場域代碼、結果與可採取的下一步。

2026-08-22T07:45:10+08:00 INFO
event=reading.accepted request_id=7bc2...
device_id=POND01-ESP32-01 event_uuid=POND01-000062
status=201 duration_ms=84

2026-08-22T07:46:03+08:00 WARNING
event=reading.rejected request_id=2f91...
device_id=POND01-ESP32-01 reason=ph_out_of_range
status=400 duration_ms=12

應該記錄

事件名稱、Request ID、匿名或內部裝置ID、狀態碼、耗時、錯誤分類、重試次數與程式版本。

不應記錄

完整Token、密碼、Session Cookie、個資、完整信用卡資料或未遮罩的敏感Payload。

Log本身也是敏感資料。必須限制存取、設定保存期限、避免無限增長,且不能讓使用者輸入破壞Log格式或偽造紀錄。

四、Django Logging設定與使用

# settings.py:教學版,以console交給部署平台收集
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "verbose": {
            "format": "{asctime} {levelname} {name} {message}",
            "style": "{",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "verbose",
        },
    },
    "loggers": {
        "aquaculture": {
            "handlers": ["console"],
            "level": "INFO",
            "propagate": False,
        },
        "django.request": {
            "handlers": ["console"],
            "level": "WARNING",
            "propagate": False,
        },
    },
}
# views.py
import logging
logger = logging.getLogger("aquaculture.api")


def record_success(request_id, device_id, event_uuid, duration_ms):
    logger.info(
        "event=reading.accepted request_id=%s device_id=%s "
        "event_uuid=%s duration_ms=%d",
        request_id, device_id, event_uuid, duration_ms,
    )


def record_failure(request_id, device_id):
    logger.exception(
        "event=reading.failed request_id=%s device_id=%s",
        request_id, device_id,
    )

logger.exception()應在例外處理區使用,它會留下Stack Trace。Django官方建議以Python logging取得具名Logger,並透過LOGGING設定Handler、Formatter與Level。

Level用途場域例
DEBUG開發診斷細節序列化前後欄位;正式環境通常不大量開啟
INFO正常的重要事件資料接收、備份完成、裝置重新上線
WARNING尚能服務但值得注意資料過舊、重試增加、磁碟逼近門檻
ERROR某項操作失敗資料庫寫入失敗、外部通知失敗
CRITICAL系統性重大故障核心資料庫不可用或大量API全面失敗

五、Request ID:把ESP32、API與資料庫串成一條線

ESP32每筆事件已有event_uuid,HTTP請求再加入X-Request-ID。若Client未提供,Django產生新的UUID,回應時也帶回,學生便能從裝置Serial Log一路查到伺服器Log。

# middleware.py(簡化示例)
import uuid


class RequestIdMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        incoming = request.headers.get("X-Request-ID", "")
        request.request_id = incoming[:80] if incoming else str(uuid.uuid4())

        response = self.get_response(request)
        response["X-Request-ID"] = request.request_id
        return response

不要無條件信任Header。限制長度與格式,避免把任意使用者輸入直接插入結構化Log;Request ID只用於追蹤,不是安全憑證。

六、PythonAnywhere上要看哪幾種Log?

Log主要內容適合回答
Access LogURL、狀態碼、回應大小與回應時間哪個API慢?錯誤率何時上升?
Error Log載入、WSGI與部署錯誤為何Reload後網站無法啟動?
Server/應用LogDjango logger、Traceback及自訂事件是哪個裝置或資料造成例外?
Task LogScheduled/Always-on Task執行結果備份、清理或監控排程是否真的完成?

平台介面與方案限制可能調整,請以PythonAnywhere目前帳號的Web與Tasks頁面為準。正式營運應建立固定巡檢責任,而不是出事後才第一次找Log。

七、備份不是複製檔案,而是一套可還原的制度

備什麼資料庫、媒體檔、環境設定、部署文件與程式版本。
多久一次依可接受資料損失RPO決定,例如每日完整+更頻繁增量。
放哪裡不可只留在同一帳號或同一主機;至少一份異地/離線。
怎麼證明定期在隔離環境還原,核對筆數、附件與核心流程。

先定義RPO與RTO

指標問題範例
RPO最多可接受遺失多久的資料?水質資料最多遺失1小時
RTO故障後多久必須恢復服務?4小時內恢復API與儀表板

3-2-1原則:至少3份資料、2種不同媒介、1份異地。再加上加密、權限、保留週期與還原演練,才形成真正的備份策略。

八、不同資料庫的備份方式不能混用

# PostgreSQL:自訂格式,使用pg_restore還原
pg_dump -Fc -d DATABASE_NAME -f backup_YYYYMMDD.dump
pg_restore --clean --if-exists -d RESTORE_TEST_DB backup_YYYYMMDD.dump

# MySQL:邏輯備份示意
mysqldump --single-transaction DATABASE_NAME > backup_YYYYMMDD.sql

# SQLite:網站低流量或停止寫入時,以sqlite3一致性備份
sqlite3 db.sqlite3 ".backup 'backup_YYYYMMDD.sqlite3'"

帳密不要直接寫在可被其他使用者看到的命令或程式碼中;應使用平台提供的安全環境變數或憑證機制。命令選項需依資料庫版本、權限與託管平台調整。

不要直接複製正在大量寫入的SQLite檔案當成可靠備份。也不要只備資料庫而忘記使用者上傳的照片、附件與對應程式版本。

還原演練的五步驟

  1. 建立隔離的空白測試資料庫。
  2. 用備份檔還原,不覆蓋正式環境。
  3. 執行Migration/相容性檢查。
  4. 核對關鍵表筆數、最新時間與附件。
  5. 用Postman/pytest跑核心API流程並記錄實際RTO。

九、監控:圖表不是目的,能採取行動才是

LatencyP50、P95與P99延遲,不只看平均值。
Traffic每分鐘請求數、活躍裝置與資料寫入量。
Errors4xx、5xx、例外與失敗工作比例。
SaturationCPU、記憶體、磁碟、連線池與Worker負荷。

智慧養殖還要增加資料新鮮度:每座魚塭最後上傳時間、離線裝置數、離線佇列深度、警報未確認時間。

監控項目告警例第一個行動
網站可用性連續3次健康檢查失敗確認平台狀態、Error Log與最近部署
API錯誤率5分鐘內5xx超過門檻依Request ID抽樣Traceback
API延遲P95連續10分鐘超標分解網路、Web、DB與外部服務時間
裝置資料新鮮度預期5分鐘上傳,15分鐘未收到查LWT、Wi-Fi、電源與離線佇列
備份排程失敗、檔案為0或超過期限停止自動刪除舊備份並人工處理
磁碟可用空間低於設定門檻查Log、媒體與備份增長來源

十、健康檢查:Liveness與Readiness要分開

# views.py(簡化示例)
from django.db import connection
from django.http import JsonResponse


def live(request):
    return JsonResponse({"status": "alive"})


def ready(request):
    try:
        with connection.cursor() as cursor:
            cursor.execute("SELECT 1")
            cursor.fetchone()
        return JsonResponse({"status": "ready"})
    except Exception:
        return JsonResponse({"status": "not_ready"}, status=503)

健康端點要快、穩定且不洩密。不要回傳資料庫密碼、Token、完整例外或伺服器內部路徑;也不要讓Liveness依賴所有外部服務,否則短暫外部故障可能引起不必要重啟。

十一、告警要包含「誰、何時、怎麼處理」

不好的告警可行動的告警
網站壞了!PROD API 5xx達12%,開始07:42,影響/api/v1/readings/,Runbook:OPS-API-01,值班:A組
裝置離線POND01-ESP32-01已15分鐘無資料,最後RSSI -76、最後LWT offline、最近event_uuid與場域聯絡方式

同一問題重複發送數百次會造成告警疲勞。應設定持續時間、合併、冷卻與恢復通知;重大告警必須有負責人及Runbook。

十二、效能第一原則:先量測,找出真正瓶頸

瀏覽器/ESP32總耗時−Access Log伺服器耗時≈網路傳輸與連線成本

PythonAnywhere官方說明建議比較瀏覽器Network總時間與Access Log回應時間,先區分網路與Web App。接著再把Django處理拆成資料庫、檔案、外部API與Python運算。

瓶頸觀察證據常見改善
網路/TLSClient慢,但Access Log處理快縮小Payload、連線重用、選擇合適部署區域
N+1查詢清單筆數越多,SQL數量線性增加select_related()、prefetch_related()
缺索引依device_id、measured_at查詢越來越慢依實際Query與執行計畫建立索引
傳太多資料API回傳數萬筆水質紀錄分頁、時間區間、只取必要欄位
同步重工作寄信、AI分析或報表拖慢Request移至背景工作並提供狀態
靜態/媒體檔大量圖片由Django直接處理正確Static/Media服務、壓縮與快取
Worker不足單次不慢,但多人同時使用就排隊縮短請求、增加合適Worker或方案資源

十三、Django ORM效能:少查、查對、只取需要的資料

# 不佳:迴圈中每次存取pond,可能形成N+1查詢
readings = PondReading.objects.all()[:100]
for reading in readings:
    print(reading.pond.name)

# 改善:ForeignKey使用select_related一次Join
readings = (
    PondReading.objects
    .select_related("pond")
    .only("event_uuid", "measured_at", "water_temp_c", "pond__name")
    .order_by("-measured_at")[:100]
)
# 常用查詢可考慮複合索引,但必須先量測
class PondReading(models.Model):
    device_id = models.CharField(max_length=80)
    measured_at = models.DateTimeField()

    class Meta:
        indexes = [
            models.Index(fields=["device_id", "-measured_at"]),
        ]

索引不是免費午餐。它會占空間並增加寫入成本;應以真實查詢、資料量與執行計畫決定。快取也會帶來資料過期與失效策略問題,不應拿來掩蓋錯誤查詢。

十四、部署後的日、週、月維運節奏

頻率建議工作
每日自動健康檢查、備份結果、5xx、裝置離線、磁碟與憑證到期監控
每週人工抽查Log、慢API、備份檔、未確認告警及使用量趨勢
每月隔離還原演練、帳號/Token盤點、套件更新評估、效能基準比較
每次部署Migration備份、部署清單、Smoke Test、版本標記與Rollback方案
每次事故後無責檢討:時間線、根因、影響、復原與防止再發措施

PythonAnywhere排程:可用Scheduled Task執行備份檢查或Django Management Command;功能與額度依帳號方案而異。排程「被建立」不代表「有成功」,必須監控Exit Code、輸出與備份新鮮度。

十五、故障演練:讓學生真的把系統救回來

No-AI|Log追蹤

教師提供ESP32 Serial Log、Access Log與Django Traceback;學生用event_uuid與Request ID重建故障時間線,找出第一個失敗點。

AI Pair|維運假設

請AI提出網站變慢的可能原因,學生依量測證據排序、駁回無證據猜測,並設計每個假設的最小驗證。

Challenge AI|災難復原

在隔離環境模擬資料誤刪、備份還原、裝置斷線與API延遲;量測RPO/RTO並完成Runbook與事後檢討。

十六、上線營運驗收清單

檢核通過證據
Log可追查可用Request ID從ESP32追到API結果,且無敏感資料
備份可還原在隔離環境成功還原並通過Postman/pytest核心測試
監控可告警網站、5xx、延遲、資料新鮮度、備份與磁碟皆有門檻
告警有人處理每項重大告警有負責人、Runbook與升級路徑
效能有基準記錄P50/P95、Query數與資料量,改善前後可比較
部署可回復每個版本有標記、Migration計畫、Smoke Test與Rollback方案

十七、延伸閱讀(官方文件)

部署平台、資料庫與Django版本會變動;正式操作前應以目前使用版本及帳號方案的官方文件為準。

封包旅行記 EP07|真正的上線,不是網址能打開;而是故障能追、資料能救、異常有人知道、系統能持續變好。

Django REST API安全第一課: HTTPS、CSRF、CORS與Token

封包旅行記 EP05|API安全邊界

Django REST API安全第一課:
HTTPS、CSRF、CORS與Token

ESP32把JSON送到Django,不只要問「送到了嗎」,還要問:途中有沒有被看見或竄改?伺服器怎麼辨認裝置?瀏覽器為何擋下請求?Cookie與Token應該用哪一套防護?

一、先記住:四個機制保護的是不同問題

HTTPS保護傳輸途中,提供加密、完整性與伺服器身分驗證。
CSRF防止瀏覽器在自動攜帶Cookie時,被惡意網站借用登入身分送出操作。
CORS是瀏覽器的跨來源讀取規則,決定哪個網頁來源可讀取API回應。
Token讓API辨認呼叫者;仍須搭配權限、過期/輪替、撤銷與HTTPS。

HTTPS+身分驗證+權限+輸入驗證才是一條完整防線。CORS不是登入機制,CSRF Token也不是API存取權杖,Token更不能取代HTTPS。

二、兩條連線路徑,安全需求並不相同

瀏覽器管理介面

人員登入Session CookieCSRF

Django後台或同站儀表板通常使用Session。瀏覽器會自動帶Cookie,因此POST、PUT、PATCH、DELETE等修改操作需要CSRF防護。

ESP32裝置API

裝置身分Authorization HeaderToken

ESP32不是瀏覽器,不受瀏覽器同源政策約束;通常使用HTTPS並在Header主動攜帶裝置Token,不使用使用者Session Cookie。

呼叫者常見驗證CSRFCORS
Django同站網頁Session Cookie修改資料時需要同來源通常不涉及
不同網域的前端SPACookie或Header Token使用Cookie驗證時仍要正確處理瀏覽器需要允許該Origin
ESP32/伺服器程式Token、API Key或更強的裝置憑證若不依賴瀏覽器自動帶Cookie,通常不是此威脅模型不由瀏覽器執行,CORS不會保護或阻擋它

三、HTTPS:先保護道路,再談通行證

HTTP明文傳輸時,Token、感測資料與控制命令可能被同網段或傳輸路徑上的攻擊者讀取或竄改。HTTPS透過TLS建立安全通道,但它不會自動判斷使用者能不能查看某座魚塭,也不會驗證JSON欄位是否合理。

ESP32驗證伺服器憑證→TLS加密通道→Authorization Token→Django權限與資料驗證

Django正式環境安全設定示意

# settings.py(正式環境示意,需依部署架構調整)
DEBUG = False
ALLOWED_HOSTS = ["api.example.org"]

SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True

# 只有在「可信任的反向代理」確實設定此Header時才使用:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

不要盲目複製:HSTS與Proxy Header設定錯誤可能造成網站無法存取或偽造HTTPS判定。應先確認Nginx、平台代理及網域均已正確提供HTTPS,再逐項啟用並測試。

ESP32端也要驗證憑證。僅使用「insecure」模式雖然畫面上有HTTPS,卻放棄了伺服器身分驗證,可能受到中間人攻擊。應正確同步時間並配置可信任CA。

四、CSRF:防止別的網站借用你的Cookie

假設教師已登入Django後台,Session Cookie保存在瀏覽器。若瀏覽器被誘導開啟惡意網站,該網站可能嘗試向Django送出「刪除資料」請求;瀏覽器可能自動附上Cookie。CSRF Token用來證明這次修改操作來自可信任頁面流程。

<form method="post">
  {% csrf_token %}
  <button type="submit">更新設備名稱</button>
</form>

AJAX使用SessionAuthentication時,需取得CSRF Cookie並在不安全方法的Request Header傳送X-CSRFToken。Django REST Framework官方文件指出,SessionAuthentication下的POST、PUT、PATCH、DELETE需要有效CSRF Token。

不要用@csrf_exempt當作通用除錯方法。若真正需求是讓ESP32呼叫API,應建立清楚的Token驗證API View,而不是關掉整個網站的CSRF保護。

五、CORS:瀏覽器的讀取許可,不是API門鎖

Origin由通訊協定+主機+Port組成。https://dashboard.example.org中的JavaScript呼叫https://api.example.org就是跨來源;瀏覽器可能先送出OPTIONS預檢,伺服器再以CORS Header表示是否允許。

CORS能做什麼

讓受信任的前端網頁在瀏覽器中讀取API回應;限制哪些Origin、Method與Header可使用。

CORS不能做什麼

不能阻止curl、ESP32或攻擊者伺服器直接呼叫API,也不能取代Authentication與Permission。

# pip install django-cors-headers

INSTALLED_APPS = [
    # ...
    "corsheaders",
]

MIDDLEWARE = [
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    # ...
]

CORS_ALLOWED_ORIGINS = [
    "https://dashboard.example.org",
]
CORS_URLS_REGEX = r"^/api/.*$"

# 僅Cookie跨站情境才評估開啟,並搭配CSRF與Cookie策略:
CORS_ALLOW_CREDENTIALS = False

正式環境避免CORS_ALLOW_ALL_ORIGINS = True。應列出真正需要的Origin;CORS與CSRF_TRUSTED_ORIGINS是兩套不同設定,不要以為允許CORS就自動通過CSRF。

六、Token:回答「你是誰」,Permission回答「你能做什麼」

DRF內建TokenAuthentication適合教學與簡單Client–Server情境。Client以Header送出Authorization: Token <key>。官方文件也提醒,內建Token是較簡單的實作;正式系統若需要每裝置多Token、到期、輪替或細緻權限,應評估更完整的方案。

# settings.py
INSTALLED_APPS = [
    # ...
    "rest_framework",
    "rest_framework.authtoken",
]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.TokenAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

# 設定後執行:python manage.py migrate
# views.py
from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

class DeviceEventView(APIView):
    authentication_classes = [TokenAuthentication]
    permission_classes = [IsAuthenticated]

    def post(self, request):
        event_uuid = request.data.get("event_uuid")
        if not event_uuid:
            return Response({"error": "event_uuid required"}, status=400)

        # 還要驗證:此Token可否代表這台device_id、欄位型別與資料範圍
        return Response({"accepted": True, "event_uuid": event_uuid}, status=201)
curl -X POST "https://api.example.org/api/events/" \
  -H "Authorization: Token YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"event_uuid":"DEVICE-001-0001","event_type":"STORY_START"}'
一台裝置一個身分:不要把同一個管理員Token燒錄到所有ESP32。每台裝置使用獨立憑證,伺服器限制其可操作的device_id;外洩時才能只撤銷單一裝置。

七、常見迷思:看起來能用,實際上仍不安全

迷思問題正確觀念
用了HTTPS就安全任何持有Token者仍可能越權HTTPS之外還要驗證、權限、輸入驗證與日誌
開放CORS讓ESP32能連ESP32不受瀏覽器CORS限制查DNS、TLS、Token、API路徑與防火牆
CSRF錯誤就加csrf_exempt移除重要防線且混淆驗證模式Session走CSRF;裝置API走Header Token與Permission
Token放在URL比較方便可能出現在日誌、瀏覽紀錄與Referer放在Authorization Header,且避免輸出到log
前端藏起按鈕就代表沒權限攻擊者可直接呼叫API每個API在伺服器端檢查Permission與物件所有權
所有裝置共用一把Token一台外洩等於整個場域失守個別憑證、最小權限、可撤銷及輪替

八、從ESP32到Django的安全檢查順序

  1. TLS:網址是否為HTTPS?憑證鏈、主機名稱與ESP32時間是否正確?
  2. Authentication:Header格式是否正確?Token是否有效、遭撤銷或誤寫到log?
  3. Permission:此裝置能否寫入指定device_id與API?
  4. Validation:JSON欄位、型別、長度、數值範圍與時間是否合理?
  5. Idempotency:QoS或重試造成重複請求時,event_uuid是否能去重?
  6. Audit:記錄結果、裝置ID、時間與Request ID,但不得記錄完整Token。
HTTP結果判讀下一步
401 Unauthorized沒有提供或無法驗證身分檢查Authorization Header與Token狀態
403 Forbidden身分已知但不允許,或Session情境的CSRF失敗查Permission、物件所有權及CSRF日誌
400 Bad Request資料格式或欄位驗證失敗查看安全且不洩密的錯誤內容
瀏覽器顯示CORS錯誤瀏覽器無法讀取回應;伺服器仍可能已收到請求看Browser Network與Server log,確認Origin、OPTIONS及Header

九、場域最低安全基線

傳輸層

全程HTTPS、有效憑證、ESP32驗證CA、停用明文API。

身分與權限

每台裝置獨立Token、最小權限、可撤銷、定期輪替。

應用與營運

Serializer驗證、節流、UUID去重、安全日誌、備份與更新。

祕密不進Git:使用環境變數或平台祕密管理保存Django SECRET_KEY、資料庫密碼與API憑證;Token不可寫進公開程式碼、截圖或教學log。

十、學生實作:安全不是抄設定,而是建立威脅模型

No-AI|四機制分類

針對「封包被偷看、惡意網站借Cookie、前端跨網域、ESP32身分冒用」四個情境,分別判斷HTTPS、CSRF、CORS、Token誰負責,並說明誰不能解決。

AI Pair|安全審查

提供一份去除真實密鑰的settings.py與API View,請AI列出風險;學生必須逐條對照官方文件,標示接受、修正或駁回及理由。

Challenge AI|紅藍隊驗證

測試無Token、錯Token、越權device_id、重複event_uuid、錯誤Origin與無CSRF請求;完成預期狀態碼、Server log與修補證據。

十一、上線前驗收清單

檢核通過證據
HTTPS與憑證驗證HTTP會安全導向HTTPS;ESP32拒絕錯誤憑證
Session與CSRF合法表單可操作;缺少或錯誤CSRF的修改請求被拒絕
CORS最小開放只有指定Origin可由瀏覽器讀取API,且僅套用需要的路徑
Token與Permission每台裝置獨立;不可寫入別台設備資料
不洩漏祕密程式庫、log、錯誤頁與截圖均無Token或密碼
異常與撤銷演練外洩Token可單獨停用,服務不中斷且留下稽核紀錄

十二、延伸閱讀(官方文件)

安全設定會隨框架版本與部署平台調整;實作時應以目前使用版本的官方文件為準。

封包旅行記 EP05|真正的API安全,不是把錯誤訊息消掉,而是讓每條連線都能回答:誰在呼叫、能做什麼、資料是否安全、失敗後如何追查。

一個封包如何從ESP32走到Django?

封包旅行記 EP01|大學部網際網路應用

一個封包如何從
ESP32走到Django?

按下按鈕、讀到水溫或播放一段故事之後,資料如何穿越Wi-Fi、路由器、DNS、Internet與HTTPS,最後被Django接收並寫進Database?

ESP32Wi-FiDNSTCP/TLSHTTP/JSONDjango

一個按鈕按下後,真正發生了什麼?

在智慧生活系統中,我們常看到ESP32序列監控視窗顯示「POST成功」,Django Dashboard也出現一筆新資料。看起來只是幾行程式,但在這短短幾秒內,資料其實經過多個設備、位址與協定。

本篇的重點不是再做一個能上傳資料的Demo,而是能清楚回答:資料從哪裡產生、每一層加了什麼資訊、封包如何找到伺服器,以及失敗時應該檢查哪一層。

學習目標

說得清架構

說明ESP32、AP、Router、DNS、Internet、Django與Database的責任。

看得懂封包

分辨MAC、IP、TCP、TLS、HTTP與JSON各自負責的工作。

找得到故障

使用分層診斷,不再把所有問題都歸因於「Wi-Fi不好」。

先看完整路線

感測器/按鈕
→
ESP32
→
Wi-Fi AP
→
Router/NAT
→
DNS
→
Internet
→
Django
→
Database

回應則沿著已建立的連線反方向返回:Database → Django → HTTPS Response → Internet → Router → ESP32。

五層看懂同一筆資料

應用層HTTP+JSON+REST API定義要送什麼資料、送到哪一個功能入口
安全/傳輸層TLS+TCP加密、建立可靠連線、排序、重送與確認
網路層IP+Routing決定封包從來源IP送往目的IP的路徑
資料連結層Wi-Fi+MAC在目前區域網路的一跳內傳送Frame
實體層2.4GHz無線電波把位元轉成無線訊號,在空氣中傳遞
重要:日常口語常把所有傳輸資料都叫「封包」,但精確來說,應用層是資料、TCP是Segment、IP是Packet、Wi-Fi/Ethernet是Frame。本篇在描述整體旅程時沿用「封包」作為易懂稱呼。

封包出發前:先定義這次事件

以水井三寶智慧互動展覽為例,使用者按下「烏龜故事」按鈕後,ESP32可以把事件整理成JSON。這份JSON是應用層要傳遞的內容,不包含MAC、IP或TCP資訊。

JSON Payload
{
  "event_uuid": "SHUIJING-001-0000000062",
  "event_type": "STORY_START",
  "story": "002",
  "source": "button",
  "network_status": "online",
  "metadata": {
    "firmware": "Layer4-V1.4",
    "jq_confirmed": true
  }
}

為什麼需要event_uuid?

網路逾時後ESP32可能重送同一事件。Django可用唯一識別碼判斷是否重複,避免同一筆資料寫入兩次。

為什麼要有metadata?

保留韌體版本、確認狀態及診斷資訊,方便日後比較不同設備與版本的行為。

八站完成一次封包旅行

01

ESP32加入Wi-Fi,取得網路身分

Association → Authentication → DHCP

ESP32先以STA模式加入無線基地台。連線成功後,通常透過DHCP取得四項重要資訊:

資訊範例用途
本機IP192.168.1.119ESP32在目前LAN中的位址
Subnet Mask255.255.255.0判斷目的地是否位於同一區域網路
Default Gateway192.168.1.1前往其他網路與Internet的出口
DNS Server192.168.1.1或ISP DNS將網域名稱解析成IP位址
檢查證據:序列監控應輸出SSID、本機IP、Gateway、DNS與RSSI。只有顯示 WiFi.status()==WL_CONNECTED,還不能證明Internet或Django已正常。
02

DNS把網域名稱翻譯成IP

Domain Name → DNS Query → Server IP

程式使用的網址可能是:

https://shuijingtreasures.pythonanywhere.com/api/events/

ESP32並不知道這個字串位於世界哪裡,因此會向DNS Server詢問:shuijingtreasures.pythonanywhere.com對應哪一個IP?DNS回覆後,ESP32才能建立後續連線。

診斷觀念:STA=ONLINE只代表取得區域網路連線。若DNS失敗,ESP32仍然無法用網域名稱連到Django。測試時可分別檢查「能否到Gateway」、「能否解析Domain」與「能否連上Server」。
03

ARP找到Gateway的MAC位址

下一跳不是遠端Django,而是本地Router

Django不在同一個Subnet,所以ESP32不會直接找Django的MAC。它先透過ARP找出Default Gateway的MAC位址,再把Wi-Fi Frame交給Router。

Wi-Fi來源MACESP32的MAC
Wi-Fi目的MACAP/Gateway在這一跳使用的MAC
IP來源位址192.168.1.119
IP目的位址DNS解析得到的Django主機IP

每經過一個路由節點,Frame的MAC資訊可能改變;但IP Packet的目的IP仍指向遠端伺服器。

04

Router執行NAT並選擇路徑

Private IP → Public IP → Internet

192.168.1.119是私有IP,不能直接在全球Internet上被路由。Router會執行NAT/PAT,把ESP32的私有IP與來源Port轉換成路由器的公網IP與暫時Port,並記錄對應關係。

位置來源目的
LAN內192.168.1.119:隨機來源PortServer-IP:443
Internet側Public-IP:轉換後PortServer-IP:443

伺服器回應抵達Router後,Router依NAT表把資料交回原本的ESP32。

05

TCP建立可靠連線

SYN → SYN-ACK → ACK

HTTPS一般建立在TCP之上。ESP32與伺服器先進行三向交握,確認雙方都能收發資料。TCP負責:

可靠性

透過Sequence Number、ACK、逾時與重送,降低資料遺失造成的錯誤。

有序傳輸

即使Segment經過不同路徑或抵達順序不同,也能重新排列成正確資料。

Timeout不一定是Django錯誤:TCP連線建立失敗,可能來自DNS錯誤、Port被阻擋、Server未回應、路由問題或訊號品質不穩。
06

TLS確認伺服器並加密資料

Certificate → Key Exchange → Encrypted Channel

因為網址使用HTTPS,TCP建立後還要進行TLS Handshake。ESP32檢查憑證是否可信、網域是否相符、憑證是否在有效期限內,再協商加密金鑰。

不要把正式系統設成「不驗證憑證」。使用不安全Client或略過憑證驗證,雖可能暫時連線成功,卻失去確認伺服器身分的能力。教學測試與正式部署應清楚區分。
07

HTTP把JSON送進Django API

POST+Header+Body

安全通道建立後,ESP32送出HTTP Request。概念上包含以下內容:

Request LinePOST /api/events/ HTTP/1.1
Hostshuijingtreasures.pythonanywhere.com
Content-Typeapplication/json
AuthorizationBearer DEVICE_TOKEN(範例,依系統設計)
Body{"event_uuid":"...","event_type":"STORY_START",...}

HTTP定義「怎麼送」,JSON定義「送什麼」,REST API定義「送到哪一個功能入口」。

08

Django驗證、處理並寫入Database

URL → View → Validation → Model → Response

Django收到Request後,通常依序進行:

  1. URL Router將 /api/events/交給對應View。
  2. 驗證Method、Content-Type、Token與JSON格式。
  3. 檢查必要欄位、資料型別及event_uuid是否重複。
  4. 使用Model寫入Database。
  5. 回傳JSON與適當HTTP Status Code。
Django View示意
import json
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .models import DeviceEvent

@require_POST
def create_event(request):
    try:
        data = json.loads(request.body)
        event_uuid = data["event_uuid"]

        event, created = DeviceEvent.objects.get_or_create(
            event_uuid=event_uuid,
            defaults={
                "event_type": data["event_type"],
                "story": data.get("story", ""),
                "source": data.get("source", "unknown"),
                "metadata": data.get("metadata", {}),
            },
        )

        return JsonResponse(
            {"ok": True, "created": created, "id": event.id},
            status=201 if created else 200,
        )
    except (KeyError, json.JSONDecodeError) as exc:
        return JsonResponse(
            {"ok": False, "error": str(exc)}, status=400
        )
成功不只看200:新增資源通常可回傳201 Created;重複事件可回200並標示created:false;格式錯誤用400;未授權用401;伺服器錯誤用500。

ESP32送出HTTPS POST的示意程式

以下程式聚焦資料旅程,憑證、Token、逾時、重送與離線Queue仍需依正式系統補齊。不得把真實密鑰直接寫進公開文章或GitHub。

Arduino/ESP32示意
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>

const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* API_URL =
  "https://shuijingtreasures.pythonanywhere.com/api/events/";

void postEvent(const String& payload) {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("NETWORK ERROR: Wi-Fi disconnected");
    return;  // 正式版應排入離線Queue
  }

  WiFiClientSecure client;
  // 正式版:設定並驗證正確CA憑證
  // client.setCACert(ROOT_CA);

  HTTPClient https;
  https.setTimeout(8000);

  if (!https.begin(client, API_URL)) {
    Serial.println("HTTPS BEGIN FAILED");
    return;
  }

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

  int statusCode = https.POST(payload);
  String response = https.getString();

  Serial.printf("HTTP STATUS: %d\n", statusCode);
  Serial.println("RESPONSE: " + response);

  https.end();
}

封包裡到底包了什麼?

「封裝」可以想像成一層一層加上信封。最裡面是JSON,外面依序包上HTTP、TLS、TCP、IP與Wi-Fi Frame。

由內到外主要資訊在哪裡被處理
JSON資料事件類型、故事編號、裝置資訊ESP32程式與Django View
HTTPMethod、Path、Header、Body、Status CodeHTTPClient、Web Server、Django
TLS憑證、加密參數、加密後內容ESP32安全Client與HTTPS伺服器
TCP來源/目的Port、Sequence、ACK兩端作業系統或網路堆疊
IP來源IP、目的IP、TTLESP32、Router及Internet路由器
Wi-Fi Frame本地一跳使用的MAC與錯誤檢查ESP32、AP與區域網路

出錯時,從哪裡開始查?

Power→Device→Wi-Fi→IP→Gateway→DNS→TCP/TLS→HTTP→API→Database
現象可能層級應取得的證據
無法取得IPWi-Fi/DHCPSSID、密碼、WiFi狀態、DHCP紀錄
有IP但Domain解析失敗DNSDNS Server、解析結果、其他Domain測試
連線逾時Routing/TCP/FirewallGateway、目的IP、Port 443、Timeout位置
TLS失敗憑證/時間/網域憑證錯誤、系統時間、CA與Host名稱
HTTP 400JSON/API驗證Request Body、Content-Type、Django錯誤回應
HTTP 401/403身分與權限Token、Header、裝置權限與ACL
HTTP 500Django/DatabaseServer Log、Exception、Migration與資料庫狀態
ESP32顯示成功但Dashboard沒資料API/Database/Dashboard QueryResponse、資料表紀錄、查詢條件與時間範圍
工程判斷要用證據:不要只說「網路不穩」或「伺服器壞了」。應記錄失敗在哪一層、看到什麼狀態碼、哪一個測試成功、哪一個測試失敗。

三項學生任務

No-AI|畫出封包旅行圖

標示ESP32、AP、Router、DNS、Internet、Django與Database,並為每段寫出主要協定。

AI Pair|解釋封裝

請AI協助比較Frame、Packet、Segment與HTTP資料,再以自己的話修正與重畫。

Challenge|故障闖關

教師設定錯誤DNS、錯誤API Path、無效Token或重複event_uuid,學生用證據定位與修復。

學習檢核

問題學生應能回答的重點
ESP32已取得IP,為何仍可能無法連到Django?Gateway、DNS、Routing、TCP、TLS、HTTP與API仍可能失敗。
為什麼目的MAC不是Django伺服器的MAC?MAC只負責本地一跳;跨網路時先交給Gateway。
NAT做了什麼?把私有IP與Port轉換為公網IP與Port,並保存回程對應。
HTTP、JSON與REST API如何分工?HTTP定義傳送方式,JSON定義資料格式,REST API定義資源入口與操作。
為何需要event_uuid?讓伺服器辨識重送與重複事件,支援冪等處理。
如何證明事件已成功?同時檢查ESP32 Log、HTTP Status、Django Response、Database與Dashboard。

一個封包的旅程,就是一套系統的縮影

ESP32負責把實體事件轉成資料;Wi-Fi與Router把資料送出場域;DNS找到服務;TCP與TLS提供可靠且安全的通道;HTTP與JSON定義溝通;Django與Database把事件保存成可分析的資訊。

真正學會網際網路,不是只讓資料送成功,而是能解釋每一站、驗證每一層、修復每一種失敗。

系列定位:《封包旅行記》EP01。適用於大學部網際網路應用、物聯網與智慧生活、ESP32聯網、Django雲端平台及USR場域型專題。範例中的網址、Token、IP與資料內容均為教學示意,正式系統應採安全憑證、環境變數、裝置身分、權限控管與完整異常處理。