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

Esquema del proyecto: el navegador pide la página al ESP32 y los botones controlan el GPIO

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=1 cambia el pin a 1 (o a 0) y responde OK.
  • /status?gpio=5 responde solo un 1 o un 0 en 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).

Asistente de proyecto nuevo de la extensión ESP IDF con la selección de versión

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.

Formulario del asistente con nombre, carpeta, chip, placa y puerto del proyecto

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

Selección de la plantilla de ESP IDF para el proyecto nuevo

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

Notificación de VS Code para abrir el proyecto recién creado

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

Explorador de VS Code con la carpeta main desplegada y el archivo main.c abierto

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:

Menú contextual de VS Code para crear un archivo nuevo dentro de la carpeta main

Kconfig
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

Archivo Kconfig.projbuild abierto en VS Code con el menú WiFi Configuration

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.

Ícono del engranaje que abre el editor de configuración del SDK

Editor de configuración del SDK con los campos de SSID y clave del WiFi

El código completo

Reemplaza el contenido de main.c por este:

C
/*  
  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.

Selección del puerto COM en la barra inferior de VS Code

Selección del chip de destino en la extensión ESP IDF

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

Botón de compilar proyecto en la barra de ESP IDF

Mensaje de compilación terminada sin errores en la terminal de VS Code

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.

Mensaje de carga del firmware completada en la terminal de VS Code

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 de gpio_set_level(), llama a nvs_set_u8() y nvs_commit(); en app_main(), lee ese valor con nvs_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ámetro gpio de la URL y actúa solo sobre LED_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 /output de 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

Versión chilena con componentes en stock local en MechatronicStore.