What You Will Build
A phone-controlled LED with explicit On and Off buttons. The ESP32 creates its own network, accepts a validated form submission and shows the last commanded state. This is the first actuator example after the existing read-only AP server, not a replacement for it.
Before You Start
Classic ESP32-WROOM DevKit, Arduino IDE 2.x and Arduino-ESP32 3.3.2 as the API review baseline, not the latest-release claim. Select ESP32 Dev Module only for the stated generic WROOM board. Other ESP32 families need their own GPIO and LEDC review. Complete the Serial-only IDE upload first. Documentation/code-structure and local browser checks do not establish compilation or physical operation; these sketches have not been compiled or hardware-tested. Use one low-current red indicator LED such as the documented WP7113ID, with 470 Ω in series. Do not substitute high-power LEDs, relay coils or LED strips.
Parts
- Classic ESP32-WROOM DevKitGPIO25 exposed and unused; USB data cable.
- One low-current red indicator LEDKingbright WP7113ID is the documented example; identify the actual anode/cathode.
- 470 Ω 0.25 W resistorOne series resistor for this LED; no direct LED-to-GPIO connection.
- Breadboard and short jumpersDisconnect USB before changing wires.
Wiring
GPIO25 drives a series path through 470 Ω to the LED anode; the cathode returns to GND. Resistor placement on either side of the LED is electrically equivalent if it remains in series. Check polarity using the actual datasheet or power-off diode test. No built-in GPIO2 LED is assumed.
-
1
Disconnect USB and identify LED polarity.
-
2
Wire GPIO25 through the 470 Ω resistor to the anode, and cathode to GND.
-
3
Check that the resistor is in series rather than bypassed by a breadboard row.
-
4
Reconnect USB, upload the sketch and read Serial at 115200.
Wiring Reference
| Signal | ESP32 Pin | Direction | Notes |
|---|---|---|---|
| 470 Ω resistor → LED anode | GPIO25 | Output | Series path; LED cathode returns to GND |
| LED cathode | GND | Ground | Verify actual LED polarity |
| DevKit power | USB data cable | Power | No onboard LED or 5 V signal assumed |
Libraries and Setup
- Built-in WiFi and WebServer from Arduino-ESP32 3.3.2; no additional Arduino library.
Configure the Example
Change CHANGE_ME_1234 to your own 8–63 character AP password. Upload and open Serial at 115200. Join the printed ESP32 network, stay connected if your phone reports no internet, and open the printed http:// address directly. The classic board uses a 2.4 GHz AP on channel 6, allows one connected station and does not join your router. No internet, external JavaScript, cloud account or third-party Arduino library is required. Press Turn on or Turn off. Only those POST submissions change the output; opening or refreshing the page does not. The page does not poll automatically, so refresh after an expiry to see the latest software state.
Control and Recovery Behavior
| Condition | Result |
|---|---|
| Startup / AP failure | GPIO25 is set LOW before AP startup; a failed AP leaves the LED OFF. |
| Valid POST /led | Exactly one value argument, 0 or 1, changes the LED and returns a 303 redirect to the page. |
| Invalid request | HTTP 400 for missing, duplicate/extra or unsupported values; an unknown or wrong-method route returns 404. |
| Idle ON command | The loop switches OFF after 30 seconds since the last accepted command; GET does not extend this window. |
Code
Install the libraries above and match the configuration and wiring before uploading.
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
WebServer server(80);
const char* AP_PASSWORD = "CHANGE_ME_1234"; // Replace with your own 8-63 character password.
bool httpReady = false;
void redirectHome() {
server.sendHeader("Location", "/");
server.send(303, "text/plain", "Open the control page\n");
}
String pageStart(const char* title) {
String page = "<!doctype html><html lang='en'><head>";
page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
page += "<style>body{font:1.1rem system-ui;max-width:40rem;margin:2rem auto;padding:1rem}button,input{font:inherit;margin:.5rem 0}input[type=range]{width:100%}</style>";
page += "<title>"; page += title; page += "</title></head><body><h1>";
page += title; page += "</h1>";
return page;
}
void sendPage(const String& page) {
server.sendHeader("Cache-Control", "no-store");
server.send(200, "text/html; charset=utf-8", page + "</body></html>");
}
void startHTTP(const char* name) {
server.onNotFound([]() { server.send(404, "text/plain", "No such route\n"); });
if (!WiFi.mode(WIFI_AP) || !WiFi.softAP(name, AP_PASSWORD, 6, false, 1)) {
Serial.println("AP failed; output stays off. Check board and password length.");
return;
}
server.begin(); httpReady = true;
Serial.print("Join "); Serial.println(name);
Serial.print("Open http://"); Serial.println(WiFi.softAPIP());
}
constexpr uint8_t LED_PIN = 25;
constexpr uint32_t COMMAND_LIFETIME_MS = 30000;
bool ledOn = false;
uint32_t lastCommand = 0;
void showPage() {
String page = pageStart("ESP32 web LED");
page += String("<p>Commanded output: ") + (ledOn ? "ON" : "OFF") + "</p>";
page += "<form method='post' action='/led'><button name='value' value='1'>Turn on</button> ";
page += "<button name='value' value='0'>Turn off</button></form>";
page += "<p>ON expires 30 seconds after the last accepted command. Refresh to read the current software state.</p>";
sendPage(page);
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, LOW);
server.on("/", HTTP_GET, showPage);
server.on("/led", HTTP_POST, []() {
if (server.args() != 1 || !server.hasArg("value") ||
(server.arg("value") != "0" && server.arg("value") != "1")) {
server.send(400, "text/plain", "Exactly one value, 0 or 1, is required\n"); return;
}
ledOn = server.arg("value") == "1";
digitalWrite(LED_PIN, ledOn ? HIGH : LOW);
lastCommand = millis();
Serial.println(ledOn ? "LED commanded ON" : "LED commanded OFF");
redirectHome();
});
startHTTP("ESP32-WebLED");
}
void loop() {
if (httpReady) server.handleClient();
if (ledOn && millis() - lastCommand >= COMMAND_LIFETIME_MS) {
ledOn = false; digitalWrite(LED_PIN, LOW);
Serial.println("Command expired: LED OFF");
}
delay(1);
}
How the Code Works
GET / renders the page without changing the LED. The two submit buttons send one value field to POST /led. The handler validates the argument count and exact string before touching GPIO25. Post/Redirect/Get avoids an ordinary page refresh repeating a prior form command. digitalWrite and the displayed ledOn flag describe the software command; neither measures actual light output. Unsigned elapsed-time subtraction handles normal millis wrap for this short timeout.
Expected Output
With the supported circuit, Turn on should illuminate the LED and redirect to a page saying ON. Turn off should extinguish it. Leaving an ON command idle should print Command expired and switch it off near 30 seconds; refresh to see OFF. These are expected bench observations, not measurements recorded during authoring.
Troubleshooting
- Cannot open the page Confirm the phone is still on the ESP32 AP, use the Serial-printed http:// address and check mobile-data/VPN routing. The AP deliberately provides no internet.
- HTTP 400 from a command Use the provided form. Exact argument counts and allowed values are checked; blank, negative, decimal or nonnumeric values are not converted silently.
- Page shows an old value Refresh the page. It reports the last accepted software command, not measured light output or shaft position. GET/refresh does not renew the command lifetime.
- Software says ON but LED is dark Unplug USB, verify polarity, GPIO25, common ground and the resistor/LED series path. A command flag does not diagnose a reversed or broken LED.
- LED never switches off exactly at the chosen instant This synchronous server can temporarily block loop servicing. The timeout is approximate cleanup, not a deterministic shutdown deadline.
Checks to Perform on Your Build
- Compare both form buttons with the LED and Serial output.
- Refresh without pressing a control and confirm it does not renew an ON command.
- Send a malformed command only on your bench: it should return 400 without changing output.
- Reset and verify startup OFF before repeating the test.
Practical Design Notes
At 3.3 V a 470 Ω resistor limits even a shorted LED path to about 7 mA; the actual LED current is lower and depends on forward voltage. This conservative resistor choice is not a universal GPIO current rating. Use the detailed Blink/component material for LED basics rather than removing current limiting. This is plain HTTP on a trusted local demonstration AP. The Wi-Fi password is network admission, not per-user HTTP authentication. Anyone admitted can issue commands; POST alone does not prevent CSRF, and the sketch has no TLS, CSRF token, rate limiting or user roles. Do not expose the port through a router or adapt this example to locks, heaters, mains equipment or safety-critical mechanisms. The synchronous WebServer can wait on slow clients; the loop timeout is best-effort cleanup, not a hard real-time or certified safety stop.
Review, Testing, and References
Author: Abdul Mubeen and the ESP32 Engine editorial team. Last updated: 2026-10-24. Reviewed: wiring logic, Arduino code structure, beginner safety, and learning sequence.
Educational level: Intermediate. Estimated completion time: Varies by build. This project is for learning and prototyping; production or unattended hardware needs additional engineering review.
