← Índice del curso

Comunicación entre Dispositivos · Tema 36 · Comunicación inalámbrica

Wi-Fi en proyectos reales

En el tema anterior un ESP32 aprendió a conectarse. Un prototipo de escritorio termina ahí; un dispositivo que se instala en una casa o en una planta, y tiene que funcionar meses sin que nadie lo toque, recién empieza. Este tema trata lo que separa a uno del otro: reconectarse solo cuando el router se reinicia, atender un navegador con un servidor web embebido y una API en JSON, enviar datos en vivo con WebSocket, gastar poca energía (el Wi-Fi es la tecnología más hambrienta del curso) y no ser la puerta de entrada de un atacante: cómo guardar la clave de la red, cómo se configura un producto sin pantalla y qué protege, y qué no, el cifrado WPA2. Se resuelve con un ESP32 y se mide con el modelo de energía del tema 34.

01 · Objetivo

De un prototipo a un producto

El sketch del tema 35 conecta bien... en el escritorio, con el router al lado y la clave escrita en el código. Un dispositivo real tiene que sobrevivir a todo lo que un prototipo ignora. Una lista de lo que puede pasar en el primer mes:

  • El router se reinicia (por un corte de luz o una actualización) y tarda uno o dos minutos en volver. El dispositivo debe reconectarse sin intervención y sin saturar la red.
  • La señal se degrada, el router cambia de canal o la concesión DHCP vence. La conexión «dice» estar activa pero ya no hay Internet.
  • El usuario cambia la clave de su Wi-Fi. Hay que poder volver a configurarlo sin reprogramarlo.
  • Una persona abre la página del dispositivo desde el teléfono, y otra desde la computadora. Quiere ver el dato ahora, no al refrescar.
  • El dispositivo funciona con baterías y se espera que dure un año.
  • Alguien en la red (un invitado, un vecino con la clave, otro dispositivo infectado) intenta leerlo o controlarlo.

El proyecto del tema es un nodo de temperatura Wi-Fi con esas propiedades. Si no hay una red guardada, crea su propia red con un formulario para configurarla; si la hay, se conecta y se vigila: reintenta con retroceso exponencial, anuncia su nombre por mDNS, sirve una página con una API JSON, envía las lecturas por WebSocket y pide usuario y contraseña para cambiar su configuración. Una segunda variante, para baterías, despierta cada cinco minutos, se conecta con el canal guardado, envía y vuelve a dormir.

02 · Volver solo

Reconexión automática

Estados de la conexión Wi-Fi Tres estados: sin conexión, conectando y conectado. Desde sin conexión se pasa a conectando cuando llega la hora del intento. Desde conectando se pasa a conectado si el estado es conectado, y la espera vuelve a 5 segundos. Si el intento dura más de 15 segundos, vuelve a sin conexión con la espera duplicada hasta 60 segundos. Desde conectado, si se pierde la conexión, vuelve a sin conexión con un reintento en 1 segundo. Sin conexiónespera el próximo intento ConectandoWiFi.begin() en curso Conectadocon IP: atiende y publica llegó la horaWL_CONNECTEDintento de más de 15 s: espera × 2 (hasta 60 s)se pierde la conexión: reintento en 1 s espera = 5 s Esperas tras fallar: 5, 10, 20, 40, 60, 60… s. Cada intento puede durar hasta 15 s.

Cuando el router se reinicia, el ESP32 pierde la asociación y ve un evento BEACON_TIMEOUT o ASSOC_LEAVE (tema 35). La biblioteca ofrece una reconexión automática (WiFi.setAutoReconnect(true)) que lo vuelve a intentar sola, y para muchos usos alcanza. Pero tiene dos límites: no deja decidir cuándo se reintenta, y no avisa al programa del estado intermedio. Para un producto es más claro hacerlo explícito con una máquina de estados como la del tema 33 para MQTT:

  1. Sin conexión. Si llegó la hora del próximo intento, se llama a WiFi.begin() y se pasa a «conectando».
  2. Conectando. Se mira el estado sin bloquear. Si es WL_CONNECTED, se pasa a «conectado» y la espera vuelve a 5 s. Si pasaron 15 s, se da el intento por fallido, se agenda el próximo con la espera actual y esta se duplica (5, 10, 20, 40, 60 s, y de ahí 60 s).
  3. Conectado. Se vigila WiFi.status(). Si se pierde, se vuelve a «sin conexión» con un reintento en 1 s, porque a veces el router estaba cambiando de canal y vuelve enseguida.

Los números importan. Si el router tarda 120 s en volver y el primer intento sale a 1 s de la caída, con 15 s por intento los intentos arrancan a los 1, 21, 46, 81 y 136 s: el último prospera y a los 139 s el nodo ya tiene IP, 19 s después del regreso. Con una espera fija de 5 s habría hecho 7 intentos en lugar de 5, y con una caída de 10 minutos, 31 intentos contra 12 (la herramienta 1 del laboratorio los calcula). El retroceso exponencial protege a los dos lados: no agobia al router, que justo en ese momento está atendiendo a todos los equipos de la casa, y no gasta energía a ciegas con la radio encendida.

Enlace no es conectividad

WL_CONNECTED dice que hay asociación con el punto de acceso y una IP. No dice que haya Internet. El router puede estar sin servicio con el Wi-Fi funcionando, el DNS puede estar caído, el servidor puede no responder. Un dispositivo robusto verifica lo que le importa: si su trabajo es publicar en un servidor, la prueba real es abrir ese socket. Un esquema sencillo:

  • Contar los fallos consecutivos de la operación real (conectar al servidor, publicar, resolver el nombre).
  • Con unos pocos fallos, reiniciar la conexión Wi-Fi (WiFi.disconnect() y begin()): resuelve asociaciones colgadas y vuelve a pedir DHCP.
  • Si sigue fallando durante varios minutos, reiniciar el microcontrolador (ESP.restart()), como último recurso. Un perro guardián (watchdog) hace lo mismo cuando el programa se cuelga (tema 33).

Y mientras no hay red hay que decidir qué hacer con los datos. Con un sensor que mide y publica, lo más simple es descartarlos: la próxima lectura reemplaza a la anterior. Si cada dato importa, se guardan en una cola en RAM (si son pocos), en memoria RTC o en flash, y se envían en orden al volver, con la confirmación de QoS 1 de MQTT (tema 32) para no duplicar ni perder. Cada opción tiene su costo en memoria, desgaste de la flash y complejidad: conviene decidirlo antes de programar.

Dos detalles prácticos. Primero, la dirección IP puede cambiar tras una reconexión (el DHCP no garantiza la misma): un cliente que guardó «192.168.1.57» deja de encontrar el dispositivo. Eso se evita con un nombre (nodo01.local, sección siguiente) o con una reserva en el router por dirección MAC. Y segundo, WiFi.persistent(false) evita que la biblioteca escriba la configuración en la flash en cada begin(): una escritura por cada intento, durante meses, es un desgaste innecesario.

03 · Un navegador como interfaz

Un servidor web embebido

Una página web es la interfaz más universal que puede tener un dispositivo: no requiere instalar nada, funciona en el teléfono y en la computadora y el usuario ya sabe usarla. La biblioteca WebServer del ESP32 (la misma API de la ESP8266WebServer) lo resuelve con pocas funciones: se registra una función por ruta con web.on(ruta, método, función), se llama a web.handleClient() en cada vuelta de loop() y, dentro de cada función, se lee con web.arg() y se responde con web.send().

Rutas del nodo.
RutaMétodoRespuestaQué hace
/GET200, HTMLPágina con la temperatura en vivo y el formulario del intervalo (en modo configuración, el formulario de la red Wi-Fi)
/apiGET200, JSON{"id":"nodo01","t":23.5,"rssi":-61,"intervalo":5,"up":120}
/setPOST303, 400 o 401Cambia el intervalo; exige usuario y contraseña; valida el valor
/guardarPOST200 o 400Solo en modo configuración: guarda la red y reinicia
cualquier otra—404La biblioteca responde sola

Decisiones que conviene tomar con criterio:

  • Un solo hilo. handleClient() atiende una petición por vez, dentro de loop(). Una función de ruta que espera (leer un sensor lento, consultar un servidor) congela todo lo demás, incluido el WebSocket y la máquina de reconexión. Las rutas deben leer valores ya guardados y responder enseguida: la medición ocurre en su propio servicio, como en el tema 33.
  • El cambio de estado es POST, no GET. En el tema 33 se usó GET por comodidad, con la advertencia de que un navegador, un buscador o un antivirus pueden repetirlo. Acá se corrige: /set acepta solo POST y tiene autenticación. Los GET quedan para leer.
  • Validar siempre en el servidor. El formulario puede limitar el valor, pero curl puede enviar cualquier cosa. La función leerIntervalo acepta de uno a cuatro dígitos entre 2 y 3600 y rechaza el resto (sin las ambigüedades de atol del tema 33, que aceptaba «45x»).
  • Páginas en memoria de programa. El HTML vive en una cadena PROGMEM (en una literal cruda R"rawliteral(…)rawliteral", sin escapar comillas) y se sustituye una marca como %I% con el valor actual. Para una interfaz más grande se guardan los archivos en el sistema de archivos del ESP32 (LittleFS) y se sirven comprimidos con gzip.
  • JSON a mano, con cuidado. Para unos pocos campos alcanza snprintf con un buffer de tamaño fijo; para estructuras más grandes, la biblioteca ArduinoJson. En ambos casos, el valor sin lectura se escribe como null, no como «nan», que no es JSON válido.
  • CORS. Si una página alojada en otro sitio quiere leer /api con JavaScript, el navegador lo bloquea salvo que la respuesta incluya Access-Control-Allow-Origin. Es una regla del navegador, no del servidor: curl funciona siempre.
  • Pocos sockets. Por omisión el ESP32 maneja del orden de diez sockets (TCP, UDP y el servidor mismo), compartidos por el servidor web, el WebSocket, MQTT, DNS y mDNS. Un navegador moderno abre varias conexiones a la vez: con unos pocos clientes simultáneos se agota.

Un nombre en lugar de una dirección

Con mDNS (multicast DNS), el dispositivo se anuncia en la red local con un nombre como nodo01.local usando multidifusión UDP en el puerto 5353, sin servidor DNS. Con MDNS.begin("nodo01") y MDNS.addService("http", "tcp", 80) lo encuentran los navegadores de Windows 10 o posterior, macOS, iOS y Linux (con Avahi); la cobertura de Android varía según la versión. Resuelve el problema de la IP que cambia, pero solo vale dentro de la misma red local.

Existen alternativas a WebServer: ESPAsyncWebServer atiende las peticiones con eventos, sin bloquear loop(), y es popular para interfaces con varias conexiones; esp_http_server, del ESP-IDF, corre en su propia tarea y soporta WebSocket y HTTPS. Para unos pocos clientes y una página, WebServer es lo más simple.

Una advertencia sobre HTTPS: se puede servir con esp_https_server, pero cada conexión TLS consume decenas de kilobytes de RAM, el certificado del dispositivo no estará firmado por una entidad reconocida (el navegador avisa de un riesgo) y hay que renovarlo. Por eso es raro; lo habitual es mantener el panel dentro de la red local y que los datos hacia Internet salgan por una conexión cifrada iniciada por el dispositivo (sección 6).

04 · Del sondeo a la notificación

WebSocket: datos en vivo

Con HTTP, el cliente pregunta y el servidor responde. Para mostrar un valor que cambia, la página tiene que preguntar una y otra vez («sondeo», polling): cada pregunta es una petición completa, con sus cabeceras, y la novedad espera, en promedio, medio período hasta que se la pregunta. WebSocket (RFC 6455) resuelve esto: después de un diálogo HTTP inicial, la misma conexión TCP queda abierta y cualquiera de los dos lados puede enviar mensajes en cualquier momento, sin cabeceras HTTP. Es lo que usan los chats, los tableros en vivo y los editores colaborativos, y en un microcontrolador cumple la misma función.

Maneras de mantener una página al día.
TécnicaCómo funcionaCostoRetardo de una novedad
Sondeo (polling)La página pide el dato cada cierto tiempo con fetchUna petición HTTP completa por consultaHasta un período (medio, en promedio)
Sondeo largoEl servidor retiene la petición hasta que hay algo que decirUna petición por novedad; el servidor mantiene conexiones colgadasCasi inmediato
Server-Sent EventsUna respuesta HTTP que no termina y el servidor va escribiendoCabecera una sola vez; solo en un sentidoCasi inmediato
WebSocketConexión bidireccional tras un upgrade de HTTPUn handshake; cabecera de 2 a 14 bytes por mensajeCasi inmediato
MQTT sobre WebSocketEl navegador se suscribe a un broker (tema 32), que habla WebSocketUn broker intermedioCasi inmediato; varios dispositivos a la vez

El handshake

El cliente pide un upgrade con una petición HTTP normal:

GET / HTTP/1.1
Host: nodo01.local:81
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

La clave es un valor aleatorio de 16 bytes en base64. El servidor la concatena con una constante fija (258EAFA5-E914-47DA-95CA-C5AB0DC85B11), calcula su SHA-1 y devuelve el resultado en base64. No es una medida de seguridad: sirve para que un cliente reconozca que el otro extremo entiende WebSocket y no un servidor HTTP cualquiera que repitió una respuesta. La herramienta 3 del laboratorio lo calcula para cualquier clave. Después del 101, la conexión deja de hablar HTTP.

Formato de una trama WebSocket Una trama tiene un primer byte con el bit FIN, tres bits reservados y el código de operación de cuatro bits; un segundo byte con el bit de máscara y la longitud de siete bits; una longitud ampliada de 2 u 8 bytes si la longitud es 126 o 127; una clave de máscara de 4 bytes solo en las tramas que van del cliente al servidor; y la carga útil. Abajo, un ejemplo: la trama del servidor con el texto hola son los bytes 81, 04, 68, 6f, 6c y 61. FIN · RSV · opcodeMASK · longitudlongitud ampliadaclave de máscaracarga útil 1 + 3 + 4 bits1 + 7 bits2 u 8 bytes4 byteslongitud bytes 1 byte1 bytesolo si longitud = 126 o 127solo cliente → servidortexto (1), binario (2) Trama WebSocket (RFC 6455) 8104686f6c61 FIN = 1, textolongitud 4«h»«o»«l»«a» Ejemplo servidor → cliente

Una trama es muy liviana. El primer byte lleva el bit FIN (último fragmento del mensaje) y el código de operación: 1 es texto, 2 binario, 8 cierre, 9 ping y 10 pong. El segundo byte indica la longitud: hasta 125, directamente; con 126 la longitud viene en los 2 bytes siguientes, y con 127, en 8. Los mensajes del cliente al servidor llevan además una máscara de 4 bytes que se aplica con XOR a los datos (una protección contra intermediarios mal diseñados, no cifrado). El mensaje «hola» del servidor son seis bytes: 81 04 68 6f 6c 61; el mismo mensaje del cliente, diez. Los ping y pong sirven para comprobar que el otro extremo sigue vivo y mantener abierta la conexión a través de routers y NAT.

Cuánto se ahorra

Un nodo que envía 24 bytes cada 5 segundos, durante una hora, son 720 mensajes. Con sondeo HTTP y unos 550 bytes de cabeceras por consulta se mueven 413 280 bytes (unos 404 KiB); con WebSocket, 500 bytes de handshake y 26 por mensaje, 19 220 bytes (unos 19 KiB): 21,5 veces menos, por cada pestaña abierta. Y la actualización llega en el momento: con sondeo cada 5 s, una novedad espera 2,5 s en promedio. Con mensajes más grandes el ahorro relativo disminuye (con 200 bytes por mensaje a 60 por minuto es de 3,7 veces) porque domina la carga útil: la herramienta 2 permite probar combinaciones.

Algunas precauciones: el servidor debe limitar cuántos clientes admite (cada conexión ocupa un socket y memoria); un cliente que desaparece (cerró la tapa del portátil, perdió el Wi-Fi) no avisa y la conexión queda «medio abierta» hasta que el ping la descubre, y por eso las bibliotecas permiten configurar un latido; los mensajes entrantes se validan como cualquier otra entrada de red (tema 33); y una página servida por https:// no puede abrir un ws:// sin cifrar (el navegador lo bloquea como «contenido mixto»): para eso hace falta wss://, o servir también la página por HTTP.

05 · Con baterías

Consumo de energía del Wi-Fi

Corriente media de cuatro estrategias con un ESP32 Gráfico de barras en escala logarítmica de la corriente media con un envío cada cinco minutos y una batería de 2400 miliamperios hora. Conectado en modem sleep: 25 miliamperios, unos 4 días. Conectado en light sleep: 3 miliamperios, unos 33 días. Deep sleep con conexión completa: 1,02 miliamperios, unos 98 días. Deep sleep con conexión rápida: 0,396 miliamperios, unos 253 días. 0,1 mA1 mA10 mA100 mA A · conectado, modem sleepB · conectado, light sleepC · deep sleep, conexión completaD · deep sleep, conexión rápida 25 mA · 4 días3 mA · 33 días1,02 mA · 98 días0,396 mA · 253 días Escala logarítmica. Batería de 2400 mAh y un envío cada 5 minutos: lo que más pesa es el tiempo con la radio encendida.

El Wi-Fi es, con diferencia, la tecnología más cara en energía de las que estudia el curso. El tema 34 mostró el principio general (la corriente media depende del tiempo con la radio encendida), y acá se aplica al ESP32. Sus estados típicos de consumo:

Corrientes típicas del ESP32 (valores de la hoja de datos, para ordenar ideas; medí los de tu placa).
EstadoQué está encendidoCorriente
TransmitiendoCPU y radio, ráfagas de TX≈ 180 – 240 mA (picos)
Recibiendo (escuchando)CPU y receptor≈ 95 – 100 mA
Modem sleep (el modo normal de un ESP32 conectado)CPU activa; la radio duerme entre balizas≈ 20 – 30 mA a 80 MHz; más a 240 MHz
Light sleep con Wi-FiCPU en pausa; la radio despierta en cada DTIMunos pocos mA
Deep sleepSolo el reloj de tiempo real (RTC) y la memoria RTC≈ 10 µA el chip; las placas de desarrollo gastan mucho más

Una placa de desarrollo común gasta de cientos de µA a varios mA en deep sleep por el regulador, el chip USB-serie y los LED: para un producto a baterías se usa el módulo sin placa, con un regulador de bajo consumo, o una placa pensada para eso.

Ahorro con el punto de acceso: DTIM

Un cliente conectado no necesita escuchar todo el tiempo. Se «duerme» y el punto de acceso le retiene los paquetes. En cada baliza (cada 102,4 ms) el punto de acceso incluye un mapa (TIM) de qué clientes dormidos tienen datos pendientes. Cada cierta cantidad de balizas, el período DTIM (por omisión 1 a 3, configurable en el router), se entregan también los mensajes de difusión. En el modo de ahorro por omisión del ESP32 (WiFi.setSleep(true), modem sleep mínimo) despierta en cada DTIM: con DTIM 1, unas diez veces por segundo. Consecuencia: un mensaje que llega al nodo puede tardar hasta DTIM × 102,4 ms (unos 300 ms con DTIM 3), y un ping mostrará esa latencia. Con WiFi.setSleep(false) se desactiva el ahorro: baja la latencia y sube el consumo.

El ciclo medir, conectar, enviar y dormir

Para una batería, mantener el Wi-Fi conectado es inviable (≈ 4 días con 2400 mAh en modem sleep; ≈ 33 días con light sleep). La estrategia es la del tema 34: dormir casi todo el tiempo y pagar la conexión en cada ciclo. Con el modelo del laboratorio (2400 mAh, un envío cada 5 minutos, radio de 120 mA, 20 µA dormido, conexión completa de 2,2 s, rápida de 0,64 s y 0,3 s de envío):

  • C · deep sleep con conexión completa: 2,5 s activo cada ciclo, 1,02 mA de media, unos 98 días.
  • D · deep sleep con conexión rápida: 0,94 s activo, 0,396 mA, unos 253 días. Se guardan el canal y el BSSID en la memoria RTC y se llama a WiFi.begin(ssid, clave, canal, bssid), que evita el escaneo. Con una IP estática, el ciclo baja todavía más.

Con un envío cada hora, esas cifras suben a 2,7 y 5,3 años (conviene compararlas con la autodescarga de la batería, de 1 a 3 % anual en litio, que pone un techo). Es el mismo efecto del tema 34: cuando la transmisión es breve, manda la corriente de reposo; cuando la conexión es larga, manda la conexión. Otras medidas que reducen el tiempo o la corriente:

  • Enviar en una sola ráfaga y cerrar. Mantener conexiones abiertas «por si acaso» cuesta energía.
  • Reducir la potencia de transmisión con WiFi.setTxPower() si la señal es buena: el pico de TX baja.
  • Mejorar la señal. Con RSSI bajo, el enlace usa un MCS lento (tema 35) y el mismo mensaje ocupa más tiempo en el aire.
  • Evitar TLS en cada despertar si no hace falta: el handshake de TLS (tema 49) suma uno o dos segundos de CPU activa, además de los de la red. Se puede reanudar la sesión, o enviar a un servidor intermedio de confianza dentro de la red.
  • No usar Wi-Fi si no hace falta. Para dos placas propias, ESP-NOW (tema 39) envía en milisegundos sin conectarse a un router; para largo alcance con pocos bytes, LoRa (tema 40).

Y se mide. Un multímetro en serie con la batería muestra la corriente media aproximada, pero los picos de TX (decenas de milisegundos) se ven solo con un analizador de consumo o un osciloscopio con una resistencia shunt. Los números de una hoja de datos y los de la placa real suelen diferir en un factor de dos o más.

06 · Que no sea la puerta de entrada

Seguridad en el dispositivo

En el tema 35 se vio cómo se protege el aire con WPA2 y WPA3. Eso es una capa de varias. Un dispositivo conectado queda expuesto en cuatro frentes, y cada uno tiene sus defensas:

Amenazas comunes de un dispositivo Wi-Fi y cómo reducirlas.
AmenazaEjemploMedida
Clave de la red débilSe captura el handshake y se prueba un diccionario offlineClave larga y al azar (tema 35; la herramienta 5 lo calcula); WPA3 si el router y el firmware lo permiten
Escucha en la red localCon WPA2-Personal, quien conoce la clave y captura el handshake de otro equipo puede descifrar su tráfico; el panel con HTTP Basic viaja en claroRed de invitados o VLAN para dispositivos IoT; WPA3; HTTPS o VPN para lo sensible; no confiar en que «es la red de casa»
Credenciales en el códigoLa clave de la red y los usuarios quedan en el código fuente y en el repositorioGuardarlas en la memoria no volátil (Preferences) y cargarlas por aprovisionamiento; archivos de secretos fuera del control de versiones
Lectura física de la flashCon acceso al dispositivo, un lector de flash recupera la red y las clavesCifrado de la flash y de la NVS del ESP32 (para productos); una clave por dispositivo, no la misma en toda la serie
Panel sin autenticación o con clave de fábricaCualquiera en la red cambia el intervalo o la configuraciónAutenticación en las rutas que modifican; contraseña distinta por dispositivo y obligación de cambiarla
Modificar con GETUna página maliciosa hace que el navegador del usuario llame a /set?i=1 (CSRF)POST con autenticación (y, en productos, un token); no usar GET para cambiar nada
Entradas sin validarUn valor largo desborda un buffer, o un número fuera de rango rompe la lógicaValidar largo, caracteres y rango; buffers de tamaño fijo (tema 33)
Exposición a InternetSe abre el puerto 80 del router hacia el ESP32No hacerlo. El dispositivo inicia la conexión hacia afuera (MQTT, HTTPS) y el panel queda en la red local
Actualización de firmwareUn OTA sin contraseña permite instalar un firmware ajenoOTA con contraseña, firma digital del firmware y un camino de actualización para los dispositivos que ya están en campo
Portal de configuración abiertoEl punto de acceso de aprovisionamiento queda encendido siempreClave en el AP, tiempo límite y apagarlo al terminar
DesautenticaciónUn tercero expulsa al nodo repitiendo tramas de gestión falsasWPA3 y 802.11w; el nodo debe seguir funcionando al reconectar (sección 2)

Aprovisionamiento: configurar sin pantalla

Un producto no puede llevar la clave de la casa del cliente en su firmware. La solución habitual: la primera vez, o después de borrar la configuración, el dispositivo levanta su propio punto de acceso (tema 35). El usuario se conecta con el teléfono, abre una página, escribe el SSID y la clave de su red, el dispositivo los guarda en la memoria no volátil y se reinicia en modo estación. El nodo del tema lo hace así: si Preferences no tiene un SSID, entra en modo configuración con la red Nodo-Config (con clave, para que nadie se asocie sin permiso) y la página / muestra el formulario. Un botón físico (el BOOT del ESP32, al encender) borra todo para volver a empezar. Un portal cautivo redirige cualquier dirección a la página de configuración, de modo que el teléfono la abre sola. El ESP32 ofrece además aprovisionamiento por Bluetooth Low Energy (tema 38) con una aplicación del fabricante.

Lo que WPA2 protege, y lo que no

  • Protege el tramo del aire entre el dispositivo y el punto de acceso contra quien no conoce la clave.
  • No protege el recorrido hasta el servidor. Después del router, los datos viajan por la red del proveedor y por Internet: para que no los lea nadie, se cifran extremo a extremo con TLS (WiFiClientSecure, wss://, MQTT sobre TLS), y se valida el certificado del servidor. Usar setInsecure() «porque no andaba» elimina la mitad de la protección.
  • No protege entre los equipos que conocen la clave. Todos los de la red comparten la misma clave maestra; por eso la separación de redes y la autenticación propia del dispositivo no son un lujo.
  • No autentica al dispositivo ante el servidor. Eso lo hacen las credenciales de la aplicación (usuario y contraseña de MQTT, tokens, certificados de cliente), que también se guardan fuera del código.

El tema 49 retoma el cifrado, la autenticación y los mensajes repetidos con más detalle. La regla de este tema es de diseño: pensar qué pasa si la red es hostil, porque en un producto real muchas veces lo es.

07 · Con el microcontrolador

El nodo completo

El programa junta lo anterior en un único archivo para un ESP32. Bibliotecas: las incluidas en el paquete de placas (WiFi, WebServer, Preferences, ESPmDNS) y WebSockets de Markus Sattler, instalada desde el gestor de bibliotecas.

#include <WiFi.h>
#include <WebServer.h>
#include <WebSocketsServer.h>    // biblioteca "WebSockets" de Markus Sattler
#include <Preferences.h>
#include <ESPmDNS.h>

// ---------- Configuración ----------
const char NOMBRE[] = "nodo01";                         // también el nombre mDNS: http://nodo01.local
const char CLAVE_PANEL[] = "cambiame-ya";               // contraseña del panel (usuario "admin")
const long INTERVALO_MIN = 2, INTERVALO_MAX = 3600;     // segundos entre envíos por WebSocket
const unsigned long LIMITE_INTENTO = 15000;            // ms para dar un intento de conexión por fallido
const unsigned long ESPERA_MAX = 60000;

// ---------- Objetos y estado ----------
Preferences prefs;
WebServer web(80);
WebSocketsServer ws(81);
char ssid[33] = "", clave[64] = "";
uint16_t intervalo = 5;
bool modoConfig = false, mdnsListo = false;
float temperatura = NAN;                                // NAN = todavía sin lectura

enum Estado { SIN_CONEXION, CONECTANDO, CONECTADO };
Estado estado = SIN_CONEXION;
unsigned long proximoIntento = 0, inicioIntento = 0, espera = 5000;

const char PAGINA[] PROGMEM = R"rawliteral(<!DOCTYPE html><meta charset=utf-8><meta name=viewport content="width=device-width"><title>Nodo</title>
<h1>Nodo Wi-Fi</h1><p>Temperatura: <b id=t>--</b> &deg;C &middot; <span id=e>conectando...</span></p>
<form action=/set method=post><label>Intervalo (s) <input name=i value=%I%></label> <button>Guardar</button></form>
<script>var w=new WebSocket('ws://'+location.hostname+':81/');
w.onopen=function(){e.textContent='en vivo'};w.onclose=function(){e.textContent='sin conexion'};
w.onmessage=function(m){t.textContent=JSON.parse(m.data).t};</script>)rawliteral";

const char PAGINA_CONFIG[] PROGMEM = R"rawliteral(<!DOCTYPE html><meta charset=utf-8><meta name=viewport content="width=device-width"><title>Configurar</title>
<h1>Configurar el nodo</h1><form action=/guardar method=post>
<p><label>Red Wi-Fi (SSID) <input name=s maxlength=32></label></p>
<p><label>Contrase&ntilde;a <input name=p type=password maxlength=63></label></p>
<button>Guardar y reiniciar</button></form>)rawliteral";

// ---------- Validación (la única puerta de entrada de un valor nuevo) ----------
bool leerIntervalo(const String &texto, long &valor) {  // solo dígitos, de 1 a 4, dentro del rango
    if (texto.length() < 1 || texto.length() > 4) return false;
    for (unsigned i = 0; i < texto.length(); i++) if (!isdigit(texto[i])) return false;
    valor = texto.toInt();
    return valor >= INTERVALO_MIN && valor <= INTERVALO_MAX;
}

// ---------- Rutas web ----------
void rutaRaiz() {
    if (modoConfig) { web.send_P(200, "text/html; charset=utf-8", PAGINA_CONFIG); return; }
    String html = FPSTR(PAGINA);
    html.replace("%I%", String(intervalo));
    web.send(200, "text/html; charset=utf-8", html);
}

void rutaApi() {
    char t[12], json[128];
    if (isnan(temperatura)) strcpy(t, "null"); else snprintf(t, sizeof(t), "%.1f", temperatura);
    snprintf(json, sizeof(json), "{\"id\":\"%s\",\"t\":%s,\"rssi\":%d,\"intervalo\":%u,\"up\":%lu}",
             NOMBRE, t, WiFi.RSSI(), intervalo, millis() / 1000);
    web.send(200, "application/json", json);
}

void rutaSet() {                                        // POST con autenticación: cambia el estado del nodo
    if (!web.authenticate("admin", CLAVE_PANEL)) { web.requestAuthentication(); return; }
    long valor;
    if (!leerIntervalo(web.arg("i"), valor)) { web.send(400, "text/plain", "Intervalo invalido (2 a 3600)"); return; }
    if (valor != intervalo) { intervalo = valor; prefs.putUShort("intervalo", intervalo); }   // se escribe solo si cambió
    web.sendHeader("Location", "/");
    web.send(303);
}

void rutaGuardar() {                                    // solo en modo configuración
    if (!modoConfig) { web.send(404, "text/plain", "No existe"); return; }
    String s = web.arg("s"), p = web.arg("p");
    if (s.length() < 1 || s.length() > 32 || p.length() > 63 || (p.length() > 0 && p.length() < 8)) {
        web.send(400, "text/plain", "SSID o clave invalidos"); return;
    }
    prefs.putString("ssid", s);
    prefs.putString("clave", p);
    web.send(200, "text/plain", "Guardado. Reiniciando...");
    delay(500);
    ESP.restart();
}

// ---------- WebSocket ----------
void enviarLectura(uint8_t num) {                       // num = 255: a todos los clientes
    char t[12], json[64];
    if (isnan(temperatura)) strcpy(t, "null"); else snprintf(t, sizeof(t), "%.1f", temperatura);
    snprintf(json, sizeof(json), "{\"t\":%s,\"up\":%lu}", t, millis() / 1000);
    if (num == 255) ws.broadcastTXT(json); else ws.sendTXT(num, json);
}

void alWs(uint8_t num, WStype_t tipo, uint8_t *dato, size_t largo) {
    if (tipo == WStype_CONNECTED) {
        Serial.printf("WebSocket: cliente %u desde %s\n", num, ws.remoteIP(num).toString().c_str());
        enviarLectura(num);                             // el cliente nuevo recibe enseguida el último valor
    }
}

void servicioLectura() {
    static unsigned long ultimo = 0;
    if (millis() - ultimo < (unsigned long)intervalo * 1000UL) return;
    ultimo = millis();
    temperatura = temperatureRead();                    // temperatura del chip: reemplazar por el sensor real
    if (ws.connectedClients() > 0) enviarLectura(255);
}

// ---------- Wi-Fi: máquina de estados con retroceso exponencial ----------
void servicioWifi() {
    switch (estado) {
        case CONECTADO:
            if (WiFi.status() != WL_CONNECTED) {
                estado = SIN_CONEXION;
                proximoIntento = millis() + 1000;
                Serial.println("Enlace perdido");
            }
            break;
        case SIN_CONEXION:
            if ((long)(millis() - proximoIntento) >= 0) {   // comparación segura ante el desborde de millis()
                WiFi.disconnect();
                WiFi.begin(ssid, clave);
                inicioIntento = millis();
                estado = CONECTANDO;
                Serial.println("Conectando...");
            }
            break;
        case CONECTANDO:
            if (WiFi.status() == WL_CONNECTED) {
                estado = CONECTADO;
                espera = 5000;                          // un éxito reinicia el calendario
                Serial.print("Conectado, IP "); Serial.println(WiFi.localIP());
                if (!mdnsListo && MDNS.begin(NOMBRE)) { MDNS.addService("http", "tcp", 80); mdnsListo = true; }
            } else if (millis() - inicioIntento > LIMITE_INTENTO) {
                estado = SIN_CONEXION;
                proximoIntento = millis() + espera;
                Serial.printf("Intento fallido; próximo en %lu s\n", espera / 1000);
                espera = min(espera * 2, ESPERA_MAX);   // 5, 10, 20, 40, 60, 60... s
            }
            break;
    }
}

// ---------- Programa principal ----------
void setup() {
    Serial.begin(115200);
    pinMode(0, INPUT_PULLUP);                           // botón BOOT: mantenerlo al arrancar borra la red guardada
    prefs.begin("nodo", false);
    if (digitalRead(0) == LOW) { prefs.clear(); Serial.println("Configuración borrada"); }
    prefs.getString("ssid", ssid, sizeof(ssid));
    prefs.getString("clave", clave, sizeof(clave));
    intervalo = prefs.getUShort("intervalo", 5);
    if (intervalo < INTERVALO_MIN || intervalo > INTERVALO_MAX) intervalo = 5;
    modoConfig = (ssid[0] == 0);

    WiFi.persistent(false);                             // no escribir la red en la flash en cada begin()
    WiFi.setAutoReconnect(false);                       // la reconexión la maneja servicioWifi()
    web.on("/", rutaRaiz);
    web.on("/api", rutaApi);
    web.on("/set", HTTP_POST, rutaSet);
    web.on("/guardar", HTTP_POST, rutaGuardar);
    web.begin();
    ws.begin();
    ws.onEvent(alWs);

    if (modoConfig) {
        WiFi.mode(WIFI_AP);
        WiFi.softAP("Nodo-Config", "configurar1");
        Serial.print("Modo configuración: http://"); Serial.println(WiFi.softAPIP());
    } else {
        WiFi.mode(WIFI_STA);
        WiFi.setHostname(NOMBRE);
    }
}

void loop() {
    if (!modoConfig) servicioWifi();
    web.handleClient();
    ws.loop();
    if (!modoConfig) servicioLectura();
}

Qué hay que notar:

  • loop() nunca espera. Cada servicio (Wi-Fi, web, WebSocket, lectura) mira el reloj y vuelve enseguida. La conexión usa WiFi.begin() sin bucle de espera y se vigila con la máquina de estados; el único delay está en rutaGuardar, justo antes de reiniciar.
  • Dos modos con el mismo código. Si no hay SSID guardado, modoConfig arma el punto de acceso y muestra el formulario, y todo lo demás (reconexión, lectura) se omite. En modo normal, /guardar responde 404, de modo que nadie puede reemplazar la red desde la red.
  • Un único camino de validación. leerIntervalo se usa para cualquier valor nuevo. El intervalo se guarda en Preferences solo si cambió, para no desgastar la flash.
  • Los datos viajan de dos maneras. /api (HTTP, a pedido, para scripts y para curl) y el WebSocket del puerto 81 (en vivo, para la página). Un cliente nuevo recibe de inmediato el último valor, sin esperar al próximo ciclo.
  • temperatureRead() es la temperatura interna del chip (no la del ambiente); está para que el sketch funcione sin hardware. Se reemplaza por el DS18B20 del tema 33, que se lee con el mismo patrón sin esperas.
  • Falta, a propósito, lo que haría falta en un producto: HTTPS, una clave de panel distinta por dispositivo, tokens contra CSRF y un tiempo límite del modo configuración.

Variante para baterías

La segunda versión no sirve nada: despierta, mide, se conecta con el canal guardado, envía por HTTP y vuelve a dormir. Las variables RTC_DATA_ATTR sobreviven al sueño profundo, de modo que el canal y el BSSID de la última conexión están disponibles al despertar. Si la conexión rápida falla (el router cambió de canal), se borran y el ciclo siguiente hace un escaneo completo.

#include <WiFi.h>
#include <HTTPClient.h>

const char SSID[] = "MiRed";
const char CLAVE[] = "mi-clave-larga";
const char URL[] = "http://192.168.1.10:8080/lectura";     // el servidor que recibe las lecturas
const uint64_t PERIODO_S = 300;                            // un envío cada 5 minutos
const unsigned long LIMITE_MS = 10000;

RTC_DATA_ATTR bool tengoDatos = false;                     // se conservan durante el sueño profundo
RTC_DATA_ATTR int32_t canal = 0;
RTC_DATA_ATTR uint8_t bssid[6];

bool conectar() {
    WiFi.mode(WIFI_STA);
    if (tengoDatos) WiFi.begin(SSID, CLAVE, canal, bssid); // conexión rápida: sin escanear todos los canales
    else WiFi.begin(SSID, CLAVE);
    unsigned long t0 = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - t0 < LIMITE_MS) delay(50);
    if (WiFi.status() != WL_CONNECTED) { tengoDatos = false; return false; }   // la próxima vez, escaneo completo
    canal = WiFi.channel();
    memcpy(bssid, WiFi.BSSID(), 6);
    tengoDatos = true;
    return true;
}

bool enviar(float t) {
    HTTPClient http;
    http.setTimeout(3000);
    http.begin(URL);
    http.addHeader("Content-Type", "application/json");
    char cuerpo[48];
    snprintf(cuerpo, sizeof(cuerpo), "{\"id\":\"nodo01\",\"t\":%.1f}", t);
    int codigo = http.POST(cuerpo);
    http.end();
    return codigo == 200;
}

void setup() {
    Serial.begin(115200);
    float t = temperatureRead();                           // reemplazar por la lectura del sensor real
    if (conectar()) Serial.println(enviar(t) ? "Enviado" : "El servidor no respondió");
    else Serial.println("Sin Wi-Fi: se reintenta en el próximo ciclo");
    WiFi.disconnect(true);                                 // apaga la radio antes de dormir
    WiFi.mode(WIFI_OFF);
    esp_sleep_enable_timer_wakeup(PERIODO_S * 1000000ULL);
    esp_deep_sleep_start();                                // al despertar, vuelve a empezar desde setup()
}

void loop() {}                                             // no se llega nunca

Al despertar de un sueño profundo el programa empieza de nuevo en setup(), y loop() no se ejecuta. El tiempo de cada ciclo depende de la red: medilo con millis() y calculá la autonomía con la herramienta 4.

08 · Ver lo que pasa

Verificar con herramientas

Con el nodo en 192.168.1.57 y el panel en nodo01.local:

Comandos de verificación.
Qué se pruebaComandoResultado esperado
El nombre mDNSping nodo01.localResponde desde la IP del nodo (con latencias de decenas de ms y picos por el ahorro de energía)
La APIcurl http://nodo01.local/api{"id":"nodo01","t":41.2,"rssi":-58,"intervalo":5,"up":320}
Cambio sin credencialescurl -i -X POST -d "i=10" http://nodo01.local/set401 Unauthorized con WWW-Authenticate: Basic
Cambio válidocurl -i -u admin:cambiame-ya -d "i=10" http://nodo01.local/set303 See Other; la API muestra el intervalo 10
Valores inválidoscurl -i -u admin:cambiame-ya -d "i=1e3" http://nodo01.local/set (también 1, 3601, abc)400 Bad Request; el intervalo no cambia
Un cambio con GETcurl -i "http://nodo01.local/set?i=10"404 o 405: no hay ruta GET que modifique
WebSocketwebsocat ws://nodo01.local:81/ (o la consola del navegador: new WebSocket("ws://nodo01.local:81/"))Un mensaje {"t":41.2,"up":321} al conectar y otro cada intervalo
ReconexiónApagar el router un par de minutos y mirar el monitor serieEnlace perdido, intentos con «próximo en 5, 10, 20 s…» y Conectado, IP … al volver
AprovisionamientoMantener BOOT al encender, conectarse a Nodo-Config y abrir http://192.168.4.1El formulario; tras guardar, el nodo reinicia y se conecta a la red indicada
ConsumoMultímetro o analizador de corriente en serie con la bateríaPicos de 100 a 250 mA al conectar y envío; μA en deep sleep

Dos herramientas de análisis sirven para ver lo que se discutió. En las herramientas del desarrollador del navegador (pestaña «Red»), la conexión WebSocket aparece como una entrada con estado «101 Switching Protocols» y la lista de mensajes con su tamaño y su momento. Y con Wireshark (filtro websocket o tcp.port == 81) se ven el handshake y las tramas de dos a diez bytes de cabecera. Del lado del dispositivo, el monitor serie y los eventos del tema 35 con el código de motivo de cada desconexión.

09 · Analogía con programación

El nodo en Python

La lógica de este tema también se puede probar sin hardware: el calendario de reconexión, el cálculo del handshake de WebSocket (con las bibliotecas estándar), el armado de tramas, el tráfico comparado y la duración de la batería según la estrategia.

Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.

import base64
import hashlib
import math

GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"                # constante fija del protocolo WebSocket


def calendario(caida, primera=5, maxima=60, limite=15, inicio=1):
    inicios, t, espera = [], inicio, primera
    while True:
        inicios.append(t)
        if t >= caida:
            return inicios                                   # este intento encuentra al router de vuelta
        t += limite + espera                                 # el intento fallido dura "limite" y después se espera
        espera = min(espera * 2, maxima)


def aceptacion_ws(clave):
    return base64.b64encode(hashlib.sha1((clave + GUID).encode()).digest()).decode()


def trama_texto(texto, cliente=False):
    datos = texto.encode()
    n, mascara = len(datos), (0x80 if cliente else 0)
    cab = bytearray([0x81])                                  # FIN = 1, opcode 1 (texto)
    if n <= 125:
        cab.append(mascara | n)
    elif n <= 65535:
        cab.append(mascara | 126)
        cab += n.to_bytes(2, "big")
    else:
        cab.append(mascara | 127)
        cab += n.to_bytes(8, "big")
    if cliente:
        cab += bytes(4)                                      # clave de máscara (ceros, solo para contar bytes)
    return bytes(cab) + datos


def bytes_sondeo(mensaje, por_hora, cabeceras=550):
    return por_hora * (cabeceras + mensaje)


def bytes_websocket(mensaje, por_hora, handshake=500):
    return handshake + por_hora * len(trama_texto("x" * mensaje))


def corriente_ciclo(periodo, t_activo, i_activo_ma, i_sueno_ua):
    return (i_activo_ma * t_activo + i_sueno_ua / 1000 * (periodo - t_activo)) / periodo


def dias(capacidad_mah, corriente_ma):
    return capacidad_mah / corriente_ma / 24


def tiempo_medio_dias(largo, alfabeto, por_segundo):
    return alfabeto ** largo / 2 / por_segundo / 86400


def validar_intervalo(texto):
    if not (1 <= len(texto) <= 4 and texto.isdigit()):
        return None
    valor = int(texto)
    return valor if 2 <= valor <= 3600 else None


for caida in (30, 120):
    print(f"router caído {caida:>3} s: con retroceso {calendario(caida)}; con espera fija {calendario(caida, 5, 5)}")
print("router caído 600 s: intentos con retroceso", len(calendario(600)), "y con espera fija", len(calendario(600, 5, 5)))

clave = "dGhlIHNhbXBsZSBub25jZQ=="
print("Accept del ejemplo del RFC:", aceptacion_ws(clave))
for texto, cliente in (("hola", False), ("hola", True), ("x" * 200, False), ("x" * 70000, False)):
    t = trama_texto(texto, cliente)
    print(f"{len(texto):>6} caracteres, {'cliente' if cliente else 'servidor'}: {len(t):>6} bytes en total, cabecera de {len(t) - len(texto)}; primeros bytes {t[:4].hex()}")

for mensaje, por_hora in ((24, 720), (24, 3600)):
    a, b = bytes_sondeo(mensaje, por_hora), bytes_websocket(mensaje, por_hora)
    print(f"{por_hora} mensajes/h de {mensaje} bytes: sondeo {a:,} bytes, WebSocket {b:,} bytes, {a / b:.1f} veces")

perfiles = (("conectado, modem sleep", 25), ("conectado, light sleep", 3),
            ("deep sleep, conexión completa", corriente_ciclo(300, 2.2 + 0.3, 120, 20)),
            ("deep sleep, conexión rápida", corriente_ciclo(300, 0.64 + 0.3, 120, 20)))
for nombre, ma in perfiles:
    print(f"{nombre:>30}: {ma:.3f} mA, {dias(2400, ma):,.0f} días")

for largo, alfabeto in ((8, 26), (8, 62), (12, 26), (12, 62), (16, 94)):
    bits = largo * math.log2(alfabeto)
    print(f"{largo} símbolos de {alfabeto}: {bits:.1f} bits; promedio {tiempo_medio_dias(largo, alfabeto, 1e6):,.1f} días a 1e6 pruebas/s")

for texto in ("10", "2", "3600", "3601", "abc", " 30", "0030"):
    print(f"intervalo {texto!r}: {validar_intervalo(texto)}")

Resultado: con el router caído 30 s, los intentos con retroceso salen a 1, 21 y 46 s (con espera fija, a 1, 21 y 41 s); con 120 s, a 1, 21, 46, 81 y 136 s (la espera fija hace 7 intentos: 1, 21, 41, 61, 81, 101 y 121 s); con 600 s son 12 intentos contra 31. El Accept del ejemplo del RFC es s3pPLMBiTxaQ9kYGzzhZRbK+xOo=. La trama «hola» del servidor mide 6 bytes (cabecera de 2: 81 04…) y la del cliente 10 (cabecera de 6, 81 84…); un texto de 200 caracteres ocupa 204 bytes (cabecera de 4, 81 7e 00 c8) y uno de 70 000, 70 010 (cabecera de 10). Con 720 mensajes de 24 bytes por hora, el sondeo mueve 413 280 bytes y WebSocket 19 220, unas 21,5 veces menos; con 3600 por hora, 2 066 400 contra 94 100 (22,0 veces). Las corrientes medias son 25 mA (modem sleep, 4 días), 3 mA (light sleep, 33 días), 1,020 mA (deep sleep con conexión completa, 98 días) y 0,396 mA (con conexión rápida, 253 días). Una clave de 8 minúsculas tiene 37,6 bits y se agota en promedio en 1,2 días a un millón de pruebas por segundo; 8 símbolos de 62, 47,6 bits y 1263,5 días; 12 minúsculas, 56,4 bits y 552 250,9 días (unos 1500 años); 12 de 62, 71,5 bits; y 16 de 94, 104,9 bits. Los intervalos 10, 2, 3600 y 0030 se aceptan (10, 2, 3600 y 30) y 3601, abc y " 30" se rechazan.

Calcular el Accept con tres líneas de Python y verlo coincidir con el ejemplo del estándar es una buena forma de entender que el handshake no tiene magia: es una función conocida. Lo mismo vale para el tráfico y la batería: antes de decidir una arquitectura, unas pocas cuentas dicen cuál alternativa tiene sentido.

10 · Exploración interactiva

Laboratorio: reconexión, tráfico, handshake, batería y claves

El laboratorio tiene cinco herramientas. La primera calcula el calendario de reconexión tras la caída del router. La segunda compara el tráfico de WebSocket y de sondeo HTTP. La tercera calcula el handshake de WebSocket con una implementación de SHA-1. La cuarta estima la duración de la batería con cuatro estrategias. La quinta calcula la fortaleza de una clave frente a un ataque offline.

1. Reconexión Wi-Fi con retroceso

Inicio de cada intento (desde la caída)—
Cantidad de intentos—
Momento de la reconexión—
Demora tras el regreso del router—
Con espera fija—

Elegí cuánto dura la caída del router.

2. WebSocket frente a sondeo HTTP

Cabecera de WebSocket—
Sondeo HTTP—
WebSocket—
Ahorro—
Con todas las páginas—
Retardo de una novedad—

Elegí el tamaño y la frecuencia de los mensajes.

3. Handshake de WebSocket

Clave + GUID—
SHA-1 (20 bytes)—
Sec-WebSocket-Accept—
Respuesta del servidor—
Verificación—

Escribí la clave que envía el cliente.

4. Duración de la batería según la estrategia

A · conectado, modem sleep—
B · conectado, light sleep—
C · deep sleep, conexión completa—
D · deep sleep, conexión rápida—
La mejor—
Frente a la peor—

Completá los datos de la batería y del ciclo.

5. Fortaleza de una clave WPA2

Entropía—
Combinaciones—
Tiempo medio para dar con la clave—
Veredicto—

Elegí el largo y el alfabeto de la clave.

En la herramienta 1, el primer intento sale 1 s después de la caída, cada intento fallido dura lo indicado y el que empieza cuando el router ya volvió prospera y tarda unos 3 s en dar la IP. En la 2, los bytes son solo de aplicación (sin TCP, IP ni Wi-Fi) y el handshake se estima en 500 bytes. En la 3, la implementación de SHA-1 es la del RFC 3174. En la 4, las corrientes son valores típicos que se pueden editar. En la 5, el tiempo medio es la mitad de recorrer todas las combinaciones, para una clave elegida al azar.

Recorrido guiado

  1. Con los valores iniciales de la herramienta 1 (router caído 120 s, esperas de 5 a 60 s, intentos de 15 s): los intentos arrancan a los 1, 21, 46, 81 y 136 s; son 5 intentos, el último prospera, el nodo queda conectado a los 139 s, 19 s después del regreso. Con una espera fija de 5 s serían 7 intentos. Con una caída de 600 s: 12 intentos contra 31 y reconexión a los 664 s (64 s tarde). Con una caída de 30 s: intentos a 1, 21 y 46.
  2. Bajá la espera inicial a 1 y la máxima a 10: intentos a 1, 17, 34, 53, 76, 101 y 126 s; reconecta a los 129 s (9 s después), pero con 7 intentos y una espera máxima corta. Es el compromiso: recuperación más rápida contra más carga para el router.
  3. En la herramienta 2, con los valores iniciales (24 bytes, 12 mensajes por minuto, 1 página, 550 bytes de cabeceras): cabecera de 2 bytes, 403,6 KiB por hora con sondeo contra 18,8 KiB con WebSocket, 21,5 veces menos; la novedad espera 2,5 s con sondeo. Probá 200 bytes, 60 por minuto y 50 páginas: cabecera de 4 bytes, 2,57 MiB contra 717,7 KiB por página (3,7 veces), 128,75 MiB contra 35,04 MiB en total, y 0,5 s de retardo.
  4. En la herramienta 3, con la clave del ejemplo dGhlIHNhbXBsZSBub25jZQ==: SHA-1 b37a4f2c…b2bec4ea y Accept s3pPLMBiTxaQ9kYGzzhZRbK+xOo=, igual al del RFC 6455. Con AAAAAAAAAAAAAAAAAAAAAA== sale ICX+Yqv66kxgM0FcWaLWlFLwTAI=. Con «hola» no es una clave válida (no son 16 bytes en base64).
  5. En la herramienta 4, con los valores iniciales (2400 mAh, cada 5 minutos): A 25 mA, 4 días; B 3 mA, 33 días; C 1,02 mA, 98 días; D 0,396 mA, 253 días, 63 veces más que A. Poné un período de 60 s: C 5,02 mA y 20 días, D 1,90 mA y 53 días. Con 3600 s: C 0,103 mA (2,7 años) y D 0,051 mA (5,3 años). Cuanto menos frecuente es el envío, más pesa la corriente de reposo y menos la conexión.
  6. En la herramienta 5, con 8 minúsculas (26) a un millón de pruebas por segundo: 37,6 bits, 208 827 064 576 combinaciones y 1,2 días en promedio: débil. Con 8 símbolos de 62: 47,6 bits y unos 3 años: aceptable. Con 12 minúsculas: 56,4 bits y unos 1500 años; con 12 de 62: 71,5 bits y más de 50 millones de años. Con 6 caracteres: inválida para WPA2 (pide 8 como mínimo).
Desafío: un sensor para un invernadero

Un sensor de humedad con ESP32 debe medir y enviar por HTTP cada 10 minutos y alimentarse con una batería de 2000 mAh. El dueño pide al menos un año. Con la herramienta 4 (2000 mAh, 600 s, 120 mA, 20 µA, envío de 0,3 s): con conexión completa (2,2 s) la media es 0,52 mA y dura unos 160 días: no alcanza. Con conexión rápida (0,64 s) baja a 0,208 mA y dura unos 400 días, apenas más de un año. Y con conexión rápida e IP estática (la conexión tarda 0,14 s): 0,108 mA y unos 770 días, más de dos años. La respuesta es guardar el canal y el BSSID en memoria RTC y fijar la IP (con una reserva en el router). Hay que medir el consumo real de la placa en sueño: una placa de desarrollo con 5 mA en deep sleep arruinaría todo el cálculo, y la autodescarga y el frío de un invernadero reducen la capacidad.

11 · Comprensión

Confusiones frecuentes

«Si WiFi.status() dice conectado, hay Internet»

Dice que hay asociación y una IP. El router puede no tener salida, el DNS puede estar caído o el servidor puede estar apagado. Se verifica con la operación real (abrir el socket, publicar) y se actúa por niveles: reintentar, reiniciar el Wi-Fi, reiniciar el equipo.

«Reintentar cada segundo es lo más robusto»

Un router que vuelve de un reinicio atiende a todos los equipos de la casa al mismo tiempo; decenas de intentos por minuto de cada uno solo lo agobian, y gastan batería. El retroceso exponencial reparte los intentos y se estabiliza en un máximo razonable.

«Con WebSocket ya no hace falta validar»

Los mensajes que llegan por WebSocket son entradas de red como cualquier otra: largo, formato y rango se validan. Y el handshake no autentica a nadie: la autenticación de la página (usuario, token) se hace aparte.

«WebSocket es siempre mejor que HTTP»

Es mejor para actualizaciones frecuentes y en vivo. Para un dato que se consulta de vez en cuando, o un dispositivo que duerme casi todo el tiempo, una petición HTTP (o un mensaje MQTT) es más simple: mantener una conexión abierta cuesta un socket, memoria y energía.

«El modo de ahorro del Wi-Fi alcanza para una batería»

El modem sleep reduce el consumo, pero con el procesador activo se siguen gastando decenas de mA: con 2400 mAh, días. Para meses o años hay que dormir entre envíos con deep sleep y pagar la conexión (rápida) en cada ciclo.

«La clave de la red está segura en el firmware»

Cualquiera con acceso al dispositivo puede leer la flash y recuperar el texto de la clave, y cualquiera con acceso al código fuente o al repositorio la ve. Se guarda en la memoria no volátil, mediante aprovisionamiento, y en un producto con cifrado de flash.

«Abro el puerto 80 del router para ver el dispositivo desde afuera»

Un servidor web sin HTTPS, con una clave de panel simple y sin actualizaciones queda a la vista de todo Internet, y en minutos aparecen los escáneres automáticos. Es preferible que el dispositivo envíe sus datos a un servicio y que el panel quede dentro de la red local (o detrás de una VPN).

«Si el SSID es oculto y la clave es WPA2, es seguro»

El SSID oculto no agrega protección (aparece en las tramas de asociación). La seguridad depende de la clave y del protocolo; y aun con WPA2, quien conoce la clave puede descifrar el tráfico de otros clientes que haya capturado desde el handshake.

12 · Práctica guiada

Predecí antes de comprobar

1. Calendario de reconexión

El router vuelve a los 70 s. Con espera inicial de 5 s, máxima de 60 s, intentos de 15 s y el primero a 1 s: ¿cuándo sale cada intento y cuándo queda conectado el nodo?

Ver solución

Los intentos arrancan a 1, 1 + 15 + 5 = 21, 21 + 15 + 10 = 46 y 46 + 15 + 20 = 81 s. El de los 46 s todavía falla (el router vuelve a los 70 s); el de los 81 s ya lo encuentra y conecta unos 3 s después, a los 84 s: 14 s después del regreso.

2. Trama de WebSocket

¿Cuántos bytes ocupa un mensaje de texto de 300 bytes enviado por el servidor, y cuántos si lo envía el cliente?

Ver solución

300 supera 125, así que se usa la longitud ampliada: 1 byte (FIN y opcode) + 1 byte (longitud = 126) + 2 bytes (300) = 4 bytes de cabecera: 304 bytes. El cliente suma la máscara de 4 bytes: 8 de cabecera, 308 bytes.

3. Tráfico

Una página se actualiza cada 10 s con mensajes de 40 bytes. Con sondeo HTTP (550 bytes de cabeceras) y con WebSocket (500 bytes de handshake y 2 bytes por mensaje), ¿cuántos bytes mueven en una hora?

Ver solución

360 mensajes por hora. Sondeo: 360 × (550 + 40) = 212 400 bytes (≈ 207 KiB). WebSocket: 500 + 360 × (2 + 40) = 15 620 bytes (≈ 15,3 KiB). El sondeo mueve unas 13,6 veces más.

4. Para qué sirve el GUID

¿Qué prueba el valor Sec-WebSocket-Accept y qué no prueba?

Ver solución

Prueba que el servidor entendió la petición de WebSocket: solo quien conoce el algoritmo (SHA-1 de la clave más el GUID, en base64) puede responder así, de modo que un servidor HTTP común o una respuesta de caché no se confunde con un servidor WebSocket. No prueba la identidad del servidor ni cifra nada, y no sustituye la autenticación de usuario ni el uso de wss://.

5. Energía

Un nodo con radio de 120 mA está activo 2 s por ciclo cada 15 minutos y duerme con 20 µA. Con una batería de 1500 mAh, ¿cuánto dura? ¿Y si la conexión rápida baja el tiempo activo a 0,8 s?

Ver solución

Carga por ciclo: 120 · 2 + 0,02 · 898 = 240 + 18 = 258 mA·s; media = 258 / 900 = 0,287 mA; vida = 1500 / 0,287 = 5233 h, unos 218 días. Con 0,8 s: 96 + 18 = 114 mA·s; media 0,127 mA; vida ≈ 11 850 h, unos 493 días (≈ 1,35 años). Acortar la conexión más que duplicó la autonomía.

6. Fortaleza

Una clave de 10 caracteres al azar entre 62 símbolos, atacada a un millón de pruebas por segundo: ¿cuántos bits de entropía tiene y cuánto tarda en promedio?

Ver solución

10 · log2(62) ≈ 59,5 bits; 6210 ≈ 8,4 × 1017 combinaciones. En promedio se explora la mitad: 4,2 × 1011 s ≈ 13 300 años. Aun así, solo vale si el atacante no tiene un diccionario de la clave: «Casa2024xx» no es aleatoria.

7. Validación del panel

¿Qué responde el panel del nodo a un POST autenticado a /set con i=45, i=1e3, i=3601 e i=0030? ¿Y a i=45 sin credenciales?

Ver solución

45: 303 y guarda 45 s. 1e3: contiene una «e», que no es un dígito: 400. 3601: fuera de rango: 400. 0030: cuatro dígitos, valor 30: 303 y guarda 30 s. Sin credenciales, cualquiera de ellos da 401 antes de mirar el valor: la autenticación va primero.

13 · Síntesis

Ideas para recordar

  • Un dispositivo real debe sobrevivir a lo que un prototipo ignora: reinicios del router, cambios de clave, señal débil, baterías, usuarios y atacantes. Se diseña como servicios que no esperan: Wi-Fi, web, WebSocket y lectura, cada uno con su reloj.
  • La reconexión es una máquina de estados con intentos de duración limitada y esperas crecientes (5, 10, 20, 40, 60 s). Un éxito reinicia el calendario. WL_CONNECTED es enlace, no conectividad: se verifica con la operación real y se actúa por niveles hasta reiniciar.
  • El servidor web embebido atiende una petición por vez: las rutas leen datos ya guardados y responden rápido. Se valida en el servidor, lo que cambia el estado es POST con autenticación y el nombre .local (mDNS) evita depender de la IP.
  • WebSocket mantiene una conexión TCP abierta tras un handshake HTTP: el servidor envía cuando hay novedades, con una cabecera de 2 bytes en lugar de cientos. Ahorra del orden de 20 veces el tráfico y evita el retardo del sondeo. El Accept es el SHA-1 de la clave más un GUID en base64: no cifra ni autentica.
  • El Wi-Fi es la tecnología más costosa en energía. Conectado, el ESP32 gasta de 3 a 30 mA y dura días; con deep sleep, un ciclo medir, conectar, enviar y dormir con conexión rápida (canal y BSSID guardados en RTC, IP fija) dura meses o años. La corriente media es lo que cuenta, y se mide.
  • La seguridad está en capas: WPA2/WPA3 protege el aire; TLS protege hasta el servidor; la autenticación y la validación del dispositivo protegen la aplicación. Las credenciales no van en el código: se aprovisionan por un punto de acceso temporal y se guardan en la memoria no volátil. No se abren puertos hacia Internet: el dispositivo inicia la conexión.
  • Con WPA2-Personal, la longitud de la clave es la defensa (el ataque offline es posible) y quien conoce la clave puede descifrar el tráfico de otros clientes: para dispositivos IoT conviene una red separada.
  • Todo esto se prueba antes con modelos (calendarios, tráfico, batería, entropía) y después con herramientas: curl, websocat, el navegador, Wireshark y un medidor de corriente.

¿Listo para el siguiente tema?

Antes de avanzar, calculá de memoria la secuencia de intentos de reconexión con esperas de 5, 10, 20 y 40 s y un intento de 15 s; explicá por qué un panel que cambia el intervalo con GET es una mala idea y qué se usa en su lugar; y decidí qué estrategia de energía usarías para un sensor que envía una vez por hora durante un año (deep sleep con conexión rápida) y por qué no sirve dejar el Wi-Fi conectado. Si podés justificar las tres respuestas, sabés llevar un nodo Wi-Fi desde el escritorio hasta el campo.

Con este tema terminamos Wi-Fi. En los siguientes pasamos a Bluetooth: primero el Bluetooth clásico, con su perfil de puerto serie y los módulos HC-05, y después Bluetooth Low Energy, pensado justamente para los dispositivos de bajo consumo que acabamos de ver.