Note: This tutorial builds on the previous two parts (STA mode, AP mode) and explains how to keep your ESP32's WiFi connection stable over the long term. The focus is on automatically starting AP mode and setting up a Web Configuration Portal when STA connection fails, allowing users to modify STA credentials via a phone/computer browser and reboot ESP32 with one click for self-recovery. Please complete the STA and AP mode tutorials first.
In the previous two projects, you've learned how to make ESP32 connect to a router (STA) or become a hotspot (AP). But in real-world scenarios, WiFi is not always "smooth sailing":
In demo scenarios, when disconnected, you just press the reset button, or modify code and re-upload. But in actual deployment scenarios, the device may be installed in ceilings, outdoor electrical boxes, or remote cabinets, making on-site operation impossible.
The most typical pain point: When a user changes their home router's WiFi password, the ESP32 can no longer connect. To make the device work again, the traditional approach is: remove the device → connect to computer → modify code → re-upload. This is almost infeasible for large-scale deployment.
This tutorial's solution: When ESP32 cannot connect, it automatically becomes a WiFi hotspot and starts a small web server. The user connects to this hotspot with their phone, opens 192.168.4.1 in the browser, directly enters the new WiFi credentials on the webpage, clicks "Save and Reboot", and ESP32 will reconnect with the new password. No computer needed, no coding needed, no program upload needed.
| Cause | Symptom | This Solution's Response |
|---|---|---|
| Router reboot | Brief disconnect, auto-recovers | Exponential backoff reconnection, auto-recovery |
| Weak signal | RSSI < -80 dBm, frequent disconnects | Reconnection + statistics alerting |
| Password changed | Can never connect | Web portal to modify password |
| DHCP failure | Associated but no IP obtained | Timeout then enter AP fallback |
| ESP32 abnormality | Program freeze or memory leak | Watchdog hard reboot |
| Mode | Role | Role in Stability Solution |
|---|---|---|
| STA | Client connecting to router | Primary working mode, tried first |
| AP | Acts as hotspot itself | Fallback mode, activated when STA fails, sets up Web Configuration Portal |
Core idea of this project: Prefer STA mode to connect to router; if multiple failures occur, automatically switch to AP mode, start the Web Configuration Portal, let user modify STA credentials via browser and reboot with one click, ESP32 reconnects with new credentials.
A Web Configuration Portal means ESP32 runs a small HTTP server in AP mode. Users access its IP address via browser, see a web form, enter new WiFi credentials in the form, and after submission, ESP32 saves the credentials to Non-Volatile Storage (NVS), then reboots and connects to the router with the new credentials.
NVS (Non-Volatile Storage) is a special flash storage area on ESP32 where data is not lost after power off. The Arduino framework provides access through the Preferences library.
Simple analogy: NVS is like ESP32's "little notebook" that can still be read after reboot. WiFi credentials are recorded in this notebook, so they can be read every time it boots, without needing to reconfigure each time.
NVS characteristics:
This project uses namespace wifi_creds and stores two keys:
ssid: WiFi namepass: WiFi passwordRepeatedly call WiFi.status() in loop() to query status. This is the approach used in the previous two projects, simple and intuitive.
Register a callback function that ESP32 firmware automatically calls when WiFi status changes.
WiFi.onEvent(onWiFiEvent); // Register callback
void onWiFiEvent(WiFiEvent_t event) {
if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) {
// Got IP, can start business logic
}
}
Advantages: Timely response, decoupled code, no CPU waste.
This project uses Event-Driven + State Machine + Web Portal combination, which is the mainstream approach for industrial-grade WiFi applications.
A State Machine is a programming model that breaks complex processes into "states + transitions". The system is in only one state at any time, and jumps to another state when specific events occur.
This project defines 6 states:
| State | Meaning | LED Behavior | Web Portal |
|---|---|---|---|
BOOT |
Just powered on | Off | - |
STA_TRYING |
Trying STA connection | Slow blink (500ms) | - |
STA_OK |
STA connected, working normally | Solid on | - |
STA_LOST |
STA disconnected, retrying | Fast blink (150ms) | - |
AP_FALLBACK |
STA failed, fell back to AP | Double-blink | Running |
FAIL |
AP also failed, waiting for watchdog reboot | Off | - |
| Technology | Purpose | Implementation |
|---|---|---|
| Event-driven callback | Real-time perception of WiFi status changes | WiFi.onEvent() |
| Non-blocking reconnect | Doesn't block main loop, can do other things | millis() timing |
| Exponential backoff | Avoid overwhelming router with frequent reconnects | reconnectInterval *= 2 |
| AP fallback + Web portal | Let user modify password via webpage when STA fails | startAPFallback() + WebServer |
| NVS persistence | Credentials survive power off, auto-read on reboot | Preferences library |
| One-click reboot | Remote reboot ESP32 via webpage button | ESP.restart() |
| Watchdog reboot | Hard reboot in extreme cases, self-healing | handleFail() |
| LED status indicator | See current status without computer | updateLED() |
| State statistics | Record reconnect counts etc. for diagnostics | reconnectAttempts etc. |
This project uses three libraries bundled with ESP32 board support, no additional installation needed:
| Library | Purpose |
|---|---|
WiFi.h |
WiFi connection, AP mode, event callbacks |
WebServer.h |
HTTP server, provides web configuration portal |
Preferences.h |
NVS non-volatile storage, saves WiFi credentials |
If you haven't installed ESP32 board support yet, please refer to the "Library Installation Guide" in Part 1 "WiFi Connection Tutorial".
| Function | Description |
|---|---|
WiFi.onEvent(callback) |
Register WiFi event callback function |
WiFi.setAutoReconnect(bool) |
Enable/disable underlying auto-reconnect (set to false in this project, manual management) |
| Event Constant | Trigger Timing |
|---|---|
ARDUINO_EVENT_WIFI_STA_START |
STA mode started |
ARDUINO_EVENT_WIFI_STA_CONNECTED |
STA associated with router (but no IP yet) |
ARDUINO_EVENT_WIFI_STA_GOT_IP |
STA obtained IP address (truly connected) |
ARDUINO_EVENT_WIFI_STA_DISCONNECTED |
STA disconnected |
ARDUINO_EVENT_WIFI_AP_START |
AP mode started |
Key point: Only when
GOT_IPevent is received is it truly connected.CONNECTEDonly means associated with router, may still be waiting for DHCP.
| Function | Description |
|---|---|
WebServer server(port) |
Create HTTP server object, specify port |
server.on(path, method, handler) |
Register route: path + method (GET/POST) + handler function |
server.begin() |
Start server, begin listening |
server.handleClient() |
Handle client requests (must be called repeatedly in loop) |
server.send(code, type, content) |
Send HTTP response |
server.arg("name") |
Get form-submitted field value |
server.stop() / server.close() |
Stop server |
server.onNotFound(handler) |
Register 404 handler function |
| Function | Description |
|---|---|
prefs.begin("namespace", readOnly) |
Open namespace, true read-only, false read-write |
prefs.putString("key", value) |
Store string |
prefs.getString("key", default) |
Read string, returns default if not exists |
prefs.end() |
Close namespace (must be called, otherwise data may not flush) |
| Function | Description |
|---|---|
WiFi.disconnect() |
Disconnect current connection (call before reconnect) |
WiFi.RSSI() |
Get signal strength (dBm) |
WiFi.softAPgetStationNum() |
Get connected client count in AP mode |
ESP.restart() |
Soft reboot ESP32 |
Reading Guide: This code is divided into 10 modules by function. Each module is responsible for one independent category of functionality, with code and explanation together. Recommended to read in order, but modules are relatively independent and can be skipped as needed.
Module dependency diagram:
Module 1 (Config) ──────► All modules reference parameters here Module 2 (State Machine) ─► Modules 5/6/7 driven by state Module 3 (Global Vars) ──► Shared by all modules Module 4 (NVS Storage) ──► Module 5 (load at boot), Module 8 (call on web save) Module 5 (WiFi Events) ──► Module 6 (trigger reconnect), Module 7 (trigger fallback) Module 6 (WiFi Reconnect) ─► Module 7 (enter fallback after failure) Module 7 (AP Fallback) ──► Module 8 (start web server) Module 8 (Web Portal) ───► Module 4 (save credentials to NVS) Module 9 (Diagnostics LED) ► Independent, only reads state variables Module 10 (Main Program) ─► Assembles all modules, drives state machine
Responsibility: Centralizes all user-adjustable parameters. Modify behavior by changing only here, without touching other modules.
// --- AP Fallback Hotspot (fixed, so users always know what to connect to) ---
const char* AP_SSID = "ESP32_Setup"; // Fallback AP name
const char* AP_PASSWORD = "12345678"; // Fallback AP password (>= 8 chars)
// --- Pin Definitions ---
#define LED_BUILTIN 2
// --- Timing Parameters (all in milliseconds) ---
const unsigned long CONNECT_TIMEOUT = 15000; // STA connect timeout
const unsigned long RECONNECT_MIN_MS = 5000; // Initial reconnect interval
const unsigned long RECONNECT_MAX_MS = 60000; // Max reconnect interval
const unsigned long STATUS_PRINT_MS = 5000; // Status print interval
const unsigned long WDT_TIMEOUT_MS = 300000; // Watchdog restart (5 min in FAIL)
const unsigned long AP_RETRY_STA_MS = 120000; // Retry STA from AP every 2 min
// --- Max reconnect attempts before falling back to AP ---
const int MAX_RECONNECT_ATTEMPTS = 6;
Code Explanation:
| Parameter | Meaning | Adjustment Suggestion |
|---|---|---|
AP_SSID / AP_PASSWORD |
Fallback hotspot info, hardcoded so users know which to connect | Change to recognizable name |
LED_BUILTIN |
LED pin number | May differ on different boards (GPIO0/4/33) |
CONNECT_TIMEOUT |
First STA connect timeout (15 sec) | Increase to 30 sec for weak signal |
RECONNECT_MIN_MS |
Min reconnect interval (5 sec) | Can reduce to 3 sec for faster response |
RECONNECT_MAX_MS |
Max reconnect interval (60 sec) | Can reduce to 30 sec for more aggressive |
WDT_TIMEOUT_MS |
Watchdog timeout (5 min) | Too short causes frequent reboots |
AP_RETRY_STA_MS |
STA retry period in AP mode (2 min) | Longer saves power, shorter recovers faster |
MAX_RECONNECT_ATTEMPTS |
How many failures before AP fallback | Larger is more persistent, smaller falls back faster |
Design point: AP hotspot info is hardcoded, so no matter how STA fails, users know to connect to
ESP32_Setup. STA credentials are stored in NVS and can be dynamically modified via webpage (see Module 4).
Responsibility: Defines all possible states and the current state variable, serving as the "skeleton" of the entire program.
enum WiFiState {
STATE_BOOT, // Just powered on
STATE_STA_TRYING, // Trying to connect in STA mode
STATE_STA_OK, // STA connected successfully
STATE_STA_LOST, // STA connection lost, trying to recover
STATE_AP_FALLBACK, // STA failed, fell back to AP mode (web portal active)
STATE_FAIL // Total failure (should not normally happen)
};
WiFiState state = STATE_BOOT;
// Convert state enum to readable string (for logging and web display)
String stateToString(WiFiState s) {
switch (s) {
case STATE_BOOT: return "BOOT";
case STATE_STA_TRYING: return "STA_TRYING";
case STATE_STA_OK: return "STA_OK";
case STATE_STA_LOST: return "STA_LOST";
case STATE_AP_FALLBACK: return "AP_FALLBACK";
case STATE_FAIL: return "FAIL";
default: return "UNKNOWN";
}
}
Code Explanation:
enum WiFiState: Defines all states using enum, far more readable than using numbers 0/1/2state: Global variable recording current state (declared in Module 3, defined here)stateToString(): Converts state to string for serial logging and web displayBeginner tip: Using names (like
STA_OK) instead of numbers (like2) for states makes code immediately clear about "what it's doing now".
Responsibility: Declares resources shared across all modules. These variables pass information between modules.
// --- Library Objects ---
WebServer server(80);
Preferences prefs;
// --- Stored Credentials (loaded from NVS at boot) ---
String staSSID = "";
String staPassword = "";
// --- Timing Variables ---
unsigned long lastReconnectAttempt = 0;
unsigned long reconnectInterval = RECONNECT_MIN_MS;
unsigned long lastStatusPrint = 0;
unsigned long stateEnterTime = 0;
unsigned long lastBlinkToggle = 0;
// --- Statistics (for diagnostics) ---
int reconnectAttempts = 0;
int successfulConnects = 0;
int disconnectCount = 0;
bool ledState = false;
Code Explanation:
| Variable | Type | Purpose | Using Modules |
|---|---|---|---|
server |
WebServer | HTTP server object, port 80 | Modules 7, 8 |
prefs |
Preferences | NVS storage object | Module 4 |
staSSID / staPassword |
String | STA credentials loaded from NVS | Modules 4, 5, 8 |
lastReconnectAttempt |
unsigned long | Last reconnect timestamp | Module 6 |
reconnectInterval |
unsigned long | Current reconnect interval (grows exponentially) | Module 6 |
stateEnterTime |
unsigned long | Time when entered current state | Modules 6, 7 |
reconnectAttempts |
int | Retry count since last disconnect | Modules 5, 6 |
successfulConnects |
int | Cumulative successful connection count | Modules 5, 9 |
disconnectCount |
int | Cumulative disconnect count | Modules 5, 9 |
Key change: Unlike the previous version,
staSSID / staPasswordare no longer hardcoded constants butStringtype variables loaded from NVS, modifiable via webpage.
Responsibility: Handles persistent storage of WiFi credentials. Data survives power off, auto-read on reboot.
void loadCredentials() {
prefs.begin("wifi_creds", true); // read-only
staSSID = prefs.getString("ssid", "");
staPassword = prefs.getString("pass", "");
prefs.end();
Serial.print("[NVS] Loaded SSID: ");
Serial.println(staSSID.length() > 0 ? staSSID : "(empty)");
}
void saveCredentials(String ssid, String pass) {
prefs.begin("wifi_creds", false); // read-write
prefs.putString("ssid", ssid);
// Only update password if user provided a non-empty value
// (so "leave blank to keep current" works on the web form)
if (pass.length() > 0) {
prefs.putString("pass", pass);
}
prefs.end();
Serial.println("[NVS] Credentials saved");
Serial.print(" SSID: ");
Serial.println(ssid);
}
Code Explanation:
loadCredentials() — Called once at boot:
prefs.begin("wifi_creds", true): Opens namespace wifi_creds, second parameter true means read-onlygetString("ssid", ""): Reads key ssid, returns default value "" if not existsprefs.end(): Closes namespace, must be calledsaveCredentials() — Called when webpage saves:
prefs.begin("wifi_creds", false): Second parameter false means read-write modeputString(): Writes key-value pairCall timing:
loadCredentials(): Called in setup() of Module 10saveCredentials(): Called in handleSave() of Module 8NVS simple analogy: Like ESP32's "little notebook".
loadCredentials()is "open notebook to read records",saveCredentials()is "write new records in notebook".
Responsibility: Handles WiFi event callbacks, is the "brain" of the entire program, senses WiFi status changes in real-time and drives state transitions.
void onWiFiEvent(WiFiEvent_t event) {
switch (event) {
case WiFiEvent_t::ARDUINO_EVENT_WIFI_STA_START:
Serial.println("[EVENT] STA started");
break;
case WiFiEvent_t::ARDUINO_EVENT_WIFI_STA_CONNECTED:
Serial.println("[EVENT] STA associated with AP");
break;
case WiFiEvent_t::ARDUINO_EVENT_WIFI_STA_GOT_IP:
// This is the real "connected" event - we have an IP address now
Serial.print("[EVENT] Got IP: ");
Serial.println(WiFi.localIP());
successfulConnects++;
reconnectInterval = RECONNECT_MIN_MS; // Reset backoff after success
switchState(STATE_STA_OK);
break;
case WiFiEvent_t::ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
Serial.println("[EVENT] STA disconnected");
disconnectCount++;
if (state == STATE_STA_OK) {
switchState(STATE_STA_LOST);
}
break;
case WiFiEvent_t::ARDUINO_EVENT_WIFI_AP_START:
Serial.println("[EVENT] AP fallback started");
break;
default:
break;
}
}
Event Explanation:
| Event | This Module's Handling |
|---|---|
STA_START |
Only logs |
STA_CONNECTED |
Only logs (no IP yet, can't count as success) |
STA_GOT_IP |
Key event — resets backoff counter, switches to STA_OK |
STA_DISCONNECTED |
Increments disconnect count, switches from STA_OK to STA_LOST |
AP_START |
Only logs |
Common pitfall:
STA_CONNECTEDdoes not mean "connected"! It may still be in DHCP process, starting communication without IP will fail. Must wait forSTA_GOT_IPto be truly usable.
void switchState(WiFiState newState) {
Serial.print("[STATE] ");
Serial.print(stateToString(state));
Serial.print(" -> ");
Serial.println(stateToString(newState));
// Exit actions (cleanup when leaving a state)
if (state == STATE_AP_FALLBACK && newState != STATE_AP_FALLBACK) {
stopAPFallback(); // Stop web server and AP when leaving AP_FALLBACK
}
state = newState;
stateEnterTime = millis();
// Entry actions (setup when entering a state)
if (newState == STATE_STA_TRYING) {
Serial.println("[ACTION] Connecting to STA: " + staSSID);
WiFi.mode(WIFI_STA);
WiFi.begin(staSSID.c_str(), staPassword.c_str());
lastReconnectAttempt = millis();
reconnectAttempts++;
} else if (newState == STATE_AP_FALLBACK) {
startAPFallback();
}
}
Code Explanation:
This is the single entry point function for the state machine; all state transitions go through here, making it easy to add logging and guard conditions.
AP_FALLBACK, calls stopAPFallback() (Module 7) to clean up web server and APSTA_TRYING: Initiates new WiFi connectionAP_FALLBACK: Calls startAPFallback() (Module 7)State machine best practice: All state transitions go through this one function, avoiding scattered direct assignments. This is "symmetric design": entry actions have corresponding exit actions.
Responsibility: Handles reconnection logic after STA connection failure or disconnect, uses exponential backoff to avoid overwhelming the router.
STATE_STA_TRYING Handlervoid handleSTATrying() {
unsigned long now = millis();
if (now - lastReconnectAttempt >= CONNECT_TIMEOUT) {
Serial.println("[ACTION] STA connect timeout, entering AP fallback");
switchState(STATE_AP_FALLBACK);
}
// Successful connection is handled by the GOT_IP event in Module 5
}
Logic: If no GOT_IP event received within 15 seconds (possibly wrong password, router not responding), gives up and switches to AP fallback. Success case is handled by Module 5's event callback; this only handles timeout.
STATE_STA_LOST Handlervoid handleSTALost() {
unsigned long now = millis();
if (now - lastReconnectAttempt >= reconnectInterval) {
lastReconnectAttempt = now;
reconnectAttempts++;
Serial.print("[ACTION] Reconnect attempt #");
Serial.print(reconnectAttempts);
Serial.print(" (interval ");
Serial.print(reconnectInterval / 1000);
Serial.println("s)");
WiFi.disconnect();
WiFi.begin(staSSID.c_str(), staPassword.c_str());
// Exponential backoff: double the interval up to the max
reconnectInterval = min(reconnectInterval * 2, RECONNECT_MAX_MS);
// If too many failed attempts, fall back to AP portal
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
Serial.println("[ACTION] Too many failed attempts, entering AP fallback");
switchState(STATE_AP_FALLBACK);
}
}
}
Core Logic:
millis() to determine if it's time to retry, no delay()WiFi.disconnect() clears old state, avoids underlying state confusionExponential backoff illustration:
1st failure → wait 5 sec to retry
2nd failure → wait 10 sec to retry
3rd failure → wait 20 sec to retry
4th failure → wait 40 sec to retry
5th failure → wait 60 sec to retry (reached max)
6th failure → switch to AP fallback
Why use exponential backoff?
Module dependency: After 6 failed reconnection attempts, calls Module 5's
switchState(STATE_AP_FALLBACK)to enter fallback mode.
Responsibility: After STA fails continuously, switches to AP mode and starts web portal, letting users modify configuration via browser.
void startAPFallback() {
Serial.println("[ACTION] Starting AP fallback: " + String(AP_SSID));
WiFi.mode(WIFI_AP);
bool ok = WiFi.softAP(AP_SSID, AP_PASSWORD, 6, 0, 4);
if (ok) {
Serial.print(" AP IP: ");
Serial.println(WiFi.softAPIP());
Serial.println(" Open http://192.168.4.1 in a browser to configure WiFi");
setupWebServer(); // Module 8: register web routes
server.begin(); // Start HTTP server
Serial.println(" Web server started on port 80");
} else {
Serial.println(" AP start failed, entering FAIL state");
switchState(STATE_FAIL);
}
}
Logic:
WiFi.mode(WIFI_AP): Switch to pure AP modeWiFi.softAP(...): Start hotspot (SSID/password from Module 1)setupWebServer(): Call Module 8 to register web routesserver.begin(): Start HTTP serverFAIL state (see 7.4)void stopAPFallback() {
server.stop();
server.close();
WiFi.softAPdisconnect(true);
Serial.println("[ACTION] AP fallback stopped");
}
Logic: Called when leaving AP_FALLBACK state (triggered by Module 5's switchState), cleans up web server and disconnects AP, avoiding WiFi mode confusion.
STATE_AP_FALLBACK Handlervoid handleAPFallback() {
// Serve web portal clients (must be called frequently)
server.handleClient();
unsigned long now = millis();
// Auto-retry STA only if NO user is currently connected to the AP
// (so we don't kick off someone who is configuring via the web page)
if (now - stateEnterTime >= AP_RETRY_STA_MS) {
int clients = WiFi.softAPgetStationNum();
if (clients == 0) {
Serial.println("[ACTION] Auto-retrying STA from AP fallback (no clients connected)");
reconnectAttempts = 0;
reconnectInterval = RECONNECT_MIN_MS;
switchState(STATE_STA_TRYING);
} else {
Serial.print("[ACTION] Web client connected (");
Serial.print(clients);
Serial.println("), deferring STA retry");
stateEnterTime = now; // Reset timer to check again later
}
}
}
Key Logic:
server.handleClient(): Must be called in each loop iteration to handle browser requests (Module 8)Why this matters? If a user is entering password on the webpage and ESP32 suddenly switches to STA mode, the AP hotspot disappears, the user's phone disconnects, and form submission fails. By checking
softAPgetStationNum(), ensures mode is never switched while someone is online.
STATE_FAIL Handler (Watchdog)void handleFail() {
unsigned long now = millis();
if (now - stateEnterTime >= WDT_TIMEOUT_MS) {
Serial.println("[ACTION] Watchdog timeout, restarting ESP32...");
delay(100);
ESP.restart();
}
}
Logic: If even AP mode can't start (very rare), waits 5 minutes then hard reboots ESP32. This is the last line of defense.
Responsibility: HTTP server that lets users configure WiFi credentials via browser. This is the "user interaction interface".
void setupWebServer() {
server.on("/", HTTP_GET, handleRoot);
server.on("/save", HTTP_POST, handleSave);
server.on("/reboot", HTTP_POST, handleReboot);
server.on("/status", HTTP_GET, handleStatus);
server.onNotFound([]() {
server.send(404, "text/plain", "404: Not Found");
});
}
Route Table:
| Path | Method | Function |
|---|---|---|
/ |
GET | Display config homepage (HTML form) |
/save |
POST | Save new WiFi credentials and reboot |
/reboot |
POST | Reboot only (no credential change) |
/status |
GET | Return JSON format status info (for debugging) |
| Other | - | Return 404 |
Simple analogy: Like different windows at a restaurant — / is the "order window", /save is the "place order window", /reboot is the "clear table window".
GET /void handleRoot() {
String html = getHTMLPage();
server.send(200, "text/html", html);
}
Returns HTML configuration page (see 8.6).
POST /savevoid handleSave() {
String newSSID = server.arg("ssid");
String newPass = server.arg("pass");
newSSID.trim();
if (newSSID.length() == 0) {
server.send(400, "text/html", "<h2>Error: SSID cannot be empty</h2><a href='/'>Back</a>");
return;
}
// Save to NVS (Module 4)
saveCredentials(newSSID, newPass);
// Update in-memory copies
staSSID = newSSID;
if (newPass.length() > 0) {
staPassword = newPass;
}
// Respond then reboot
String html = "<!DOCTYPE html><html><head>...";
html += "<h2>Saved!</h2><p>SSID: <b>" + newSSID + "</b></p>";
html += "<p>ESP32 is rebooting... Please reconnect to your WiFi after ~10 seconds.</p>";
html += "</body></html>";
server.send(200, "text/html", html);
Serial.println("[WEB] Credentials saved via web portal, rebooting...");
delay(500);
ESP.restart();
}
Flow:
server.arg("ssid"): Get user-entered SSID from formnewSSID.trim(): Remove leading/trailing spacessaveCredentials(): Save to NVS (calls Module 4)delay(500): Wait for response to be sentESP.restart(): RebootKey: Must
server.send()the response first, thenESP.restart(). If rebooting directly, user's browser sees "connection interrupted" and doesn't know if save succeeded.
POST /rebootvoid handleReboot() {
String html = "<!DOCTYPE html><html>...";
html += "<h2>Rebooting...</h2><p>ESP32 is restarting. Please wait ~10 seconds.</p>";
html += "</body></html>";
server.send(200, "text/html", html);
Serial.println("[WEB] Reboot requested via web portal");
delay(500);
ESP.restart();
}
Function: Doesn't modify any credentials, only reboots ESP32. Used to manually let ESP32 retry connection after router reboot, or to test reboot functionality.
GET /status (JSON)void handleStatus() {
String json = "{";
json += "\"state\":\"" + stateToString(state) + "\",";
json += "\"uptime_s\":" + String(millis() / 1000) + ",";
json += "\"reconnect_attempts\":" + String(reconnectAttempts) + ",";
// ... more fields
json += "}";
server.send(200, "application/json", json);
}
Returns JSON format status info, convenient for advanced users or third-party programs to read. Browser accessing http://192.168.4.1/status will see:
{"state":"AP_FALLBACK","uptime_s":45,"reconnect_attempts":6,...}
String getHTMLPage() {
String html = "<!DOCTYPE html><html lang='zh-CN'>";
html += "<head><meta charset='UTF-8'>";
html += "<meta name='viewport' content='width=device-width, initial-scale=1'>";
html += "<title>ESP32 WiFi Configuration</title>";
html += "<style>/* ... mobile-friendly CSS ... */</style>";
html += "</head><body>";
html += "<div class='card'>";
html += "<h2>ESP32 WiFi Settings</h2>";
html += "<form action='/save' method='POST'>";
html += "<label>WiFi Name (SSID)</label>";
html += "<input type='text' name='ssid' value='" + staSSID + "' required>";
html += "<label>WiFi Password</label>";
html += "<input type='text' name='pass' placeholder='Leave blank to keep current'>";
html += "<button type='submit' class='btn-save'>Save and Reboot</button>";
html += "</form>";
html += "<form action='/reboot' method='POST'>";
html += "<button type='submit' class='btn-reboot'>Reboot Only (No Change)</button>";
html += "</form>";
html += "<div class='stats'>Uptime: " + String(millis() / 1000) + "s | ...</div>";
html += "</div></body></html>";
return html;
}
Page Structure:
┌─────────────────────────────────────┐
│ ESP32 WiFi Settings │
├─────────────────────────────────────┤
│ WiFi Name (SSID) │
│ ┌─────────────────────────────┐ │
│ │ MyHomeWiFi │ │ ← Current SSID (pre-filled)
│ └─────────────────────────────┘ │
│ WiFi Password │
│ ┌─────────────────────────────┐ │
│ │ Leave blank to keep current │ │ ← Blank means no change
│ └─────────────────────────────┘ │
│ [ Save and Reboot (green) ] │ ← POST /save
│ [ Reboot Only (red) ] │ ← POST /reboot
│ Uptime: 45s | Reconnects: 6 | │ ← Runtime stats
└─────────────────────────────────────┘
Code Explanation:
<meta name='viewport' ...>: Makes webpage auto-adapt width on mobile (responsive design)value='" + staSSID + "': Pre-fills input box with currently saved SSIDplaceholder='Leave blank to keep current': Placeholder hint<form>: One submits to /save, one to /rebootstats: Shows runtime statisticsModule dependency:
handleSave()calls Module 4'ssaveCredentials()to save credentials, thenESP.restart()to reboot.
Responsibility: Serial status reporting and LED blink patterns, letting users understand device status without a computer.
void printStatus() {
Serial.println("\n---------- WiFi Status ----------");
Serial.print("State : ");
Serial.println(stateToString(state));
if (state == STATE_STA_OK || state == STATE_STA_LOST || state == STATE_STA_TRYING) {
Serial.print("Mode : STA");
Serial.print(" | SSID: ");
Serial.println(WiFi.SSID());
Serial.print("IP : ");
Serial.println(WiFi.localIP());
Serial.print("RSSI (dBm) : ");
Serial.println(WiFi.RSSI());
} else if (state == STATE_AP_FALLBACK) {
Serial.print("Mode : AP");
Serial.print(" | SSID: ");
Serial.println(AP_SSID);
Serial.print("AP IP : ");
Serial.println(WiFi.softAPIP());
Serial.print("Connected clients : ");
Serial.println(WiFi.softAPgetStationNum());
Serial.println("Web portal : http://192.168.4.1");
}
Serial.print("Reconnect attempts: ");
Serial.print(reconnectAttempts);
Serial.print(" | Successful connects: ");
Serial.print(successfulConnects);
Serial.print(" | Disconnects: ");
Serial.println(disconnectCount);
Serial.print("Saved SSID : ");
Serial.println(staSSID);
Serial.print("Uptime (s) : ");
Serial.println(millis() / 1000);
Serial.println("----------------------------------\n");
}
Diagnostic value: Periodically reviewing this data after deployment lets you judge if the device is healthy:
Disconnects increasing dozens of times per hour → poor signal, may need antenna or repositioningSuccessful connects continuously increasing → frequent disconnects and reconnects, unstable environmentRSSI < -80 → weak signal, may need repositioningvoid updateLED() {
unsigned long now = millis();
unsigned long blinkInterval = 0;
switch (state) {
case STATE_STA_OK:
digitalWrite(LED_BUILTIN, HIGH); // Solid on
return;
case STATE_STA_TRYING:
blinkInterval = 500; // Slow blink
break;
case STATE_STA_LOST:
blinkInterval = 150; // Fast blink
break;
case STATE_AP_FALLBACK: {
// Double-blink: on 100ms, off 100ms, on 100ms, off 500ms
unsigned long phase = (now - lastBlinkToggle) % 800;
bool on = (phase < 100) || (phase >= 200 && phase < 300);
digitalWrite(LED_BUILTIN, on ? HIGH : LOW);
return;
}
case STATE_FAIL:
case STATE_BOOT:
default:
digitalWrite(LED_BUILTIN, LOW); // Off
return;
}
if (blinkInterval > 0 && now - lastBlinkToggle >= blinkInterval) {
lastBlinkToggle = now;
ledState = !ledState;
digitalWrite(LED_BUILTIN, ledState ? HIGH : LOW);
}
}
LED Pattern Table:
| State | LED Behavior | Meaning |
|---|---|---|
BOOT |
Off | Just started |
STA_TRYING |
Slow blink (500ms period) | Connecting to router |
STA_OK |
Solid on | Connection normal |
STA_LOST |
Fast blink (150ms period) | Disconnected, reconnecting |
AP_FALLBACK |
Double-blink (on-off-on-long off) | Fell back to AP mode, needs configuration |
FAIL |
Off | Total failure, waiting for watchdog |
Practical value: After deployment without computer, you can judge status just by looking at LED. Double-blink pattern is particularly recognizable, suitable for indicating "user intervention needed".
Responsibility: setup() and loop(), assembling all modules together.
setup() Initializationvoid setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n========================================");
Serial.println(" ESP32 WiFi Stability + Web Portal");
Serial.println("========================================");
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, LOW);
// Module 4: Load saved credentials from NVS
loadCredentials();
// Module 5: Register WiFi event callback + disable auto-reconnect
WiFi.onEvent(onWiFiEvent);
WiFi.setAutoReconnect(false); // We manage reconnection manually
// Decide initial state based on whether credentials exist
if (staSSID.length() == 0) {
// First-time use: no credentials saved -> go straight to AP portal
Serial.println("[BOOT] No saved credentials, starting AP config portal");
switchState(STATE_AP_FALLBACK);
} else {
// Credentials exist -> try STA first
switchState(STATE_STA_TRYING);
}
}
Step Analysis:
loadCredentials() loads credentials from NVSAP_FALLBACK, start config portalSTA_TRYING, try connecting with saved credentialsFirst-use experience: User gets a newly flashed ESP32, powers it on, LED double-blinks (indicating AP mode), phone finds
ESP32_Setuphotspot, connects, opens192.168.4.1in browser, enters home WiFi info, clicks save and reboot, done.
loop() Main Loopvoid loop() {
unsigned long now = millis();
// Drive the state machine (dispatches to Modules 6, 7, 8)
switch (state) {
case STATE_STA_TRYING: handleSTATrying(); break; // Module 6
case STATE_STA_OK: break; // Idle; events drive transitions
case STATE_STA_LOST: handleSTALost(); break; // Module 6
case STATE_AP_FALLBACK: handleAPFallback(); break; // Module 7 + 8
case STATE_FAIL: handleFail(); break; // Module 7
case STATE_BOOT: break;
}
// Module 9: Periodic status print
if (now - lastStatusPrint >= STATUS_PRINT_MS) {
lastStatusPrint = now;
printStatus();
}
// Module 9: LED indicator update
updateLED();
delay(10);
}
Code Explanation:
STATE_STA_OK does nothing — because already connected, just waits for Module 5's event callback to notify disconnectdelay(10) gives CPU breathing room, but doesn't affect event response (events are triggered asynchronously by underlying layer)Design essence: This "event-driven + state machine" structure clearly separates "when to do what" (state machine) from "how to do it" (each module). Each module minds its own business, communicates via state variables. Code readability and maintainability are far superior to cramming all logic into
loop().
Open the ino file in the resource package and do not make any changes for the time being.
Important: Unlike the previous version, this version does not require entering WiFi credentials in the code. On first boot, ESP32 will automatically enter the config portal; you fill in via webpage.
Open the Serial Monitor (baud rate 115200), first boot should show:
========================================
ESP32 WiFi Stability + Web Portal
========================================
[NVS] Loaded SSID: (empty)
[BOOT] No saved credentials, starting AP config portal
[STATE] BOOT -> AP_FALLBACK
[ACTION] Starting AP fallback: ESP32_Setup
AP IP: 192.168.4.1
Open http://192.168.4.1 in a browser to configure WiFi
Web server started on port 80
[EVENT] AP fallback started
At this point, LED should double-blink.
ESP32_Setup12345678 to connecthttp://192.168.4.1┌─────────────────────────────────────┐
│ ESP32 WiFi Settings │
├─────────────────────────────────────┤
│ WiFi Name (SSID) │
│ ┌─────────────────────────────┐ │
│ │ │ │
│ └─────────────────────────────┘ │
│ WiFi Password │
│ ┌─────────────────────────────┐ │
│ │ Leave blank to keep current │ │
│ └─────────────────────────────┘ │
│ [ Save and Reboot (green) ] │
│ [ Reboot Only (red) ] │
└─────────────────────────────────────┘
After ESP32 reboots, it will read the just-saved credentials from NVS and try to connect to your home WiFi. Serial Monitor should show:
[NVS] Loaded SSID: MyHomeWiFi
[STATE] BOOT -> STA_TRYING
[ACTION] Connecting to STA: MyHomeWiFi
[EVENT] STA started
[EVENT] STA associated with AP
[EVENT] Got IP: 192.168.1.105
[STATE] STA_TRYING -> STA_OK
At this point, LED should be solid on, indicating successful connection.
AP_FALLBACKESP32_Setup hotspot192.168.4.1 in browserNo computer, no Arduino IDE, no coding needed throughout.
ESP32_Setup hotspot (ESP32 must be in AP mode)192.168.4.1========================================
ESP32 WiFi Stability + Web Portal
========================================
[NVS] Loaded SSID: (empty)
[BOOT] No saved credentials, starting AP config portal
[STATE] BOOT -> AP_FALLBACK
[ACTION] Starting AP fallback: ESP32_Setup
AP IP: 192.168.4.1
Open http://192.168.4.1 in a browser to configure WiFi
Web server started on port 80
[EVENT] AP fallback started
---------- WiFi Status ----------
State : AP_FALLBACK
Mode : AP | SSID: ESP32_Setup
AP IP : 192.168.4.1
Connected clients : 0
Web portal : http://192.168.4.1
Reconnect attempts: 0 | Successful connects: 0 | Disconnects: 0
Saved SSID :
Uptime (s) : 5
----------------------------------
========================================
ESP32 WiFi Stability + Web Portal
========================================
[NVS] Loaded SSID: MyHomeWiFi
[STATE] BOOT -> STA_TRYING
[ACTION] Connecting to STA: MyHomeWiFi
[EVENT] STA started
[EVENT] STA associated with AP
[EVENT] Got IP: 192.168.1.105
[STATE] STA_TRYING -> STA_OK
(Serial side)
[WEB] Credentials saved via web portal, rebooting...
(After reboot)
[NVS] Loaded SSID: MyHomeWiFi
[STATE] BOOT -> STA_TRYING
[ACTION] Connecting to STA: MyHomeWiFi
[EVENT] Got IP: 192.168.1.105
[STATE] STA_TRYING -> STA_OK
[WEB] Reboot requested via web portal
(After reboot, starts from setup again)
Q1: Why doesn't first boot require entering WiFi info in the code?
A: Because this version uses NVS persistent storage. On first boot, it detects NVS is empty and directly enters AP config portal. User fills in via webpage, saves to NVS. Every subsequent boot reads from NVS. This is the standard approach for industrial-grade products, far more flexible than "hardcoding in code".
Q2: Can't open webpage 192.168.4.1?
A: Please check:
ESP32_Setup hotspot? (Sometimes phone auto-switches back to 4G)http://192.168.4.1 (note the http://)https://, please manually change back to http://Q3: After saving and rebooting, still can't connect to WiFi?
A: Please check:
[EVENT] logs via serial to determine if it's wrong password or weak signalQ4: What happens if password field is left blank?
A: Blank means "keep original password, only change SSID". This is the design in saveCredentials():
if (pass.length() > 0) {
prefs.putString("pass", pass);
}
Only overwrites if user entered a new password. This way, users only changing SSID don't need to re-enter password.
Q5: Will auto-retry STA in AP mode kick me off?
A: No. The code checks WiFi.softAPgetStationNum(), as long as there's a client connected, it won't switch modes. Only retries when no one is connected.
Q6: Can the webpage have password protection? To prevent others from tampering?
A: Yes, but omitted in this tutorial for simplicity. You can add HTTP Basic Auth in setupWebServer():
if (!server.authenticate("admin", "mypass")) {
return server.requestAuthentication();
}
Note that AP password and webpage password are two different layers of protection, recommended to set both.
Q7: Can data in NVS be cleared?
A: Yes, add in code:
prefs.begin("wifi_creds", false);
prefs.clear();
prefs.end();
Or use esptool.py erase_region command to clear NVS partition. The simplest method is to re-flash firmware (doesn't clear NVS, needs separate erase).
| Problem | Possible Cause | Solution |
|---|---|---|
| First boot goes directly to STA_TRYING | Previous code left NVS data | Use prefs.clear() to clear NVS |
| Webpage won't open | Phone using 4G not WiFi | Disable mobile data |
| Webpage won't open | Address uses https | Change to http:// |
| Can't connect after save | Wrong SSID/password | Check case, reconfigure |
| Can't connect after save | Router is 5GHz | Use 2.4GHz |
| AP mode LED doesn't double-blink | Wrong pin definition | Check LED_BUILTIN |
| Webpage button no response | Browser cache | Force refresh (Ctrl+F5) |
ESP.restart() doesn't work |
Hardware fault | Check power supply |
const char* AP_SSID = "MyIOT_Setup"; // Change to recognizable name
const char* AP_PASSWORD = "setup12345"; // Change to your password
void setupWebServer() {
server.on("/", HTTP_GET, []() {
if (!server.authenticate("admin", "esp32pass")) {
return server.requestAuthentication();
}
handleRoot();
});
// ... add protection to other routes too
}
Display nearby WiFi on homepage, users can select instead of typing:
String getWiFiScanOptions() {
String html = "<select name='ssid'>";
int n = WiFi.scanNetworks();
for (int i = 0; i < n; i++) {
html += "<option value='" + WiFi.SSID(i) + "'>";
html += WiFi.SSID(i) + " (" + WiFi.RSSI(i) + " dBm)";
html += "</option>";
}
html += "</select>";
return html;
}
Make browser auto-popup config page after connecting to hotspot (no need to manually enter IP):
#include <DNSServer.h>
DNSServer dnsServer;
// In setup: dnsServer.start(53, "*", WiFi.softAPIP());
// In loop: dnsServer.processNextRequest();
Support storing 3 WiFi networks, try in priority order:
prefs.begin("wifi_creds", false);
prefs.putString("ssid1", "HomeWiFi");
prefs.putString("ssid2", "OfficeWiFi");
prefs.putString("ssid3", "PhoneHotspot");
prefs.end();
NVS is based on flash, with limited erase/write cycles (about 100,000 times). Each credential save writes data, don't call saveCredentials() frequently in loop(). Normal use (occasional password changes) is more than sufficient.
This tutorial's web portal has no password protection, anyone who can connect to ESP32_Setup hotspot can modify configuration. In actual products you should:
This tutorial uses HTTP (plaintext), browser may warn "not secure". Acceptable for LAN configuration scenarios, but for public internet access, must use HTTPS (requires TLS certificate, much more complex).
Some phone browsers auto-redirect 192.168.4.1 to https://, causing access failure. Solutions:
http://192.168.4.1server.handleClient() Must Be Called in loopWeb server doesn't automatically process requests, must call server.handleClient() repeatedly in loop(). If a state handler takes too long, webpage response will be slow. This tutorial calls it in handleAPFallback(), only responding to web requests in AP mode.
When leaving AP_FALLBACK, must call stopAPFallback() to stop web server and disconnect AP. Otherwise, after switching to STA mode, AP still running, will cause WiFi mode confusion.
ESP.restart() DelayESP.restart() doesn't take effect immediately, waits a few hundred milliseconds. The delay(500) in code is to let HTTP response be sent first, avoiding user browser errors.
This is "seamless upgrade" design: User plugs in at home, if new device auto-guides configuration; if already configured, directly connects to WiFi.