顯示具有 智慧養殖 標籤的文章。 顯示所有文章
顯示具有 智慧養殖 標籤的文章。 顯示所有文章

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 協助文字整理、程式碼說明、版面配置與教學資訊圖生成;文章中的範例門檻僅供教學,正式養殖警示值須由作者與場域專業人員依實際需求確認。

2026年8月2日 星期日

[水井村USR]從 Atlas Aquaponics Kit 到 Django 雲端監控平台:智慧養殖 IoT 系統整合教學





Atlas Scientific Aquaponics Kit V1.8 × Adafruit HUZZAH32 ESP32 Feather × Django REST API × PythonAnywhere
智慧養殖不只是把感測器接上 ESP32,而是要完成「水質感測、資料處理、網路傳輸、雲端儲存與即時呈現」的完整資料鏈。本教學整理實際測試過程,說明如何將 RTD、pH、DO 與 EC 感測資料,透過 HTTPS POST 傳送到水井村 USR 的 Django REST API,並在 Web Dashboard 查看多家養殖戶的即時狀態。

一、系統整合的重要概念

RTD/pH/DO/EC ESP32 讀值與補償 Wi-Fi HTTPS JSON Django REST API 資料庫 Web Dashboard
請先將「Atlas Aquaponics V1.8 Django REST API 系統整合圖」上傳至 Blogger, 再將 HTML 中的 SYSTEM_IMAGE_URL 換成圖片網址。
圖:水質感測、ESP32、Django REST API 與 Dashboard 的完整資料流程。

系統中,ESP32 並不是單純讀值後顯示在序列監控視窗,而是扮演「邊緣節點」: 負責啟用各 EZO 電路、讀取感測器、執行溫度補償、建立 JSON、取得網路時間,並定時將資料送往雲端。

二、硬體與感測器

感測器 用途 實際 I²C 位址 資料用途
RTD 水溫 0x66(102) 水溫監控與其他感測器的溫度補償
pH 酸鹼值 0x63(99) 判斷水體酸鹼狀況
DO 溶氧 0x61(97) 評估水體含氧與水車運轉需求
EC 導電度 0x69(105) 推估鹽度與水體離子濃度
實作重點:本機 EC 的位址不是常見預設值 0x64,而是 0x69。除錯時不能只相信原廠預設值,應先執行 I²C 掃描,再依實際位址建立 EZO 物件。

三、正確設定 Aquaponics V1.8 腳位

本系統採用 Adafruit HUZZAH32 ESP32 Feather。實際測試後,I²C 使用 GPIO23 與 GPIO22, 各感測器也有獨立的 Enable 腳位。

程式碼 1:I²C、Enable 與 EZO 位址
constexpr uint8_t I2C_SDA = 23;
constexpr uint8_t I2C_SCL = 22;

constexpr uint8_t EN_PH  = 13;
constexpr uint8_t EN_DO  = 12;
constexpr uint8_t EN_EC  = 27;
constexpr uint8_t EN_RTD = 33;
constexpr uint8_t EN_CO2 = 15;
constexpr uint8_t EN_HUM = 32;

constexpr uint8_t ADDR_DO  = 97;   // 0x61
constexpr uint8_t ADDR_PH  = 99;   // 0x63
constexpr uint8_t ADDR_RTD = 102;  // 0x66
constexpr uint8_t ADDR_EC  = 105;  // 0x69,本機 EC 已改址

Ezo_board PH  = Ezo_board(ADDR_PH,  "PH");
Ezo_board DO  = Ezo_board(ADDR_DO,  "DO");
Ezo_board EC  = Ezo_board(ADDR_EC,  "EC");
Ezo_board RTD = Ezo_board(ADDR_RTD, "RTD");

constexpr unsigned long READ_DELAY_MS = 1100;
constexpr unsigned long SENSOR_INTERVAL_MS = 5000;
constexpr unsigned long UPLOAD_INTERVAL_MS = 15000;
constexpr unsigned long WIFI_RETRY_INTERVAL_MS = 10000;

// EC 換算鹽度暫用係數;正式部署前應以標準液或鹽度計校正。
constexpr float EC_TO_SALINITY_FACTOR = 0.00064f;

const long GMT_OFFSET_SEC = 8L * 60L * 60L;
const int DAYLIGHT_OFFSET_SEC = 0;
const char* NTP_SERVER_1 = "pool.ntp.org";
const char* NTP_SERVER_2 = "time.google.com";

struct SensorData {
  float waterTemperature;
  float ph;
  float dissolvedOxygen;
  float conductivity;
  float salinity;

  bool temperatureValid;
  bool phValid;
  bool dissolvedOxygenValid;
  bool conductivityValid;
};

SensorData sensorData = {
  NAN, NAN, NAN, NAN, NAN,
  false, false, false, false
};

unsigned long lastSensorTime = 0;
unsigned long lastUploadTime = 0;
unsigned long lastWiFiRetryTime = 0;

void enableCircuits() {
  pinMode(EN_PH, OUTPUT);
  pinMode(EN_DO, OUTPUT);
  pinMode(EN_EC, OUTPUT);
  pinMode(EN_RTD, OUTPUT);
  pinMode(EN_CO2, OUTPUT);
  pinMode(EN_HUM, OUTPUT);

  digitalWrite(EN_PH, LOW);
  digitalWrite(EN_DO, LOW);
  digitalWrite(EN_EC, LOW);

  digitalWrite(EN_RTD, HIGH);
  digitalWrite(EN_CO2, HIGH);
  digitalWrite(EN_HUM, HIGH);

  delay(2000);
}

void connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  Serial.println();
  Serial.print("[WiFi] 正在連線:");
  Serial.println(WIFI_SSID);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  unsigned long startedAt = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - startedAt < 15000) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("[WiFi] 連線成功");
    Serial.print("[WiFi] IP:");
    Serial.println(WiFi.localIP());
    Serial.print("[WiFi] RSSI:");
    Serial.print(WiFi.RSSI());
    Serial.println(" dBm");
  } else {
    Serial.println("[WiFi] 本次連線失敗,稍後重試");
  }
}

void maintainWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  if (millis() - lastWiFiRetryTime >= WIFI_RETRY_INTERVAL_MS) {
    lastWiFiRetryTime = millis();
    connectWiFi();
  }
}

void initializeTime() {
  configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER_1, NTP_SERVER_2);

  Serial.print("[NTP] 同步時間");
  struct tm timeInfo;

  for (int i = 0; i < 20; i++) {
    if (getLocalTime(&timeInfo, 1000)) {
      Serial.println();
      Serial.println("[NTP] 同步成功");
      return;
    }
    Serial.print(".");
  }

  Serial.println();
  Serial.println("[NTP] 尚未取得時間");
}

String getRecordedAt() {
  struct tm timeInfo;
  if (!getLocalTime(&timeInfo, 3000)) return "";

  char buffer[25];
  strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%S", &timeInfo);
  return String(buffer) + "+08:00";
}

bool receiveValue(Ezo_board& sensor, float& value) {
  receive_and_print_reading(sensor);

  if (sensor.get_error() != Ezo_board::SUCCESS) return false;

  value = sensor.get_last_received_reading();
  return !isnan(value);
}

void readSensors() {
  Serial.println();
  Serial.println("========== 感測器讀值 ==========");

  RTD.send_read_cmd();
  delay(READ_DELAY_MS);

  float temperature = NAN;
  sensorData.temperatureValid = receiveValue(RTD, temperature);

  if (sensorData.temperatureValid && temperature > -1000.0f) {
    sensorData.waterTemperature = temperature;
    Serial.printf("水溫 RTD:%.2f C\n", sensorData.waterTemperature);

    PH.send_cmd_with_num("T,", temperature);
    DO.send_cmd_with_num("T,", temperature);
    EC.send_cmd_with_num("T,", temperature);
  } else {
    sensorData.temperatureValid = false;
    Serial.println("水溫 RTD:無有效資料");

    PH.send_cmd_with_num("T,", 25.0);
    DO.send_cmd_with_num("T,", 25.0);
    EC.send_cmd_with_num("T,", 25.0);
  }

  delay(350);

  PH.send_read_cmd();
  DO.send_read_cmd();
  EC.send_read_cmd();

  delay(READ_DELAY_MS);

  float phValue = NAN;
  float doValue = NAN;
  float ecValue = NAN;

  sensorData.phValid = receiveValue(PH, phValue);
  sensorData.dissolvedOxygenValid = receiveValue(DO, doValue);
  sensorData.conductivityValid = receiveValue(EC, ecValue);

  if (sensorData.phValid) sensorData.ph = phValue;
  if (sensorData.dissolvedOxygenValid) sensorData.dissolvedOxygen = doValue;

  if (sensorData.conductivityValid) {
    sensorData.conductivity = ecValue;
    sensorData.salinity = ecValue * EC_TO_SALINITY_FACTOR;
  }

  Serial.println();
  Serial.println("---------- 結果 ----------");

  if (sensorData.phValid) Serial.printf("pH:%.2f\n", sensorData.ph);
  else Serial.println("pH:無有效資料");

  if (sensorData.dissolvedOxygenValid) {
    Serial.printf("溶氧 DO:%.2f mg/L\n", sensorData.dissolvedOxygen);
  } else {
    Serial.println("溶氧 DO:無有效資料");
  }

  if (sensorData.conductivityValid) {
    Serial.printf("導電度 EC:%.0f uS/cm\n", sensorData.conductivity);
    Serial.printf("換算鹽度:%.2f ppt\n", sensorData.salinity);
  } else {
    Serial.println("導電度 EC:無有效資料");
  }

  Serial.println("==========================");
}

String jsonNumberOrNull(float value, bool valid, unsigned int decimals) {
  if (!valid || isnan(value)) return "null";
  return String((double)value, decimals);
}

String escapeJsonString(const String& input) {
  String output;
  output.reserve(input.length() + 8);

  for (size_t i = 0; i < input.length(); i++) {
    char c = input.charAt(i);
    switch (c) {
      case '"': output += "\\\""; break;
      case '\\': output += "\\\\"; break;
      case '\n': output += "\\n"; break;
      case '\r': output += "\\r"; break;
      case '\t': output += "\\t"; break;
      default: output += c; break;
    }
  }

  return output;
}

String buildPayload(const String& recordedAt) {
  String payload;
  payload.reserve(420);

  payload += "{";
  payload += "\"token\":\"" + escapeJsonString(API_TOKEN) + "\",";
  payload += "\"farm_name\":\"" + escapeJsonString(FARM_NAME) + "\",";
  payload += "\"pond_code\":\"" + escapeJsonString(POND_CODE) + "\",";

  payload += "\"water_temperature\":";
  payload += jsonNumberOrNull(sensorData.waterTemperature, sensorData.temperatureValid, 2);
  payload += ",";

  payload += "\"salinity\":";
  payload += jsonNumberOrNull(sensorData.salinity, sensorData.conductivityValid, 2);
  payload += ",";

  payload += "\"ph\":";
  payload += jsonNumberOrNull(sensorData.ph, sensorData.phValid, 2);
  payload += ",";

  payload += "\"dissolved_oxygen\":";
  payload += jsonNumberOrNull(sensorData.dissolvedOxygen, sensorData.dissolvedOxygenValid, 2);
  payload += ",";

  payload += "\"water_source\":\"" + escapeJsonString(WATER_SOURCE) + "\",";
  payload += "\"recorded_at\":\"" + recordedAt + "\"";
  payload += "}";

  return payload;
}

void uploadToDjango() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[API] Wi-Fi 未連線,本次不上傳");
    return;
  }

  String recordedAt = getRecordedAt();
  if (recordedAt.length() == 0) {
    Serial.println("[API] 尚未取得 NTP 時間,本次不上傳");
    return;
  }

  String payload = buildPayload(recordedAt);

  NetworkClientSecure secureClient;
  secureClient.setInsecure(); // 測試階段;正式部署建議改用 CA 憑證

  HTTPClient http;
  http.setTimeout(15000);

  Serial.println();
  Serial.println("========== Django API 上傳 ==========");
  Serial.print("[API] URL:");
  Serial.println(API_URL);
  Serial.print("[API] Payload:");
  Serial.println(payload);

  if (!http.begin(secureClient, API_URL)) {
    Serial.println("[API] HTTPS 初始化失敗");
    return;
  }

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

  int httpCode = http.POST(payload);
  Serial.print("[API] HTTP 狀態碼:");
  Serial.println(httpCode);

  if (httpCode > 0) {
    String response = http.getString();
    Serial.print("[API] 回應:");
    Serial.println(response);

    if (httpCode >= 200 && httpCode < 300) {
      Serial.println("[API] 上傳成功");
    } else {
      Serial.println("[API] 上傳失敗,請檢查 token、欄位或資料格式");
    }
  } else {
    Serial.print("[API] 連線錯誤:");
    Serial.println(http.errorToString(httpCode));
  }

  http.end();
  Serial.println("====================================");
}

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

  Serial.println();
  Serial.println("Atlas Aquaponics V1.8 + Django API 啟動");
  Serial.printf("SDA=GPIO%u, SCL=GPIO%u\n", I2C_SDA, I2C_SCL);
  Serial.printf("EC 位址=%u (0x%02X)\n", ADDR_EC, ADDR_EC);

  enableCircuits();

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000);
  Wire.setTimeOut(1000);

  connectWiFi();
  if (WiFi.status() == WL_CONNECTED) initializeTime();

  readSensors();

  lastSensorTime = millis();
  lastUploadTime = millis();
}

void loop() {
  maintainWiFi();

  unsigned long now = millis();

  if (now - lastSensorTime >= SENSOR_INTERVAL_MS) {
    lastSensorTime = now;
    readSensors();
  }

  if (now - lastUploadTime >= UPLOAD_INTERVAL_MS) {
    lastUploadTime = now;
    uploadToDjango();
  }

  delay(10);
}

constexpr uint8_t ADDR_DO  = 97;   // 0x61
constexpr uint8_t ADDR_PH  = 99;   // 0x63
constexpr uint8_t ADDR_RTD = 102;  // 0x66
constexpr uint8_t ADDR_EC  = 105;  // 0x69,本機 EC 已改址

Ezo_board PH  = Ezo_board(ADDR_PH,  "PH");
Ezo_board DO  = Ezo_board(ADDR_DO,  "DO");
Ezo_board EC  = Ezo_board(ADDR_EC,  "EC");
Ezo_board RTD = Ezo_board(ADDR_RTD, "RTD");

constexpr unsigned long READ_DELAY_MS = 1100;
constexpr unsigned long SENSOR_INTERVAL_MS = 5000;
constexpr unsigned long UPLOAD_INTERVAL_MS = 15000;
constexpr unsigned long WIFI_RETRY_INTERVAL_MS = 10000;

// EC 換算鹽度暫用係數;正式部署前應以標準液或鹽度計校正。
constexpr float EC_TO_SALINITY_FACTOR = 0.00064f;

const long GMT_OFFSET_SEC = 8L * 60L * 60L;
const int DAYLIGHT_OFFSET_SEC = 0;
const char* NTP_SERVER_1 = "pool.ntp.org";
const char* NTP_SERVER_2 = "time.google.com";

struct SensorData {
  float waterTemperature;
  float ph;
  float dissolvedOxygen;
  float conductivity;
  float salinity;

  bool temperatureValid;
  bool phValid;
  bool dissolvedOxygenValid;
  bool conductivityValid;
};

SensorData sensorData = {
  NAN, NAN, NAN, NAN, NAN,
  false, false, false, false
};

unsigned long lastSensorTime = 0;
unsigned long lastUploadTime = 0;
unsigned long lastWiFiRetryTime = 0;

void enableCircuits() {
  pinMode(EN_PH, OUTPUT);
  pinMode(EN_DO, OUTPUT);
  pinMode(EN_EC, OUTPUT);
  pinMode(EN_RTD, OUTPUT);
  pinMode(EN_CO2, OUTPUT);
  pinMode(EN_HUM, OUTPUT);

  digitalWrite(EN_PH, LOW);
  digitalWrite(EN_DO, LOW);
  digitalWrite(EN_EC, LOW);

  digitalWrite(EN_RTD, HIGH);
  digitalWrite(EN_CO2, HIGH);
  digitalWrite(EN_HUM, HIGH);

  delay(2000);
}

void connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  Serial.println();
  Serial.print("[WiFi] 正在連線:");
  Serial.println(WIFI_SSID);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  unsigned long startedAt = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - startedAt < 15000) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("[WiFi] 連線成功");
    Serial.print("[WiFi] IP:");
    Serial.println(WiFi.localIP());
    Serial.print("[WiFi] RSSI:");
    Serial.print(WiFi.RSSI());
    Serial.println(" dBm");
  } else {
    Serial.println("[WiFi] 本次連線失敗,稍後重試");
  }
}

void maintainWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  if (millis() - lastWiFiRetryTime >= WIFI_RETRY_INTERVAL_MS) {
    lastWiFiRetryTime = millis();
    connectWiFi();
  }
}

void initializeTime() {
  configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER_1, NTP_SERVER_2);

  Serial.print("[NTP] 同步時間");
  struct tm timeInfo;

  for (int i = 0; i < 20; i++) {
    if (getLocalTime(&timeInfo, 1000)) {
      Serial.println();
      Serial.println("[NTP] 同步成功");
      return;
    }
    Serial.print(".");
  }

  Serial.println();
  Serial.println("[NTP] 尚未取得時間");
}

String getRecordedAt() {
  struct tm timeInfo;
  if (!getLocalTime(&timeInfo, 3000)) return "";

  char buffer[25];
  strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%S", &timeInfo);
  return String(buffer) + "+08:00";
}

bool receiveValue(Ezo_board& sensor, float& value) {
  receive_and_print_reading(sensor);

  if (sensor.get_error() != Ezo_board::SUCCESS) return false;

  value = sensor.get_last_received_reading();
  return !isnan(value);
}

void readSensors() {
  Serial.println();
  Serial.println("========== 感測器讀值 ==========");

  RTD.send_read_cmd();
  delay(READ_DELAY_MS);

  float temperature = NAN;
  sensorData.temperatureValid = receiveValue(RTD, temperature);

  if (sensorData.temperatureValid && temperature > -1000.0f) {
    sensorData.waterTemperature = temperature;
    Serial.printf("水溫 RTD:%.2f C\n", sensorData.waterTemperature);

    PH.send_cmd_with_num("T,", temperature);
    DO.send_cmd_with_num("T,", temperature);
    EC.send_cmd_with_num("T,", temperature);
  } else {
    sensorData.temperatureValid = false;
    Serial.println("水溫 RTD:無有效資料");

    PH.send_cmd_with_num("T,", 25.0);
    DO.send_cmd_with_num("T,", 25.0);
    EC.send_cmd_with_num("T,", 25.0);
  }

  delay(350);

  PH.send_read_cmd();
  DO.send_read_cmd();
  EC.send_read_cmd();

  delay(READ_DELAY_MS);

  float phValue = NAN;
  float doValue = NAN;
  float ecValue = NAN;

  sensorData.phValid = receiveValue(PH, phValue);
  sensorData.dissolvedOxygenValid = receiveValue(DO, doValue);
  sensorData.conductivityValid = receiveValue(EC, ecValue);

  if (sensorData.phValid) sensorData.ph = phValue;
  if (sensorData.dissolvedOxygenValid) sensorData.dissolvedOxygen = doValue;

  if (sensorData.conductivityValid) {
    sensorData.conductivity = ecValue;
    sensorData.salinity = ecValue * EC_TO_SALINITY_FACTOR;
  }

  Serial.println();
  Serial.println("---------- 結果 ----------");

  if (sensorData.phValid) Serial.printf("pH:%.2f\n", sensorData.ph);
  else Serial.println("pH:無有效資料");

  if (sensorData.dissolvedOxygenValid) {
    Serial.printf("溶氧 DO:%.2f mg/L\n", sensorData.dissolvedOxygen);
  } else {
    Serial.println("溶氧 DO:無有效資料");
  }

  if (sensorData.conductivityValid) {
    Serial.printf("導電度 EC:%.0f uS/cm\n", sensorData.conductivity);
    Serial.printf("換算鹽度:%.2f ppt\n", sensorData.salinity);
  } else {
    Serial.println("導電度 EC:無有效資料");
  }

  Serial.println("==========================");
}

String jsonNumberOrNull(float value, bool valid, unsigned int decimals) {
  if (!valid || isnan(value)) return "null";
  return String((double)value, decimals);
}

String escapeJsonString(const String& input) {
  String output;
  output.reserve(input.length() + 8);

  for (size_t i = 0; i < input.length(); i++) {
    char c = input.charAt(i);
    switch (c) {
      case '"': output += "\\\""; break;
      case '\\': output += "\\\\"; break;
      case '\n': output += "\\n"; break;
      case '\r': output += "\\r"; break;
      case '\t': output += "\\t"; break;
      default: output += c; break;
    }
  }

  return output;
}

String buildPayload(const String& recordedAt) {
  String payload;
  payload.reserve(420);

  payload += "{";
  payload += "\"token\":\"" + escapeJsonString(API_TOKEN) + "\",";
  payload += "\"farm_name\":\"" + escapeJsonString(FARM_NAME) + "\",";
  payload += "\"pond_code\":\"" + escapeJsonString(POND_CODE) + "\",";

  payload += "\"water_temperature\":";
  payload += jsonNumberOrNull(sensorData.waterTemperature, sensorData.temperatureValid, 2);
  payload += ",";

  payload += "\"salinity\":";
  payload += jsonNumberOrNull(sensorData.salinity, sensorData.conductivityValid, 2);
  payload += ",";

  payload += "\"ph\":";
  payload += jsonNumberOrNull(sensorData.ph, sensorData.phValid, 2);
  payload += ",";

  payload += "\"dissolved_oxygen\":";
  payload += jsonNumberOrNull(sensorData.dissolvedOxygen, sensorData.dissolvedOxygenValid, 2);
  payload += ",";

  payload += "\"water_source\":\"" + escapeJsonString(WATER_SOURCE) + "\",";
  payload += "\"recorded_at\":\"" + recordedAt + "\"";
  payload += "}";

  return payload;
}

void uploadToDjango() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[API] Wi-Fi 未連線,本次不上傳");
    return;
  }

  String recordedAt = getRecordedAt();
  if (recordedAt.length() == 0) {
    Serial.println("[API] 尚未取得 NTP 時間,本次不上傳");
    return;
  }

  String payload = buildPayload(recordedAt);

  NetworkClientSecure secureClient;
  secureClient.setInsecure(); // 測試階段;正式部署建議改用 CA 憑證

  HTTPClient http;
  http.setTimeout(15000);

  Serial.println();
  Serial.println("========== Django API 上傳 ==========");
  Serial.print("[API] URL:");
  Serial.println(API_URL);
  Serial.print("[API] Payload:");
  Serial.println(payload);

  if (!http.begin(secureClient, API_URL)) {
    Serial.println("[API] HTTPS 初始化失敗");
    return;
  }

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

  int httpCode = http.POST(payload);
  Serial.print("[API] HTTP 狀態碼:");
  Serial.println(httpCode);

  if (httpCode > 0) {
    String response = http.getString();
    Serial.print("[API] 回應:");
    Serial.println(response);

    if (httpCode >= 200 && httpCode < 300) {
      Serial.println("[API] 上傳成功");
    } else {
      Serial.println("[API] 上傳失敗,請檢查 token、欄位或資料格式");
    }
  } else {
    Serial.print("[API] 連線錯誤:");
    Serial.println(http.errorToString(httpCode));
  }

  http.end();
  Serial.println("====================================");
}

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

  Serial.println();
  Serial.println("Atlas Aquaponics V1.8 + Django API 啟動");
  Serial.printf("SDA=GPIO%u, SCL=GPIO%u\n", I2C_SDA, I2C_SCL);
  Serial.printf("EC 位址=%u (0x%02X)\n", ADDR_EC, ADDR_EC);

  enableCircuits();

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000);
  Wire.setTimeOut(1000);

  connectWiFi();
  if (WiFi.status() == WL_CONNECTED) initializeTime();

  readSensors();

  lastSensorTime = millis();
  lastUploadTime = millis();
}

void loop() {
  maintainWiFi();

  unsigned long now = millis();

  if (now - lastSensorTime >= SENSOR_INTERVAL_MS) {
    lastSensorTime = now;
    readSensors();
  }

  if (now - lastUploadTime >= UPLOAD_INTERVAL_MS) {
    lastUploadTime = now;
    uploadToDjango();
  }

  delay(10);
}

Wire.begin(23, 22) 明確指定 SDA 與 SCL,避免使用其他 ESP32 開發板的預設值。 EC 物件則必須使用已確認的位址 105,否則會出現 No Data 或 I²C 傳輸失敗。

四、為什麼先讀 RTD?

pH、DO 與 EC 都會受到水溫影響。因此每輪量測先讀取 RTD,再把溫度傳給其他三個模組, 能讓讀值更符合當下水體狀況。

程式碼 2:溫度補償流程
RTD.send_read_cmd();
delay(READ_DELAY_MS);

float temperature = NAN;
sensorData.temperatureValid =
  receiveValue(RTD, temperature);

if (sensorData.temperatureValid &&
    temperature > -1000.0f) {
  sensorData.waterTemperature = temperature;

  PH.send_cmd_with_num("T,", temperature);
  DO.send_cmd_with_num("T,", temperature);
  EC.send_cmd_with_num("T,", temperature);
} else {
  PH.send_cmd_with_num("T,", 25.0);
  DO.send_cmd_with_num("T,", 25.0);
  EC.send_cmd_with_num("T,", 25.0);
}

若 RTD 暫時失效,程式改用 25°C 作為備援值,避免 pH、DO 與 EC 全部停止工作。 這是一種「容錯設計」,但正式系統仍應把 RTD 失效狀況記錄或發出警示。

五、Wi-Fi 與 Django REST API 設定

程式碼 3:Wi-Fi、API 與場域識別
const char* WIFI_SSID = "請填入WiFi名稱";
const char* WIFI_PASSWORD = "請填入WiFi密碼";

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

const char* API_TOKEN = "abc123";
const char* FARM_NAME = "湖虎戰隊";
const char* POND_CODE = "1";
const char* WATER_SOURCE = "直流變頻水車運轉中";

constexpr uint8_t I2C_SDA = 23;
constexpr uint8_t I2C_SCL = 22;

constexpr uint8_t EN_PH  = 13;
constexpr uint8_t EN_DO  = 12;
constexpr uint8_t EN_EC  = 27;
constexpr uint8_t EN_RTD = 33;
constexpr uint8_t EN_CO2 = 15;
constexpr uint8_t EN_HUM = 32;

constexpr uint8_t ADDR_DO  = 97;   // 0x61
constexpr uint8_t ADDR_PH  = 99;   // 0x63
constexpr uint8_t ADDR_RTD = 102;  // 0x66
constexpr uint8_t ADDR_EC  = 105;  // 0x69,本機 EC 已改址

Ezo_board PH  = Ezo_board(ADDR_PH,  "PH");
Ezo_board DO  = Ezo_board(ADDR_DO,  "DO");
Ezo_board EC  = Ezo_board(ADDR_EC,  "EC");
Ezo_board RTD = Ezo_board(ADDR_RTD, "RTD");

constexpr unsigned long READ_DELAY_MS = 1100;
constexpr unsigned long SENSOR_INTERVAL_MS = 5000;
constexpr unsigned long UPLOAD_INTERVAL_MS = 15000;
constexpr unsigned long WIFI_RETRY_INTERVAL_MS = 10000;

// EC 換算鹽度暫用係數;正式部署前應以標準液或鹽度計校正。
constexpr float EC_TO_SALINITY_FACTOR = 0.00064f;

const long GMT_OFFSET_SEC = 8L * 60L * 60L;
const int DAYLIGHT_OFFSET_SEC = 0;
const char* NTP_SERVER_1 = "pool.ntp.org";
const char* NTP_SERVER_2 = "time.google.com";

struct SensorData {
  float waterTemperature;
  float ph;
  float dissolvedOxygen;
  float conductivity;
  float salinity;

  bool temperatureValid;
  bool phValid;
  bool dissolvedOxygenValid;
  bool conductivityValid;
};

SensorData sensorData = {
  NAN, NAN, NAN, NAN, NAN,
  false, false, false, false
};

unsigned long lastSensorTime = 0;
unsigned long lastUploadTime = 0;
unsigned long lastWiFiRetryTime = 0;

void enableCircuits() {
  pinMode(EN_PH, OUTPUT);
  pinMode(EN_DO, OUTPUT);
  pinMode(EN_EC, OUTPUT);
  pinMode(EN_RTD, OUTPUT);
  pinMode(EN_CO2, OUTPUT);
  pinMode(EN_HUM, OUTPUT);

  digitalWrite(EN_PH, LOW);
  digitalWrite(EN_DO, LOW);
  digitalWrite(EN_EC, LOW);

  digitalWrite(EN_RTD, HIGH);
  digitalWrite(EN_CO2, HIGH);
  digitalWrite(EN_HUM, HIGH);

  delay(2000);
}

void connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  Serial.println();
  Serial.print("[WiFi] 正在連線:");
  Serial.println(WIFI_SSID);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  unsigned long startedAt = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - startedAt < 15000) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("[WiFi] 連線成功");
    Serial.print("[WiFi] IP:");
    Serial.println(WiFi.localIP());
    Serial.print("[WiFi] RSSI:");
    Serial.print(WiFi.RSSI());
    Serial.println(" dBm");
  } else {
    Serial.println("[WiFi] 本次連線失敗,稍後重試");
  }
}

void maintainWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  if (millis() - lastWiFiRetryTime >= WIFI_RETRY_INTERVAL_MS) {
    lastWiFiRetryTime = millis();
    connectWiFi();
  }
}

void initializeTime() {
  configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER_1, NTP_SERVER_2);

  Serial.print("[NTP] 同步時間");
  struct tm timeInfo;

  for (int i = 0; i < 20; i++) {
    if (getLocalTime(&timeInfo, 1000)) {
      Serial.println();
      Serial.println("[NTP] 同步成功");
      return;
    }
    Serial.print(".");
  }

  Serial.println();
  Serial.println("[NTP] 尚未取得時間");
}

String getRecordedAt() {
  struct tm timeInfo;
  if (!getLocalTime(&timeInfo, 3000)) return "";

  char buffer[25];
  strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%S", &timeInfo);
  return String(buffer) + "+08:00";
}

bool receiveValue(Ezo_board& sensor, float& value) {
  receive_and_print_reading(sensor);

  if (sensor.get_error() != Ezo_board::SUCCESS) return false;

  value = sensor.get_last_received_reading();
  return !isnan(value);
}

void readSensors() {
  Serial.println();
  Serial.println("========== 感測器讀值 ==========");

  RTD.send_read_cmd();
  delay(READ_DELAY_MS);

  float temperature = NAN;
  sensorData.temperatureValid = receiveValue(RTD, temperature);

  if (sensorData.temperatureValid && temperature > -1000.0f) {
    sensorData.waterTemperature = temperature;
    Serial.printf("水溫 RTD:%.2f C\n", sensorData.waterTemperature);

    PH.send_cmd_with_num("T,", temperature);
    DO.send_cmd_with_num("T,", temperature);
    EC.send_cmd_with_num("T,", temperature);
  } else {
    sensorData.temperatureValid = false;
    Serial.println("水溫 RTD:無有效資料");

    PH.send_cmd_with_num("T,", 25.0);
    DO.send_cmd_with_num("T,", 25.0);
    EC.send_cmd_with_num("T,", 25.0);
  }

  delay(350);

  PH.send_read_cmd();
  DO.send_read_cmd();
  EC.send_read_cmd();

  delay(READ_DELAY_MS);

  float phValue = NAN;
  float doValue = NAN;
  float ecValue = NAN;

  sensorData.phValid = receiveValue(PH, phValue);
  sensorData.dissolvedOxygenValid = receiveValue(DO, doValue);
  sensorData.conductivityValid = receiveValue(EC, ecValue);

  if (sensorData.phValid) sensorData.ph = phValue;
  if (sensorData.dissolvedOxygenValid) sensorData.dissolvedOxygen = doValue;

  if (sensorData.conductivityValid) {
    sensorData.conductivity = ecValue;
    sensorData.salinity = ecValue * EC_TO_SALINITY_FACTOR;
  }

  Serial.println();
  Serial.println("---------- 結果 ----------");

  if (sensorData.phValid) Serial.printf("pH:%.2f\n", sensorData.ph);
  else Serial.println("pH:無有效資料");

  if (sensorData.dissolvedOxygenValid) {
    Serial.printf("溶氧 DO:%.2f mg/L\n", sensorData.dissolvedOxygen);
  } else {
    Serial.println("溶氧 DO:無有效資料");
  }

  if (sensorData.conductivityValid) {
    Serial.printf("導電度 EC:%.0f uS/cm\n", sensorData.conductivity);
    Serial.printf("換算鹽度:%.2f ppt\n", sensorData.salinity);
  } else {
    Serial.println("導電度 EC:無有效資料");
  }

  Serial.println("==========================");
}

String jsonNumberOrNull(float value, bool valid, unsigned int decimals) {
  if (!valid || isnan(value)) return "null";
  return String((double)value, decimals);
}

String escapeJsonString(const String& input) {
  String output;
  output.reserve(input.length() + 8);

  for (size_t i = 0; i < input.length(); i++) {
    char c = input.charAt(i);
    switch (c) {
      case '"': output += "\\\""; break;
      case '\\': output += "\\\\"; break;
      case '\n': output += "\\n"; break;
      case '\r': output += "\\r"; break;
      case '\t': output += "\\t"; break;
      default: output += c; break;
    }
  }

  return output;
}

String buildPayload(const String& recordedAt) {
  String payload;
  payload.reserve(420);

  payload += "{";
  payload += "\"token\":\"" + escapeJsonString(API_TOKEN) + "\",";
  payload += "\"farm_name\":\"" + escapeJsonString(FARM_NAME) + "\",";
  payload += "\"pond_code\":\"" + escapeJsonString(POND_CODE) + "\",";

  payload += "\"water_temperature\":";
  payload += jsonNumberOrNull(sensorData.waterTemperature, sensorData.temperatureValid, 2);
  payload += ",";

  payload += "\"salinity\":";
  payload += jsonNumberOrNull(sensorData.salinity, sensorData.conductivityValid, 2);
  payload += ",";

  payload += "\"ph\":";
  payload += jsonNumberOrNull(sensorData.ph, sensorData.phValid, 2);
  payload += ",";

  payload += "\"dissolved_oxygen\":";
  payload += jsonNumberOrNull(sensorData.dissolvedOxygen, sensorData.dissolvedOxygenValid, 2);
  payload += ",";

  payload += "\"water_source\":\"" + escapeJsonString(WATER_SOURCE) + "\",";
  payload += "\"recorded_at\":\"" + recordedAt + "\"";
  payload += "}";

  return payload;
}

void uploadToDjango() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[API] Wi-Fi 未連線,本次不上傳");
    return;
  }

  String recordedAt = getRecordedAt();
  if (recordedAt.length() == 0) {
    Serial.println("[API] 尚未取得 NTP 時間,本次不上傳");
    return;
  }

  String payload = buildPayload(recordedAt);

  NetworkClientSecure secureClient;
  secureClient.setInsecure(); // 測試階段;正式部署建議改用 CA 憑證

  HTTPClient http;
  http.setTimeout(15000);

  Serial.println();
  Serial.println("========== Django API 上傳 ==========");
  Serial.print("[API] URL:");
  Serial.println(API_URL);
  Serial.print("[API] Payload:");
  Serial.println(payload);

  if (!http.begin(secureClient, API_URL)) {
    Serial.println("[API] HTTPS 初始化失敗");
    return;
  }

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

  int httpCode = http.POST(payload);
  Serial.print("[API] HTTP 狀態碼:");
  Serial.println(httpCode);

  if (httpCode > 0) {
    String response = http.getString();
    Serial.print("[API] 回應:");
    Serial.println(response);

    if (httpCode >= 200 && httpCode < 300) {
      Serial.println("[API] 上傳成功");
    } else {
      Serial.println("[API] 上傳失敗,請檢查 token、欄位或資料格式");
    }
  } else {
    Serial.print("[API] 連線錯誤:");
    Serial.println(http.errorToString(httpCode));
  }

  http.end();
  Serial.println("====================================");
}

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

  Serial.println();
  Serial.println("Atlas Aquaponics V1.8 + Django API 啟動");
  Serial.printf("SDA=GPIO%u, SCL=GPIO%u\n", I2C_SDA, I2C_SCL);
  Serial.printf("EC 位址=%u (0x%02X)\n", ADDR_EC, ADDR_EC);

  enableCircuits();

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000);
  Wire.setTimeOut(1000);

  connectWiFi();
  if (WiFi.status() == WL_CONNECTED) initializeTime();

  readSensors();

  lastSensorTime = millis();
  lastUploadTime = millis();
}

void loop() {
  maintainWiFi();

  unsigned long now = millis();

  if (now - lastSensorTime >= SENSOR_INTERVAL_MS) {
    lastSensorTime = now;
    readSensors();
  }

  if (now - lastUploadTime >= UPLOAD_INTERVAL_MS) {
    lastUploadTime = now;
    uploadToDjango();
  }

  delay(10);
}

farm_namepond_code 用來識別資料屬於哪一個養殖戶及魚塭;同一套程式部署到其他魚塭時,只需修改這些參數。

API 接收端點:
https://shuijingusr.pythonanywhere.com/api/pond/sensor-reading/

六、使用 NTP 建立可信的時間戳記

感測資料若沒有時間,就無法製作趨勢圖、比較不同時段或判斷異常。因此 ESP32 連上 Wi-Fi 後, 透過 NTP 取得台灣時間,並轉換成 Django API 可接受的 ISO 8601 格式。

程式碼 4:時間格式
configTime(
  8L * 60L * 60L,
  0,
  "pool.ntp.org",
  "time.google.com"
);

// 輸出範例
2026-08-02T17:05:00+08:00

七、建立符合 API 規格的 JSON

Django REST API 要求固定欄位名稱,因此 ESP32 需把感測資料組成指定 JSON。 無效感測值會輸出為 null,避免把舊資料誤當成本次讀值。

程式碼 5:建立 JSON Payload
String buildPayload(const String& recordedAt) {
  String payload;
  payload.reserve(420);

  payload += "{";
  payload += "\"token\":\"" + escapeJsonString(API_TOKEN) + "\",";
  payload += "\"farm_name\":\"" + escapeJsonString(FARM_NAME) + "\",";
  payload += "\"pond_code\":\"" + escapeJsonString(POND_CODE) + "\",";

  payload += "\"water_temperature\":";
  payload += jsonNumberOrNull(sensorData.waterTemperature, sensorData.temperatureValid, 2);
  payload += ",";

  payload += "\"salinity\":";
  payload += jsonNumberOrNull(sensorData.salinity, sensorData.conductivityValid, 2);
  payload += ",";

  payload += "\"ph\":";
  payload += jsonNumberOrNull(sensorData.ph, sensorData.phValid, 2);
  payload += ",";

  payload += "\"dissolved_oxygen\":";
  payload += jsonNumberOrNull(sensorData.dissolvedOxygen, sensorData.dissolvedOxygenValid, 2);
  payload += ",";

  payload += "\"water_source\":\"" + escapeJsonString(WATER_SOURCE) + "\",";
  payload += "\"recorded_at\":\"" + recordedAt + "\"";
  payload += "}";

  return payload;
}

void uploadToDjango() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[API] Wi-Fi 未連線,本次不上傳");
    return;
  }

  String recordedAt = getRecordedAt();
  if (recordedAt.length() == 0) {
    Serial.println("[API] 尚未取得 NTP 時間,本次不上傳");
    return;
  }

  String payload = buildPayload(recordedAt);

  NetworkClientSecure secureClient;
  secureClient.setInsecure(); // 測試階段;正式部署建議改用 CA 憑證

  HTTPClient http;
  http.setTimeout(15000);

  Serial.println();
  Serial.println("========== Django API 上傳 ==========");
  Serial.print("[API] URL:");
  Serial.println(API_URL);
  Serial.print("[API] Payload:");
  Serial.println(payload);

  if (!http.begin(secureClient, API_URL)) {
    Serial.println("[API] HTTPS 初始化失敗");
    return;
  }

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

  int httpCode = http.POST(payload);
  Serial.print("[API] HTTP 狀態碼:");
  Serial.println(httpCode);

  if (httpCode > 0) {
    String response = http.getString();
    Serial.print("[API] 回應:");
    Serial.println(response);

    if (httpCode >= 200 && httpCode < 300) {
      Serial.println("[API] 上傳成功");
    } else {
      Serial.println("[API] 上傳失敗,請檢查 token、欄位或資料格式");
    }
  } else {
    Serial.print("[API] 連線錯誤:");
    Serial.println(http.errorToString(httpCode));
  }

  http.end();
  Serial.println("====================================");
}

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

  Serial.println();
  Serial.println("Atlas Aquaponics V1.8 + Django API 啟動");
  Serial.printf("SDA=GPIO%u, SCL=GPIO%u\n", I2C_SDA, I2C_SCL);
  Serial.printf("EC 位址=%u (0x%02X)\n", ADDR_EC, ADDR_EC);

  enableCircuits();

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000);
  Wire.setTimeOut(1000);

  connectWiFi();
  if (WiFi.status() == WL_CONNECTED) initializeTime();

  readSensors();

  lastSensorTime = millis();
  lastUploadTime = millis();
}

void loop() {
  maintainWiFi();

  unsigned long now = millis();

  if (now - lastSensorTime >= SENSOR_INTERVAL_MS) {
    lastSensorTime = now;
    readSensors();
  }

  if (now - lastUploadTime >= UPLOAD_INTERVAL_MS) {
    lastUploadTime = now;
    uploadToDjango();
  }

  delay(10);
}

實際送出的資料格式如下:

JSON 範例
{
  "token": "abc123",
  "farm_name": "湖虎戰隊",
  "pond_code": "1",
  "water_temperature": 30.01,
  "salinity": 0.28,
  "ph": 6.80,
  "dissolved_oxygen": 31.92,
  "water_source": "直流變頻水車運轉中",
  "recorded_at": "2026-08-02T17:05:00+08:00"
}
鹽度換算提醒:目前以 EC × 0.00064 進行近似換算。 正式部署前應使用標準鹽度液或可靠鹽度計校正。若 EZO-EC 已直接啟用 Salinity 輸出,則應直接解析模組回傳的鹽度欄位,不宜再以固定係數換算。

八、使用 HTTPS POST 上傳 Django

程式碼 6:HTTPS POST 核心流程
void uploadToDjango() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[API] Wi-Fi 未連線,本次不上傳");
    return;
  }

  String recordedAt = getRecordedAt();
  if (recordedAt.length() == 0) {
    Serial.println("[API] 尚未取得 NTP 時間,本次不上傳");
    return;
  }

  String payload = buildPayload(recordedAt);

  NetworkClientSecure secureClient;
  secureClient.setInsecure(); // 測試階段;正式部署建議改用 CA 憑證

  HTTPClient http;
  http.setTimeout(15000);

  Serial.println();
  Serial.println("========== Django API 上傳 ==========");
  Serial.print("[API] URL:");
  Serial.println(API_URL);
  Serial.print("[API] Payload:");
  Serial.println(payload);

  if (!http.begin(secureClient, API_URL)) {
    Serial.println("[API] HTTPS 初始化失敗");
    return;
  }

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

  int httpCode = http.POST(payload);
  Serial.print("[API] HTTP 狀態碼:");
  Serial.println(httpCode);

  if (httpCode > 0) {
    String response = http.getString();
    Serial.print("[API] 回應:");
    Serial.println(response);

    if (httpCode >= 200 && httpCode < 300) {
      Serial.println("[API] 上傳成功");
    } else {
      Serial.println("[API] 上傳失敗,請檢查 token、欄位或資料格式");
    }
  } else {
    Serial.print("[API] 連線錯誤:");
    Serial.println(http.errorToString(httpCode));
  }

  http.end();
  Serial.println("====================================");
}

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

  Serial.println();
  Serial.println("Atlas Aquaponics V1.8 + Django API 啟動");
  Serial.printf("SDA=GPIO%u, SCL=GPIO%u\n", I2C_SDA, I2C_SCL);
  Serial.printf("EC 位址=%u (0x%02X)\n", ADDR_EC, ADDR_EC);

  enableCircuits();

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000);
  Wire.setTimeOut(1000);

  connectWiFi();
  if (WiFi.status() == WL_CONNECTED) initializeTime();

  readSensors();

  lastSensorTime = millis();
  lastUploadTime = millis();
}

void loop() {
  maintainWiFi();

  unsigned long now = millis();

  if (now - lastSensorTime >= SENSOR_INTERVAL_MS) {
    lastSensorTime = now;
    readSensors();
  }

  if (now - lastUploadTime >= UPLOAD_INTERVAL_MS) {
    lastUploadTime = now;
    uploadToDjango();
  }

  delay(10);
}

上傳時設定 Content-Type: application/json, Django 才能將 Request Body 解析為 JSON。若回傳 200~299,表示伺服器已接受資料; 常見的成功狀態是 201 Created

HTTP 狀態 意義 建議檢查
201 資料建立成功 可到 Dashboard 查看最新資料
400 JSON 或欄位格式錯誤 檢查欄位名稱、資料型態與必填值
401/403 驗證失敗 檢查 token 或後端權限
500 後端程式錯誤 檢查 Django Log、Model 與 Serializer
負值 ESP32 網路或 TLS 連線錯誤 檢查 Wi-Fi、DNS、網址與憑證

九、讀取與上傳週期

5 秒更新感測值
15 秒上傳雲端
4 項水質感測資料
HTTPSJSON 傳輸

感測器可以頻繁更新,但不必每次讀值都立刻寫入資料庫。採用「5 秒讀取、15 秒上傳」, 可以兼顧即時性、網路負擔與資料庫容量。

十、從資料到 Dashboard

Django 後端收到資料後,依序完成:

  1. 驗證 token。
  2. 確認 farm_name 與 pond_code。
  3. 檢查水溫、鹽度、pH、溶氧與時間格式。
  4. 將資料寫入資料庫。
  5. 回傳 JSON 與 HTTP 狀態。
  6. Dashboard 查詢各魚塭最新一筆資料並呈現。
實際整合後,Dashboard 已可顯示「湖虎戰隊」的水溫、鹽度、pH 與溶氧資料, 並與其他養殖場域並列,形成多養殖戶即時監控介面。

前往水井村 USR 智慧養殖監控平台

十一、實作中最重要的除錯經驗

  1. 先確認開發板:本案為 Adafruit HUZZAH32 ESP32 Feather,PSRAM 應設為 Disabled。
  2. 不要套用別款板子的腳位:實際 SDA/SCL 是 GPIO23/22。
  3. 先掃描 I²C:EC 實際位址為 0x69,而不是假設中的 0x64。
  4. 分層測試:先驗證硬體讀值,再加入 Wi-Fi,最後才加入 API。
  5. 保留伺服器回應:序列監控要印出 HTTP 狀態與 Response Body,才能快速定位錯誤。
  6. 感測值需校正:通訊成功不代表量測一定正確,pH、DO 與 EC 仍應依原廠程序校正。

十二、後續發展

完成 ESP32 與 Django REST API 整合後,可以進一步加入:

  • 水質異常門檻與 Line/Email 通知。
  • 歷史趨勢圖與日、週、月報表。
  • 多魚塭 LoRa 節點與集中式 Gateway。
  • 低溶氧時自動啟動直流變頻水車。
  • Raspberry Pi、LLM、RAG 與 AI Agent 養殖決策建議。
  • 把感測、控制、節能與減碳成果納入水井村 USR 智慧養殖示範。

結語

這套系統的價值,不只是把四個數值顯示在網頁上,而是建立一條可追蹤、可分析、可擴充的智慧養殖資料鏈。 Atlas Scientific 提供穩定的水質感測,ESP32 完成邊緣運算與網路傳輸,Django REST API 負責資料治理, Dashboard 則讓養殖戶、研究團隊與場域管理者可以在同一個介面掌握即時狀況。

AI 協作聲明: 本文由作者主導內容規劃,並使用生成式 AI 協助文字整理、程式碼說明、版面設計與內容潤飾; 文章所述腳位、I²C 位址、感測流程與 Django REST API 整合,均依實際硬體測試結果整理。

附錄:程式碼

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
/*
  水井 USR 智慧養殖
  Atlas Scientific Aquaponics V1.8 + Django REST API 整合版

  硬體:
  - Adafruit HUZZAH32 ESP32 Feather
  - Atlas Aquaponics V1.8
  - RTD / pH / DO / EC

  已驗證腳位:
  pH EN   -> GPIO13
  DO EN   -> GPIO12
  EC EN   -> GPIO27
  RTD EN  -> GPIO33
  CO2 EN  -> GPIO15
  HUM EN  -> GPIO32

  I2C:
  SDA -> GPIO23
  SCL -> GPIO22

  已確認 I2C 位址:
  DO  -> 0x61
  pH  -> 0x63
  RTD -> 0x66
  EC  -> 0x69(本機已改址)

  Arduino IDE:
  Board: Adafruit ESP32 Feather
  PSRAM: Disabled
  Serial Monitor: 115200
*/

#include <Wire.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <NetworkClientSecure.h>
#include <time.h>

#include <Ezo_i2c.h>
#include <Ezo_i2c_util.h>

const char* WIFI_SSID = "請填入WiFi名稱";
const char* WIFI_PASSWORD = "請填入WiFi密碼";

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

const char* API_TOKEN = "abc123";
const char* FARM_NAME = "湖虎戰隊";
const char* POND_CODE = "1";
const char* WATER_SOURCE = "直流變頻水車運轉中";

constexpr uint8_t I2C_SDA = 23;
constexpr uint8_t I2C_SCL = 22;

constexpr uint8_t EN_PH  = 13;
constexpr uint8_t EN_DO  = 12;
constexpr uint8_t EN_EC  = 27;
constexpr uint8_t EN_RTD = 33;
constexpr uint8_t EN_CO2 = 15;
constexpr uint8_t EN_HUM = 32;

constexpr uint8_t ADDR_DO  = 97;   // 0x61
constexpr uint8_t ADDR_PH  = 99;   // 0x63
constexpr uint8_t ADDR_RTD = 102;  // 0x66
constexpr uint8_t ADDR_EC  = 105;  // 0x69,本機 EC 已改址

Ezo_board PH  = Ezo_board(ADDR_PH,  "PH");
Ezo_board DO  = Ezo_board(ADDR_DO,  "DO");
Ezo_board EC  = Ezo_board(ADDR_EC,  "EC");
Ezo_board RTD = Ezo_board(ADDR_RTD, "RTD");

constexpr unsigned long READ_DELAY_MS = 1100;
constexpr unsigned long SENSOR_INTERVAL_MS = 5000;
constexpr unsigned long UPLOAD_INTERVAL_MS = 15000;
constexpr unsigned long WIFI_RETRY_INTERVAL_MS = 10000;

// EC 換算鹽度暫用係數;正式部署前應以標準液或鹽度計校正。
constexpr float EC_TO_SALINITY_FACTOR = 0.00064f;

const long GMT_OFFSET_SEC = 8L * 60L * 60L;
const int DAYLIGHT_OFFSET_SEC = 0;
const char* NTP_SERVER_1 = "pool.ntp.org";
const char* NTP_SERVER_2 = "time.google.com";

struct SensorData {
  float waterTemperature;
  float ph;
  float dissolvedOxygen;
  float conductivity;
  float salinity;

  bool temperatureValid;
  bool phValid;
  bool dissolvedOxygenValid;
  bool conductivityValid;
};

SensorData sensorData = {
  NAN, NAN, NAN, NAN, NAN,
  false, false, false, false
};

unsigned long lastSensorTime = 0;
unsigned long lastUploadTime = 0;
unsigned long lastWiFiRetryTime = 0;

void enableCircuits() {
  pinMode(EN_PH, OUTPUT);
  pinMode(EN_DO, OUTPUT);
  pinMode(EN_EC, OUTPUT);
  pinMode(EN_RTD, OUTPUT);
  pinMode(EN_CO2, OUTPUT);
  pinMode(EN_HUM, OUTPUT);

  digitalWrite(EN_PH, LOW);
  digitalWrite(EN_DO, LOW);
  digitalWrite(EN_EC, LOW);

  digitalWrite(EN_RTD, HIGH);
  digitalWrite(EN_CO2, HIGH);
  digitalWrite(EN_HUM, HIGH);

  delay(2000);
}

void connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  Serial.println();
  Serial.print("[WiFi] 正在連線:");
  Serial.println(WIFI_SSID);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  unsigned long startedAt = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - startedAt < 15000) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("[WiFi] 連線成功");
    Serial.print("[WiFi] IP:");
    Serial.println(WiFi.localIP());
    Serial.print("[WiFi] RSSI:");
    Serial.print(WiFi.RSSI());
    Serial.println(" dBm");
  } else {
    Serial.println("[WiFi] 本次連線失敗,稍後重試");
  }
}

void maintainWiFi() {
  if (WiFi.status() == WL_CONNECTED) return;

  if (millis() - lastWiFiRetryTime >= WIFI_RETRY_INTERVAL_MS) {
    lastWiFiRetryTime = millis();
    connectWiFi();
  }
}

void initializeTime() {
  configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER_1, NTP_SERVER_2);

  Serial.print("[NTP] 同步時間");
  struct tm timeInfo;

  for (int i = 0; i < 20; i++) {
    if (getLocalTime(&timeInfo, 1000)) {
      Serial.println();
      Serial.println("[NTP] 同步成功");
      return;
    }
    Serial.print(".");
  }

  Serial.println();
  Serial.println("[NTP] 尚未取得時間");
}

String getRecordedAt() {
  struct tm timeInfo;
  if (!getLocalTime(&timeInfo, 3000)) return "";

  char buffer[25];
  strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%S", &timeInfo);
  return String(buffer) + "+08:00";
}

bool receiveValue(Ezo_board& sensor, float& value) {
  receive_and_print_reading(sensor);

  if (sensor.get_error() != Ezo_board::SUCCESS) return false;

  value = sensor.get_last_received_reading();
  return !isnan(value);
}

void readSensors() {
  Serial.println();
  Serial.println("========== 感測器讀值 ==========");

  RTD.send_read_cmd();
  delay(READ_DELAY_MS);

  float temperature = NAN;
  sensorData.temperatureValid = receiveValue(RTD, temperature);

  if (sensorData.temperatureValid && temperature > -1000.0f) {
    sensorData.waterTemperature = temperature;
    Serial.printf("水溫 RTD:%.2f C\n", sensorData.waterTemperature);

    PH.send_cmd_with_num("T,", temperature);
    DO.send_cmd_with_num("T,", temperature);
    EC.send_cmd_with_num("T,", temperature);
  } else {
    sensorData.temperatureValid = false;
    Serial.println("水溫 RTD:無有效資料");

    PH.send_cmd_with_num("T,", 25.0);
    DO.send_cmd_with_num("T,", 25.0);
    EC.send_cmd_with_num("T,", 25.0);
  }

  delay(350);

  PH.send_read_cmd();
  DO.send_read_cmd();
  EC.send_read_cmd();

  delay(READ_DELAY_MS);

  float phValue = NAN;
  float doValue = NAN;
  float ecValue = NAN;

  sensorData.phValid = receiveValue(PH, phValue);
  sensorData.dissolvedOxygenValid = receiveValue(DO, doValue);
  sensorData.conductivityValid = receiveValue(EC, ecValue);

  if (sensorData.phValid) sensorData.ph = phValue;
  if (sensorData.dissolvedOxygenValid) sensorData.dissolvedOxygen = doValue;

  if (sensorData.conductivityValid) {
    sensorData.conductivity = ecValue;
    sensorData.salinity = ecValue * EC_TO_SALINITY_FACTOR;
  }

  Serial.println();
  Serial.println("---------- 結果 ----------");

  if (sensorData.phValid) Serial.printf("pH:%.2f\n", sensorData.ph);
  else Serial.println("pH:無有效資料");

  if (sensorData.dissolvedOxygenValid) {
    Serial.printf("溶氧 DO:%.2f mg/L\n", sensorData.dissolvedOxygen);
  } else {
    Serial.println("溶氧 DO:無有效資料");
  }

  if (sensorData.conductivityValid) {
    Serial.printf("導電度 EC:%.0f uS/cm\n", sensorData.conductivity);
    Serial.printf("換算鹽度:%.2f ppt\n", sensorData.salinity);
  } else {
    Serial.println("導電度 EC:無有效資料");
  }

  Serial.println("==========================");
}

String jsonNumberOrNull(float value, bool valid, unsigned int decimals) {
  if (!valid || isnan(value)) return "null";
  return String((double)value, decimals);
}

String escapeJsonString(const String& input) {
  String output;
  output.reserve(input.length() + 8);

  for (size_t i = 0; i < input.length(); i++) {
    char c = input.charAt(i);
    switch (c) {
      case '"': output += "\\\""; break;
      case '\\': output += "\\\\"; break;
      case '\n': output += "\\n"; break;
      case '\r': output += "\\r"; break;
      case '\t': output += "\\t"; break;
      default: output += c; break;
    }
  }

  return output;
}

String buildPayload(const String& recordedAt) {
  String payload;
  payload.reserve(420);

  payload += "{";
  payload += "\"token\":\"" + escapeJsonString(API_TOKEN) + "\",";
  payload += "\"farm_name\":\"" + escapeJsonString(FARM_NAME) + "\",";
  payload += "\"pond_code\":\"" + escapeJsonString(POND_CODE) + "\",";

  payload += "\"water_temperature\":";
  payload += jsonNumberOrNull(sensorData.waterTemperature, sensorData.temperatureValid, 2);
  payload += ",";

  payload += "\"salinity\":";
  payload += jsonNumberOrNull(sensorData.salinity, sensorData.conductivityValid, 2);
  payload += ",";

  payload += "\"ph\":";
  payload += jsonNumberOrNull(sensorData.ph, sensorData.phValid, 2);
  payload += ",";

  payload += "\"dissolved_oxygen\":";
  payload += jsonNumberOrNull(sensorData.dissolvedOxygen, sensorData.dissolvedOxygenValid, 2);
  payload += ",";

  payload += "\"water_source\":\"" + escapeJsonString(WATER_SOURCE) + "\",";
  payload += "\"recorded_at\":\"" + recordedAt + "\"";
  payload += "}";

  return payload;
}

void uploadToDjango() {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[API] Wi-Fi 未連線,本次不上傳");
    return;
  }

  String recordedAt = getRecordedAt();
  if (recordedAt.length() == 0) {
    Serial.println("[API] 尚未取得 NTP 時間,本次不上傳");
    return;
  }

  String payload = buildPayload(recordedAt);

  NetworkClientSecure secureClient;
  secureClient.setInsecure(); // 測試階段;正式部署建議改用 CA 憑證

  HTTPClient http;
  http.setTimeout(15000);

  Serial.println();
  Serial.println("========== Django API 上傳 ==========");
  Serial.print("[API] URL:");
  Serial.println(API_URL);
  Serial.print("[API] Payload:");
  Serial.println(payload);

  if (!http.begin(secureClient, API_URL)) {
    Serial.println("[API] HTTPS 初始化失敗");
    return;
  }

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

  int httpCode = http.POST(payload);
  Serial.print("[API] HTTP 狀態碼:");
  Serial.println(httpCode);

  if (httpCode > 0) {
    String response = http.getString();
    Serial.print("[API] 回應:");
    Serial.println(response);

    if (httpCode >= 200 && httpCode < 300) {
      Serial.println("[API] 上傳成功");
    } else {
      Serial.println("[API] 上傳失敗,請檢查 token、欄位或資料格式");
    }
  } else {
    Serial.print("[API] 連線錯誤:");
    Serial.println(http.errorToString(httpCode));
  }

  http.end();
  Serial.println("====================================");
}

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

  Serial.println();
  Serial.println("Atlas Aquaponics V1.8 + Django API 啟動");
  Serial.printf("SDA=GPIO%u, SCL=GPIO%u\n", I2C_SDA, I2C_SCL);
  Serial.printf("EC 位址=%u (0x%02X)\n", ADDR_EC, ADDR_EC);

  enableCircuits();

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000);
  Wire.setTimeOut(1000);

  connectWiFi();
  if (WiFi.status() == WL_CONNECTED) initializeTime();

  readSensors();

  lastSensorTime = millis();
  lastUploadTime = millis();
}

void loop() {
  maintainWiFi();

  unsigned long now = millis();

  if (now - lastSensorTime >= SENSOR_INTERVAL_MS) {
    lastSensorTime = now;
    readSensors();
  }

  if (now - lastUploadTime >= UPLOAD_INTERVAL_MS) {
    lastUploadTime = now;
    uploadToDjango();
  }

  delay(10);
}