2026年8月12日 星期三

用 Django 打造網站後台統計

網際網路應用實作教材

用 Django 打造網站後台統計

以「水井三寶」品牌網站為案例,從匿名訪客辨識、瀏覽事件、每日彙總,到 Django Admin 統計與 PythonAnywhere 部署,完成一套兼顧教學、實用與隱私的網站分析功能。

發表社群|智慧生活科技專業社群案例網站|水井三寶難度|入門至進階建議課時|3–4 小時

01 · Motivation網站上線之後,如何知道真的有人使用?

網站是否有人造訪,只看訂單或留言是不夠的。多數使用者會先閱讀故事、觀看影片、操作地圖,經過多次接觸後才採取行動。

水井三寶網站包含品牌故事、線上繪本、影音、互動地圖、編程任務與工藝商城。因此,除了「總瀏覽次數」,更需要知道使用者對哪種內容感興趣,以及教育活動是否真的被操作。

內容成效哪些故事、繪本和影片最常被閱讀或觀看?
教學互動哪些地圖景點被點選?編程任務是否完成?
轉換行為使用者是否加入購物車、送出訂單或聯絡訊息?
案例選擇:第一版採用 Django 自建統計,不依賴外部分析服務,適合 PythonAnywhere 免費方案與課堂示範,也能讓學生完整理解資料從瀏覽器進入資料庫的過程。

02 · Learning Goals本單元學習目標

  • 理解「瀏覽次數」與「不重複訪客」的差異。
  • 使用 Django Middleware 自動記錄公開頁面瀏覽。
  • 使用 JavaScript 與 Django 端點記錄影片、閱讀、地圖及任務事件。
  • 設計原始事件與每日彙總兩層資料模型。
  • 在 Django Admin 呈現期間統計、熱門頁面、篩選及 CSV 匯出。
  • 理解匿名化、資料最小化與保存期限等隱私原則。
  • 將功能部署到 PythonAnywhere 並完成測試。

03 · Architecture資料如何從網站走到管理後台?

使用者瀏覽器瀏覽、播放、翻頁、點選
DjangoMiddleware/事件端點
SQLite原始事件+每日彙總
Django Admin查詢、篩選、匯出

系統採用兩種蒐集方式:

1伺服器自動記錄每次成功開啟公開頁面時,由 Middleware 記錄 page_view
2前端互動記錄影片播放、繪本翻頁或地圖點選時,由 JavaScript 傳送事件。
3後端業務記錄加入購物車、完成訂購及送出聯絡表單時,直接由 Django View 寫入。

04 · Data Model設計原始事件與每日彙總

原始事件 AnalyticsEvent

每次操作保存一筆事件,方便日後回答「何時發生、發生在哪一頁、涉及哪一項內容」。

class AnalyticsEvent(models.Model):
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
    event_type = models.CharField(max_length=32, choices=EVENT_CHOICES)
    path = models.CharField(max_length=500, blank=True, db_index=True)
    content_type = models.CharField(max_length=40, blank=True)
    content_id = models.CharField(max_length=80, blank=True)
    content_title = models.CharField(max_length=200, blank=True)
    visitor_hash = models.CharField(max_length=64, db_index=True)
    referrer_domain = models.CharField(max_length=255, blank=True)
    metadata = models.JSONField(default=dict, blank=True)

每日彙總 DailyAnalytics

同一天、同一事件與同一內容合併為一筆,保存操作次數與匿名訪客數。這能降低後台報表每次掃描大量原始事件的負擔。

class DailyAnalytics(models.Model):
    date = models.DateField(db_index=True)
    event_type = models.CharField(max_length=32, choices=EVENT_CHOICES)
    path = models.CharField(max_length=500, blank=True)
    content_type = models.CharField(max_length=40, blank=True)
    content_id = models.CharField(max_length=80, blank=True)
    total_count = models.PositiveIntegerField(default=0)
    unique_visitors = models.PositiveIntegerField(default=0)
指標意義例子
total_count事件總發生次數同一人看首頁 3 次,計為 3
unique_visitors不重複匿名訪客數同一人看首頁 3 次,計為 1
event_type使用者行為類別page_view、video_play、map_location

05 · Middleware自動記錄公開頁面瀏覽

Middleware 位於請求與回應的共同通道,適合處理每個頁面都需要執行的統計邏輯。只有 GET 且回應狀態為 200 的公開頁面才列入統計。

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

    def __call__(self, request):
        prepare_visitor(request)
        response = self.get_response(request)

        should_track = (
            request.method == "GET"
            and response.status_code == 200
            and not request.path.startswith(EXCLUDED_PREFIXES)
            and not is_bot(request)
            and not request.user.is_staff
        )
        if should_track:
            record_event(request, "page_view")
        set_visitor_cookie(request, response)
        return response

settings.py 中,應放在 AuthenticationMiddleware 後方,才能辨識登入者是否為管理人員:

MIDDLEWARE = [
    # ...
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "website.middleware.AnalyticsMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
]
不要把所有請求都算成瀏覽:後台、圖片、CSS、JavaScript、影片、404 頁面及搜尋機器人都應排除,否則數據會被嚴重放大。

06 · Event Tracking不只看頁面,也記錄學習互動

事件觸發時機教學意義
reading_start進入線上讀物知道作品被開啟幾次
reading_page切換繪本頁面觀察閱讀進度
reading_complete抵達最後一頁估算完成閱讀人數
video_play/video_complete影片播放/播畢比較點閱與完播
map_location點選地圖景點辨識熱門文化景點
mission_start/mission_complete開始/完成編程任務評估教材使用情況
add_to_cart/order_complete購物流程觀察商品轉換

HTML 元件用 data-* 描述事件,不必在每個按鈕重寫一套程式:

<button
  data-analytics-event="map_location"
  data-content-type="map_location"
  data-content-id="{{ location.id }}"
  data-content-title="{{ location.name }}">
  {{ location.coordinate }}
</button>

JavaScript 透過同源 POST 傳送資料,並附上 CSRF Token:

sendAnalyticsEvent("video_play", {
  content_type: "video",
  content_id: video.dataset.contentId,
  content_title: video.dataset.contentTitle
});

伺服器端必須再次檢查事件白名單與欄位長度。前端送來的內容永遠不能直接信任。

07 · Admin Dashboard把資料變成可用的後台資訊

後台「每日流量統計」首頁提供三個時間範圍,以及最近 30 日熱門頁面:

今日掌握當日瀏覽次數與訪客數。
最近 7 日觀察活動或課程期間的短期變化。
最近 30 日比較內容的中期成效。

後台應具備的操作

  • 依日期、事件、內容類型篩選。
  • 搜尋頁面名稱、內容名稱及網址路徑。
  • 查看熱門頁面排行榜。
  • 選取資料並匯出 UTF-8 CSV,供 Excel 或研究報告使用。
  • 瀏覽原始事件以除錯,但不允許一般後台操作任意修改統計。
@admin.register(DailyAnalytics)
class DailyAnalyticsAdmin(admin.ModelAdmin):
    list_display = (
        "date", "event_type", "page_title",
        "content_title", "total_count", "unique_visitors"
    )
    list_filter = ("event_type", "date", "content_type")
    search_fields = ("page_title", "content_title", "path")
    actions = [export_as_csv]

08 · Privacy by Design統計功能也要尊重使用者

本案例不保存 IP 位址,也不建立姓名、Email 與瀏覽紀錄的關聯。瀏覽器只保存隨機識別碼,伺服器再以網站密鑰進行 SHA-256 雜湊。

visitor_hash = hashlib.sha256(
    f"{settings.SECRET_KEY}:{visitor_id}".encode("utf-8")
).hexdigest()
資料最小化只記錄回答教學與營運問題所需的欄位。
匿名處理不保存 IP,也不以統計資料辨識真實身分。
定期清理原始事件保留 180 日,每日彙總可長期保存。
python manage.py prune_analytics --days 180
網站公告建議:在隱私權政策中說明網站使用匿名 Cookie 進行流量與操作統計、使用目的、保存期間及聯絡方式。

09 · Deployment部署到 PythonAnywhere

將修訂檔上傳並解壓至專案根目錄後,在 Bash Console 執行:

cd /home/ShuijingTreasures/ShuijingTreasures
python manage.py migrate
python manage.py collectstatic --noinput
python manage.py check
python manage.py test

最後到 Web 頁面按下 Reload,再依序完成以下驗證:

  1. 用未登入的瀏覽器開啟首頁兩次。
  2. 登入 Django Admin,進入「每日流量統計」。
  3. 確認瀏覽次數為 2、訪客人數為 1。
  4. 播放影片、點選地圖景點,再確認對應事件。
  5. 管理者瀏覽後台或公開頁面時,不應增加統計。

10 · Classroom Practice課堂練習與延伸挑戰

練習一|判讀指標

某日首頁有 120 次瀏覽、45 位訪客;繪本有 60 次開始閱讀、18 次完成閱讀。請計算平均每位訪客瀏覽次數與閱讀完成率,並提出一項改善建議。

練習二|新增分享事件

新增 share_click 事件,在使用者按下社群分享按鈕時記錄分享平台及內容名稱。思考哪些 metadata 可以保存,哪些資料不應保存。

進階挑戰|完成率報表

在後台增加「繪本完成率」與「影片完播率」,並討論分母應使用事件次數還是不重複訪客數。

11 · Troubleshooting常見問題

後台完全沒有統計資料

確認已執行 migration、Middleware 已加入 settings、使用者不是後台管理員,且瀏覽器未送出 DNT(Do Not Track)。

影片播放沒有被記錄

確認新版 JavaScript 已執行 collectstatic、HTML 有 data-analytics-video,並在瀏覽器開發工具檢查事件 POST 是否回傳 200。

collectstatic 出現 PermissionError

chmod -R u+rwX static staticfiles
python manage.py collectstatic --noinput

訪客數看起來比預期多

使用者清除 Cookie、改用其他瀏覽器或裝置時,會被視為新的匿名訪客。此數字是近似值,不等同真實人數。

12 · Summary從「能上線」走向「能被理解」

網站開發不只是在頁面上呈現內容,也需要建立回饋循環:發布內容、觀察使用、分析結果,再改善設計與教材。

透過 Django Middleware、事件端點、每日彙總與 Admin 報表,水井三寶網站能在不保存 IP 的前提下,掌握文化內容、教育互動與商品流程的使用情況。這套案例同時串連 HTTP、Cookie、資料庫、JavaScript、後台管理、部署及資料倫理,是一個完整的網際網路課程實作單元。

DjangoPythonJavaScript網站分析PythonAnywhere資料隱私地方創生

智慧生活科技專業社群|網際網路課程教材

案例:水井三寶品牌網站「三寶同行・水井共生」

本教材可作為課堂示範、實作講義及專業社群文章使用。發布前請依實際課程補上作者、日期與授權方式。

網際網路 × USR: 從地方故事到數位共生平台

USR × Internet × Django × 地方創生

網際網路 × USR:
從地方故事到數位共生平台

以「水井三寶」網站為案例,重新思考一門網際網路課程: 學生真正需要學的,不只是把網頁做出來,而是理解 一個網站為什麼存在、服務誰,以及它如何把地方問題轉換成數位解決方案。

🏘️
地方故事
柴井車・白馬・烏龜
姻緣花・工藝・生活記憶
💻
數位轉譯
故事・角色・教材
影音・工藝・商品
🌱
數位共生平台
文化保存 × 教育
品牌 × 地方行動

一、為什麼要成立「水井三寶網站」?

USR做網站的目的,不應只是「把成果放上網」,而是把分散在地方的人、故事、工藝、教材與行動, 整理成一個可以持續被看見、被使用、被傳承的數位入口。
1

讓地方記憶留下來

水井村的柴井車、白馬、烏龜、姻緣花,以及長輩的生活記憶, 原本散落在人與場域之中。網站將這些內容轉換成故事、圖像、角色與數位文本, 讓地方知識得以保存,也讓下一個世代能重新閱讀。

2

把文化轉成可理解的品牌

地方文化很多,但網站使用者不一定知道從哪裡開始認識。 因此,以白馬、烏龜、姻緣花形成「水井三寶」, 就是一次文化轉譯:把地方歷史與生產、生態、生活的概念, 變成容易理解、記憶與傳播的角色系統。

3

讓工藝、教育與地方行動串起來

玉米籜工藝、蓪草姻緣花、繪本、影音、課程與地方體驗, 不再各自存在,而能透過網站串聯成: 故事 → 教材 → 體驗 → 工藝 → 商品, 逐步形成地方文化價值鏈。

4

建立可以持續累積的USR平台

過去很多USR成果會隨著活動結束而逐漸散落。 網站則可以持續更新,讓學生、社區夥伴、工藝師與教師不斷加入新的內容, 讓一次性的活動,慢慢累積成長期的地方數位平台。

學生畢業,平台不畢業;
活動結束,地方知識仍然留下。

二、網站設計構思,可以教學生什麼?

如果只把「水井三寶網站」當成 Django 範例, 學生可能只看到 URL、View、Template 和 Model。 但如果從USR需求反推網站架構, 每一個網站功能,就可以變成一堂「Internet技術如何回應真實問題」的課。

📖

品牌故事

教「資訊架構」與「內容設計」。 學生必須思考:地方資料很多,首頁第一屏到底應該讓使用者先知道什麼?

HTML Template Content Design
🐎

水井三寶角色

教「資料模型」與「文化轉譯」。 角色並不是裝飾,而是將生產、生態、生活轉化成使用者容易理解的資訊。

Model View UX
🎬

線上閱讀與影音故事

教多媒體網頁設計,讓學生理解文字、圖片、影音如何被組織成不同年齡都能使用的數位教材。

Media JavaScript Digital Learning
🧺

工藝商城

教資料庫與電子商務,更重要的是讓學生思考: 一件地方工藝品如何同時呈現材料、作者、文化故事、價格與使用情境?

Database CRUD E-commerce
📰

最新消息與網站後台

教 Django Admin 與 CRUD,也讓學生理解: 真正的網站不是「做完就結束」,而是必須有人持續新增、修改與維護。

Admin CRUD CMS
🤝

聯絡合作

教 Form、HTTP POST 與資料處理,同時讓學生理解: 網站的終點不只是瀏覽,而是促成學校、社區、工藝師與外部夥伴之間真正的合作。

Form HTTP Participation

不是「功能清單」,而是「需求 × 技術 × USR價值」

地方需求

文化要保存
教材要被使用
工藝要被看見
夥伴要能參與
Internet 技術

HTML / CSS
HTTP / URL
Django / Database
Deployment
USR 價值

文化傳承
教育推廣
地方品牌
持續共創

三、最值得教學生的,其實是「網站怎麼被想出來」

在AI能快速生成HTML、CSS甚至Django程式碼的時代, 最有價值的能力已經不只是「背語法」, 而是能不能從一個模糊的地方問題, 逐步定義出一個值得做、有人需要、能真正使用的網站
課堂開始時,不要先說:

「今天我們來學 Django。」

而可以改問學生:

「如果水井村只有故事、照片、工藝品與長輩, 我們要怎麼利用 Internet,讓更多人認識它, 而且讓這些內容能夠持續累積?」

網站不是從程式開始,而是從問題開始

① 地方問題
故事分散、工藝曝光不足、USR成果不容易累積
② 使用者需求
居民、兒童、教師、遊客、創作者、合作夥伴需要什麼?
③ 內容策略
故事、角色、教材、影音、工藝、商品、活動
④ 資訊架構
首頁 → 品牌故事 → 水井三寶 → 智慧學習 → 線上閱讀 → 工藝商城 → 最新消息 → 聯絡合作
⑤ 資料模型
Story / Product / Course / News / Order / Contact
⑥ Web實作
Django / Template / CRUD / Session / Admin / Media
⑦ 部署到 Internet,回到真實使用者
測試 → 回饋 → 修正 → 持續營運

把這個案例帶進課堂,可以改變學生的學習順序

① 先看地方
有哪些人?故事?問題?資源?
② 再定義網站
誰要用?需要什麼?資訊如何組織?
③ 最後才寫程式
HTML、HTTP、Django、Database

結語:從「會做網站」走向「知道為什麼做網站」

透過「水井三寶」這個真實USR案例, 學生學到的不只是如何完成一個網站, 而是如何從地方場域中發現問題、辨識使用者、整理內容、建立資訊架構與資料模型, 最後再使用 Web 技術把想法真正部署到 Internet。

💡
從「會寫」到「會想」
先問功能為什麼需要存在
🛠️
從「作業」到「作品」
面對真正的使用者與問題
🌏
從「網站」到「社會實踐」
讓技術成為地方改變的工具
AI可以幫學生寫網站,
但「什麼網站值得被做出來」,
仍然需要人去理解地方、理解使用者,也理解問題。

因此,《網際網路 × USR:從地方故事到數位共生平台》 最適合教學生的一課,不是如何複製「水井三寶」網站, 而是帶著學生真正走過一次完整的數位設計歷程:

從地方出發 → 發現問題 → 理解使用者 → 設計網站 → 建立系統 → 回到地方

案例網站
水井三寶|三寶同行・水井共生

本文可作為「網際網路」、「Web程式設計」、「Django實務」、 「PBL專題實作」與「USR地方實踐」課程之導讀教材。

2026年8月3日 星期一

[水井村USR] EP07|智慧養殖警示系統實戰:水質異常即時通知

智慧生活科技專業社群|IoT 入門系列|Email × LINE Messaging API × App 推播
前一篇已完成 Django Dashboard,即時呈現水溫、pH、溶氧與鹽度。EP07 將進一步建立「主動警示」:當水質超出設定範圍,Django 自動建立警示紀錄,並透過 Email、LINE Messaging API 或 Firebase Cloud Messaging 將訊息送到管理者手機。

請先將 EP07 教學資訊圖上傳 Blogger,再把 EP07_MAIN_IMAGE_URL 換成圖片網址。

圖:水質感測、Django 異常判斷與多管道通知的完整流程。
版本提醒:資訊圖中的「LINE Notify」屬於舊版架構;LINE Notify 服務已停止,本文改採 LINE Official Account 的 Messaging API Push Message

一、為什麼 Dashboard 還不夠?

Dashboard 必須由使用者主動開啟。如果養殖戶正在工作、休息或不在電腦旁,可能無法立即發現異常。因此警示系統應具備:

快速發現新資料寫入後立即檢查。
主動通知系統主動傳送 Email、LINE 或 App 推播。
保留紀錄保存異常類型、數值、時間及處理狀態。
避免洗版使用冷卻時間與恢復通知。
感測資料 Django REST API 異常判斷 建立警示紀錄 Email/LINE/FCM

二、先定義警示門檻

項目示範正常範圍示範異常條件
水溫18~32°C低於 18°C 或高於 32°C
pH6.0~8.5低於 6.0 或高於 8.5
溶氧 DO高於 4.0 mg/L低於 4.0 mg/L
導電度 EC200~2,000 µS/cm低於 200 或高於 2,000
重要:以上門檻僅供程式教學。正式使用時,必須依養殖物種、生命階段、鹽度、季節、放養密度與專業養殖建議設定。

三、建立警示資料模型

models.py
from django.db import models

class Alert(models.Model):
    LEVEL_CHOICES = [
        ("INFO", "資訊"),
        ("WARNING", "警告"),
        ("CRITICAL", "嚴重"),
    ]

    pond = models.ForeignKey(
        "Pond",
        on_delete=models.CASCADE,
        related_name="alerts"
    )

    reading = models.ForeignKey(
        "SensorReading",
        on_delete=models.CASCADE,
        related_name="alerts"
    )

    alert_type = models.CharField(
        max_length=50
    )

    message = models.CharField(
        max_length=300
    )

    level = models.CharField(
        max_length=10,
        choices=LEVEL_CHOICES
    )

    is_read = models.BooleanField(
        default=False
    )

    is_resolved = models.BooleanField(
        default=False
    )

    created_at = models.DateTimeField(
        auto_now_add=True
    )

    resolved_at = models.DateTimeField(
        null=True,
        blank=True
    )

    class Meta:
        ordering = ["-created_at"]

警示紀錄除了保存訊息,也應保存是否已讀、是否解除,以及對應的原始感測資料。

四、建立異常判斷函式

services/alert_service.py
def check_abnormal(reading):
    alerts = []

    temperature = reading.water_temperature
    ph = reading.ph
    do = reading.dissolved_oxygen
    salinity = reading.salinity

    if temperature is not None:
        if temperature > 32:
            alerts.append({
                "type": "HIGH_TEMPERATURE",
                "level": "WARNING",
                "message":
                    f"水溫過高:{temperature:.2f}°C"
            })

        elif temperature < 18:
            alerts.append({
                "type": "LOW_TEMPERATURE",
                "level": "WARNING",
                "message":
                    f"水溫過低:{temperature:.2f}°C"
            })

    if ph is not None and (
        ph > 8.5 or ph < 6.0
    ):
        alerts.append({
            "type": "PH_ABNORMAL",
            "level": "WARNING",
            "message":
                f"pH 異常:{ph:.2f}"
        })

    if do is not None and do < 4.0:
        alerts.append({
            "type": "LOW_DO",
            "level": "CRITICAL",
            "message":
                f"溶氧過低:{do:.2f} mg/L"
        })

    return alerts

五、感測資料寫入後觸發警示

APIView 概念
@api_view(["POST"])
def sensor_reading(request):
    serializer = SensorReadingSerializer(
        data=request.data
    )

    if not serializer.is_valid():
        return Response(
            serializer.errors,
            status=400
        )

    reading = serializer.save()

    alert_items = check_abnormal(reading)

    for item in alert_items:
        alert = Alert.objects.create(
            pond=reading.pond,
            reading=reading,
            alert_type=item["type"],
            level=item["level"],
            message=item["message"]
        )

        dispatch_alert(alert)

    return Response(
        serializer.data,
        status=201
    )
異常判斷放在 Django 端而不是 ESP32,可以統一修改門檻、管理通知方式,且不需要重新燒錄現場設備。

六、Email 通知

settings.py
EMAIL_BACKEND =
    "django.core.mail.backends.smtp.EmailBackend"

EMAIL_HOST = "smtp.gmail.com"
EMAIL_PORT = 587
EMAIL_USE_TLS = True

EMAIL_HOST_USER =
    "your_account@gmail.com"

EMAIL_HOST_PASSWORD =
    "請使用應用程式密碼"
發送 Email
from django.conf import settings
from django.core.mail import send_mail

def send_alert_email(alert):
    subject = (
        f"【智慧養殖警示】"
        f"{alert.pond}-{alert.alert_type}"
    )

    message = (
        f"魚塭:{alert.pond}\n"
        f"等級:{alert.level}\n"
        f"訊息:{alert.message}\n"
        f"時間:{alert.created_at}\n"
    )

    send_mail(
        subject,
        message,
        settings.DEFAULT_FROM_EMAIL,
        ["manager@example.com"],
        fail_silently=False
    )

七、使用 LINE Messaging API 推播

使用 LINE Official Account 的 Messaging API,可以向已符合推播條件的使用者或群組送出訊息。

LINE Push Message
import requests
from django.conf import settings

def send_line_message(alert, user_id):
    url = (
        "https://api.line.me/"
        "v2/bot/message/push"
    )

    headers = {
        "Authorization":
            f"Bearer {settings.LINE_CHANNEL_ACCESS_TOKEN}",

        "Content-Type":
            "application/json"
    }

    body = {
        "to": user_id,
        "messages": [
            {
                "type": "text",
                "text": (
                    "【智慧養殖警示】\n"
                    f"魚塭:{alert.pond}\n"
                    f"等級:{alert.level}\n"
                    f"訊息:{alert.message}\n"
                    f"時間:{alert.created_at}"
                )
            }
        ]
    }

    response = requests.post(
        url,
        headers=headers,
        json=body,
        timeout=15
    )

    response.raise_for_status()
Channel Access Token、User ID 與伺服器金鑰不可直接寫入公開的程式庫或部落格,應放入環境變數。

八、使用 Firebase Cloud Messaging

若系統已有 Android、iOS 或 Web App,可透過 Firebase Cloud Messaging(FCM)發送通知。正式環境建議由可信任的伺服器端使用 Firebase Admin SDK。

Python/Firebase Admin SDK 概念
import firebase_admin
from firebase_admin import credentials
from firebase_admin import messaging

def send_fcm_alert(alert, device_token):
    message = messaging.Message(
        notification=messaging.Notification(
            title="智慧養殖警示",
            body=alert.message
        ),
        data={
            "pond_id": str(alert.pond_id),
            "alert_id": str(alert.id),
            "level": alert.level
        },
        token=device_token
    )

    return messaging.send(message)

九、統一派送通知

dispatch_alert()
def dispatch_alert(alert):
    errors = []

    try:
        send_alert_email(alert)
    except Exception as exc:
        errors.append(
            f"Email:{exc}"
        )

    try:
        send_line_message(
            alert,
            alert.pond.manager_line_user_id
        )
    except Exception as exc:
        errors.append(
            f"LINE:{exc}"
        )

    try:
        send_fcm_alert(
            alert,
            alert.pond.manager_device_token
        )
    except Exception as exc:
        errors.append(
            f"FCM:{exc}"
        )

    return errors
正式系統不宜讓 API Request 等待三種通知全部完成。可將通知工作交給 Celery、RQ 或背景工作佇列執行。

十、避免重複警示洗版

若 ESP32 每 15 秒上傳一次,而 DO 持續偏低,系統可能每 15 秒傳一次訊息。應設定冷卻時間:

冷卻時間範例
from django.utils import timezone
from datetime import timedelta

def recently_sent(
    pond,
    alert_type,
    minutes=10
):
    since = (
        timezone.now()
        - timedelta(minutes=minutes)
    )

    return Alert.objects.filter(
        pond=pond,
        alert_type=alert_type,
        created_at__gte=since,
        is_resolved=False
    ).exists()

建立警示前先檢查:

避免重複建立
if not recently_sent(
    reading.pond,
    item["type"],
    minutes=10
):
    Alert.objects.create(...)

十一、加入恢復通知

只通知異常還不夠。數值恢復正常時,也應通知管理者:

恢復概念
active_alerts = Alert.objects.filter(
    pond=reading.pond,
    alert_type="LOW_DO",
    is_resolved=False
)

if (
    reading.dissolved_oxygen is not None
    and reading.dissolved_oxygen >= 4.5
):
    for alert in active_alerts:
        alert.is_resolved = True
        alert.resolved_at = timezone.now()
        alert.save()

        send_recovery_notification(alert)
建議使用「觸發值」與「恢復值」兩個門檻,例如 DO 低於 4.0 觸發、回升到 4.5 才解除,可避免數值在邊界附近反覆跳動。

十二、Dashboard 顯示警示紀錄

時間魚塭類型訊息等級狀態
14:32:151 號池溶氧異常DO:3.20 mg/LCRITICAL未處理
14:31:401 號池水溫異常水溫:33.5°CWARNING未處理
14:20:102 號池pH 異常pH:9.10WARNING已讀
Template 狀態顏色
{% if alert.level == "CRITICAL" %}
  <span class="badge bg-danger">
    CRITICAL
  </span>

{% elif alert.level == "WARNING" %}
  <span class="badge bg-warning text-dark">
    WARNING
  </span>

{% else %}
  <span class="badge bg-info">
    INFO
  </span>
{% endif %}

十三、警示狀態處理

警示頁應提供:

  • 標記為已讀。
  • 標記為已處理。
  • 加入處理說明。
  • 顯示解除時間。
  • 依魚塭、等級、日期篩選。
  • 匯出 CSV。

十四、資料逾時也需要警示

即使水質數值正常,若設備長時間沒有上傳,也可能代表斷電、Wi-Fi 中斷或感測器故障。

資料逾時判斷
from django.utils import timezone
from datetime import timedelta

last_reading = (
    pond.readings
    .order_by("-recorded_at")
    .first()
)

if (
    last_reading is None
    or last_reading.recorded_at
       < timezone.now()
         - timedelta(minutes=5)
):
    create_offline_alert(pond)

十五、建議的警示分級

等級用途通知方式
INFO設備恢復、校正完成Dashboard 紀錄
WARNING水溫或 pH 接近危險值Email 或 LINE
CRITICAL溶氧過低、設備離線Email+LINE+App 推播

十六、常見問題與排除

問題可能原因處理方式
收不到 EmailSMTP、Port、應用程式密碼錯誤檢查 Django Email 設定與垃圾郵件
LINE 推播失敗Token、User ID、好友關係或權限問題查看 Messaging API 回應內容
FCM 收不到裝置 Token 過期或 App 權限未開更新 Token 並檢查通知權限
同一異常重複通知沒有冷卻時間加入 recently_sent()
異常解除後仍顯示沒有恢復判斷加入 is_resolved 與 resolved_at
API 變慢同步發送多個通知改用背景工作佇列

十七、完整整合流程

Atlas EZO ESP32 Django API 儲存資料 判斷異常 建立 Alert 多管道通知

十八、本篇重點

  1. 異常判斷集中在 Django,方便統一管理。
  2. 每次異常都應建立可追蹤的 Alert 紀錄。
  3. Email、LINE Messaging API 與 FCM 可依等級搭配。
  4. 必須設定冷卻時間,避免重複洗版。
  5. 恢復正常時也應發送解除通知。
  6. 資料逾時與設備離線同樣需要警示。
  7. 正式系統宜使用背景工作處理通知。

結語

完成 EP07 後,智慧養殖平台不再只是被動顯示數據,而能主動發現問題、保存異常紀錄並通知管理者。當警示系統加入分級、冷卻時間、恢復判斷與多管道派送後,才能避免訊息洗版,同時讓真正重要的異常被快速看見。這是智慧養殖系統從「監控」走向「主動管理」的重要一步。

技術更新說明:LINE 通知部分採用 LINE Messaging API;App 推播採用 Firebase Cloud Messaging。部署前請依官方文件建立權杖、設定接收對象與檢查服務限制。
AI 協作聲明:本文由作者主導智慧養殖警示架構、異常門檻概念、Django 資料模型、通知流程與場域需求設計,並使用生成式 AI 協助文字整理、程式碼說明、版面配置與教學資訊圖生成;文章中的範例門檻僅供教學,正式養殖警示值須由作者與場域專業人員依實際需求確認。

[水井村USR] EP06|Django Dashboard 實戰:打造智慧養殖即時監控平台

智慧生活科技專業社群|IoT 入門系列|適合大一新生
前一篇已完成 ESP32 將水溫、pH、溶氧與鹽度上傳 Django REST API。EP06 要進一步把資料轉換成真正能使用的 Dashboard:以資訊卡呈現最新值、使用 Chart.js 顯示趨勢、透過 AJAX 自動更新,並支援多養殖戶、警示與歷史查詢。

請先將 EP06 主教學圖上傳 Blogger,再把 EP06_MAIN_IMAGE_URL 換成圖片網址。


圖:從 Atlas 感測器、ESP32、Django REST API、資料庫到 Dashboard 的完整流程。

一、Dashboard 為什麼重要?

感測資料存進資料庫並不代表系統已經完成。真正的價值,在於把資料轉換成使用者可以快速理解的畫面。

即時狀態快速掌握水溫、pH、DO 與鹽度。
歷史趨勢觀察數值是否逐漸上升或下降。
異常警示在溶氧過低或水溫過高時提醒。
多場域管理同時查看不同養殖戶與魚塭。
核心觀念:Dashboard 不是資料庫,而是協助使用者判斷與決策的資訊介面。

二、完整資料流程

Atlas 感測器 ESP32 Wi-Fi Django REST API 資料庫 Dashboard

ESP32 負責蒐集與傳送資料;Django 負責接收、保存、查詢與組織資料;瀏覽器則負責將資料呈現成資訊卡與圖表。

三、Dashboard 首頁應該顯示什麼?

30.1°C水溫
6.80pH
6.8 mg/L溶氧
0.44 ppt鹽度

養殖戶打開首頁後,最好在五秒內看到:

  • 目前水溫。
  • 目前 pH。
  • 目前溶氧。
  • 目前鹽度或導電度。
  • 資料更新時間。
  • 目前狀態是否異常。

四、Django 資料模型

models.py 範例
from django.db import models

class Pond(models.Model):
    farm_name = models.CharField(
        max_length=100
    )

    pond_code = models.CharField(
        max_length=30
    )

    def __str__(self):
        return f"{self.farm_name} / {self.pond_code}"


class SensorReading(models.Model):
    pond = models.ForeignKey(
        Pond,
        on_delete=models.CASCADE,
        related_name="readings"
    )

    water_temperature = models.FloatField(
        null=True,
        blank=True
    )

    salinity = models.FloatField(
        null=True,
        blank=True
    )

    ph = models.FloatField(
        null=True,
        blank=True
    )

    dissolved_oxygen = models.FloatField(
        null=True,
        blank=True
    )

    recorded_at = models.DateTimeField()

    class Meta:
        ordering = ["-recorded_at"]
欄位型態用途
pondForeignKey對應養殖戶與魚塭
water_temperatureFloatField水溫
salinityFloatField鹽度
phFloatField酸鹼值
dissolved_oxygenFloatField溶氧
recorded_atDateTimeField感測時間

五、取得最新一筆資料

views.py
from django.shortcuts import render
from .models import Pond

def dashboard_home(request):
    ponds = Pond.objects.all()

    rows = []

    for pond in ponds:
        latest = (
            pond.readings
            .order_by("-recorded_at")
            .first()
        )

        rows.append({
            "pond": pond,
            "latest": latest,
        })

    return render(
        request,
        "dashboard/home.html",
        {"rows": rows}
    )

這個 View 會逐一讀取所有魚塭,並查詢每個魚塭的最新一筆感測資料。

六、建立 Bootstrap 資訊卡

templates/dashboard/home.html
<div class="row g-3">

  <div class="col-md-3">
    <div class="card h-100 shadow-sm">
      <div class="card-body">
        <small class="text-muted">水溫</small>
        <h2 id="temperature">
          {{ latest.water_temperature|default:"--" }}
        </h2>
        <span>°C</span>
      </div>
    </div>
  </div>

  <div class="col-md-3">
    <div class="card h-100 shadow-sm">
      <div class="card-body">
        <small class="text-muted">pH</small>
        <h2 id="ph">
          {{ latest.ph|default:"--" }}
        </h2>
      </div>
    </div>
  </div>

</div>
Bootstrap 的 Grid 系統可以讓四張資訊卡在電腦上並排,在手機上自動換行,形成響應式版面。

七、建立最新資料 API

若 Dashboard 要自動更新,瀏覽器需要一個可查詢最新資料的 API。

views.py:latest_reading
from rest_framework.decorators import api_view
from rest_framework.response import Response

@api_view(["GET"])
def latest_reading(request, pond_id):
    pond = Pond.objects.get(id=pond_id)

    latest = (
        pond.readings
        .order_by("-recorded_at")
        .first()
    )

    if latest is None:
        return Response(
            {"message": "No data"},
            status=404
        )

    return Response({
        "water_temperature":
            latest.water_temperature,

        "salinity":
            latest.salinity,

        "ph":
            latest.ph,

        "dissolved_oxygen":
            latest.dissolved_oxygen,

        "recorded_at":
            latest.recorded_at,
    })
urls.py
path(
    "api/pond/<int:pond_id>/latest/",
    views.latest_reading,
    name="latest-reading"
)

八、AJAX 自動更新資訊卡

JavaScript 自動刷新
async function loadLatest() {
  const response = await fetch(
    "/api/pond/1/latest/"
  );

  if (!response.ok) {
    console.log("讀取失敗");
    return;
  }

  const data = await response.json();

  document.getElementById(
    "temperature"
  ).textContent =
    data.water_temperature ?? "--";

  document.getElementById(
    "ph"
  ).textContent =
    data.ph ?? "--";

  document.getElementById(
    "dissolvedOxygen"
  ).textContent =
    data.dissolved_oxygen ?? "--";

  document.getElementById(
    "salinity"
  ).textContent =
    data.salinity ?? "--";
}

loadLatest();

setInterval(
  loadLatest,
  5000
);
瀏覽器 每 5 秒呼叫 API 取得最新 JSON 更新資訊卡

九、建立歷史趨勢 API

views.py:trend_data
from django.utils import timezone
from datetime import timedelta

@api_view(["GET"])
def trend_data(request, pond_id):
    start_time = (
        timezone.now()
        - timedelta(hours=2)
    )

    readings = (
        SensorReading.objects
        .filter(
            pond_id=pond_id,
            recorded_at__gte=start_time
        )
        .order_by("recorded_at")
    )

    data = []

    for item in readings:
        data.append({
            "time":
                item.recorded_at.strftime(
                    "%H:%M"
                ),

            "temperature":
                item.water_temperature,

            "ph":
                item.ph,

            "dissolved_oxygen":
                item.dissolved_oxygen,

            "salinity":
                item.salinity,
        })

    return Response(data)

十、使用 Chart.js 顯示趨勢

HTML 畫布
<canvas id="trendChart"></canvas>
Chart.js 初始化
const chartContext = document
  .getElementById("trendChart")
  .getContext("2d");

const trendChart = new Chart(
  chartContext,
  {
    type: "line",

    data: {
      labels: [],

      datasets: [
        {
          label: "水溫",
          data: []
        },
        {
          label: "pH",
          data: []
        },
        {
          label: "溶氧",
          data: []
        },
        {
          label: "鹽度",
          data: []
        }
      ]
    },

    options: {
      responsive: true,
      maintainAspectRatio: false
    }
  }
);

十一、將 API 資料放入圖表

更新 Chart.js
async function loadTrend() {
  const response = await fetch(
    "/api/pond/1/trend/"
  );

  const rows = await response.json();

  trendChart.data.labels =
    rows.map(row => row.time);

  trendChart.data.datasets[0].data =
    rows.map(row => row.temperature);

  trendChart.data.datasets[1].data =
    rows.map(row => row.ph);

  trendChart.data.datasets[2].data =
    rows.map(row =>
      row.dissolved_oxygen
    );

  trendChart.data.datasets[3].data =
    rows.map(row => row.salinity);

  trendChart.update();
}

loadTrend();

setInterval(
  loadTrend,
  30000
);
水溫、pH、溶氧與鹽度的數值範圍不同。正式 Dashboard 可使用雙 Y 軸,或將不同單位拆成多張圖,避免曲線互相壓縮。

十二、多養殖戶監控

養殖戶魚塭水溫鹽度pH溶氧
博論水質系統001/北港溪29.4°C0.0 ppt8.010.0 mg/L
湖虎戰隊1/文蛤池29.3°C0.5 ppt6.86.8 mg/L
LiaoZike41243166/水井蛤蜊池30.0°C0.4 ppt7.16.5 mg/L

多場域 Dashboard 的重點是每筆感測資料都必須關聯到正確的 Pond,才能依養殖戶、場域或魚塭進行分類查詢。

十三、狀態判斷與警示

Python 狀態判斷範例
def evaluate_status(reading):
    alerts = []

    if (
        reading.dissolved_oxygen
        is not None
        and reading.dissolved_oxygen < 3
    ):
        alerts.append("溶氧過低")

    if (
        reading.water_temperature
        is not None
        and reading.water_temperature > 35
    ):
        alerts.append("水溫過高")

    if (
        reading.ph is not None
        and (
            reading.ph < 5
            or reading.ph > 9
        )
    ):
        alerts.append("pH 異常")

    return alerts
提醒:上述門檻僅為程式示例。正式養殖警示值應依養殖物種、成長階段、鹽度、水溫與場域管理需求設定。

十四、在 Template 顯示警示

Bootstrap Alert
{% if alerts %}
  <div class="alert alert-danger">
    <strong>目前異常:</strong>

    <ul class="mb-0">
      {% for alert in alerts %}
        <li>{{ alert }}</li>
      {% endfor %}
    </ul>
  </div>
{% else %}
  <div class="alert alert-success">
    目前監測狀態正常
  </div>
{% endif %}

十五、資料更新時間與離線判斷

數值看起來正常,不代表設備一定在線。若最後資料時間已經超過設定範圍,Dashboard 應標示「資料逾時」或「裝置離線」。

離線判斷範例
from django.utils import timezone
from datetime import timedelta

offline = (
    latest is None
    or latest.recorded_at
       < timezone.now()
         - timedelta(minutes=5)
)
狀態建議顯示
5 分鐘內有資料綠色:正常監測
5~15 分鐘沒有資料橘色:資料延遲
超過 15 分鐘沒有資料紅色:裝置離線
從未上傳資料灰色:尚無資料

十六、Dashboard 頁面結構

建議目錄
project/
├─ dashboard/
│  ├─ models.py
│  ├─ views.py
│  ├─ urls.py
│  ├─ serializers.py
│  └─ templates/
│     └─ dashboard/
│        ├─ base.html
│        ├─ home.html
│        ├─ pond_detail.html
│        └─ history.html
│
├─ static/
│  ├─ css/
│  │  └─ dashboard.css
│  └─ js/
│     ├─ latest.js
│     └─ trend.js
│
└─ manage.py

十七、RWD 響應式設計

智慧養殖 Dashboard 可能在辦公室電腦、平板或養殖戶手機上使用,因此版面必須能自動調整。

Bootstrap Grid 建議
<div class="col-12 col-sm-6 col-xl-3">
  ...
</div>
這個設定代表:手機一列一張、平板一列兩張、大螢幕一列四張資訊卡。

十八、Dashboard 實際連線

開啟水井村 USR 智慧養殖 Dashboard

https://shuijingusr.pythonanywhere.com/dashboard/

水井村 USR 智慧養殖 Dashboard
請將實際 Dashboard 截圖上傳 Blogger,再替換 EP06_DASHBOARD_IMAGE_URL
圖:多養殖戶即時水質狀態與場域分布。

十九、常見問題與排除

問題可能原因解決方法
Dashboard 沒有資料資料庫尚無資料或 View 查詢錯誤先到 Django Admin 確認資料
資訊卡顯示 None感測欄位允許 null使用 default 或 JavaScript 的 ??
Chart.js 沒有曲線API 回傳空陣列或欄位名稱不一致先在瀏覽器開啟 API 檢查 JSON
自動更新失敗API URL、權限或 JavaScript 錯誤查看瀏覽器 Console 與 Network
時間差 8 小時Django 時區設定錯誤確認 TIME_ZONE 與 USE_TZ
顯示舊資料排序方向錯誤使用 -recorded_at 取得最新值
頁面載入過慢一次查詢過多歷史資料限制時間範圍並建立索引

二十、效能與資料量

若 ESP32 每 15 秒上傳一次,一天約產生 5,760 筆資料。多台裝置長期運作後,資料量會快速增加。

  • Dashboard 只查詢必要時間範圍。
  • 在 pond 與 recorded_at 建立資料庫索引。
  • 歷史報表可做每分鐘、每小時或每日彙整。
  • 最新值可使用快取,減少重複查詢。
  • 定期備份與清理測試資料。

二十一、本篇重點

  1. Dashboard 的目的,是將資料轉換成可快速判斷的資訊。
  2. Bootstrap Card 適合呈現最新水質值。
  3. Django API 提供最新值與歷史趨勢資料。
  4. AJAX 與 setInterval 可定時更新畫面。
  5. Chart.js 適合呈現時間序列。
  6. 多養殖戶管理必須建立 Pond 與 SensorReading 關聯。
  7. 警示值、離線判斷與更新時間同樣重要。

結語

完成 EP06 後,智慧養殖系統已從「收集資料」進一步提升為「呈現資訊與支援決策」。Django 負責整合資料庫與 API,Bootstrap 建立清楚的資訊卡,Chart.js 呈現歷史變化,AJAX 則讓畫面自動更新。當 Dashboard 能同時呈現即時值、趨勢、警示、時間與多場域狀態時,IoT 系統才真正開始對養殖管理產生價值。

AI 協作聲明:本文由作者主導 Dashboard 架構設計、Django 資料模型與 API 規劃、Bootstrap 介面、Chart.js 趨勢圖、自動更新與智慧養殖場域需求整理,並使用生成式 AI 協助文字整理、程式碼說明、版面配置與教學資訊圖生成;所有系統流程、介面概念與程式內容均依本專案實際開發經驗整理,並由作者審核修訂。

[水井村USR] EP05|ESP32 × Django REST API:將感測資料上傳雲端

智慧生活科技專業社群|IoT 入門系列|適合大一新生
前四篇已完成 I²C、四種水質感測器、ESP32 讀值,以及 Wi-Fi AP 與 NVS 設定。EP05 將把這些成果送上雲端:ESP32 透過 Wi-Fi 將水溫、pH、溶氧與鹽度組成 JSON,再以 HTTP POST 傳送到 Django REST API,最後顯示於智慧養殖 Dashboard。

請先將 EP05 主圖上傳 Blogger,再把 EP05_MAIN_IMAGE_URL 換成圖片網址。


圖:從 Atlas 水質感測器、ESP32、Django REST API 到 Dashboard 的完整資料流程。

一、這一篇要完成什麼?

任務 1|建立 JSON把感測值轉成 API 可接受的資料格式。
任務 2|HTTP POSTESP32 將 JSON 傳送到 Django。
任務 3|REST APIDjango 驗證並儲存資料。
任務 4|Dashboard顯示各養殖戶最新水質狀況。

二、完整系統架構

Atlas 感測器 ESP32 Wi-Fi HTTP POST Django REST API 資料庫 Dashboard

ESP32 不需要知道資料庫帳號密碼,也不需要直接操作資料表,只要把資料送到 API 即可。

核心觀念:ESP32 是資料提供者;Django REST API 是資料入口;資料庫負責保存;Dashboard 負責呈現。

三、實際使用的 API 與 Dashboard

本專案 API 端點:

Django REST API
https://shuijingusr.pythonanywhere.com/api/pond/sensor-reading/

智慧養殖 Dashboard:

開啟智慧養殖 Dashboard

四、什麼是 REST API?

REST API 是讓不同裝置或程式透過 HTTP 交換資料的方式。ESP32、手機 App、網站與 Python 程式,都可以使用相同 API。

HTTP 方法用途本篇是否使用
GET讀取資料Dashboard 查詢資料時使用
POST新增資料ESP32 上傳感測資料
PUT/PATCH修改資料本篇未使用
DELETE刪除資料本篇未使用

五、JSON 是什麼?

JSON 是 IoT 最常見的資料交換格式。每筆資料由 Key 與 Value 組成:

本專案 JSON 格式
{
  "token": "abc123",
  "farm_name": "湖虎戰隊",
  "pond_code": "1",
  "water_temperature": 30.01,
  "salinity": 0.44,
  "ph": 6.80,
  "dissolved_oxygen": 31.92,
  "water_source": "直流變頻水車運轉中",
  "recorded_at": "2026-08-03T21:30:00+08:00"
}
數值欄位應使用數字,不要加上引號。例如 "ph": 6.80,不要寫成 "ph": "6.80"

六、ESP32 建立 JSON

程式碼 1:建立 Payload
String buildPayload() {
  String payload;

  payload += "{";
  payload += "\"token\":\"abc123\",";
  payload += "\"farm_name\":\"湖虎戰隊\",";
  payload += "\"pond_code\":\"1\",";
  payload += "\"water_temperature\":30.01,";
  payload += "\"salinity\":0.44,";
  payload += "\"ph\":6.80,";
  payload += "\"dissolved_oxygen\":31.92,";
  payload += "\"water_source\":\"直流變頻水車運轉中\"";
  payload += "}";

  return payload;
}

正式程式中,不應把感測值寫死,而是帶入 RTD、pH、DO 與 EC 讀值。

七、確認 Wi-Fi 已連線

程式碼 2:連線檢查
if (WiFi.status() != WL_CONNECTED) {
  Serial.println("Wi-Fi 尚未連線,本次不上傳");
  return;
}

若 Wi-Fi 尚未連線,程式應停止本次 POST,避免重複產生連線錯誤。

八、ESP32 使用 HTTPS POST

程式碼 3:HTTP POST 核心流程
#include <WiFi.h>
#include <HTTPClient.h>
#include <NetworkClientSecure.h>

const char* API_URL =
  "https://shuijingusr.pythonanywhere.com/"
  "api/pond/sensor-reading/";

void uploadData(String payload) {
  if (WiFi.status() != WL_CONNECTED) {
    return;
  }

  NetworkClientSecure client;

  // 測試階段使用
  client.setInsecure();

  HTTPClient http;

  http.begin(client, API_URL);

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

  http.addHeader(
    "Accept",
    "application/json"
  );

  int httpCode =
    http.POST(payload);

  String response =
    http.getString();

  Serial.print("HTTP Code:");
  Serial.println(httpCode);

  Serial.print("Response:");
  Serial.println(response);

  http.end();
}
測試時可使用 setInsecure(),正式部署時建議改用伺服器根憑證驗證 HTTPS。

九、Content-Type 為什麼重要?

Django 需要知道 Request Body 是 JSON,因此必須設定:

HTTP Header
Content-Type: application/json

若沒有這個 Header,後端可能無法正確解析資料。

十、Django REST API 的資料流程

Request JSON View Serializer Model Database
Model定義資料庫欄位。
Serializer驗證 JSON 與轉換資料。
View處理 POST Request。
URL設定 API 路徑。

十一、Django Model 概念

models.py 範例
from django.db import models

class SensorReading(models.Model):
    token = models.CharField(max_length=100)
    farm_name = models.CharField(max_length=100)
    pond_code = models.CharField(max_length=30)

    water_temperature = models.FloatField(
        null=True,
        blank=True
    )

    salinity = models.FloatField(
        null=True,
        blank=True
    )

    ph = models.FloatField(
        null=True,
        blank=True
    )

    dissolved_oxygen = models.FloatField(
        null=True,
        blank=True
    )

    water_source = models.CharField(
        max_length=200,
        blank=True
    )

    recorded_at = models.DateTimeField()

十二、Serializer 的工作

serializers.py 範例
from rest_framework import serializers
from .models import SensorReading

class SensorReadingSerializer(
    serializers.ModelSerializer
):
    class Meta:
        model = SensorReading
        fields = "__all__"

Serializer 會檢查:

  • 欄位是否存在。
  • 數值格式是否正確。
  • 時間格式是否可解析。
  • 必填資料是否缺少。

十三、API View 概念

views.py 範例
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status

@api_view(["POST"])
def sensor_reading(request):
    serializer = SensorReadingSerializer(
        data=request.data
    )

    if serializer.is_valid():
        serializer.save()

        return Response(
            serializer.data,
            status=status.HTTP_201_CREATED
        )

    return Response(
        serializer.errors,
        status=status.HTTP_400_BAD_REQUEST
    )

十四、URL 設定

urls.py 範例
from django.urls import path
from . import views

urlpatterns = [
    path(
        "api/pond/sensor-reading/",
        views.sensor_reading,
        name="sensor-reading"
    ),
]

十五、HTTP 狀態碼如何判斷?

狀態碼意義處理方式
200請求成功後端已接受或回傳資料
201新增成功感測資料已寫入資料庫
400資料格式錯誤檢查 JSON、欄位與資料型態
401/403驗證失敗檢查 token 或權限
500後端錯誤檢查 Django Log、Model、Serializer
負值ESP32 連線錯誤檢查 Wi-Fi、DNS、HTTPS 與網址

十六、使用 Postman 先測試 API

在 ESP32 上傳前,可先用 Postman 測試:

  1. Method 選擇 POST。
  2. 輸入 API 網址。
  3. Body 選擇 raw。
  4. 格式選擇 JSON。
  5. 貼入 Payload。
  6. 按 Send。
若 Postman 可成功,但 ESP32 不行,問題通常在 Wi-Fi、HTTPS、Header 或 ESP32 JSON 字串。

十七、實際上傳結果

序列監控範例
========== Django API 上傳 ==========
[API] URL:
https://shuijingusr.pythonanywhere.com/
api/pond/sensor-reading/

[API] HTTP 狀態碼:201
[API] 上傳成功
====================================

十八、Dashboard 即時顯示

資料寫入資料庫後,Dashboard 可以顯示:

  • 養殖戶與魚塭。
  • 水溫。
  • 鹽度。
  • pH。
  • 溶氧。
  • 最新資料時間。
水井村 USR 智慧養殖 Dashboard
請將 Dashboard 截圖上傳 Blogger,再替換 EP05_DASHBOARD_IMAGE_URL
圖:ESP32 上傳後,Dashboard 顯示多家養殖戶的最新水質狀態。

十九、常見問題與排除

問題可能原因建議
POST 回傳 -1Wi-Fi、DNS、HTTPS 失敗先確認 ESP32 能否取得 IP
400 Bad RequestJSON 或欄位格式不符查看 Django 回應內容
403 ForbiddenToken 或權限錯誤檢查 token
500 Internal Server ErrorDjango 程式錯誤查看 PythonAnywhere Error Log
Dashboard 沒更新查詢不是最新資料檢查排序與最新一筆查詢
時間不正確NTP 未同步或時區錯誤使用 ISO 8601+08:00

二十、本篇重點

  1. ESP32 使用 JSON 封裝感測資料。
  2. 透過 HTTP POST 傳送到 Django REST API。
  3. Content-Type 必須設定為 application/json。
  4. Serializer 負責驗證資料。
  5. 201 表示資料新增成功。
  6. Dashboard 從資料庫取得最新監測值。

結語

完成 EP05 後,智慧養殖系統已經建立從感測器到雲端的完整資料鏈。ESP32 透過 I²C 取得水質資料,使用 Wi-Fi 與 HTTPS 將 JSON 傳送到 Django REST API,再由資料庫與 Dashboard 完成保存與呈現。這也是 IoT 系統中最重要的核心能力:把真實世界的感測資料,轉化為可查詢、可分析、可視化的數位資訊。

AI 協作聲明:本文由作者主導系統設計、ESP32 程式開發、Django REST API 建置、資料格式規劃、API 測試與 Dashboard 整合,並使用生成式 AI 協助文字整理、程式碼說明、版面設計與教學資訊圖生成;所有 API 欄位、測試流程與系統架構均依本專案實際開發成果整理,並由作者審核修訂。

[水井村USR] EP04|ESP32 Wi-Fi AP 模式與 NVS 設定

智慧生活科技專業社群|IoT 入門系列|適合大一新生
在前面的文章中,我們已經完成 ESP32 與 Atlas Scientific 四種水質感測器的整合。接下來要解決的是實際部署時最常遇到的問題:每個場域的 Wi-Fi SSID 與密碼都不同,難道每次都要修改程式再重新燒錄嗎?本篇將介紹如何讓 ESP32 在無法連線時自動進入 AP 模式,讓使用者用手機輸入 SSID 與密碼,儲存到 NVS Flash,重新啟動後再自動連線。

請先將 EP04 主圖上傳 Blogger,再把 EP04_MAIN_IMAGE_URL 換成圖片網址。
圖:ESP32 從 AP 設定、NVS 儲存到重新連線的完整流程。

一、為什麼需要 Wi-Fi AP 模式?

一般 ESP32 範例通常直接把 Wi-Fi 帳密寫死在程式中:

固定帳密寫法
const char* ssid = "MyHome";
const char* password = "12345678";

這種寫法雖然簡單,但每換一個 Wi-Fi,就要重新修改程式並燒錄。對智慧養殖、智慧農業或社區 IoT 設備而言,現場維護並不方便。

更好的方法:ESP32 找不到已儲存的 Wi-Fi 設定時,就自動建立自己的 AP 熱點,讓手機進入設定頁。

二、完整流程

ESP32 開機 讀取 NVS 嘗試連線 失敗進入 AP 手機輸入 SSID/密碼 寫入 NVS 重新啟動 自動連線
第一次開機尚未儲存 Wi-Fi 設定。
AP 模式ESP32 建立設定熱點。
NVS 儲存SSID 與密碼寫入 Flash。
自動連線重新啟動後讀回設定。

三、ESP32 建立 AP 熱點

當 ESP32 找不到 Wi-Fi 設定,或連線逾時,就建立自己的 AP:

程式碼 1:啟動 Soft-AP
String apName = "Shuijing-Setup-A1B2C3";
WiFi.mode(WIFI_AP_STA);
WiFi.softAP(apName.c_str(), "shuijing123");

手機搜尋 Wi-Fi 時,就會看到:

手機看到的熱點
Shuijing-Setup-A1B2C3
熱點名稱後面的六碼可由 ESP32 的 MAC 位址產生,讓每台設備都有不同名稱,避免多台裝置同時部署時混淆。

四、建立手機設定頁

ESP32 可透過內建 WebServer 建立設定頁。手機連上 AP 後,在瀏覽器輸入:

設定頁網址
http://192.168.4.1

設定頁包含 Wi-Fi SSID、Wi-Fi 密碼與「儲存並重新連線」按鈕。

程式碼 2:建立首頁路由
WebServer configServer(80);
configServer.on("/", HTTP_GET, handleConfigRoot);
configServer.begin();

五、手機送出設定

實際測試時,部分手機的 Captive Portal 對 POST 相容性不佳,因此後來改用 GET,並指定完整網址:

表單送出方式
<form method="GET" action="http://192.168.4.1/save">

ESP32 端接受所有 HTTP 方法:

程式碼 3:接收設定
configServer.on("/save", HTTP_ANY, handleConfigSave);
實際除錯經驗:若手機按鈕沒有反應,先用完整的 Chrome 或 Safari 開啟 192.168.4.1,不要只依賴手機自動跳出的迷你 Captive Portal 視窗。

六、NVS 是什麼?

NVS 是 Non-Volatile Storage,中文可理解為「非揮發性儲存」。它使用 ESP32 內建 Flash,資料在斷電與重新啟動後仍會保留。

項目說明
函式庫Preferences
儲存位置ESP32 內建 Flash 的 NVS 分區
斷電後資料仍保留
適合資料SSID、密碼、裝置編號、API 參數

七、把 SSID 與密碼寫入 NVS

程式碼 4:儲存 Wi-Fi 設定
#include <Preferences.h>
Preferences preferences;

bool saveWiFiCredentials(const String& ssid, const String& password) {
  preferences.begin("wifi-config", false);
  size_t ssidResult = preferences.putString("ssid", ssid);
  size_t passwordResult = preferences.putString("password", password);
  preferences.end();
  return ssidResult > 0;
}

這段程式開啟名為 wifi-config 的 namespace,並以 ssidpassword 作為 Key。

八、寫入後立即回讀驗證

本次開發中曾遇到重新啟動後顯示「尚未儲存 SSID」。因此加入寫入後立即回讀驗證:

程式碼 5:回讀確認
String verifySsid = preferences.getString("ssid", "");
String verifyPassword = preferences.getString("password", "");
bool ssidOK = verifySsid == ssid;
bool passwordOK = verifyPassword == password;
只要回讀值與原始輸入相同,就表示資料確實已寫入 NVS Flash。

九、顯示設定完成並重新啟動

設定成功後,手機顯示完成頁;ESP32 再排程重新啟動:

程式碼 6:安排重新啟動
restartScheduled = true;
restartAt = millis() + 5000;

loop() 中檢查:

程式碼 7:執行重新啟動
if (restartScheduled && millis() - restartAt >= 0) {
  configServer.stop();
  dnsServer.stop();
  WiFi.softAPdisconnect(true);
  delay(500);
  ESP.restart();
}

十、重新開機後讀取 NVS

這一步非常重要。即使資料已經寫入 Flash,重新啟動後仍必須主動讀回:

程式碼 8:開機讀取 NVS
void loadWiFiCredentials() {
  preferences.begin("wifi-config", true);
  wifiSsid = preferences.getString("ssid", "");
  wifiPassword = preferences.getString("password", "");
  preferences.end();
}

正確的 setup 流程是:

setup() loadWiFiCredentials() connectWiFi() 失敗則 startConfigPortal()
這次真正的問題:資料其實已寫入 NVS,但重新啟動後沒有呼叫 loadWiFiCredentials(),所以 RAM 中的 wifiSsid 仍是空字串。

十一、自動連線 Wi-Fi

程式碼 9:使用 NVS 帳密連線
bool connectWiFi() {
  if (wifiSsid.length() == 0) return false;
  WiFi.mode(WIFI_STA);
  WiFi.begin(wifiSsid.c_str(), wifiPassword.c_str());
  unsigned long startedAt = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - startedAt < 20000) {
    delay(500);
  }
  return WiFi.status() == WL_CONNECTED;
}

十二、序列監控應看到什麼?

儲存時
========== NVS 寫入結果 ==========
[NVS] SSID 寫入 bytes:10
[NVS] 密碼寫入 bytes:9
[NVS] SSID 回讀:成功
[NVS] 密碼回讀:成功
=================================
[AP] 設定已寫入,5 秒後重新啟動
重新啟動後
========== NVS Wi-Fi 設定 ==========
[NVS] SSID key:存在
[NVS] Password key:存在
[WiFi] 已儲存 SSID:Home_WiFi
===================================
[WiFi] 嘗試連線:Home_WiFi
[WiFi] 連線成功
[WiFi] IP:192.168.1.120

十三、從 Wi-Fi 到 Django Dashboard

Atlas 水質感測器 ESP32 Wi-Fi JSON Django REST API Dashboard

完成 AP 模式與 NVS 設定後,ESP32 就不再綁定單一網路。設備移到新的魚塭或養殖戶,只要用手機重新設定 Wi-Fi,就能繼續上傳資料。

十四、本篇重點

  1. ESP32 無法連線時,可自動啟動 Soft-AP。
  2. 手機可透過 192.168.4.1 開啟設定頁。
  3. SSID 與密碼使用 Preferences 寫入 NVS Flash。
  4. 寫入後應立即回讀驗證。
  5. 重新啟動後必須再次讀取 NVS。
  6. 成功連線後即可恢復 Django REST API 上傳。

結語

將 Wi-Fi 帳密寫死在程式中,適合實驗室測試,但不適合實際場域部署。透過 AP 模式、WebServer、DNSServer 與 Preferences,ESP32 可以在沒有螢幕與鍵盤的情況下,由手機完成 Wi-Fi 設定,並在重新啟動後自動連線。這讓智慧養殖系統從開發板實驗,進一步成為真正可交付、可維護的 IoT 設備。

AI 協作聲明:本文由作者主導教學規劃、ESP32 Wi-Fi AP 模式實作、NVS 儲存測試、手機設定流程與 Django REST API 系統整合,並使用生成式 AI 協助文字整理、程式碼說明、版面設計與教學資訊圖生成;所有程式流程、測試結果與系統架構均依實際測試結果整理,並由作者審核修訂。

[水井村USR] EP03|ESP32 讀取 I²C 感測器實戰

智慧生活科技專業社群|IoT 入門系列|適合大一新生
EP01 認識 I²C,EP02 認識四種水質感測器;EP03 則進入真正的程式實作。本文以 Atlas Scientific Aquaponics Kit 為例,帶領學生完成接線、掃描位址、送出讀取命令、解析回傳資料與序列埠監看。

請先將 EP03 主圖上傳 Blogger,再把 EP03_MAIN_IMAGE_URL 換成圖片網址。

圖:ESP32 讀取四種 I²C 水質感測器的完整流程。

一、這一篇要完成什麼?

任務 1|接線確認 ESP32、SDA、SCL、3.3V 與 GND。
任務 2|掃描找出所有 I²C 裝置位址。
任務 3|讀取向指定感測 IC 傳送 R 指令。
任務 4|解析將字串轉成可使用的水質數值。

二、系統接線

ESP32 SDA/SCL Bus RTD、pH、DO、EC
ESP32功能本案接腳
SDA資料線GPIO23
SCL時脈線GPIO22
3.3V供電依板卡設計
GND共地所有模組共地

圖:四顆 EZO IC 共用 SDA 與 SCL。

三、第一步:啟動 I²C

程式碼 1:初始化 Wire
#include <Wire.h>

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

  // SDA = GPIO23,SCL = GPIO22
  Wire.begin(23, 22);

  // 使用 100 kHz
  Wire.setClock(100000);
}
重點:Wire.begin(SDA, SCL) 必須符合實際開發板與 PCB 配線,不能直接套用別張 ESP32 開發板的預設值。

四、第二步:使用 I²C Scanner 找出所有裝置

程式碼 2:掃描 1~126 位址
void scanI2C() {
  for (uint8_t address = 1; address < 127; address++) {
    Wire.beginTransmission(address);
    uint8_t error = Wire.endTransmission();

    if (error == 0) {
      Serial.print("找到位址:0x");

      if (address < 16) {
        Serial.print("0");
      }

      Serial.println(address, HEX);
    }
  }
}

圖:掃描成功後,可以確認四顆 EZO IC 是否真的存在。

本系統實際掃描結果:

序列監控結果
找到 0x61 → EZO DO
找到 0x63 → EZO pH
找到 0x66 → EZO RTD
找到 0x69 → EZO EC
實務經驗:EC 原本常見預設位址是 0x64,但本機實際位址為 0x69。Scanner 能快速避免「程式正確、位址卻錯誤」的問題。

五、第三步:向感測 IC 傳送讀取命令

Atlas EZO IC 使用字串命令。最常見的讀取命令是:

送出 R 命令
Wire.beginTransmission(address);
Wire.write("R");
Wire.endTransmission();

感測 IC 收到命令後,需要時間完成量測,因此不能立刻讀回。

送出 R 等待約 900~1100 ms requestFrom() 接收資料

六、第四步:讀回感測器資料

程式碼 3:讀取單顆 EZO IC
String readEzo(uint8_t address, unsigned long waitMs) {
  String result = "";

  Wire.beginTransmission(address);
  Wire.write("R");

  if (Wire.endTransmission() != 0) {
    return "ERROR";
  }

  delay(waitMs);

  Wire.requestFrom((int)address, 20);

  while (Wire.available()) {
    char c = Wire.read();

    if (c == '\0') {
      break;
    }

    result += c;
  }

  return result;
}
為什麼使用 String?因為 EZO IC 回傳的是字元資料,例如 6.80,先用字串接收,再轉成 float。

七、第五步:讀取四顆感測器

程式碼 4:依序讀取
constexpr uint8_t DO_ADDR  = 0x61;
constexpr uint8_t PH_ADDR  = 0x63;
constexpr uint8_t RTD_ADDR = 0x66;
constexpr uint8_t EC_ADDR  = 0x69;

void loop() {
  String temperature = readEzo(RTD_ADDR, 1100);
  String ph = readEzo(PH_ADDR, 1100);
  String dissolvedOxygen = readEzo(DO_ADDR, 1100);
  String conductivity = readEzo(EC_ADDR, 1100);

  Serial.print("T=");
  Serial.print(temperature);

  Serial.print("  pH=");
  Serial.print(ph);

  Serial.print("  DO=");
  Serial.print(dissolvedOxygen);

  Serial.print("  EC=");
  Serial.println(conductivity);

  delay(3000);
}

圖:RTD、pH、DO 與 EC 的讀取結果。

八、完整入門版程式

ESP32+四顆 I²C 感測 IC
#include <Wire.h>

constexpr uint8_t SDA_PIN = 23;
constexpr uint8_t SCL_PIN = 22;

constexpr uint8_t DO_ADDR  = 0x61;
constexpr uint8_t PH_ADDR  = 0x63;
constexpr uint8_t RTD_ADDR = 0x66;
constexpr uint8_t EC_ADDR  = 0x69;

String readEzo(uint8_t address, unsigned long waitMs) {
  String result = "";

  Wire.beginTransmission(address);
  Wire.write("R");

  uint8_t error = Wire.endTransmission();

  if (error != 0) {
    return "ERROR";
  }

  delay(waitMs);

  Wire.requestFrom((int)address, 20);

  while (Wire.available()) {
    char c = Wire.read();

    if (c == '\0') {
      break;
    }

    result += c;
  }

  return result;
}

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

  Wire.begin(SDA_PIN, SCL_PIN);
  Wire.setClock(100000);

  Serial.println("I2C Sensor Reading Start...");
}

void loop() {
  String temperature = readEzo(RTD_ADDR, 1100);
  String ph = readEzo(PH_ADDR, 1100);
  String dissolvedOxygen = readEzo(DO_ADDR, 1100);
  String conductivity = readEzo(EC_ADDR, 1100);

  Serial.print("T=");
  Serial.print(temperature);

  Serial.print("  pH=");
  Serial.print(ph);

  Serial.print("  DO=");
  Serial.print(dissolvedOxygen);

  Serial.print("  EC=");
  Serial.println(conductivity);

  delay(3000);
}

九、常見錯誤如何判斷?

現象可能原因先做什麼
Scanner 找不到裝置SDA/SCL 錯誤、供電、Enable、位址改變確認接線並重新掃描
endTransmission() 不等於 0裝置沒有 ACK確認位址是否存在
No Data等待時間不足或感測 IC 尚未完成延長至 1100 ms
數值明顯不合理探棒未浸水、未校正、缺少溫度補償確認探棒與校正狀態
只有某一顆失效位址錯誤或單一模組問題單獨讀取該模組

十、從感測器到 Dashboard

I²C 感測 IC ESP32 Wi-Fi JSON Django REST API Dashboard
完成本篇後,學生已經能把「I²C 理論」轉換成「真正可執行的感測器程式」。下一篇可進一步介紹如何把資料上傳 Django REST API。

十一、本篇重點

  1. 先用 I²C Scanner 確認裝置是否存在。
  2. 每顆 IC 都使用不同 Address。
  3. 送出 R 指令後要等待感測器處理。
  4. 回傳資料通常是字串,需要解析。
  5. 硬體、位址、等待時間與校正,是除錯的四個重點。

結語

ESP32 讀取 I²C 感測器的核心流程並不複雜:指定 Address、送出命令、等待處理、讀回資料。真正的工程能力,來自能否用 Scanner、錯誤碼與序列監控快速定位問題。透過 Atlas Aquaponics Kit 的實際案例,大一新生可以把 IC 通訊、感測器、Arduino 程式與智慧養殖應用連結起來。

AI 協作聲明:本文由作者主導教學目標、硬體測試、程式驗證與案例設計,並使用生成式 AI 協助文字整理、程式碼說明、版面配置與教學資訊圖生成;文章中的 ESP32 腳位、I²C 位址、Atlas EZO 讀取流程與除錯經驗,均依本專案實際測試結果整理,並由作者審核修訂。