一、為什麼不能只把 SSID 與密碼寫在程式裡?
最初程式採用固定設定:
const char* WIFI_SSID = "Home_WiFi";
const char* WIFI_PASSWORD = "12345678";
這種方法雖然簡單,但會帶來三個限制:
- 設備移到新場域時,必須重新燒錄程式。
- Wi-Fi 密碼變更後,現場人員無法自行設定。
- 同一份程式很難快速部署到不同魚塭或養殖戶。
因此,較適合現場部署的方式是:
二、Wi-Fi 設定儲存在哪裡?
ESP32 的 SSID 與密碼不是存入傳統 EEPROM,而是使用 Preferences 寫入內建 Flash 的 NVS(Non-Volatile Storage)。
| 項目 | 說明 |
|---|---|
| 儲存位置 | ESP32 內建 Flash 的 NVS 分區 |
| 斷電後是否保留 | 會保留 |
| 重新啟動後是否保留 | 會保留 |
| 適合資料 | SSID、密碼、場域編號、裝置設定等少量 Key/Value 資料 |
三、需要使用的函式庫
#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 += "&"; break;
case '<': result += "<"; break;
case '>': result += ">"; break;
case '"': result += """; break;
case '\'': result += "'"; 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 模式。
四、開機後的完整流程
- ESP32 開機,先從 NVS 讀取 SSID 與密碼。
- 若有帳密,使用 Station 模式嘗試連線。
- 20 秒內連線成功,開始 NTP 校時、感測器讀值與 Django API 上傳。
- 若連線失敗,切換成 AP+Station 模式。
- 建立熱點 Shuijing-Setup-XXXXXX。
- 手機連線後開啟 http://192.168.4.1。
- 輸入 SSID 與密碼,寫入 NVS。
- ESP32 自動重新啟動,重新讀取 NVS 並連線。
五、從 NVS 讀取與寫入 Wi-Fi 設定
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, 序列監控會顯示失敗,避免重新啟動後才發現資料不存在。
六、建立 AP 設定熱點與手機頁面
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 開啟。
七、手機設定步驟
- ESP32 無法連上原有 Wi-Fi,會自動啟動 AP 模式。
- 手機 Wi-Fi 選擇 Shuijing-Setup-XXXXXX。
- 輸入 AP 密碼 shuijing123。
- 開啟瀏覽器並輸入 http://192.168.4.1。
- 選擇現場 Wi-Fi SSID,輸入密碼。
- 按下「儲存並重新連線」。
- 手機顯示設定完成,ESP32 自動重新啟動。
- 重新啟動後從 NVS 讀回帳密並連線。
八、Wi-Fi 連線函式
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
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();
}
正確順序是:
十、實作中遇到的問題與修正
| 問題 | 原因 | 修正方式 |
|---|---|---|
| 按下儲存後手機沒有反應 | 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 上傳。
後續還可延伸:
- 在設定頁加入 farm_name、pond_code 與 API token。
- 加入長按按鍵清除 NVS 設定。
- 加入 LED 顯示 AP、連線中、已連線與上傳成功狀態。
- 加入 OTA 遠端更新。
- 加入多組 Wi-Fi 帳密備援。
結語
透過 Soft-AP、WebServer、DNSServer 與 Preferences,ESP32 可以在沒有螢幕與鍵盤的情況下, 由手機完成 Wi-Fi 設定。NVS 則確保 SSID 與密碼在重新啟動與斷電後仍能保留。 這套流程特別適合智慧養殖、智慧農業、社區 IoT 與其他需要現場部署的設備。
