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

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