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_name 與 pond_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 後端收到資料後,依序完成:
- 驗證 token。
- 確認 farm_name 與 pond_code。
- 檢查水溫、鹽度、pH、溶氧與時間格式。
- 將資料寫入資料庫。
- 回傳 JSON 與 HTTP 狀態。
- Dashboard 查詢各魚塭最新一筆資料並呈現。
實際整合後,Dashboard 已可顯示「湖虎戰隊」的水溫、鹽度、pH 與溶氧資料,
並與其他養殖場域並列,形成多養殖戶即時監控介面。
前往水井村 USR 智慧養殖監控平台
十一、實作中最重要的除錯經驗
- 先確認開發板:本案為 Adafruit HUZZAH32 ESP32 Feather,PSRAM 應設為 Disabled。
- 不要套用別款板子的腳位:實際 SDA/SCL 是 GPIO23/22。
- 先掃描 I²C:EC 實際位址為 0x69,而不是假設中的 0x64。
- 分層測試:先驗證硬體讀值,再加入 Wi-Fi,最後才加入 API。
- 保留伺服器回應:序列監控要印出 HTTP 狀態與 Response Body,才能快速定位錯誤。
- 感測值需校正:通訊成功不代表量測一定正確,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 整合,均依實際硬體測試結果整理。