2026年8月2日 星期日

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


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

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

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

最初程式採用固定設定:

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

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

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

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

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

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

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

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

三、需要使用的函式庫

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  delay(2000);
}

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

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

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

  return result;
}

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

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

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

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

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

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

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

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

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

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

  preferences.end();

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  preferences.end();

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

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

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

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

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

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

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

  return ssidOK && passwordOK;
}

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

  wifiSsid = "";
  wifiPassword = "";
}

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

  String page;
  page.reserve(7000);

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

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

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

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

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

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

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

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

  WiFi.scanDelete();
  return page;
}


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

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

  page += htmlEscape(ssid);

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

  return page;
}


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

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

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

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

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

  newSsid.trim();

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

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

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

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

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

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

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

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

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

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

void handleConfigClear() {
  clearWiFiCredentials();

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

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

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

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

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

  String apName = buildApName();

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

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

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

  delay(300);

  IPAddress apIp = WiFi.softAPIP();

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

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

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

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

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

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

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

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

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

  configServer.onNotFound(
    handleCaptivePortalRedirect
  );

  configServer.begin();
  configPortalActive = true;

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

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

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

  configPortalActive = false;
}

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

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

  stopConfigPortal();

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

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

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

  unsigned long startedAt = millis();

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

  Serial.println();

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("[WiFi] 連線成功");
    Serial.print("[WiFi] IP:");
    Serial.println(WiFi.localIP());
    Serial.print("[WiFi] RSSI:");
    Serial.print(WiFi.RSSI());
    Serial.println(" dBm");
    return true;
  }

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

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

    return;
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

  RTD.send_read_cmd();
  delay(READ_DELAY_MS);

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

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

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

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

  delay(350);

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

  delay(READ_DELAY_MS);

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

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

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

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

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

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

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

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

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

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

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

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

  return output;
}

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

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

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

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

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

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

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

  return payload;
}

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

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

  String payload = buildPayload(recordedAt);

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

  HTTPClient http;
  http.setTimeout(15000);

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

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

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

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

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

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

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

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

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

  enableCircuits();

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

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

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

  readSensors();

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

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

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

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

  maintainWiFi();

  unsigned long now = millis();

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

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

  delay(10);
}

其中:

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

四、開機後的完整流程

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

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

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

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

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

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

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

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

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

  preferences.end();

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  preferences.end();

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

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

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

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

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

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

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

  return ssidOK && passwordOK;
}

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

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

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

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

  String apName = buildApName();

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

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

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

  delay(300);

  IPAddress apIp = WiFi.softAPIP();

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

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

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

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

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

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

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

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

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

  configServer.onNotFound(
    handleCaptivePortalRedirect
  );

  configServer.begin();
  configPortalActive = true;

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

AP 模式的重要設定包括:

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

七、手機設定步驟

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

八、Wi-Fi 連線函式

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

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

  stopConfigPortal();

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

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

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

  unsigned long startedAt = millis();

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

  Serial.println();

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("[WiFi] 連線成功");
    Serial.print("[WiFi] IP:");
    Serial.println(WiFi.localIP());
    Serial.print("[WiFi] RSSI:");
    Serial.print(WiFi.RSSI());
    Serial.println(" dBm");
    return true;
  }

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

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

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

程式碼 5:setup() 的正確順序
void setup() {
  Serial.begin(115200);
  delay(1500);

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

  enableCircuits();

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

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

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

  readSensors();

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

正確順序是:

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

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

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

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

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

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

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

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

後續還可延伸:

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

結語

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

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

沒有留言:

張貼留言