Servidor web ESP32 asíncrono y WebSocket: panel en tiempo real
Servidor web asíncrono ESP32 con WebSocket: controla un relé y ve sensores en vivo sin recargar. Instalación, PROGMEM o LittleFS, esp32.local, clave, errores.
Un servidor web asíncrono ESP32 (ESP32 async web server), creado con la biblioteca ESPAsyncWebServer, convierte tu ESP32 en un servidor web rápido capaz de responder a varios móviles y ordenadores a la vez. Con el soporte de WebSocket que trae la propia biblioteca, el navegador y la placa mantienen una línea siempre abierta: pulsas el botón de la página y el relé se activa al instante, y cuando cambia la temperatura el número de la página se actualiza solo, sin recargar. Cualquier otro dispositivo que tenga abierto el mismo panel ve tu cambio en el mismo segundo. En resumen, esta pareja es la forma más práctica de montar tu propio panel domótico dentro de tu red local, sin ningún servicio en la nube.
En esta guía verás primero en qué se diferencia un servidor asíncrono de la clásica biblioteca WebServer y por qué WebSocket es más rápido que las peticiones HTTP normales. Después instalarás las bibliotecas correctas, pondrás en marcha el servidor más sencillo posible y crearás un panel de control en tiempo real con un relé y un sensor DHT22. A continuación aprenderás a guardar el HTML en PROGMEM o en LittleFS, a usar esp32.local en lugar de la dirección IP, a gestionar varios clientes, a proteger el panel con una contraseña sencilla y a solucionar los errores de compilación y de conexión más habituales. El código está escrito para funcionar con las versiones 2.x y 3.x del núcleo ESP32 en el Arduino IDE.

¿Qué es el servidor web asíncrono del ESP32 y cómo funciona?
La biblioteca WebServer.h que viene con el núcleo ESP32 es síncrona: las peticiones entrantes solo se atienden, una a una y por orden, cuando se llama a server.handleClient() dentro de loop(). Si loop() tiene un delay() de 2 segundos, la página también espera 2 segundos; si dos personas se conectan a la vez, una espera a que termine la otra. ESPAsyncWebServer, en cambio, se ejecuta en una tarea de FreeRTOS aparte (async_tcp) que crea la biblioteca AsyncTCP. Cuando llega una petición, sus cabeceras se leen en segundo plano, la función que escribiste (el callback) se llama cuando la petición está lista y la respuesta también se envía en segundo plano. Mientras tanto, loop() sigue con lo suyo. Así pueden quedar abiertas varias conexiones a la vez y son posibles las conexiones permanentes como WebSocket y Server-Sent Events (SSE). Si te interesan los dos núcleos del ESP32 y las tareas, echa un vistazo al artículo sobre FreeRTOS en el ESP32.
| Característica | WebServer.h (síncrono) | ESPAsyncWebServer (asíncrono) |
|---|---|---|
| Instalación | Viene con el núcleo ESP32 | 2 bibliotecas desde el Gestor de bibliotecas |
| Atención de peticiones | Una a una con handleClient() en loop() | En la tarea async_tcp en segundo plano, varias a la vez |
| delay() dentro de loop() | Retrasa la respuesta de la página | No afecta al servidor |
| WebSocket | No; requiere otra biblioteca (p. ej., WebSockets de Markus Sattler) | Integrado (AsyncWebSocket) |
| Server-Sent Events | No | Integrado (AsyncEventSource) |
| Archivos estáticos | Con serveStatic | serveStatic, soporte .gz automático y control de caché |
| Ojo con | Es la más fácil de aprender | Nada de delay() ni tareas largas en los callbacks |
¿Qué es WebSocket y en qué se diferencia de una petición HTTP?
En una petición HTTP normal, el navegador abre una conexión, dice "dame esta página", recibe la respuesta y la conversación termina. El servidor no puede decirle nada al navegador por su cuenta; para ver un valor nuevo, el navegador tiene que volver a preguntar. WebSocket (RFC 6455) es un saludo inicial que empieza como HTTP: el navegador añade a su petición la cabecera Upgrade: websocket, el ESP32 acepta con la respuesta 101 Switching Protocols y la misma conexión TCP queda abierta. A partir de ahí, ambos lados pueden enviar mensajes cuando quieran (full-duplex). Cada mensaje solo añade entre 2 y 14 bytes de sobrecarga, mientras que cada petición HTTP lleva cientos de bytes de cabeceras. Por eso los mensajes WebSocket llegan en milisegundos y es el método más eficiente para paneles que envían varias actualizaciones por segundo. Así se comparan las cuatro formas de mantener una página al día con los valores de un sensor:
| Método | Cómo funciona | Latencia y carga | Cuándo usarlo |
|---|---|---|---|
| Recargar la página (meta refresh) | El navegador descarga la página entera cada pocos segundos | Alta; la página parpadea cada vez | Mejor evitarlo |
| Consulta periódica con fetch (polling) | JavaScript pide un JSON pequeño cada 1–2 segundos | Media; cada petición lleva cabeceras nuevas | Un único valor que cambia poco |
| Server-Sent Events (SSE) | El servidor envía eventos en un solo sentido por una conexión abierta | Baja; solo del servidor al navegador | Paneles que solo muestran datos |
| WebSocket | Mensajes en ambos sentidos por una conexión abierta | La más baja; tramas de pocos bytes | Botones, deslizadores y datos en vivo a la vez |
Bibliotecas necesarias: instalar ESPAsyncWebServer y AsyncTCP
- Asegúrate de tener instalado el paquete de placas ESP32 (Espressif Systems) en el Arduino IDE; los pasos están en ¿Qué es el ESP32?
- Abre el Gestor de bibliotecas (Ctrl+Mayús+I) y busca ESPAsyncWebServer. Instala la biblioteca ESP Async WebServer cuyo autor es ESP32Async.
- Esta biblioteca no declara su dependencia en el Gestor de bibliotecas, así que el IDE no te ofrecerá instalar nada más: instala por separado la biblioteca Async TCP, publicada también por ESP32Async. En un ESP8266 necesitas en su lugar la biblioteca ESP Async TCP de ESP32Async (ESPAsyncTCP.h).
- Para el ejemplo del panel, instala la DHT sensor library de Adafruit y la dependencia Adafruit Unified Sensor que pide. Si es la primera vez que instalas una biblioteca, consulta la guía para añadir bibliotecas en Arduino.
- Si en la carpeta Documentos/Arduino/libraries hay carpetas ESPAsyncWebServer-master, AsyncTCP-master o ESPAsyncWebSrv añadidas antes desde un ZIP, bórralas. Dos copias de la misma biblioteca hacen que el compilador elija la equivocada.
Primer paso: el servidor más sencillo con AsyncWebServer
Antes de pasar al panel, veamos la lógica de la biblioteca con un ejemplo pequeño. El código de abajo conecta el ESP32 a tu red de casa, muestra un saludo en la página principal y enciende y apaga el LED integrado con las direcciones /led?durum=1 y /led?durum=0. Súbelo, abre el Monitor Serie a 115200 baudios y escribe la dirección que aparece en el navegador de un móvil conectado a la misma Wi-Fi. Si la placa no se conecta a la red, usa el código de diagnóstico de la guía el ESP32 no se conecta a la Wi-Fi.
1#include <WiFi.h>
2#include <AsyncTCP.h>
3#include <ESPAsyncWebServer.h>
4
5const char* ssid = "AgAdi"; // Wi-Fi adın / your Wi-Fi name
6const char* password = "Sifre123"; // Wi-Fi şifren / your Wi-Fi password
7const int LED_PIN = 2; // çoğu DevKit'te dahili LED / onboard LED on most DevKits
8
9AsyncWebServer server(80); // 80 = standart HTTP portu / standard HTTP port
10bool ledAcik = false;
11
12void setup() {
13 Serial.begin(115200);
14 pinMode(LED_PIN, OUTPUT);
15
16 WiFi.mode(WIFI_STA);
17 WiFi.begin(ssid, password);
18 while (WiFi.status() != WL_CONNECTED) {
19 delay(500);
20 Serial.print(".");
21 }
22 Serial.println();
23 Serial.print("Adres / address: http://");
24 Serial.println(WiFi.localIP());
25
26 // Ana sayfa / home page
27 server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
28 request->send(200, "text/plain", "Merhaba, ben ESP32 / Hello from ESP32");
29 });
30
31 // /led?durum=1 yakar, /led?durum=0 söndürür / turns the LED on or off
32 server.on("/led", HTTP_GET, [](AsyncWebServerRequest *request) {
33 if (request->hasParam("durum")) {
34 ledAcik = request->getParam("durum")->value() == "1";
35 digitalWrite(LED_PIN, ledAcik ? HIGH : LOW);
36 }
37 request->send(200, "text/plain", ledAcik ? "LED: ON" : "LED: OFF");
38 });
39
40 // Tanımsız adresler / unknown addresses
41 server.onNotFound([](AsyncWebServerRequest *request) {
42 request->send(404, "text/plain", "Sayfa yok / Not found");
43 });
44
45 server.begin();
46}
47
48void loop() {
49 // Sunucu arka planda çalışır, loop() boş kalabilir / the server runs in the background
50}| Instrucción | Para qué sirve |
|---|---|
| AsyncWebServer server(80) | Crea el servidor que escucha en el puerto HTTP 80 |
| server.on("/led", HTTP_GET, ...) | Registra la función (callback) que se ejecuta para una dirección y un tipo de petición |
| request->hasParam("durum") | Comprueba si la dirección incluye el parámetro ?durum=... |
| request->getParam("durum")->value() | Devuelve el valor del parámetro como String |
| request->send(código, tipo, contenido) | Envía la respuesta: 200 correcto, 404 no encontrado; tipo text/plain, text/html o application/json |
| server.onNotFound(...) | Se ejecuta para cualquier dirección no definida |
| server.begin() | Arranca el servidor; llámalo después de todas las líneas server.on() |
Materiales y conexiones del panel de control
En el ejemplo del panel encenderás y apagarás una lámpara o un ventilador con un módulo de relé y medirás temperatura y humedad con un DHT22. Necesitas un ESP32 DevKit, un módulo de relé de 5 V de un canal, un módulo sensor DHT22 (o DHT11), una protoboard y cables jumper. Al elegir el relé, prefiere módulos con optoacoplador. El DHT22 mide de −40 a 80 °C con una precisión de ±0,5 °C y solo puede leerse como mucho una vez cada 2 segundos; el DHT11 es más barato, pero ofrece ±2 °C en el rango de 0 a 50 °C. Para más detalles del sensor, mira el artículo del sensor de temperatura y humedad DHT11, y para el mapa de pines de tu placa, la herramienta Pinout.
| Pin del módulo | ESP32 DevKit | ESP8266 NodeMCU | Nota |
|---|---|---|---|
| VCC del relé | VIN (5 V) | VIN o VU (5 V) | La bobina del relé necesita 5 V; se alimenta por USB |
| GND del relé | GND | GND | Tierra común |
| IN del relé | GPIO 26 | D1 (GPIO 5) | La mayoría de módulos se activan con LOW |
| VCC (+) del DHT22 | 3V3 | 3V3 | Alimentado a 3,3 V, la línea de datos también va a 3,3 V |
| DATA (OUT) del DHT22 | GPIO 4 | D2 (GPIO 4) | Sensor suelto: resistencia de 10 kΩ entre DATA y VCC |
| GND (−) del DHT22 | GND | GND | Tierra común |
Panel de control en tiempo real: relé, DHT22 y WebSocket
Vamos ahora con el proyecto principal. El código de abajo es un solo archivo: el HTML, el CSS y el JavaScript están dentro como un array PROGMEM. Al abrir la página, el navegador establece una conexión WebSocket con /ws. Cada 2 segundos, el ESP32 lee el DHT22 y envía a todos los navegadores la temperatura, la humedad, el estado del relé y el número de dispositivos conectados en un único mensaje JSON. Cuando pulsas el botón, el navegador envía el mensaje toggle, el ESP32 cambia el relé y vuelve a comunicar el nuevo estado a todos. Cambia el nombre de red y la contraseña, súbelo y abre en el navegador http://esp32.local o la dirección IP del Monitor Serie.
1#include <WiFi.h>
2#include <AsyncTCP.h>
3#include <ESPAsyncWebServer.h>
4#include <ESPmDNS.h>
5#include <DHT.h>
6
7const char* ssid = "AgAdi"; // Wi-Fi adın / your Wi-Fi name
8const char* password = "Sifre123"; // Wi-Fi şifren / your Wi-Fi password
9
10const int ROLE_PIN = 26; // röle modülü IN / relay module IN
11const bool ROLE_AKTIF_LOW = true; // çoğu modül LOW ile çeker / most modules switch on LOW
12const int DHT_PIN = 4; // DHT22 veri pini / DHT22 data pin
13
14DHT dht(DHT_PIN, DHT22); // DHT11 için DHT11 yaz / write DHT11 for a DHT11
15AsyncWebServer server(80);
16AsyncWebSocket ws("/ws"); // WebSocket adresi / WebSocket path
17
18bool roleAcik = false;
19float sicaklik = NAN;
20float nem = NAN;
21volatile bool gonder = false; // loop() yayın yapsın mı? / should loop() broadcast?
22unsigned long sonOlcum = 0;
23unsigned long sonTemizlik = 0;
24
25// Tarayıcıya giden sayfa / the page sent to the browser
26const char index_html[] PROGMEM = R"rawliteral(
27<!DOCTYPE html>
28<html lang="tr">
29<head>
30<meta charset="utf-8">
31<meta name="viewport" content="width=device-width, initial-scale=1">
32<title>ESP32 Panel</title>
33<style>
34body{font-family:sans-serif;background:#0f172a;color:#e2e8f0;text-align:center;margin:0;padding:16px}
35.kart{background:#1e293b;border-radius:12px;padding:16px;margin:12px auto;max-width:320px}
36.deger{font-size:2.4em;font-weight:bold}
37button{font-size:1.2em;padding:14px 32px;border:0;border-radius:8px;color:#fff;width:100%}
38.acik{background:#16a34a}.kapali{background:#475569}
39</style>
40</head>
41<body>
42<h2>ESP32 Panel</h2>
43<p id="baglanti">Bağlanıyor / Connecting...</p>
44<div class="kart">Sıcaklık / Temperature<div class="deger"><span id="sicaklik">--</span> °C</div></div>
45<div class="kart">Nem / Humidity<div class="deger"><span id="nem">--</span> %</div></div>
46<div class="kart">Röle / Relay<p><button id="dugme" class="kapali" onclick="degistir()">KAPALI / OFF</button></p></div>
47<p>Bağlı cihaz / Clients: <span id="istemci">0</span></p>
48<script>
49let ws;
50const $ = (id) => document.getElementById(id);
51function baglan() {
52 ws = new WebSocket('ws://' + location.host + '/ws');
53 ws.onopen = () => { $('baglanti').textContent = 'Bağlı / Connected'; };
54 ws.onclose = () => {
55 $('baglanti').textContent = 'Koptu, yeniden deneniyor / Reconnecting...';
56 setTimeout(baglan, 2000);
57 };
58 ws.onmessage = (e) => {
59 const d = JSON.parse(e.data);
60 $('sicaklik').textContent = d.sicaklik === null ? '--' : d.sicaklik.toFixed(1);
61 $('nem').textContent = d.nem === null ? '--' : d.nem.toFixed(0);
62 $('istemci').textContent = d.istemci;
63 $('dugme').textContent = d.role ? 'AÇIK / ON' : 'KAPALI / OFF';
64 $('dugme').className = d.role ? 'acik' : 'kapali';
65 };
66}
67function degistir() {
68 if (ws.readyState === WebSocket.OPEN) ws.send('toggle');
69}
70baglan();
71</script>
72</body>
73</html>
74)rawliteral";
75
76void roleAyarla(bool acik) {
77 roleAcik = acik;
78 digitalWrite(ROLE_PIN, ROLE_AKTIF_LOW ? !acik : acik);
79}
80
81// Durumu JSON metnine çevir / build the state as JSON text
82String durumJson() {
83 String s = "{\"role\":";
84 s += roleAcik ? "true" : "false";
85 s += ",\"sicaklik\":";
86 s += isnan(sicaklik) ? String("null") : String(sicaklik, 1);
87 s += ",\"nem\":";
88 s += isnan(nem) ? String("null") : String(nem, 0);
89 s += ",\"istemci\":";
90 s += (unsigned long)ws.count();
91 s += "}";
92 return s;
93}
94
95// WebSocket olayları (async_tcp görevinde çalışır) / WebSocket events (run in the async_tcp task)
96void wsOlay(AsyncWebSocket *sunucu, AsyncWebSocketClient *client, AwsEventType type,
97 void *arg, uint8_t *data, size_t len) {
98 switch (type) {
99 case WS_EVT_CONNECT:
100 Serial.printf("Istemci #%u baglandi / connected: %s\n",
101 (unsigned)client->id(), client->remoteIP().toString().c_str());
102 gonder = true; // yeni gelen güncel durumu alsın / newcomer gets the state
103 break;
104 case WS_EVT_DISCONNECT:
105 Serial.printf("Istemci #%u ayrildi / disconnected\n", (unsigned)client->id());
106 gonder = true;
107 break;
108 case WS_EVT_DATA: {
109 AwsFrameInfo *info = (AwsFrameInfo *)arg;
110 // Tek parça, tam bir metin mesajı mı? / a complete single-frame text message?
111 if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
112 if (len == 6 && memcmp(data, "toggle", 6) == 0) {
113 roleAyarla(!roleAcik);
114 gonder = true; // tüm ekranlar eşitlensin / sync every screen
115 }
116 }
117 break;
118 }
119 default: // PONG, ERROR vb. / PONG, ERROR etc.
120 break;
121 }
122}
123
124void setup() {
125 Serial.begin(115200);
126 pinMode(ROLE_PIN, OUTPUT);
127 roleAyarla(false); // açılışta röle kapalı / relay off at start
128 dht.begin();
129
130 WiFi.mode(WIFI_STA);
131 WiFi.begin(ssid, password);
132 Serial.print("Baglaniyor / connecting");
133 while (WiFi.status() != WL_CONNECTED) {
134 delay(500);
135 Serial.print(".");
136 }
137 WiFi.setSleep(false); // daha hızlı tepki / lower latency
138 Serial.println();
139 Serial.print("http://");
140 Serial.println(WiFi.localIP());
141
142 if (MDNS.begin("esp32")) { // http://esp32.local
143 MDNS.addService("http", "tcp", 80);
144 Serial.println("http://esp32.local");
145 }
146
147 ws.onEvent(wsOlay);
148 server.addHandler(&ws);
149
150 server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
151 request->send(200, "text/html", index_html);
152 });
153 server.onNotFound([](AsyncWebServerRequest *request) {
154 request->send(404, "text/plain", "Sayfa yok / Not found");
155 });
156 server.begin();
157}
158
159void loop() {
160 if (millis() - sonOlcum >= 2000) { // DHT22 en fazla 2 sn'de bir / DHT22: once per 2 s
161 sonOlcum = millis();
162 float t = dht.readTemperature();
163 float h = dht.readHumidity();
164 if (!isnan(t) && !isnan(h)) { // hatalı okumayı atla / skip failed reads
165 sicaklik = t;
166 nem = h;
167 }
168 gonder = true;
169 }
170
171 if (gonder) {
172 gonder = false;
173 ws.textAll(durumJson()); // tüm tarayıcılara yolla / send to every browser
174 }
175
176 if (millis() - sonTemizlik >= 1000) {
177 sonTemizlik = millis();
178 ws.cleanupClients(); // kopuk istemcileri temizle / drop stale clients
179 }
180}¿Cómo funciona el código?
- AsyncWebSocket ws("/ws") crea el punto de acceso WebSocket y server.addHandler(&ws) lo conecta al servidor. La dirección del JavaScript debe coincidir exactamente con esta ruta.
- La función wsOlay() se llama en cada conexión, desconexión y mensaje entrante. WS_EVT_CONNECT indica un cliente nuevo, WS_EVT_DISCONNECT un cliente que se va y WS_EVT_DATA un mensaje que llega.
- AwsFrameInfo contiene la información de la trama recibida. Comprobar final, index == 0 y len confirma que el mensaje llegó completo en una sola trama, y WS_TEXT, que es texto. Los comandos cortos siempre caben en una trama.
- Los datos recibidos se comparan junto con su longitud usando memcmp(); así no dependes de que haya o no un carácter nulo al final de los datos.
- Como los callbacks se ejecutan en la tarea async_tcp, solo cambian el relé y activan la bandera gonder. El envío real se hace en loop() con ws.textAll(); de este modo todos los mensajes salen desde un único sitio y en orden.
- ws.cleanupClients() se llama una vez por segundo: libera la memoria de las conexiones caídas y cierra la conexión más antigua cuando el número de clientes supera el límite.
- En el lado de JavaScript, el evento onclose vuelve a conectar tras 2 segundos. Aunque el ESP32 se reinicie o la Wi-Fi se corte un instante, la página se recupera sola. Como la dirección se forma con location.host, la página funciona tanto con la IP como con esp32.local.
- WiFi.setSleep(false) desactiva el ahorro de energía de la Wi-Fi. El modo de suspensión del módem que viene por defecto puede añadir retrasos de hasta varios cientos de milisegundos; desactivarlo en un panel enchufado a la red hace que los botones respondan al instante.
Elegir JSON como formato de mensaje te facilita la vida: el navegador convierte el texto recibido en un objeto en una sola línea con JSON.parse(). Un mensaje de ejemplo tiene este aspecto: {"role":true,"sicaklik":23.4,"nem":48,"istemci":2}. Cuando no se puede leer el sensor, el valor se envía como null y la página muestra "--"; si un fallo de lectura saliera como NaN, el JSON no válido impediría que la página se actualizara. Como cada mensaje ocupa unos 60 bytes, ni siquiera varias actualizaciones por segundo cargan la red. Para mensajes más complejos puedes pasarte a la biblioteca ArduinoJson, pero para un panel de este tamaño unir Strings es más que suficiente. También puedes probar el mismo código sin cambios conectando un LED o un zumbador en lugar del relé.
¿Dónde guardar el HTML: PROGMEM o LittleFS?
En el código del panel, la página está en un literal de cadena sin procesar escrito como R"rawliteral( ... )rawliteral". Esta sintaxis te permite usar comillas y saltos de línea sin caracteres de escape. En los Arduino basados en AVR, PROGMEM hace falta para guardar el texto en la flash en lugar de en la RAM; en el ESP32, los arrays constantes ya se leen desde la flash, así que PROGMEM solo se escribe por compatibilidad y no hace daño. Cuando la página crece, editar HTML dentro del código se vuelve incómodo; en ese punto es más ordenado mover los archivos al sistema de archivos LittleFS de la flash.
| Criterio | PROGMEM (dentro del código) | LittleFS (sistema de archivos) |
|---|---|---|
| Preparación | Sin pasos extra, un solo archivo | Necesita la carpeta data y un plugin de subida |
| Edición | HTML dentro de un array de C++ | Archivos index.html, style.css y app.js separados |
| Actualización | Vuelves a subir el sketch tras cada cambio | Solo vuelves a subir el sistema de archivos |
| Tamaño | Ideal para páginas de pocos KB | Unos 1,5 MB de espacio con la partición por defecto |
| Imágenes y fuentes | Poco práctico | Se pueden servir iconos, imágenes y archivos .gz |
| ¿Cuándo? | Primeras pruebas y paneles pequeños | Interfaces de varias páginas o que crecen |
Servir archivos HTML, CSS y JavaScript con LittleFS
- Dentro de la carpeta del sketch (la que contiene el archivo .ino), crea una carpeta llamada data y guarda como data/index.html el HTML que hay entre R"rawliteral( y )rawliteral" en el código del panel. Si quieres, puedes separar el CSS y el JavaScript en sus propios archivos.
- Arduino IDE 2 no admite el antiguo menú ESP32 Sketch Data Upload. En su lugar, descarga el archivo .vsix del plugin arduino-littlefs-upload y colócalo en C:\Users\(tu usuario)\.arduinoIDE\plugins en Windows o en ~/.arduinoIDE/plugins en macOS y Linux (crea la carpeta si no existe); después reinicia el IDE. El plugin necesita el IDE 2.2.1 o posterior.
- Cierra el Monitor Serie si está abierto, abre la paleta de comandos con Ctrl+Mayús+P (Cmd+Mayús+P en macOS) y elige Upload LittleFS to Pico/ESP8266/ESP32. Los archivos se escriben en la partición del sistema de archivos de la flash.
- Comprueba que en Herramientas > Partition Scheme está elegido un esquema con partición de sistema de archivos (por ejemplo Default 4MB with spiffs). LittleFS usa esa partición, que se llama spiffs.
- Sube el código de abajo como siempre. Mientras la opción Erase All Flash del menú Herramientas esté desactivada, subir el sketch no borra el sistema de archivos; cuando cambies los archivos, basta con repetir el paso 3.
1#include <WiFi.h>
2#include <AsyncTCP.h>
3#include <ESPAsyncWebServer.h>
4#include <LittleFS.h>
5
6const char* ssid = "AgAdi"; // Wi-Fi adın / your Wi-Fi name
7const char* password = "Sifre123"; // Wi-Fi şifren / your Wi-Fi password
8
9AsyncWebServer server(80);
10
11void setup() {
12 Serial.begin(115200);
13
14 if (!LittleFS.begin(true)) { // true: bağlanamazsa biçimlendir / format if mount fails
15 Serial.println("LittleFS hatasi / LittleFS error");
16 return;
17 }
18
19 WiFi.mode(WIFI_STA);
20 WiFi.begin(ssid, password);
21 while (WiFi.status() != WL_CONNECTED) {
22 delay(500);
23 }
24 Serial.println(WiFi.localIP());
25
26 // data/ klasörünün içeriğini sun / serve the contents of the data/ folder
27 server.serveStatic("/", LittleFS, "/")
28 .setDefaultFile("index.html") // varsayılan index.htm'dir / default is index.htm
29 .setCacheControl("max-age=600"); // tarayıcı 10 dk önbelleğe alsın / cache for 10 min
30
31 server.onNotFound([](AsyncWebServerRequest *request) {
32 request->send(404, "text/plain", "Sayfa yok / Not found");
33 });
34 server.begin();
35}
36
37void loop() {
38}mDNS: esp32.local en lugar de la dirección IP
El router puede dar al ESP32 una IP distinta cada vez que arranca, y cifras como 192.168.1.47 cuestan de recordar. mDNS (multicast DNS) permite que un dispositivo anuncie su propio nombre en la red local. Gracias a la línea MDNS.begin("esp32") del código del panel, los dispositivos de la misma red pueden abrir la página en http://esp32.local, y MDNS.addService("http", "tcp", 80) indica a las apps que exploran la red que aquí hay un servidor web. No añadas ".local" al nombre y usa solo minúsculas, números y guiones. Si usas varios ESP32, da a cada uno un nombre distinto (cocina, salon, etc.), porque dos dispositivos con el mismo nombre se estorban. mDNS no tiene el mismo soporte en todos los sistemas:
| Sistema | Soporte de .local | Si no funciona |
|---|---|---|
| iPhone, iPad, macOS | Integrado (Bonjour) | Comprueba que estás en la misma red Wi-Fi |
| Windows 10 y 11 | Suele funcionar en versiones actualizadas | Instala el servicio Bonjour o usa la IP |
| Linux | Funciona si avahi-daemon está instalado | Instala los paquetes avahi-daemon y nss-mdns |
| Android | Depende de la versión y del navegador | Usa la dirección IP o crea una reserva DHCP en el router |
Varios clientes: móvil, tableta y ordenador a la vez
Lo que más gusta de WebSocket es que todo el que abre el panel ve el mismo estado. Cuando enciendes el relé desde el móvil, el ESP32 envía el nuevo estado a todas las conexiones con textAll() y la pestaña del ordenador se pone en verde en ese mismo momento. Cada pestaña es un cliente distinto: si abres tres pestañas en el mismo navegador, el contador marca 3. Por defecto, la biblioteca permite 8 clientes WebSocket simultáneos en el ESP32 y 4 en el ESP8266 (DEFAULT_MAX_WS_CLIENTS). Este límite no se aplica solo; cuando se llama a cleanupClients(), se cierra la conexión más antigua que lo supera. Además, ten en cuenta lo siguiente:
- Cuando el móvil se suspende: un móvil con la pantalla apagada puede no cerrar bien la conexión. Esas conexiones a medio cerrar se limpian con el tiempo gracias a cleanupClients() y al envío periódico de datos.
- Ritmo de envío: por cada cliente pueden esperar en cola como máximo 32 mensajes (8 en el ESP8266). Si llamas a textAll() en cada vuelta de loop(), la cola se llena y los mensajes nuevos se descartan sin avisar; si pones Herramientas > Core Debug Level en Warn o en un nivel más detallado, verás el aviso Too many messages queued en el Monitor Serie. Envía un valor solo cuando cambie o unas pocas veces por segundo.
- Cliente recién conectado: envía enseguida el estado actual a cada página que se conecte; si no, el usuario verá un panel vacío hasta la primera medición. En el código del panel se encarga de ello la bandera que se activa en WS_EVT_CONNECT.
- Responder a un solo cliente: para contestar solo al navegador que preguntó y no a todos, usa client->text("..."); el número del cliente te lo da client->id().
- Memoria: cada conexión abierta ocupa un socket TCP y memoria de búfer. Los 520 KB de RAM del ESP32 sobran para unos cuantos clientes, pero imprimir de vez en cuando la memoria libre con ESP.getFreeHeap() te ayuda a detectar fugas a tiempo.
Protección sencilla con contraseña
Cualquiera en tu red doméstica que conozca la dirección del panel puede encender y apagar el relé. Con las funciones request->authenticate() y request->requestAuthentication() de la biblioteca puedes poner usuario y contraseña a la página; el navegador abre su propia ventana de inicio de sesión y recuerda los datos durante la sesión. El método por defecto es la autenticación Digest, es decir, la contraseña no viaja por la red en texto plano. Pero proteger solo la página no basta: la dirección WebSocket es otra puerta y alguien podría conectarse directamente a ws://ip/ws. El ejemplo de abajo cierra también esa puerta: en cada arranque el ESP32 genera una clave aleatoria, se la da solo al navegador que ha iniciado sesión en /anahtar y, con setFilter(), rechaza las conexiones WebSocket que no traen la clave correcta.
1#include <WiFi.h>
2#include <AsyncTCP.h>
3#include <ESPAsyncWebServer.h>
4
5const char* ssid = "AgAdi"; // Wi-Fi adın / your Wi-Fi name
6const char* password = "Sifre123"; // Wi-Fi şifren / your Wi-Fi password
7const char* KULLANICI = "admin"; // panel kullanıcı adı / panel username
8const char* PAROLA = "Gizli-2026"; // panel parolası / panel password
9const int LED_PIN = 2;
10
11AsyncWebServer server(80);
12AsyncWebSocket ws("/ws");
13String wsAnahtar; // her açılışta yeni anahtar / a new key on every boot
14bool ledAcik = false;
15
16const char index_html[] PROGMEM = R"rawliteral(
17<!DOCTYPE html>
18<html><head><meta charset="utf-8">
19<meta name="viewport" content="width=device-width, initial-scale=1"></head>
20<body style="font-family:sans-serif;text-align:center">
21<h2>Korumali panel / Protected panel</h2>
22<button style="font-size:1.4em;padding:12px 32px" onclick="ws && ws.send('toggle')">LED</button>
23<p id="durum">...</p>
24<script>
25let ws;
26fetch('/anahtar').then(r => r.text()).then(k => {
27 ws = new WebSocket('ws://' + location.host + '/ws?k=' + k);
28 ws.onmessage = (e) => { document.getElementById('durum').textContent = e.data; };
29});
30</script>
31</body></html>
32)rawliteral";
33
34bool girisVar(AsyncWebServerRequest *request) {
35 return request->authenticate(KULLANICI, PAROLA);
36}
37
38void setup() {
39 Serial.begin(115200);
40 pinMode(LED_PIN, OUTPUT);
41
42 WiFi.mode(WIFI_STA);
43 WiFi.begin(ssid, password);
44 while (WiFi.status() != WL_CONNECTED) {
45 delay(500);
46 }
47 Serial.println(WiFi.localIP());
48
49 // Wi-Fi açıkken random() donanım üretecini kullanır / random() uses the hardware RNG
50 wsAnahtar = String(random(0x7FFFFFFF), HEX) + String(random(0x7FFFFFFF), HEX);
51
52 // Sayfa: tarayıcı kullanıcı adı ve parola sorar / page: the browser asks for login
53 server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
54 if (!girisVar(request)) return request->requestAuthentication();
55 request->send(200, "text/html", index_html);
56 });
57
58 // Anahtar yalnız giriş yapana verilir / the key is given only after login
59 server.on("/anahtar", HTTP_GET, [](AsyncWebServerRequest *request) {
60 if (!girisVar(request)) return request->requestAuthentication();
61 request->send(200, "text/plain", wsAnahtar);
62 });
63
64 // Doğru anahtarı getirmeyen bağlantıyı reddet / reject sockets without the right key
65 ws.setFilter([](AsyncWebServerRequest *request) {
66 return request->hasParam("k") && request->getParam("k")->value() == wsAnahtar;
67 });
68
69 ws.onEvent([](AsyncWebSocket *sunucu, AsyncWebSocketClient *client, AwsEventType type,
70 void *arg, uint8_t *data, size_t len) {
71 if (type == WS_EVT_DATA && len == 6 && memcmp(data, "toggle", 6) == 0) {
72 ledAcik = !ledAcik;
73 digitalWrite(LED_PIN, ledAcik ? HIGH : LOW);
74 sunucu->textAll(ledAcik ? "LED: ON" : "LED: OFF");
75 }
76 });
77
78 server.addHandler(&ws);
79 server.begin();
80}
81
82void loop() {
83 ws.cleanupClients(); // saniyede bir yeterli / once per second is enough
84 delay(1000);
85}Notas para el ESP8266 y la Raspberry Pi Pico W
La misma biblioteca funciona también en el ESP8266 (NodeMCU, Wemos D1 mini). Esto es lo que tienes que cambiar: instala la biblioteca ESP Async TCP de ESP32Async en lugar de Async TCP; incluye ESP8266WiFi.h en lugar de WiFi.h, ESPAsyncTCP.h en lugar de AsyncTCP.h y ESP8266mDNS.h en lugar de ESPmDNS.h, y añade la línea MDNS.update() dentro de loop(). Sustituye WiFi.setSleep(false) por WiFi.setSleepMode(WIFI_NONE_SLEEP) y ajusta los pines según la columna NodeMCU de la tabla de conexiones. El ESP8266 tiene solo unos 80 KB de RAM utilizable, así que mantén la página pequeña y recuerda que el límite de clientes es 4. Si usas una Raspberry Pi Pico W, encontrarás cómo montar un servidor web con MicroPython en la guía Raspberry Pi Pico W Wi-Fi; las versiones nuevas de ESPAsyncWebServer también admiten los chips RP2040 y RP2350 en el lado de Arduino.
Ideas de proyectos con el servidor web ESP32
- Enchufe inteligente de varios canales: con una placa de 4 relés, crea botones separados para una lámpara, un ventilador y un enchufe; distingue los comandos con textos cortos como "r1" y "r2". Para elegir el relé, consulta el artículo del módulo de relé.
- Panel de riego: muestra en vivo el valor del sensor de humedad del suelo, ajusta el umbral de riego con un deslizador en la página y activa la bomba a mano cuando quieras.
- Estación meteorológica: usa un BME280 en lugar del DHT22 para añadir la presión y muestra las últimas 60 mediciones en un pequeño gráfico de líneas en la página.
- Lámpara con selector de color: envía por WebSocket el valor de un selector de color de la página y pinta al instante una tira WS2812B NeoPixel.
- Servo con deslizador: envía el valor del deslizador en cada movimiento y gira un servomotor; ideal para orientar una cámara o para un pestillo.
- Registro con marca de tiempo: añade a cada medición la hora obtenida por NTP y actualiza tu código de forma inalámbrica por OTA en lugar de hacerlo siempre por USB.
Errores frecuentes y cómo solucionarlos
| Síntoma | Causa | Solución |
|---|---|---|
| 'mbedtls_md5_starts_ret' was not declared in this scope | Biblioteca antigua de me-no-dev con el núcleo ESP32 3.x | Instala ESP Async WebServer y Async TCP de ESP32Async y borra las carpetas antiguas |
| Multiple libraries were found for "AsyncTCP.h" | Hay dos copias de la misma biblioteca | Borra las copias antiguas -master de la carpeta libraries |
| Aviso deprecated con send_P | send_P se está retirando en la versión nueva | Usa request->send(200, "text/html", index_html) |
| La página no abre en absoluto | El móvil está en la red de invitados o con datos móviles, o la IP ha cambiado | Conéctate a la misma Wi-Fi y usa la IP actual del Monitor Serie |
| La página abre, pero los valores se quedan en -- | WebSocket no conecta: ruta distinta o addHandler olvidado | Abre la consola con F12 e iguala ws("/ws") con la dirección del JavaScript |
| Task watchdog got triggered con async_tcp | delay(), un bucle largo o una petición HTTP dentro de un callback | Mantén el callback corto y pasa el trabajo a loop() con una bandera |
| Too many messages queued | Se llama a textAll() con demasiada frecuencia | Envía solo cuando haya cambios o unas pocas veces por segundo |
| La página de LittleFS da 404 | No se subió la carpeta data o falta setDefaultFile | Sube los archivos con el plugin y añade setDefaultFile("index.html") |
| does not exist, no permits for creation en el Monitor Serie | El archivo pedido no está en LittleFS | Revisa el nombre del archivo y sus mayúsculas y minúsculas; las líneas con .gz son inofensivas |
| El relé nunca se apaga | El módulo de 5 V no reconoce 3,3 V como HIGH | Usa un módulo compatible con 3,3 V, JD-VCC o un transistor |
| El ESP32 se reinicia al activarse el relé | La corriente de la bobina hunde la alimentación (brownout) | Usa un buen adaptador de 5 V y un condensador de 470 µF |
| esp32.local no abre | El dispositivo no admite mDNS o el nombre se escribió con .local | Escribe MDNS.begin("esp32") y, si hace falta, usa la dirección IP |
| El botón responde con retraso | Está activa la suspensión del módem Wi-Fi | Llama a WiFi.setSleep(false) después de conectar |
La combinación de un servidor web asíncrono ESP32 y WebSocket es el camino más corto hacia un panel de control rápido que funciona sin servicios en la nube ni apps para el móvil y que pueden compartir varios dispositivos. Empieza con el panel PROGMEM de un solo archivo, pasa a LittleFS cuando crezca la interfaz y dale a cada dispositivo nuevo de tu red su propio nombre mDNS. Si acabas de conocer el ESP32, encontrarás lo básico en ¿Qué es el ESP32? En la app BlueGrays también puedes seguir paso a paso, con esquemas de circuito y código listo para usar, proyectos ESP32 como un servidor web asíncrono, un contador con WebSocket, el acceso por mDNS, un reloj NTP y la actualización OTA.
Preguntas frecuentes
¿Qué diferencia hay entre ESPAsyncWebServer y WebServer en el ESP32?
La biblioteca WebServer atiende las peticiones una a una mediante la llamada a handleClient() en loop(); si loop() va lento, la página también. ESPAsyncWebServer atiende las peticiones en una tarea aparte en segundo plano, aguanta varias conexiones a la vez y trae integrados WebSocket y SSE. Para paneles en tiempo real, la versión asíncrona es la más adecuada.
¿Cuántos clientes pueden conectarse a la vez a un servidor web ESP32?
Por defecto, ESPAsyncWebServer permite 8 clientes WebSocket en el ESP32 y cierra los que sobran al llamar a cleanupClients(); en el ESP8266 el límite es 4. Como las peticiones normales de página son breves, más dispositivos pueden abrir la página uno tras otro. En la práctica, un panel doméstico funciona sin problemas con 3–5 dispositivos simultáneos.
¿Puedo acceder a mi servidor web ESP32 desde internet?
Técnicamente es posible con una redirección de puertos en el router, pero no se recomienda: la biblioteca no ofrece HTTPS en el ESP32 y el dispositivo queda expuesto a ataques desde internet. Para el control a distancia, un bot de Telegram, un sistema basado en MQTT o la función VPN de tu router son mucho más seguros.
¿Por qué no funciona esp32.local?
El motivo más habitual es que el móvil o el ordenador no saben resolver nombres mDNS; ocurre sobre todo en algunos móviles Android y en versiones antiguas de Windows. También es un error escribir MDNS.begin("esp32.local") en el código, porque el nombre va sin .local. Si aun así no se resuelve, usa la dirección IP del Monitor Serie o reserva una IP fija para el ESP32 en el router.
¿Funciona un servidor web ESP32 sin router?
Sí. Con WiFi.softAP("ESP32-Panel", "12345678") el ESP32 crea su propia red Wi-Fi; conecta el móvil a ella y abre 192.168.4.1 en el navegador. En este modo de punto de acceso no hay internet, pero el panel y WebSocket funcionan igual. La contraseña de la red debe tener al menos 8 caracteres.
¿Qué biblioteca ESPAsyncWebServer debo instalar?
En el Gestor de bibliotecas del Arduino IDE, instala ESP Async WebServer y Async TCP cuyo autor es ESP32Async. La versión original de la cuenta me-no-dev ya no se mantiene y no compila con el núcleo ESP32 3.x. Borra también de tu carpeta libraries las copias antiguas añadidas desde un ZIP.
Proyectos para hacer después de leer esto
Ver todos los proyectos
Servidor web asíncrono ESP32 (con indicación de IP en la pantalla LCD)
En este proyecto, convertimos el ESP32 en un servidor web asíncrono que puedes controlar desde tu teléfono o tu ordenador, siempre que estén conectados a la misma red Wi-Fi. Gracias a la biblioteca ESPAsyncWebServer, el servidor puede procesar varias solicitudes al mismo tiempo (a diferencia del clásico WebServer.h, que se bloquea con una sola solicitud).

Contador ESP32 WebSocket (2 botones + pantalla LCD + web en tiempo real)
En este proyecto, dos botones conectados al ESP32 (GPIO 5 suma, GPIO 4 resta) cambian un contador. El valor se actualiza al instante en una pantalla LCD I2C de 16x2 y, gracias al soporte WebSocket de ESPAsyncWebServer, en todos los navegadores conectados sin recargar la página. En lugar del sondeo HTTP se usa una única conexión persistente y el ESP32 envía el nuevo valor a todos con ws.textAll().

ESP32 mDNS: Acceso al dispositivo mediante bluegrays-esp.local (con pantalla LCD)
En este proyecto, al añadir el servicio mDNS (Multicast DNS) al ESP32, permitimos el acceso mediante un nombre de host legible, como bluegrays-esp.local, en lugar de una dirección IP. Aunque la IP asignada por el DHCP cambie, siempre podrás acceder al dispositivo con el mismo nombre. Además, al ejecutar un pequeño servidor web en él, enviamos comandos de control de los LED desde el navegador mediante la dirección http://bluegrays-esp.local/. En la pantalla LCD I2C se muestran el nombre de ho

Conexión Wi-Fi STA con ESP32 (con pantalla LCD)
En este proyecto, conectamos el ESP32 a la red Wi-Fi doméstica en modo Station (STA) de forma segura y tolerante a fallos. Separamos el SSID y la contraseña en el archivo secrets.h para evitar que se filtren a Git, configuramos un mecanismo de reconexión automática y mostramos el nivel de señal RSSI y la dirección IP en una pantalla LCD I2C. El LED interno actúa como indicador de estado.

ESP32 DHT22 Temperatura y humedad (con pantalla LCD)
Se trata de un proyecto que mide la temperatura y la humedad ambientales mediante el sensor DHT22 y las muestra en tiempo real en una pantalla LCD I2C 16x2. Los datos del sensor se leen mediante el protocolo de un solo cable (one-wire) utilizando la biblioteca DHT de Adafruit. Este proyecto constituye la base para estaciones meteorológicas IoT, sistemas de automatización doméstica inteligente o sistemas de monitorización ambiental en invernaderos.

Iluminación Inteligente ESP32 (PIR + LDR + Relé + OLED)
¡Convierte tu pasillo, baño o despensa en iluminación inteligente con ESP32! Utilizando un sensor de movimiento PIR, un sensor de luz LDR y un módulo de relé, construimos el escenario clásico "la luz se enciende cuando se detecta movimiento en la oscuridad" — si el ambiente ya está iluminado, el movimiento NO activa la luz, ahorrando energía durante el día. La luz permanece encendida durante 30 segundos; si el movimiento continúa, el temporizador se reinicia y la luz permanece encendida más tiem

Actualización OTA del ESP32 — Instalación inalámbrica del firmware (con pantalla LCD)
En este proyecto aprenderás a instalar un nuevo firmware en el ESP32 a través de Wi-Fi, sin necesidad de un cable USB. Con la biblioteca ArduinoOTA, el ESP32 se hace visible en la red y aparece automáticamente como bluegrays-ota en la pestaña «Puerto de red» del IDE de Arduino. La primera instalación se realiza mediante USB; a partir de ahí, todas las actualizaciones se realizan de forma inalámbrica, lo cual es una característica fundamental para los dispositivos de campo. En la pantalla LCD

ESP32: hora real desde Internet con NTP
El ESP32 se conecta a Wi-Fi y obtiene la hora real con precisión de milisegundos desde servidores NTP (Network Time Protocol). Gracias a configTzTime(), se aplica automáticamente la zona horaria de Turquía (TRT, UTC+3) y el cambio DST se gestiona mediante la cadena POSIX TZ. La hora se muestra en el monitor serie y en una pantalla LCD I2C opcional. configTzTime() inicia el servicio SNTP; se realizan resincronizaciones periódicas mientras hay acceso a la red, y el reloj del sistema sigue avan
Más artículos
App BlueGrays
Monta este circuito paso a paso en tu teléfono
Esquema, código y lista de piezas en una sola pantalla. Más de 200 proyectos de Arduino, ESP32 y Pico listos para montar.
Escanea con tu teléfono
Apunta la cámara al código y se abrirá la tienda de apps de tu teléfono.
o ábrela en la tienda

