Ejercicio 03 — Montar un servidor web en el ESP32 que sirva una página para…
Dificultad: amarillo · Módulo 09 (Salto a ESP32 + WiFi)
Enunciado
Montar un servidor web en el ESP32 que sirva una página para encender/apagar el LED interno (GPIO 2) desde el navegador.
Montaje (ESP32 DevKit)
Ninguno (LED interno en GPIO 2)
Cómo se resuelve
El reto del módulo, y el que le da la vuelta a todo lo anterior: hasta ahora la placa pedía; a partir de aquí la placa responde. Deja de ser cliente y pasa a ser servidor, una máquina que se queda escuchando en la red y a la que cualquiera puede abrir desde un navegador. El resultado es una página web servida desde un chip del tamaño de un sello con la que enciendes y apagas un LED.
La librería nueva es WebServer.h, también incluida en el core del ESP32, y el objeto se crea a nivel global:
WebServer server(80);El 80 es el puerto. La IP identifica la máquina dentro de la red; el puerto identifica el servicio dentro de la máquina. El 80 es el puerto de HTTP por convenio, y por eso al escribir http://192.168.1.50 en el navegador no hay que añadir nada más: el navegador ya da por hecho el 80. Si pusieras WebServer server(8080), la página seguiría funcionando pero habría que escribir http://192.168.1.50:8080.
El LED es el integrado de la placa:
const int LED = 2;GPIO 2 es donde el DevKit del ESP32 lleva soldado su LED, con su resistencia ya puesta en la placa, así que no hay que montar nada. Fíjate en que el número es el del GPIO, no una numeración de placa como en el UNO. Si prefieres un LED externo y visible, cambias ese 2 por el GPIO que uses y montas LED más resistencia de 220 Ω contra GND, recordando que el pin da 3.3 V, no 5 V.
La página se construye en memoria
Un servidor web no sirve ficheros aquí: fabrica el HTML como texto y lo manda. Eso es lo que hace paginaHTML(), y su primera línea es la interesante:
String estado = digitalRead(LED) ? "ENCENDIDO" : "APAGADO";digitalRead sobre un pin configurado como salida devuelve el nivel en el que ese pin está ahora mismo. Es decir, el programa no guarda una variable con el estado del LED: pregunta al propio hardware. Es un truco pequeño pero elegante, porque elimina la posibilidad de que la variable y la realidad se desincronicen. El operador ? : es el condicional en una línea: si la lectura es distinta de cero, "ENCENDIDO"; si no, "APAGADO".
El resto de la función va concatenando el HTML con +=: la cabecera con <meta charset='utf-8'> y el viewport para que se vea bien en el móvil, un <h1> con el título, un párrafo que inserta la variable estado en negrita, y dos enlaces, /on y /off. Se usan comillas simples dentro del HTML precisamente para no tener que escapar las dobles que delimitan el literal de C++. El resultado no es una página bonita, y no pretende serlo: es la mínima cantidad de HTML que demuestra el concepto.
Lo que importa de esta función es cuándo se llama. Se llama en cada respuesta, no una vez al arrancar, y por eso la página que ve el navegador siempre refleja el estado real del LED en el instante de pedirla.
Rutas y manejadores
Un servidor HTTP es, en el fondo, una tabla que asocia rutas con funciones. Aquí hay tres:
void manejarRaiz() { server.send(200, "text/html", paginaHTML()); }
void manejarOn() {
digitalWrite(LED, HIGH);
server.send(200, "text/html", paginaHTML());
}
void manejarOff() {
digitalWrite(LED, LOW);
server.send(200, "text/html", paginaHTML());
}server.send() recibe tres cosas: el código de estado (200, “todo bien”), el tipo de contenido (text/html, para que el navegador sepa que debe interpretarlo como página y no mostrarlo como texto plano) y el cuerpo.
El patrón de manejarOn y manejarOff merece atención: primero actúan sobre el hardware, y solo después generan la página. El orden no es indiferente. Como paginaHTML() lee el estado del pin en el momento de construirse, hacer el digitalWrite antes garantiza que la página que recibes ya muestra el estado nuevo. Si lo hicieras al revés, la respuesta te diría “APAGADO” justo después de haber encendido el LED, y tendrías que recargar para ver la verdad.
Los tres manejadores devuelven la misma página. Eso es lo que hace que la interfaz funcione como una interfaz: pulsas “Encender”, el navegador va a /on, el LED cambia y lo que recibes de vuelta es otra vez la página completa, con los dos enlaces y el estado actualizado. No hay JavaScript ni nada dinámico: cada clic es una recarga entera.
El arranque
setup() prepara el pin con pinMode(LED, OUTPUT) y lo deja explícitamente en LOW, para partir de un estado conocido en vez de heredar el que hubiera. Luego repite la conexión WiFi que ya conoces del ejercicio 01: WIFI_STA, WiFi.begin(SSID, PASSWORD) con "Wokwi-GUEST" y contraseña vacía "", y el bucle de espera con delay(250) imprimiendo puntos. En una placa real, esas dos credenciales hay que cambiarlas por las de tu red.
Después imprime la línea que hay que leer sí o sí:
Serial.print("Abre en el navegador: http://");
Serial.println(WiFi.localIP());Esa IP es la única forma de llegar al servidor. El sketch no la anuncia por ningún otro sitio, y el fallo más frecuente del ejercicio es arrancarlo, no mirar el monitor serie y quedarse esperando a que “pase algo”. No va a pasar nada: el servidor está esperando a que alguien le pida algo.
Finalmente se registra la tabla de rutas y se abre la puerta:
server.on("/", manejarRaiz);
server.on("/on", manejarOn);
server.on("/off", manejarOff);
server.begin();server.on() no llama a la función: guarda su nombre para llamarla más tarde, cuando llegue una petición a esa ruta. Es la primera vez en el curso que pasas una función como argumento a otra, y la idea de fondo (registrar una respuesta y esperar a que ocurra el evento) es la misma que hay detrás de las interrupciones.
El loop no puede dormir
void loop() {
server.handleClient();
}Una sola línea, y es la más importante del sketch. El servidor de esta librería no atiende peticiones por su cuenta en segundo plano: solo mira si hay alguien esperando cuando tú llamas a handleClient(). Si el loop() se entretiene, el servidor se calla.
De ahí el comentario del código: no usar delay largo. Si metieras un delay(2000) para parpadear algo, durante esos dos segundos el navegador se quedaría cargando y el usuario pensaría que la placa se ha colgado. Cualquier temporización que quieras añadir a este programa tiene que hacerse comparando millis(), como en el módulo 06, para que el loop() siga dando vueltas rápido y handleClient() se ejecute muchas veces por segundo.
En Wokwi
El simulador ejecuta este ejercicio con su WiFi simulado y su red Wokwi-GUEST, y el ESP32 obtendrá una IP del router virtual. Esa IP no es alcanzable desde el navegador de tu ordenador: pertenece a la red simulada, que solo existe dentro de Wokwi. Para verla hay que usar la vista de navegador que el propio simulador ofrece (el botón del puerto o del preview que aparece cuando detecta un servidor escuchando). En una placa física, en cambio, la IP es una IP de tu red local de verdad y puedes abrirla desde el móvil o desde el portátil, siempre que estén conectados al mismo router. Desde fuera de tu casa no, porque es una dirección privada: eso ya sería otra conversación, y es parte del gancho hacia el módulo 10.
Para practicar — cópialo y complétalo
Pega este esqueleto y completa los TODO. Es la mejor forma de aprender: inténtalo antes de mirar la solución.
/*
* Curso de Arduino — Módulo 09: ESP32 + WiFi
* Ejercicio 03 — PRÁCTICA (rellena los TODO)
* Enunciado: Montar un servidor web en el ESP32 que sirva una página para
* encender/apagar el LED interno (GPIO 2) desde el navegador.
* Dificultad: amarillo
* Placa: ESP32 DevKit Montaje: ninguno (LED interno en GPIO 2)
* Correr: Arduino IDE / Wokwi (wokwi/ej03) / arduino-cli (ver README)
* NOTA: en Wokwi la red es SSID "Wokwi-GUEST" SIN contraseña. Abre la IP que
* sale por Serial en el navegador de Wokwi (botón del puerto/preview).
*/
#include <WiFi.h>
// TODO: incluye el servidor web -> #include <WebServer.h>
const char* SSID = "Wokwi-GUEST";
const char* PASSWORD = "";
const int LED = 2; // LED integrado del ESP32 DevKit = GPIO 2
// TODO: crea el servidor en el puerto 80 -> WebServer server(80);
String paginaHTML() {
String estado = digitalRead(LED) ? "ENCENDIDO" : "APAGADO";
String html = "<!DOCTYPE html><html><head><meta charset='utf-8'>";
html += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
html += "<title>ESP32 LED</title></head><body>";
html += "<h1>Control del LED (GPIO 2)</h1>";
html += "<p>Estado actual: <b>" + estado + "</b></p>";
html += "<p><a href='/on'>Encender</a> | <a href='/off'>Apagar</a></p>";
html += "</body></html>";
return html;
}
void manejarRaiz() {
// TODO: responde con la página -> server.send(200, "text/html", paginaHTML())
}
void manejarOn() {
// TODO: enciende el LED -> digitalWrite(LED, HIGH)
// TODO: responde con la página actualizada
}
void manejarOff() {
// TODO: apaga el LED -> digitalWrite(LED, LOW)
// TODO: responde con la página actualizada
}
void setup() {
Serial.begin(115200);
pinMode(LED, OUTPUT);
digitalWrite(LED, LOW);
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASSWORD);
Serial.print("Conectando WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(250);
Serial.print(".");
}
Serial.println();
Serial.print("Abre en el navegador: http://");
Serial.println(WiFi.localIP());
// TODO: registra las rutas -> server.on("/", manejarRaiz), "/on", "/off"
// TODO: arranca el servidor -> server.begin()
}
void loop() {
// TODO: atiende a los clientes en cada vuelta -> server.handleClient()
}Solución — cópiala y ejecútala
/*
* Curso de Arduino — Módulo 09: ESP32 + WiFi
* Ejercicio 03 — MODELO (resuelto)
* Enunciado: Montar un servidor web en el ESP32 que sirva una página para
* encender/apagar el LED interno (GPIO 2) desde el navegador.
* Dificultad: amarillo
* Placa: ESP32 DevKit Montaje: ninguno (LED interno en GPIO 2)
* Correr: Arduino IDE / Wokwi (wokwi/ej03) / arduino-cli (ver README)
* NOTA: en Wokwi la red es SSID "Wokwi-GUEST" SIN contraseña. Abre la IP que
* sale por Serial en el navegador de Wokwi (botón del puerto/preview).
*/
#include <WiFi.h>
#include <WebServer.h> // servidor HTTP del core ESP32
const char* SSID = "Wokwi-GUEST";
const char* PASSWORD = "";
const int LED = 2; // LED integrado del ESP32 DevKit = GPIO 2
WebServer server(80); // servidor escuchando en el puerto 80
// Genera la página HTML según el estado actual del LED
String paginaHTML() {
String estado = digitalRead(LED) ? "ENCENDIDO" : "APAGADO";
String html = "<!DOCTYPE html><html><head><meta charset='utf-8'>";
html += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
html += "<title>ESP32 LED</title></head><body>";
html += "<h1>Control del LED (GPIO 2)</h1>";
html += "<p>Estado actual: <b>" + estado + "</b></p>";
html += "<p><a href='/on'>Encender</a> | <a href='/off'>Apagar</a></p>";
html += "</body></html>";
return html;
}
// Manejadores de cada ruta
void manejarRaiz() { server.send(200, "text/html", paginaHTML()); }
void manejarOn() {
digitalWrite(LED, HIGH);
server.send(200, "text/html", paginaHTML());
}
void manejarOff() {
digitalWrite(LED, LOW);
server.send(200, "text/html", paginaHTML());
}
void setup() {
Serial.begin(115200);
pinMode(LED, OUTPUT);
digitalWrite(LED, LOW);
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASSWORD);
Serial.print("Conectando WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(250);
Serial.print(".");
}
Serial.println();
Serial.print("Abre en el navegador: http://");
Serial.println(WiFi.localIP()); // la IP del servidor
// Asocia cada ruta (URL) con su función manejadora
server.on("/", manejarRaiz);
server.on("/on", manejarOn);
server.on("/off", manejarOff);
server.begin(); // arranca el servidor
Serial.println("Servidor web iniciado.");
}
void loop() {
server.handleClient(); // atiende las peticiones entrantes (no usar delay largo)
}Cómo ejecutar la simulación
Sin instalar nada, en el navegador
- Abre wokwi.com y crea un proyecto nuevo de la placa que pide el ejercicio.
- Pega la solución (o tu intento) en la pestaña
sketch.ino. - Abre la pestaña
diagram.json, borra lo que haya y pega el circuito de aquí abajo. - Dale al play. Verás el circuito funcionando: LEDs, pantallas y monitor serie.
Circuito del ejercicio (diagram.json)
{
"version": 1,
"author": "ESIBot",
"editor": "wokwi",
"parts": [
{ "type": "board-esp32-devkit-c-v4", "id": "esp", "top": 0, "left": 0, "attrs": {} },
{ "type": "wokwi-led", "id": "led1", "top": -50, "left": 130, "attrs": { "color": "red" } },
{ "type": "wokwi-resistor", "id": "r1", "top": 50, "left": 120, "attrs": { "value": "220" } }
],
"connections": [
[ "esp:D2", "r1:1", "green", [ "v0" ] ],
[ "r1:2", "led1:A", "green", [ "v0" ] ],
[ "led1:C", "esp:GND.1", "black", [ "v0" ] ]
],
"dependencies": {}
}En VS Code, con el repo del curso
El paquete de simulación de este ejercicio está en practica/09-esp32-wifi/wokwi/ej03/. Con la extensión Wokwi instalada y la licencia activada (lo cuenta el onboarding del curso):
make sim EJ=09-esp32-wifi/ej03y después, en VS Code, Ctrl+Shift+P → Wokwi: Start Simulator. La extensión no compila: simula un firmware ya compilado, y de compilarlo se encarga ese make.
Cómo usarlo
Con placa: abre el .ino en el Arduino IDE, elige placa y puerto y pulsa Subir. Sin placa: simulalo en Wokwi con cualquiera de las dos vias de arriba. Antes de mirar la solucion, intenta completar tu el esqueleto: es la mejor forma de aprender.