Prender un LED desde el celular es el "hola mundo" de la domótica, y casi todos los tutoriales lo hacen con Arduino IDE. Esta guía va por el otro camino: ESP IDF, el framework oficial de Espressif, el mismo que usan los productos comerciales con ESP32. El resultado es un servidor web dentro del ESP32 que sirve una página con dos botones, TURN ON y TURN OFF, para controlar el GPIO 5 desde cualquier navegador de tu red local.
Lo interesante no es el LED, es entender cómo ESP IDF arma un servidor HTTP: cómo se registran las rutas, cómo la página consulta el estado del pin y cómo las credenciales del WiFi salen del código y pasan al menú de configuración. Con eso resuelto, cambiar el LED por un relé es un detalle.
Cómo funciona el sistema

Hay tres rutas, y cada una hace una sola cosa:
/devuelve la página HTML con el estado actual del GPIO ya escrito./output?gpio=5&state=1cambia el pin a 1 (o a 0) y respondeOK./status?gpio=5responde solo un1o un0en texto plano.
La página no se recarga al apretar un botón: el JavaScript llama a /output con fetch(), y después a /status para pintar "ON" en verde u "OFF" en rojo. Además, cada 10 segundos vuelve a preguntar el estado, así que si abres la página en dos celulares, el segundo se pone al día solo.
Hardware
El montaje mínimo es un LED con su resistencia en el GPIO 5: ánodo (pata larga) al GPIO 5 a través de una resistencia de 220 Ω, cátodo a GND. Sirve cualquier placa ESP32; el autor usó una con chip ESP32-S3.
Si usas un ESP32 clásico (WROOM-32), ten en cuenta que GPIO 5 es un pin de arranque (strapping) y emite una señal breve al encender. Con un LED no importa, pero si después le conectas un relé, ese pulso puede hacerlo chasquear en cada reinicio. Para el relé conviene mover LED_PIN a un pin sin función de arranque, como GPIO 23 o GPIO 26 en el ESP32 clásico.
Software
Crear el proyecto en VS Code
Necesitas VS Code con la extensión ESP IDF instalada. Abre la extensión, despliega Advanced y entra a New Project Wizard. Elige la versión del framework (el original usa ESP IDF v6.X).

En la ventana que se abre completa el nombre del proyecto, la carpeta, el chip (esp32s3 u otro según tu placa), la configuración de la placa y el puerto COM. No guardes el proyecto en una carpeta de Google Drive, OneDrive o Dropbox: la compilación crea miles de archivos y la sincronización la vuelve lentísima.

Elige la plantilla ESP IDF Templates y crea el proyecto con Create project using template sample project.

Cuando aparezca la notificación, ábrelo con Open Project. Si no aparece, usa File > Open Folder… y elige la carpeta del proyecto.

Vas a trabajar en dos archivos: main/main.c y uno nuevo, main/Kconfig.projbuild.

Las credenciales del WiFi, fuera del código
En vez de escribir el nombre y la clave de tu red dentro de main.c (y subirlos por error a GitHub algún día), ESP IDF permite declararlos como opciones de configuración. Haz clic derecho sobre la carpeta main, elige New File… y créalo con el nombre exacto Kconfig.projbuild:

menu "Wi-Fi Configuration"
config ESP_WIFI_SSID
string "WiFi SSID"
help
SSID (network name) you want to connect to.
config ESP_WIFI_PASSWORD
string "WiFi Password"
help
Password of the Wi-Fi network you want to connect to.
endmenu

Guarda, abre el SDK Configuration Editor con el ícono del engranaje, busca "wifi", escribe tu SSID y tu clave y aprieta Save. ESP IDF convierte esos valores en las macros CONFIG_ESP_WIFI_SSID y CONFIG_ESP_WIFI_PASSWORD que el código usa.


El código completo
Reemplaza el contenido de main.c por este:
/*
Rui Santos & Sara Santos - Random Nerd Tutorials
Complete project details: https://RandomNerdTutorials.com/esp-idf-esp32-web-server-output/
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_system.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "esp_netif.h"
#include "esp_http_server.h"
#include "driver/gpio.h"
#include "sdkconfig.h"
#define MY_ESP_WIFI_SSID CONFIG_ESP_WIFI_SSID
#define MY_ESP_WIFI_PASS CONFIG_ESP_WIFI_PASSWORD
static const char *TAG = "web_server";
// Define the GPIO pin for the LED
#define LED_PIN GPIO_NUM_5
// HTML web page to serve the root / page
static const char *html_root_page =
"<!DOCTYPE html>"
"<html>"
"<head>"
"<title>ESP-IDF: Output Web Server</title>"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
"<style>"
"body { font-family: Arial, sans-serif; background-color: #f0f0f0; text-align: center; padding: 40px; margin: 0; }"
"h1 { color: #333; margin-bottom: 10px; }"
"p { font-size: 20px; color: #666; }"
".status { font-weight: bold; margin: 20px 0; }"
".status-on { color: #28a745; }"
".status-off { color: #dc3545; }"
".button {"
" display: inline-block; padding: 15px 32px; margin: 15px;"
" font-size: 20px; font-weight: bold; cursor: pointer; border: none;"
" border-radius: 8px; color: white; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"
"}"
".on { background-color: #28a745; }"
".off { background-color: #dc3545; }"
".button:hover { opacity: 0.9; transform: translateY(-2px); }"
".button:active { transform: translateY(1px); }"
"</style>"
"</head>"
"<body>"
"<h1>ESP-IDF: Output Web Server</h1>"
"<p>GPIO 5 Status: <span id=\"status\" class=\"status %s\">%s</span></p>"
"<button onclick=\"setOutput(1)\" class=\"button on\">TURN ON</button>"
"<button onclick=\"setOutput(0)\" class=\"button off\">TURN OFF</button>"
"<script>"
"function updateStatus() {"
" fetch('/status?gpio=5')"
" .then(r => r.text())"
" .then(text => {"
" const state = parseInt(text.trim());"
" const el = document.getElementById('status');"
" if (state === 1) {"
" el.className = 'status status-on';"
" el.textContent = 'ON';"
" } else {"
" el.className = 'status status-off';"
" el.textContent = 'OFF';"
" }"
" })"
" .catch(() => {});"
"}"
"function setOutput(state) {"
" fetch(`/output?gpio=5&state=${state}`)"
" .then(() => updateStatus())"
" .catch(() => {});"
"}"
"setInterval(updateStatus, 10000);"
"window.onload = updateStatus;"
"</script>"
"</body>"
"</html>";
// HTTP GET handler for root "/" - send main HTML page
static esp_err_t root_get_handler(httpd_req_t *req)
{
int gpio_state = gpio_get_level(LED_PIN);
const char *status_class = gpio_state ? "status-on" : "status-off";
const char *status_text = gpio_state ? "ON" : "OFF";
char response[2048];
snprintf(response, sizeof(response), html_root_page, status_class, status_text);
httpd_resp_set_hdr(req, "Cache-Control", "no-cache, no-store, must-revalidate");
httpd_resp_set_hdr(req, "Pragma", "no-cache");
httpd_resp_set_hdr(req, "Expires", "0");
httpd_resp_set_type(req, "text/html");
httpd_resp_send(req, response, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
// Return current status as plain text "1" or "0"
static esp_err_t status_get_handler(httpd_req_t *req)
{
char buf[100];
size_t buf_len = httpd_req_get_url_query_len(req) + 1;
int gpio = LED_PIN;
if (buf_len > 1 && httpd_req_get_url_query_str(req, buf, buf_len) == ESP_OK) {
char param[16];
if (httpd_query_key_value(buf, "gpio", param, sizeof(param)) == ESP_OK) {
gpio = atoi(param);
}
}
if (gpio < 0 || gpio >= 40) {
gpio = LED_PIN; // Fallback to default if invalid
}
int state = gpio_get_level(gpio);
char response[4];
snprintf(response, sizeof(response), "%d", state);
httpd_resp_set_type(req, "text/plain");
httpd_resp_set_hdr(req, "Cache-Control", "no-cache");
httpd_resp_send(req, response, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
// Handle TURN ON / TURN OFF commands to control GPIO
static esp_err_t output_get_handler(httpd_req_t *req)
{
char buf[100];
size_t buf_len = httpd_req_get_url_query_len(req) + 1;
int gpio = LED_PIN;
int state = 0;
if (buf_len > 1 && httpd_req_get_url_query_str(req, buf, buf_len) == ESP_OK) {
char param[16];
if (httpd_query_key_value(buf, "gpio", param, sizeof(param)) == ESP_OK) {
gpio = atoi(param);
}
if (httpd_query_key_value(buf, "state", param, sizeof(param)) == ESP_OK) {
state = atoi(param);
}
if ((state == 0 || state == 1) && gpio >= 0 && gpio < 40) {
gpio_set_level(gpio, state);
ESP_LOGI(TAG, "GPIO %d set to %d via web server", gpio, state);
}
}
// Send empty OK response (client will call updateStatus())
httpd_resp_set_type(req, "text/plain");
httpd_resp_send(req, "OK", 2);
return ESP_OK;
}
// Start the HTTP server
static httpd_handle_t start_web_server(void)
{
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = 8192;
httpd_handle_t server = NULL;
if (httpd_start(&server, &config) == ESP_OK) {
ESP_LOGI(TAG, "HTTP server started on port %d", config.server_port);
httpd_uri_t root = { .uri = "/", .method = HTTP_GET, .handler = root_get_handler };
httpd_register_uri_handler(server, &root);
httpd_uri_t output = { .uri = "/output", .method = HTTP_GET, .handler = output_get_handler };
httpd_register_uri_handler(server, &output);
httpd_uri_t status = { .uri = "/status", .method = HTTP_GET, .handler = status_get_handler };
httpd_register_uri_handler(server, &status);
return server;
}
ESP_LOGE(TAG, "Failed to start server");
return NULL;
}
// Wi-Fi and IP event handler
static void wifi_event_handler(void* arg, esp_event_base_t event_base,
int32_t event_id, void* event_data)
{
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
esp_wifi_connect();
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t* event = (ip_event_got_ip_t*) event_data;
ESP_LOGI(TAG, "Got IP Address: " IPSTR, IP2STR(&event->ip_info.ip));
ESP_LOGI(TAG, "Web Server ready! Access at http://" IPSTR "/", IP2STR(&event->ip_info.ip));
}
}
void app_main(void)
{
// Initialize NVS
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
nvs_flash_erase();
nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
// Initialize TCP/IP stack and event loop
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
// Create default Wi-Fi STA interface
esp_netif_create_default_wifi_sta();
// Initialize Wi-Fi
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
// Register event handlers
ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT,
ESP_EVENT_ANY_ID,
wifi_event_handler,
NULL, NULL));
ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT,
IP_EVENT_STA_GOT_IP,
wifi_event_handler,
NULL, NULL));
// Configure Wi-Fi STA
wifi_config_t wifi_config = {
.sta = {
.ssid = MY_ESP_WIFI_SSID,
.password = MY_ESP_WIFI_PASS
}
};
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
// Configure GPIO 5
gpio_config_t io_conf = {
.intr_type = GPIO_INTR_DISABLE,
.mode = GPIO_MODE_INPUT_OUTPUT,
.pin_bit_mask = (1ULL << LED_PIN),
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.pull_up_en = GPIO_PULLUP_DISABLE
};
ESP_ERROR_CHECK(gpio_config(&io_conf));
gpio_set_level(LED_PIN, 0);
// Start the web server (it will be ready once IP is assigned)
httpd_handle_t server = start_web_server();
if (server) {
ESP_LOGI(TAG, "Web Server initialized. Waiting for Wi-Fi connection...");
}
}
Cómo funciona por dentro
La página vive en una sola cadena. html_root_page es el HTML completo escrito como trozos de texto entre comillas que C concatena al compilar. Tiene dos %s: el primero recibe la clase CSS (status-on o status-off) y el segundo el texto "ON" u "OFF". root_get_handler() los rellena con snprintf antes de enviar.
Ojo con el tamaño del búfer. La página ocupa cerca de 1,8 KB y se arma en char response[2048]. Si le agregas estilos o botones y pasas de 2048 bytes, snprintf no falla: corta la página en silencio y el navegador recibe un HTML a medias, típicamente sin el <script> del final, así que los botones dejan de funcionar sin ningún error en el monitor. Si vas a crecer la página, agranda el búfer o, mejor, envíala en partes con httpd_resp_send_chunk(). Y si escribes un % literal en el CSS (como width: 100%), tienes que ponerlo como %%, porque snprintf lo interpreta como formato.
Por qué el pin está en GPIO_MODE_INPUT_OUTPUT. Con GPIO_MODE_OUTPUT a secas, gpio_get_level() no lee el nivel del pin y siempre devuelve 0: la página diría "OFF" aunque el LED esté prendido. El modo entrada y salida deja leer el estado real, que es lo que usan / y /status.
Las cabeceras de caché (Cache-Control, Pragma, Expires) evitan que el celular muestre una versión guardada de la página con un estado viejo.
El WiFi es por eventos. wifi_event_handler() se conecta al arrancar, reintenta si se cae la conexión y, cuando el router entrega una IP, la imprime en el monitor serial. Esa es la dirección que escribes en el navegador. app_main() inicializa la NVS (ahí guarda el WiFi su calibración), la pila TCP/IP, el WiFi en modo estación, configura el GPIO y arranca el servidor en el puerto 80.
Compilar y cargar
En la barra inferior de VS Code elige el puerto COM de tu placa y el chip correcto.


Compila con el ícono de la llave (Build Project). La primera compilación tarda varios minutos; las siguientes son mucho más rápidas.


Carga el firmware con el ícono del rayo (Flash). Si tu placa no entra sola en modo de carga, mantén apretado BOOT mientras empieza la escritura.

Pruebas
Abre el monitor serial. Cuando aparezca Web Server ready! Access at http://..., copia esa IP en el navegador de un celular conectado a la misma red. Aprieta TURN ON: el LED se prende y el estado cambia a ON en verde.
Si algo no anda:
- El monitor repite intentos de conexión: el SSID o la clave del menú de configuración están mal, o la red es de 5 GHz. El ESP32 solo se conecta a redes de 2,4 GHz.
- La IP no abre desde el celular: revisa que el celular no esté en la red de invitados. Muchos routers aíslan a los clientes de esa red.
- La página carga pero los botones no hacen nada: probablemente la agrandaste y pasó los 2048 bytes del búfer (ver arriba).
Variantes y mejoras
- Un relé con estado que sobrevive a un corte de luz. Cambia el LED por un módulo relé y guarda el último estado en la NVS: en
output_get_handler(), después degpio_set_level(), llama anvs_set_u8()ynvs_commit(); enapp_main(), lee ese valor connvs_get_u8()antes de arrancar el servidor y aplícalo al pin. Así, si se corta la luz, la lámpara vuelve como estaba y no apagada. Para un relé, ignora el parámetrogpiode la URL y actúa solo sobreLED_PIN: hoy la ruta acepta cualquier número entre 0 y 39. - Que no cualquiera lo apriete. Tal como está, cualquier persona conectada a tu WiFi puede prender y apagar la salida. Para algo más serio que un LED, agrega autenticación HTTP básica (Random Nerd Tutorials tiene la guía para ESP IDF) y cambia
/outputde GET a POST, para que ni el navegador ni una vista previa de enlace puedan activarlo por accidente. - Varias salidas. Repite los botones en el HTML con otros números de GPIO y valida cada uno contra una lista de pines permitidos en el handler, en vez del rango de 0 a 39.
Personalización para Chile
En Chile consigues todo lo necesario en MechatronicStore:
- ESP32 con Wifi y Bluetooth ESP 32 ESP WROOM-32 ESP32S Tipo C (SKU X2-10V2): la placa que corre el servidor web.
- LED 5mm rojo (SKU GA1-5): la salida que controlas desde el navegador.
- Resistencias 1/4W 1 unidad 220Ω (SKU GK1-18): limita la corriente del LED.
- Breadboard / Protoboard 830 puntos MB102 (SKU C-302): para montar sin soldar.
- Cables macho macho 40 piezas 20cm (SKU C-411): las conexiones al LED.
El original usa una placa con ESP32-S3; el ESP32 WROOM-32 del catálogo corre el mismo código. Solo elige esp32 como chip en el asistente y considera mover LED_PIN si vas a conectar un relé (ver la nota sobre GPIO 5).
Recursos
- Tutorial original (inglés): basado en ESP IDF: ESP32 Web Server, Output Control, de Random Nerd Tutorials
- Código fuente: wifi_output_web_server.c en GitHub
- Documentación adicional: primeros pasos con ESP IDF en VS Code y servidor web ESP IDF con autenticación
Versión chilena con componentes en stock local en MechatronicStore.









