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 讀取流程與除錯經驗,均依本專案實際測試結果整理,並由作者審核修訂。

[水井村USR] EP02|Atlas Scientific 四種水質感測器解析

智慧生活科技專業社群|IoT 入門系列

本篇延續 EP01〈什麼是 I²C?ESP32 如何與 IC 溝通?〉,介紹 Atlas Scientific Aquaponics Kit 中最重要的四種水質感測器,透過實際測試案例,讓大一新生了解智慧養殖系統如何量測水質。


一、智慧養殖需要量測哪些水質?

感測器量測項目用途
RTD水溫所有感測器溫度補償
pH酸鹼值了解水質是否適合養殖
DO溶氧魚蝦是否有足夠氧氣
EC導電度推估鹽度、離子濃度

二、RTD 水溫感測器

RTD(Resistance Temperature Detector)利用金屬電阻隨溫度改變的特性量測水溫。本次系統中,RTD 並不只是量測溫度,更提供 pH、DO、EC 三個感測器進行溫度補償

RTD.send_read_cmd();

PH.send_cmd_with_num("T,", temperature);
DO.send_cmd_with_num("T,", temperature);
EC.send_cmd_with_num("T,", temperature);
沒有正確的水溫補償,pH、DO 與 EC 的量測值都可能產生誤差。

三、pH 感測器

pH 值代表水中酸鹼程度,7 為中性,小於 7 為酸性,大於 7 為鹼性。

pH代表意義
0~6.9酸性
7中性
7.1~14鹼性

本次測試結果:

pH:6.80

四、DO 溶氧感測器

DO(Dissolved Oxygen)代表水中溶解氧濃度,通常以 mg/L 表示。

DO (mg/L)狀況
<3容易缺氧
5~8一般養殖
>8氧氣充足

本次系統曾成功讀得:

DO:31.92 mg/L

提醒:若未浸入水中,DO 數值可能偏高,不代表實際養殖池狀況。

五、EC 導電度感測器

EC(Electrical Conductivity)表示水中離子濃度,以 μS/cm 為單位。

本次實驗最大的收穫,就是利用 I²C Scanner 發現 EC 模組位址已不是預設的 0x64,而是:

0x69
工程實務中,不要假設感測器一定使用原廠預設位址。先掃描 I²C Bus,再開始寫程式,是良好的開發習慣。

六、四個感測器如何一起工作?

請插入:
探棒 → EZO IC → I²C → ESP32 → Wi-Fi → Django REST API → Dashboard

ESP32 的工作流程如下:

  1. 讀取 RTD 水溫。
  2. 將溫度送給 pH、DO、EC。
  3. 讀取三個感測值。
  4. 組成 JSON。
  5. 透過 REST API 上傳 Django。
  6. Dashboard 即時更新。

七、實際測試結果

水溫:30.01 °C
pH:6.80
DO:31.92 mg/L
EC:441 uS/cm

這代表 Atlas Scientific Aquaponics Kit 已完成四項水質量測,並可作為智慧養殖 IoT 系統的資料來源。

結語

四種感測器各自負責不同工作,但真正的重點不是單一感測器,而是它們透過 I²C 匯流排共同合作,再由 ESP32 整合資料、送往 Django REST API,形成完整的智慧養殖資訊系統。


AI 協作聲明

本文由作者規劃教學架構、完成 Atlas Scientific Aquaponics Kit 實際測試與驗證,並使用生成式 AI 協助文章整理、程式碼說明、版面設計與圖片建議;所有技術內容、測試數據與系統架構均由作者確認後發布。

[水井村USR] EP01|什麼是 I²C?ESP32 如何與 IC 溝通?

智慧生活科技專業社群|IoT 入門系列|適合大一新生
I²C 是「IC 與 IC 之間的通訊協定」。在智慧養殖系統中,ESP32 並不是直接讀取探棒,而是透過 I²C 與 pH、溶氧、導電度及溫度等智慧感測 IC 溝通。本篇從 Atlas Scientific Aquaponics Kit 的實際案例出發,帶大一新生認識 SDA、SCL、Address、Master/Slave 與 I²C Scanner。


請先將 EP01 教學圖上傳 Blogger,再把 HTML 中的 I2C_EP01_IMAGE_URL 換成圖片網址。
圖:I²C 兩線式通訊、Address、掃描與智慧養殖應用。

一、ESP32 真的直接讀探棒嗎?

很多初學者看到 pH 探棒或溶氧探棒,會直覺認為 ESP32 直接從探棒取得數值。實際上,多數智慧感測系統的結構是:

探棒 感測 IC I²C ESP32

探棒負責感受物理或化學變化;感測 IC 則負責放大訊號、類比數位轉換、校正、補償與資料處理。ESP32 最後只要向感測 IC 詢問:「請把目前數值告訴我。」

重要觀念:真正與 ESP32 溝通的是感測 IC,不是探棒本身。

二、什麼是 IC?

IC 是 Integrated Circuit 的縮寫,中文稱為「積體電路」。一顆看似很小的晶片,裡面可能包含:

MCU執行程式與控制量測流程。
ADC把類比訊號轉換成數位資料。
Calibration保存校正資料與修正係數。
Communication透過 UART、I²C 或 SPI 與其他 IC 溝通。

以 Atlas EZO-pH 為例,ESP32 不需要自己把電壓換算成 pH,而是送出讀取命令,EZO-pH 內部完成計算後再回傳:

概念示例
ESP32:R
pH IC:6.80

三、I²C 是什麼?

I²C 全名為 Inter-Integrated Circuit,意思就是「積體電路與積體電路之間的通訊」。它是一種同步、序列式、多裝置共用的通訊協定。

2 條線SDA、SCL
多裝置共用一條 Bus
Address每顆 IC 有門牌
同步由 Clock 控制節奏

四、I²C 只需要兩條訊號線

訊號全名功能
SDASerial Data傳送位址、命令與資料。
SCLSerial Clock提供時脈,讓所有裝置在相同節奏下交換資料。

多顆感測 IC 可以共用同一組 SDA 與 SCL,因此不用為每一顆感測器都準備一組獨立通訊線。

ESP32 SDA/SCL Bus pH IC、DO IC、EC IC、RTD IC

五、Address 就像每顆 IC 的門牌號碼

所有 IC 雖然共用 SDA 與 SCL,但每顆 IC 都必須有自己的 Address。ESP32 送出某個位址時,只有符合該位址的 IC 會回答。

感測 IC十六進位位址十進位位址
EZO-DO0x6197
EZO-pH0x6399
EZO-RTD0x66102
本系統 EZO-EC0x69105
除錯經驗:不要假設裝置一定維持原廠預設位址。本次智慧養殖系統的 EC 位址已被修改為 0x69,因此一定要先掃描實際 I²C Bus。

六、Master 與 Slave 在做什麼?

入門階段可以先把 ESP32 視為 Master,把各個感測 IC 視為 Slave。

Master:ESP32決定何時開始通訊、要跟哪一個位址對話,以及要讀取或寫入資料。
Slave:感測 IC監聽自己的位址,被點名後回應命令或提供資料。

七、一次 I²C 讀取大致發生什麼事?

  1. ESP32 送出 Start 訊號。
  2. ESP32 傳送目標 IC 的 Address。
  3. 目標 IC 回覆 ACK,表示「我在這裡」。
  4. ESP32 傳送讀取命令,例如 R
  5. IC 完成量測或計算。
  6. ESP32 向 IC 請求資料。
  7. IC 回傳數值。
  8. ESP32 送出 Stop,結束本次通訊。
Arduino Wire Library 概念流程
Wire.beginTransmission(0x63);
Wire.write("R");
Wire.endTransmission();

delay(1000);

Wire.requestFrom(0x63, 20);

八、I²C Scanner:工程師的第一個除錯工具

當感測器沒有資料時,不應立刻懷疑探棒故障。第一步應先確認該 IC 是否真的出現在 I²C Bus 上。

I²C Scanner 範例
#include <Wire.h>

void setup() {
  Serial.begin(115200);
  Wire.begin(23, 22);

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

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

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

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

void loop() {
}

正常掃描可能得到:

掃描結果
找到 I2C 位址:0x61
找到 I2C 位址:0x63
找到 I2C 位址:0x66
找到 I2C 位址:0x69

九、從 Atlas Aquaponics Kit 看 I²C 的實際應用

在本次智慧養殖系統中,ESP32 透過同一條 I²C Bus 與四顆 EZO 感測 IC 溝通:

RTD 水溫 pH DO 溶氧 EC 導電度 ESP32

ESP32 先讀取 RTD 水溫,再把溫度送給 pH、DO 與 EC 進行溫度補償,最後取得完整水質資料。

溫度補償概念
RTD.send_read_cmd();

PH.send_cmd_with_num("T,", temperature);
DO.send_cmd_with_num("T,", temperature);
EC.send_cmd_with_num("T,", temperature);

十、I²C 只是 IoT 資料鏈的第一段

I²C 解決的是「板子內部 IC 與 IC 的通訊」。當 ESP32 取得資料後,還會經過 Wi-Fi、JSON、REST API、資料庫與 Dashboard。

探棒 感測 IC I²C ESP32 Wi-Fi Django REST API Dashboard
從 I²C 到 REST API,學生可以看到一筆水質資料如何從探棒一路走到雲端,形成完整的 IoT 系統。

十一、大一新生應記住的五個重點

  1. I²C 是 IC 與 IC 之間的通訊協定。
  2. I²C 主要使用 SDA 與 SCL 兩條訊號線。
  3. 多顆 IC 可以共用同一條 Bus。
  4. 每顆 IC 必須有不同的 Address。
  5. 感測器沒有資料時,先用 I²C Scanner 確認裝置是否存在。

十二、課後思考

  • 如果兩顆 IC 使用相同 Address,會發生什麼事?
  • I²C 與 UART、SPI 有什麼不同?
  • 為什麼 SDA 與 SCL 通常需要上拉電阻?
  • 如果 I²C 線太長,通訊會遇到什麼問題?
  • 如何把掃描到的位址轉換成裝置名稱?

結語

I²C 看似只是兩條線,背後卻包含 Address、時脈、ACK、讀寫方向與匯流排共享等重要概念。對大一新生而言,先理解「ESP32 是如何點名某一顆 IC,再讀回資料」,就掌握了 I²C 的核心。從智慧養殖案例出發,也能進一步理解感測、通訊、雲端與 Dashboard 如何串成完整的智慧生活科技系統。

AI 協作聲明:本文由作者主導教學目標、案例選擇、實作測試與內容架構,並使用生成式 AI 協助文字整理、程式碼說明、版面設計與教學圖生成;文章中的 Atlas Aquaponics Kit、ESP32 腳位、I²C 位址與感測流程,均依本專案實際測試結果整理,並由作者審核修訂。