2026年8月2日 星期日

[水井村USR]ESP32 Wi-Fi 設定流程實作:連線失敗自動進入 AP 模式,手機設定後寫入 NVS 並重新連線


Atlas Aquaponics V1.8 × Adafruit HUZZAH32 ESP32 Feather × Wi-Fi Config Portal × Preferences(NVS)× Django REST API
在智慧養殖 IoT 設備中,若 Wi-Fi 帳密直接寫死在程式裡,每次更換路由器或部署到不同養殖場,都必須重新修改程式並燒錄。本文記錄如何讓 ESP32 在開機時先讀取已儲存的 Wi-Fi 設定;若連線失敗,便自動建立 AP 熱點與手機設定頁,讓使用者輸入 SSID 與密碼,寫入 ESP32 內建 Flash 的 NVS,再自動重新啟動並使用新帳密連線。
ESP32 開機 從 NVS 讀取 SSID/密碼 嘗試連線 失敗啟動 AP 手機輸入設定 寫入 Flash ESP32 重啟 使用新帳密連線

圖:ESP32 連線失敗後進入 AP 模式、手機設定、NVS 儲存與重新連線流程。

一、為什麼不能只把 SSID 與密碼寫在程式裡?

最初程式採用固定設定:

固定帳密寫法
const char* WIFI_SSID = "Home_WiFi";
const char* WIFI_PASSWORD = "12345678";

這種方法雖然簡單,但會帶來三個限制:

  1. 設備移到新場域時,必須重新燒錄程式。
  2. Wi-Fi 密碼變更後,現場人員無法自行設定。
  3. 同一份程式很難快速部署到不同魚塭或養殖戶。

因此,較適合現場部署的方式是:

開機時先讀取已儲存的 SSID 與密碼;連不上時自動建立設定熱點,讓手機完成設定。

二、Wi-Fi 設定儲存在哪裡?

ESP32 的 SSID 與密碼不是存入傳統 EEPROM,而是使用 Preferences 寫入內建 Flash 的 NVS(Non-Volatile Storage)

項目 說明
儲存位置 ESP32 內建 Flash 的 NVS 分區
斷電後是否保留 會保留
重新啟動後是否保留 會保留
適合資料 SSID、密碼、場域編號、裝置設定等少量 Key/Value 資料

三、需要使用的函式庫

程式碼 1:AP、Web Server、DNS 與 NVS
#include <Wire.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <NetworkClientSecure.h>
#include <Preferences.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <time.h>

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

// Wi-Fi 帳密由手機設定頁寫入 Preferences,不再固定寫死於程式。
String wifiSsid;
String wifiPassword;

// 首次安裝時若尚未儲存帳密,也可在此提供預設備援值;
// 不需要預設值時請維持空字串。
const char* DEFAULT_WIFI_SSID = "";
const char* DEFAULT_WIFI_PASSWORD = "";

// AP 設定熱點
const char* CONFIG_AP_PREFIX = "Shuijing-Setup";
const char* CONFIG_AP_PASSWORD = "shuijing123";

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;

Preferences preferences;
WebServer configServer(80);
DNSServer dnsServer;

bool configPortalActive = false;
bool restartScheduled = false;
unsigned long restartAt = 0;

// 儲存成功後,先導向 /done,再由完成頁安排重新啟動。
String pendingSavedSsid;

constexpr uint16_t DNS_PORT = 53;
constexpr unsigned long WIFI_CONNECT_TIMEOUT_MS = 20000;

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);
}

String htmlEscape(const String& value) {
  String result;
  result.reserve(value.length() + 8);

  for (size_t i = 0; i < value.length(); i++) {
    char c = value.charAt(i);

    switch (c) {
      case '&': result += "&amp;"; break;
      case '<': result += "&lt;"; break;
      case '>': result += "&gt;"; break;
      case '"': result += "&quot;"; break;
      case '\'': result += "&#39;"; break;
      default: result += c; break;
    }
  }

  return result;
}

String buildApName() {
  uint64_t chipId = ESP.getEfuseMac();
  char suffix[7];

  snprintf(
    suffix,
    sizeof(suffix),
    "%06llX",
    static_cast<unsigned long long>(chipId & 0xFFFFFFULL)
  );

  return String(CONFIG_AP_PREFIX) + "-" + suffix;
}

void loadWiFiCredentials() {
  bool opened = preferences.begin(
    "wifi-config",
    true
  );

  if (!opened) {
    Serial.println("[NVS] 無法開啟 wifi-config namespace");
    wifiSsid = "";
    wifiPassword = "";
    return;
  }

  bool hasSsid = preferences.isKey("ssid");
  bool hasPassword = preferences.isKey("password");

  wifiSsid = preferences.getString(
    "ssid",
    DEFAULT_WIFI_SSID
  );

  wifiPassword = preferences.getString(
    "password",
    DEFAULT_WIFI_PASSWORD
  );

  size_t ssidLength =
    preferences.getStringLength("ssid");

  size_t passwordLength =
    preferences.getStringLength("password");

  preferences.end();

  Serial.println();
  Serial.println("========== NVS Wi-Fi 設定 ==========");
  Serial.print("[NVS] SSID key:");
  Serial.println(hasSsid ? "存在" : "不存在");

  Serial.print("[NVS] Password key:");
  Serial.println(hasPassword ? "存在" : "不存在");

  Serial.print("[NVS] SSID 儲存長度:");
  Serial.println(ssidLength);

  Serial.print("[NVS] 密碼儲存長度:");
  Serial.println(passwordLength);

  Serial.print("[WiFi] 已儲存 SSID:");
  Serial.println(
    wifiSsid.length() > 0
      ? wifiSsid
      : "尚未設定"
  );

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

bool saveWiFiCredentials(
  const String& ssid,
  const String& password
) {
  if (ssid.length() == 0 || ssid.length() > 32) {
    Serial.println("[NVS] SSID 長度不合法");
    return false;
  }

  if (password.length() > 63) {
    Serial.println("[NVS] 密碼長度不合法");
    return false;
  }

  bool opened = preferences.begin(
    "wifi-config",
    false
  );

  if (!opened) {
    Serial.println("[NVS] 無法以讀寫模式開啟 namespace");
    return false;
  }

  size_t ssidResult =
    preferences.putString("ssid", ssid);

  size_t passwordResult =
    preferences.putString("password", password);

  // 寫入後立即從同一 namespace 回讀確認
  String verifySsid =
    preferences.getString("ssid", "");

  String verifyPassword =
    preferences.getString("password", "");

  preferences.end();

  bool ssidOK =
    ssidResult > 0 &&
    verifySsid == ssid;

  bool passwordOK =
    password.length() == 0
      ? verifyPassword.length() == 0
      : (
          passwordResult > 0 &&
          verifyPassword == password
        );

  Serial.println();
  Serial.println("========== NVS 寫入結果 ==========");
  Serial.print("[NVS] SSID 寫入 bytes:");
  Serial.println(ssidResult);

  Serial.print("[NVS] 密碼寫入 bytes:");
  Serial.println(passwordResult);

  Serial.print("[NVS] SSID 回讀:");
  Serial.println(
    ssidOK ? "成功" : "失敗"
  );

  Serial.print("[NVS] 密碼回讀:");
  Serial.println(
    passwordOK ? "成功" : "失敗"
  );

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

  return ssidOK && passwordOK;
}

void clearWiFiCredentials() {
  preferences.begin("wifi-config", false);
  preferences.clear();
  preferences.end();

  wifiSsid = "";
  wifiPassword = "";
}

String buildConfigPage(
  const String& message = "",
  bool success = false
) {
  int networkCount = WiFi.scanNetworks();

  String page;
  page.reserve(7000);

  page += F(
    "<!DOCTYPE html><html lang='zh-Hant'><head>"
    "<meta charset='utf-8'>"
    "<meta name='viewport' content='width=device-width,initial-scale=1'>"
    "<title>水井 USR Wi-Fi 設定</title>"
    "<style>"
    "body{margin:0;background:#eef5f3;color:#263238;"
    "font-family:Arial,'Microsoft JhengHei',sans-serif}"
    ".box{max-width:560px;margin:24px auto;padding:24px;"
    "background:#fff;border-radius:16px;"
    "box-shadow:0 8px 28px #0002}"
    "h1{margin-top:0;color:#176b5b;font-size:1.6rem}"
    "label{display:block;margin-top:16px;font-weight:700}"
    "input,select{width:100%;padding:12px;margin-top:6px;"
    "border:1px solid #bccbc7;border-radius:8px;"
    "font-size:16px;box-sizing:border-box}"
    "button{width:100%;margin-top:20px;padding:13px;"
    "border:0;border-radius:8px;background:#176b5b;"
    "color:#fff;font-size:16px;font-weight:700}"
    ".msg{padding:12px;border-radius:8px;margin-bottom:16px;"
  );

  page += success
    ? "background:#e8f7ef;color:#176b5b}"
    : "background:#fff3e4;color:#9a5a00}";

  page += F(
    ".small{color:#60706c;font-size:.9rem;line-height:1.65}"
    ".danger{background:#a33;margin-top:10px}"
    "</style></head><body><main class='box'>"
    "<h1>水井 USR 智慧養殖 Wi-Fi 設定</h1>"
  );

  if (message.length() > 0) {
    page += "<div class='msg'>";
    page += htmlEscape(message);
    page += "</div>";
  }

  page += F(
    "<p class='small'>請選擇現場的 2.4 GHz Wi-Fi,"
    "輸入密碼後儲存。ESP32 會重新啟動並嘗試連線。</p>"
    "<form method='GET' action='http://192.168.4.1/save'>"
    "<label for='ssid'>Wi-Fi SSID</label>"
    "<select id='ssidSelect' onchange="
    "\"document.getElementById('ssid').value=this.value\">"
    "<option value=''>請選擇掃描到的網路</option>"
  );

  if (networkCount > 0) {
    for (int i = 0; i < networkCount; i++) {
      String foundSsid = WiFi.SSID(i);

      page += "<option value='";
      page += htmlEscape(foundSsid);
      page += "'>";
      page += htmlEscape(foundSsid);
      page += "(";
      page += String(WiFi.RSSI(i));
      page += " dBm)</option>";
    }
  }

  page += F(
    "</select>"
    "<input id='ssid' name='ssid' maxlength='32' "
    "placeholder='也可手動輸入 SSID' required>"
    "<label for='password'>Wi-Fi 密碼</label>"
    "<input id='password' name='password' type='password' "
    "maxlength='63' placeholder='開放式 Wi-Fi 可留空'>"
    "<button type='submit'>儲存並重新連線</button>"
    "<p class='small'>按下後會直接送到 "
    "<b>http://192.168.4.1/save</b>。"
    "<br>若內建設定視窗無反應,請改用 Chrome/Safari "
    "開啟 192.168.4.1。</p>"
    "</form>"
    "<form method='GET' action='http://192.168.4.1/clear'>"
    "<button class='danger' type='submit'>清除已儲存設定</button>"
    "</form>"
    "<p class='small'>設定頁位址:192.168.4.1<br>"
    "AP 密碼:shuijing123</p>"
    "</main></body></html>"
  );

  WiFi.scanDelete();
  return page;
}


String buildSaveSuccessPage(const String& ssid) {
  String page;
  page.reserve(3500);

  page += F(
    "<!DOCTYPE html><html lang='zh-Hant'><head>"
    "<meta charset='utf-8'>"
    "<meta name='viewport' content='width=device-width,initial-scale=1'>"
    "<title>Wi-Fi 設定完成</title>"
    "<style>"
    "body{margin:0;background:#eef5f3;color:#263238;"
    "font-family:Arial,'Microsoft JhengHei',sans-serif}"
    ".box{max-width:520px;margin:28px auto;padding:28px;"
    "background:#fff;border-radius:18px;text-align:center;"
    "box-shadow:0 8px 28px #0002}"
    ".ok{width:72px;height:72px;margin:0 auto 18px;"
    "border-radius:50%;background:#e5f6ed;color:#1d7a50;"
    "display:flex;align-items:center;justify-content:center;"
    "font-size:42px;font-weight:700}"
    "h1{margin:0 0 14px;color:#176b5b;font-size:1.65rem}"
    ".ssid{display:inline-block;padding:6px 12px;"
    "border-radius:8px;background:#edf5f2;font-weight:700}"
    ".count{font-size:2rem;color:#176b5b;font-weight:700}"
    ".small{color:#60706c;line-height:1.7}"
    "</style>"
    "<script>"
    "let n=10;"
    "function tick(){"
    "document.getElementById('count').textContent=n;"
    "if(n>0){n--;setTimeout(tick,1000);}"
    "else{document.getElementById('status').textContent="
    "'ESP32 已重新啟動,請將手機連回原本的 Wi-Fi。';}"
    "}"
    "window.addEventListener('load',tick);"
    "</script>"
    "</head><body><main class='box'>"
    "<div class='ok'>✓</div>"
    "<h1>Wi-Fi 設定已完成</h1>"
    "<p>已儲存 Wi-Fi:</p><p class='ssid'>"
  );

  page += htmlEscape(ssid);

  page += F(
    "</p>"
    "<p id='status'>ESP32 將在 "
    "<span id='count' class='count'>10</span> 秒後自動重新啟動,"
    "並使用新帳密連線。</p>"
    "<p class='small'>重新啟動後,手機可能會自動離開設定熱點。"
    "請稍候約 10~30 秒,再確認智慧養殖設備是否已連線。"
    "<br>此頁不需要再按任何按鈕。"
    "<br><br>若手機沒有自動跳轉,可手動開啟:"
    "<a href='/done'>http://192.168.4.1/done</a></p>"
    "</main></body></html>"
  );

  return page;
}


void handleConfigTest() {
  Serial.println("[AP] 收到 /test 測試請求");

  configServer.send(
    200,
    "text/plain; charset=utf-8",
    "ESP32 WebServer 正常"
  );
}

void handleConfigRoot() {
  configServer.send(
    200,
    "text/html; charset=utf-8",
    buildConfigPage()
  );
}

void handleConfigSave() {
  Serial.println();
  Serial.println("[AP] 收到 /save 設定請求");
  Serial.print("[AP] HTTP method:");
  Serial.println(
    configServer.method() == HTTP_GET ? "GET" : "OTHER"
  );
  Serial.print("[AP] 參數數量:");
  Serial.println(configServer.args());

  String newSsid = configServer.arg("ssid");
  String newPassword = configServer.arg("password");

  newSsid.trim();

  Serial.print("[AP] 收到 SSID:");
  Serial.println(newSsid);
  Serial.print("[AP] 密碼長度:");
  Serial.println(newPassword.length());

  if (!saveWiFiCredentials(newSsid, newPassword)) {
    configServer.send(
      400,
      "text/html; charset=utf-8",
      buildConfigPage(
        "儲存失敗,請確認 SSID 與密碼長度。",
        false
      )
    );
    return;
  }

  wifiSsid = newSsid;
  wifiPassword = newPassword;
  pendingSavedSsid = newSsid;

  Serial.print("[AP] Wi-Fi 設定已儲存:");
  Serial.println(newSsid);
  
  String responsePage =
    "<!DOCTYPE html><html lang='zh-Hant'><head>"
    "<meta charset='utf-8'>"
    "<meta name='viewport' content='width=device-width,initial-scale=1'>"
    "<title>設定完成</title></head>"
    "<body style='font-family:Arial,sans-serif;text-align:center;"
    "padding:40px;background:#eef5f3'>"
    "<h1 style='color:#176b5b'>Wi-Fi 設定已完成</h1>"
    "<p>ESP32 將在 5 秒後重新啟動。</p>"
    "<p>請稍後將手機連回原本的 Wi-Fi。</p>"
    "</body></html>";

  configServer.send(
    200,
    "text/html; charset=utf-8",
    responsePage
  );

  restartScheduled = true;
  restartAt = millis() + 5000;

  Serial.println("[AP] 設定已寫入,5 秒後重新啟動");
}

void handleConfigDone() {
  String displaySsid =
    pendingSavedSsid.length() > 0
      ? pendingSavedSsid
      : wifiSsid;

  configServer.sendHeader(
    "Cache-Control",
    "no-store, no-cache, must-revalidate"
  );

  configServer.send(
    200,
    "text/html; charset=utf-8",
    buildSaveSuccessPage(displaySsid)
  );
}

void handleConfigClear() {
  clearWiFiCredentials();

  configServer.send(
    200,
    "text/html; charset=utf-8",
    buildConfigPage(
      "已清除 Wi-Fi 設定,ESP32 將重新啟動。",
      true
    )
  );

  restartScheduled = true;
  restartAt = millis() + 6000;
}

void handleCaptivePortalRedirect() {
  configServer.sendHeader(
    "Location",
    String("http://") +
      WiFi.softAPIP().toString() +
      "/",
    true
  );

  configServer.send(
    302,
    "text/plain",
    ""
  );
}

void startConfigPortal() {
  if (configPortalActive) {
    return;
  }

  String apName = buildApName();

  WiFi.disconnect(true, false);
  delay(200);
  WiFi.mode(WIFI_AP_STA);

  bool apStarted = WiFi.softAP(
    apName.c_str(),
    CONFIG_AP_PASSWORD
  );

  if (!apStarted) {
    Serial.println("[AP] 啟動失敗");
    return;
  }

  delay(300);

  IPAddress apIp = WiFi.softAPIP();

  dnsServer.start(
    DNS_PORT,
    "*",
    apIp
  );

  configServer.on(
    "/",
    HTTP_GET,
    handleConfigRoot
  );

  configServer.on(
    "/test",
    HTTP_ANY,
    handleConfigTest
  );

  configServer.on(
    "/save",
    HTTP_ANY,
    handleConfigSave
  );

  configServer.on(
    "/done",
    HTTP_GET,
    handleConfigDone
  );

  configServer.on(
    "/clear",
    HTTP_ANY,
    handleConfigClear
  );

  // 常見 captive portal 偵測路徑
  configServer.on(
    "/generate_204",
    HTTP_ANY,
    handleCaptivePortalRedirect
  );

  configServer.on(
    "/hotspot-detect.html",
    HTTP_ANY,
    handleCaptivePortalRedirect
  );

  configServer.on(
    "/connecttest.txt",
    HTTP_ANY,
    handleCaptivePortalRedirect
  );

  configServer.onNotFound(
    handleCaptivePortalRedirect
  );

  configServer.begin();
  configPortalActive = true;

  Serial.println();
  Serial.println("========== Wi-Fi AP 設定模式 ==========");
  Serial.print("[AP] 熱點名稱:");
  Serial.println(apName);
  Serial.print("[AP] 熱點密碼:");
  Serial.println(CONFIG_AP_PASSWORD);
  Serial.print("[AP] 設定網址:http://");
  Serial.println(apIp);
  Serial.println("請用手機連上熱點後開啟設定頁。");
  Serial.println("======================================");
}

void stopConfigPortal() {
  if (!configPortalActive) {
    return;
  }

  configServer.stop();
  dnsServer.stop();
  WiFi.softAPdisconnect(true);

  configPortalActive = false;
}

bool connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) {
    return true;
  }

  if (wifiSsid.length() == 0) {
    Serial.println("[WiFi] 尚未儲存 SSID");
    return false;
  }

  stopConfigPortal();

  Serial.println();
  Serial.print("[WiFi] 嘗試連線:");
  Serial.println(wifiSsid);

  WiFi.mode(WIFI_STA);
  WiFi.setAutoReconnect(true);
  WiFi.persistent(false);

  WiFi.begin(
    wifiSsid.c_str(),
    wifiPassword.c_str()
  );

  unsigned long startedAt = millis();

  while (
    WiFi.status() != WL_CONNECTED &&
    millis() - startedAt < WIFI_CONNECT_TIMEOUT_MS
  ) {
    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");
    return true;
  }

  Serial.println("[WiFi] 連線失敗");
  WiFi.disconnect(true, false);
  return false;
}

void maintainWiFi() {
  if (configPortalActive) {
    dnsServer.processNextRequest();
    configServer.handleClient();

    return;
  }

  if (WiFi.status() == WL_CONNECTED) {
    return;
  }

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

    if (!connectWiFi()) {
      startConfigPortal();
    }
  }
}

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);

  // 重新啟動後先從 NVS Flash 讀回 SSID 與密碼
  loadWiFiCredentials();

  if (connectWiFi()) {
    initializeTime();
  } else {
    startConfigPortal();
  }

  readSensors();

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

void loop() {
  if (
    restartScheduled &&
    static_cast<long>(millis() - restartAt) >= 0
  ) {
    Serial.println("[SYSTEM] Wi-Fi 設定完成,ESP32 重新啟動");

    if (configPortalActive) {
      configServer.stop();
      dnsServer.stop();
      WiFi.softAPdisconnect(true);
    }

    delay(500);
    ESP.restart();
  }

  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);
}

其中:

  • Preferences.h:將 Wi-Fi 設定寫入 NVS。
  • WebServer.h:建立手機可開啟的設定頁。
  • DNSServer.h:將手機的網址請求導向 ESP32 設定頁。
  • WiFi.h:控制 Station 與 Soft-AP 模式。

四、開機後的完整流程

20 秒嘗試連線時間
192.168.4.1AP 設定頁
NVS帳密永久儲存
自動重啟套用新設定
  1. ESP32 開機,先從 NVS 讀取 SSID 與密碼。
  2. 若有帳密,使用 Station 模式嘗試連線。
  3. 20 秒內連線成功,開始 NTP 校時、感測器讀值與 Django API 上傳。
  4. 若連線失敗,切換成 AP+Station 模式。
  5. 建立熱點 Shuijing-Setup-XXXXXX
  6. 手機連線後開啟 http://192.168.4.1
  7. 輸入 SSID 與密碼,寫入 NVS。
  8. ESP32 自動重新啟動,重新讀取 NVS 並連線。

五、從 NVS 讀取與寫入 Wi-Fi 設定

程式碼 2:讀取與寫入 NVS
void loadWiFiCredentials() {
  bool opened = preferences.begin(
    "wifi-config",
    true
  );

  if (!opened) {
    Serial.println("[NVS] 無法開啟 wifi-config namespace");
    wifiSsid = "";
    wifiPassword = "";
    return;
  }

  bool hasSsid = preferences.isKey("ssid");
  bool hasPassword = preferences.isKey("password");

  wifiSsid = preferences.getString(
    "ssid",
    DEFAULT_WIFI_SSID
  );

  wifiPassword = preferences.getString(
    "password",
    DEFAULT_WIFI_PASSWORD
  );

  size_t ssidLength =
    preferences.getStringLength("ssid");

  size_t passwordLength =
    preferences.getStringLength("password");

  preferences.end();

  Serial.println();
  Serial.println("========== NVS Wi-Fi 設定 ==========");
  Serial.print("[NVS] SSID key:");
  Serial.println(hasSsid ? "存在" : "不存在");

  Serial.print("[NVS] Password key:");
  Serial.println(hasPassword ? "存在" : "不存在");

  Serial.print("[NVS] SSID 儲存長度:");
  Serial.println(ssidLength);

  Serial.print("[NVS] 密碼儲存長度:");
  Serial.println(passwordLength);

  Serial.print("[WiFi] 已儲存 SSID:");
  Serial.println(
    wifiSsid.length() > 0
      ? wifiSsid
      : "尚未設定"
  );

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

bool saveWiFiCredentials(
  const String& ssid,
  const String& password
) {
  if (ssid.length() == 0 || ssid.length() > 32) {
    Serial.println("[NVS] SSID 長度不合法");
    return false;
  }

  if (password.length() > 63) {
    Serial.println("[NVS] 密碼長度不合法");
    return false;
  }

  bool opened = preferences.begin(
    "wifi-config",
    false
  );

  if (!opened) {
    Serial.println("[NVS] 無法以讀寫模式開啟 namespace");
    return false;
  }

  size_t ssidResult =
    preferences.putString("ssid", ssid);

  size_t passwordResult =
    preferences.putString("password", password);

  // 寫入後立即從同一 namespace 回讀確認
  String verifySsid =
    preferences.getString("ssid", "");

  String verifyPassword =
    preferences.getString("password", "");

  preferences.end();

  bool ssidOK =
    ssidResult > 0 &&
    verifySsid == ssid;

  bool passwordOK =
    password.length() == 0
      ? verifyPassword.length() == 0
      : (
          passwordResult > 0 &&
          verifyPassword == password
        );

  Serial.println();
  Serial.println("========== NVS 寫入結果 ==========");
  Serial.print("[NVS] SSID 寫入 bytes:");
  Serial.println(ssidResult);

  Serial.print("[NVS] 密碼寫入 bytes:");
  Serial.println(passwordResult);

  Serial.print("[NVS] SSID 回讀:");
  Serial.println(
    ssidOK ? "成功" : "失敗"
  );

  Serial.print("[NVS] 密碼回讀:");
  Serial.println(
    passwordOK ? "成功" : "失敗"
  );

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

  return ssidOK && passwordOK;
}

這一版加入「寫入後立即回讀驗證」。若 SSID 或密碼沒有真正寫入 NVS, 序列監控會顯示失敗,避免重新啟動後才發現資料不存在。

重新啟動後一定要先呼叫 loadWiFiCredentials(), 否則 Flash 中雖然有資料,RAM 裡的 wifiSsid 仍會是空字串。

六、建立 AP 設定熱點與手機頁面

程式碼 3:啟動 AP Config Portal
void startConfigPortal() {
  if (configPortalActive) {
    return;
  }

  String apName = buildApName();

  WiFi.disconnect(true, false);
  delay(200);
  WiFi.mode(WIFI_AP_STA);

  bool apStarted = WiFi.softAP(
    apName.c_str(),
    CONFIG_AP_PASSWORD
  );

  if (!apStarted) {
    Serial.println("[AP] 啟動失敗");
    return;
  }

  delay(300);

  IPAddress apIp = WiFi.softAPIP();

  dnsServer.start(
    DNS_PORT,
    "*",
    apIp
  );

  configServer.on(
    "/",
    HTTP_GET,
    handleConfigRoot
  );

  configServer.on(
    "/test",
    HTTP_ANY,
    handleConfigTest
  );

  configServer.on(
    "/save",
    HTTP_ANY,
    handleConfigSave
  );

  configServer.on(
    "/done",
    HTTP_GET,
    handleConfigDone
  );

  configServer.on(
    "/clear",
    HTTP_ANY,
    handleConfigClear
  );

  // 常見 captive portal 偵測路徑
  configServer.on(
    "/generate_204",
    HTTP_ANY,
    handleCaptivePortalRedirect
  );

  configServer.on(
    "/hotspot-detect.html",
    HTTP_ANY,
    handleCaptivePortalRedirect
  );

  configServer.on(
    "/connecttest.txt",
    HTTP_ANY,
    handleCaptivePortalRedirect
  );

  configServer.onNotFound(
    handleCaptivePortalRedirect
  );

  configServer.begin();
  configPortalActive = true;

  Serial.println();
  Serial.println("========== Wi-Fi AP 設定模式 ==========");
  Serial.print("[AP] 熱點名稱:");
  Serial.println(apName);
  Serial.print("[AP] 熱點密碼:");
  Serial.println(CONFIG_AP_PASSWORD);
  Serial.print("[AP] 設定網址:http://");
  Serial.println(apIp);
  Serial.println("請用手機連上熱點後開啟設定頁。");
  Serial.println("======================================");
}

AP 模式的重要設定包括:

  • 熱點名稱:Shuijing-Setup-裝置碼
  • 預設 AP 密碼:shuijing123
  • 設定頁:http://192.168.4.1
  • 手機若沒有自動跳出頁面,可手動用 Chrome 或 Safari 開啟。

七、手機設定步驟

  1. ESP32 無法連上原有 Wi-Fi,會自動啟動 AP 模式。
  2. 手機 Wi-Fi 選擇 Shuijing-Setup-XXXXXX
  3. 輸入 AP 密碼 shuijing123
  4. 開啟瀏覽器並輸入 http://192.168.4.1
  5. 選擇現場 Wi-Fi SSID,輸入密碼。
  6. 按下「儲存並重新連線」。
  7. 手機顯示設定完成,ESP32 自動重新啟動。
  8. 重新啟動後從 NVS 讀回帳密並連線。

八、Wi-Fi 連線函式

程式碼 4:讀取帳密後嘗試連線
bool connectWiFi() {
  if (WiFi.status() == WL_CONNECTED) {
    return true;
  }

  if (wifiSsid.length() == 0) {
    Serial.println("[WiFi] 尚未儲存 SSID");
    return false;
  }

  stopConfigPortal();

  Serial.println();
  Serial.print("[WiFi] 嘗試連線:");
  Serial.println(wifiSsid);

  WiFi.mode(WIFI_STA);
  WiFi.setAutoReconnect(true);
  WiFi.persistent(false);

  WiFi.begin(
    wifiSsid.c_str(),
    wifiPassword.c_str()
  );

  unsigned long startedAt = millis();

  while (
    WiFi.status() != WL_CONNECTED &&
    millis() - startedAt < WIFI_CONNECT_TIMEOUT_MS
  ) {
    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");
    return true;
  }

  Serial.println("[WiFi] 連線失敗");
  WiFi.disconnect(true, false);
  return false;
}

若 NVS 沒有 SSID,程式不會一直卡在連線迴圈,而是直接進入 AP 模式。 若有 SSID 但 20 秒內仍失敗,也會啟動設定頁。

九、重新啟動後一定要重新讀取 NVS

程式碼 5:setup() 的正確順序
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);

  // 重新啟動後先從 NVS Flash 讀回 SSID 與密碼
  loadWiFiCredentials();

  if (connectWiFi()) {
    initializeTime();
  } else {
    startConfigPortal();
  }

  readSensors();

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

正確順序是:

初始化感測器 初始化 I²C loadWiFiCredentials() connectWiFi() 失敗則 startConfigPortal()

十、實作中遇到的問題與修正

問題 原因 修正方式
按下儲存後手機沒有反應 Captive Portal 對 POST 回應相容性不佳 改用 GET,並指定完整網址 http://192.168.4.1/save
ESP32 沒有重新啟動 重啟排程綁定完成頁是否成功開啟 改成儲存成功後直接排程重啟
重新啟動後顯示尚未儲存 SSID setup() 沒有呼叫 loadWiFiCredentials() 開機時先從 NVS 讀回設定
SSID 寫入後不確定是否成功 只寫入、未回讀驗證 putString() 後立即 getString() 比對
手機無法自動跳出設定頁 手機系統或瀏覽器不支援 Captive Portal 自動彈出 直接使用 Chrome/Safari 開啟 192.168.4.1

十一、序列監控應看到的訊息

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

十二、整合智慧養殖系統後的價值

完成 Wi-Fi Config Portal 後,Atlas Aquaponics Kit 就不再綁定單一網路。 同一套 ESP32 程式可以快速部署到不同養殖戶與魚塭,只要手機重新設定 Wi-Fi, 即可恢復 Django REST API 上傳。

這個設計讓系統從「實驗室原型」進一步提升為「可由現場人員自行設定的 IoT 設備」。

後續還可延伸:

  • 在設定頁加入 farm_name、pond_code 與 API token。
  • 加入長按按鍵清除 NVS 設定。
  • 加入 LED 顯示 AP、連線中、已連線與上傳成功狀態。
  • 加入 OTA 遠端更新。
  • 加入多組 Wi-Fi 帳密備援。

結語

透過 Soft-AP、WebServer、DNSServer 與 Preferences,ESP32 可以在沒有螢幕與鍵盤的情況下, 由手機完成 Wi-Fi 設定。NVS 則確保 SSID 與密碼在重新啟動與斷電後仍能保留。 這套流程特別適合智慧養殖、智慧農業、社區 IoT 與其他需要現場部署的設備。

AI 協作聲明: 本文由作者主導內容規劃,並使用生成式 AI 協助文字整理、程式碼說明、版面設計與步驟圖生成; 所述流程均依 Atlas Aquaponics V1.8、Adafruit HUZZAH32 ESP32 Feather 與實際 Wi-Fi AP 設定測試結果整理。

[水井村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);
}