Saltar al contenido
IDE libre

Servidor web

Servidor web en la ESP32: controlar la placa desde el navegador

La ESP32 como servidor HTTP con socket en MicroPython: estación y punto de acceso, el texto del pedido y de la respuesta, rutas, página con sensor y asyncio.

Por Prof. Rodrigo Faustino · Actualizado el · 12 min de lectura

Métricas de este artículo

Palabras
2384
Caracteres
13.907
Tiempo de lectura
~12 min

En todos los proyectos de red vistos hasta aquí, la ESP32 fue el cliente: pide una página, envía una lectura o publica en el MQTT, y quien responde es un servidor en algún lugar. Este artículo invierte los papeles. La ESP32 se vuelve el servidor: se queda esperando, y quien pide es el navegador de tu celular. Escribes la dirección de la placa, aparece una página con la temperatura de la sala y dos botones, y tocar uno de ellos enciende el LED.

Es uno de los proyectos más queridos de quien empieza con la ESP32, porque junta todo: Wi-Fi, pines, sensores y una interfaz que cualquier persona usa sin instalar nada. Y es también una excelente manera de entender cómo funciona la web por dentro, porque en MicroPython no hay magia: el pedido llega como texto, el programa lee ese texto y escribe otro texto de vuelta.

Cliente y servidor

En la web, las dos puntas tienen papeles diferentes:

  • el cliente abre la conexión y pide algo. El navegador es un cliente; la ESP32 usando requests también;
  • el servidor se queda esperando conexiones en un puerto y responde a cada pedido. El puerto estándar de la web sin cifrado es el 80: cuando escribes http://192.168.4.1/, el navegador conecta al puerto 80 de esa dirección.

En MicroPython, las dos puntas usan el módulo socket. El cliente llama a connect(). El servidor llama a tres funciones, en orden: bind(), que ata el socket a una dirección y a un puerto; listen(), que dice "puedes empezar a aceptar"; y accept(), que espera la próxima conexión y devuelve un socket nuevo solo para conversar con ese cliente.

En el simulador de PyPlaca, el socket todavía es solo cliente: bind(), listen() y accept() dan un NotImplementedError explicando que el servidor todavía no se simula. Por eso, el servidor completo de este artículo es para llevar a la placa real. Los ejemplos con el botón "Abrir en el simulador" tratan las partes que no dependen del radio — el punto de acceso, la lectura del pedido, las rutas y el armado de la página —, y son exactamente las partes donde están los errores más comunes.

FastTeacherPara profesores

Del programa al semestre planificado

  • Plan de enseñanza y calendario montados solos
  • Diario de clases listo para pegar en el SIGA
  • Diapositivas y listas de ejercicios con solucionario
Conocer FastTeacher

Versión Community gratis · Windows

Cómo llega el navegador a la placa

Antes del servidor, el navegador necesita alcanzar la ESP32. Hay dos maneras, y la elección cambia la dirección que se escribe.

Comparación de los dos modos. A la izquierda, modo estación: el celular y la ESP32 están conectados al mismo router, que da las direcciones IP; la IP de la placa cambia y aparece en el Serial. A la derecha, punto de acceso: la propia ESP32 crea la red, con la dirección 192.168.4.1, y el celular y un notebook se conectan directo a ella, sin router y sin internet.
En el modo estación, la placa y el celular usan el router de la casa. En el modo punto de acceso, la placa crea su propia red y queda siempre en 192.168.4.1.

Modo estación (network.STA_IF): la ESP32 entra en la red Wi-Fi de la casa o del laboratorio, como cualquier celular. El router le da una dirección IP, y el celular, en la misma red, accede a esa IP. Es el modo del artículo Wi-Fi, HTTP y MQTT. El detalle es que la IP puede cambiar de un día para otro. Por eso, el programa debe imprimir wlan.ifconfig()[0] en el Serial apenas conecte: es la dirección que se escribe en el navegador.

Punto de acceso (network.AP_IF): la propia ESP32 crea una red Wi-Fi, y el celular se conecta a ella. No necesita router, lo que es excelente para llevar el proyecto a una feria o para la configuración inicial de un aparato. La dirección de la placa en ese modo viene de fábrica en el ESP-IDF, el sistema sobre el que corre el MicroPython de la ESP32: 192.168.4.1. El ejemplo de abajo crea la red en el simulador y muestra la configuración.

import network

ap = network.WLAN(network.AP_IF)
ap.active(True)
ap.config(ssid='PyPlaca-LED', key='laboratorio123', security=network.AUTH_WPA2_PSK)

ip, mascara, roteador, dns = ap.ifconfig()
print('red:', ap.config('ssid'))
print('seguridad:', ap.config('security'), '(3 = WPA2 con contraseña)')
print('abre en el navegador: http://%s/' % ip)

Fíjate en el security=network.AUTH_WPA2_PSK. En el MicroPython de la ESP32, el key solo define la contraseña; quien dice que la red usa contraseña es el security. Sin él, la red del punto de acceso puede quedar abierta para cualquier persona cercana, y cualquiera de ellas podría encender y apagar tu LED. Usa una contraseña de al menos 8 caracteres, que es el mínimo del WPA2.

Una limitación importante del modo punto de acceso: el celular conectado a la red de la placa queda sin internet, porque la ESP32 no está conectada a nada. Muchos celulares avisan de eso e intentan volver a otra red; acepta quedarte conectado.

HTTP es texto

Cuando el navegador abre http://192.168.4.1/led/on, conecta al puerto 80 y envía algunas líneas de texto. La respuesta de la ESP32 también es texto. La imagen muestra los dos.

Un navegador en un celular pide la dirección 192.168.4.1/led/on a la ESP32, que atiende en el puerto 80. Abajo, el texto del pedido: GET /led/on HTTP/1.1, los encabezados Host, User-Agent y Accept y una línea vacía. Al lado, el texto de la respuesta: HTTP/1.0 200 OK, Content-Type text/html, una línea vacía y el HTML con el título LED encendido y un enlace para apagar.
HTTP es texto: el navegador manda una línea con la ruta y algunos encabezados; la ESP32 responde con una línea de estado, encabezados, una línea en blanco y la página.

El pedido empieza con la línea de petición: el método (GET para buscar una página), la ruta (/led/on) y la versión del protocolo. Después vienen los encabezados, uno por línea, en el formato Nombre: valor, y el pedido termina en una línea vacía. Todas las líneas terminan con dos caracteres, \r\n. El servidor necesita leer hasta esa línea vacía antes de responder; si responde en el medio, algunos navegadores se quejan.

La respuesta sigue el mismo diseño: una línea de estado con la versión, un código numérico y una frase (HTTP/1.0 200 OK), los encabezados — el más importante es el Content-Type, que dice si el cuerpo es HTML, JSON o texto simple —, una línea vacía y, por último, el cuerpo, que es la página en sí. Los códigos más usados son 200 (salió bien), 404 (la ruta no existe), 303 (ve a otra página) y 500 (error en el servidor).

Leyendo el pedido y eligiendo la ruta

El corazón de un servidor es decidir qué hacer con cada ruta. Ese mapa de rutas a acciones se llama enrutamiento. El ejemplo de abajo corre en el simulador con pedidos escritos a mano, como llegarían por la red. Separa la línea de petición, ignora los encabezados, elige la ruta y arma la respuesta — todo en funciones que, después, se usan sin ningún cambio en el servidor de verdad.

from machine import Pin

led = Pin(2, Pin.OUT)

def ler_pedido(texto):
    linhas = texto.split('\r\n')
    try:
        metodo, caminho, versao = linhas[0].split(' ')
    except ValueError:
        return None, None                 # primera línea extraña
    return metodo, caminho

def resposta(codigo, corpo, tipo='text/html; charset=utf-8'):
    frases = {200: 'OK', 404: 'Not Found', 400: 'Bad Request'}
    return 'HTTP/1.0 %d %s\r\nContent-Type: %s\r\n\r\n%s' % (codigo, frases[codigo], tipo, corpo)

def ligar():
    led.on()
    return resposta(200, '<h1>LED encendido</h1>')

def desligar():
    led.off()
    return resposta(200, '<h1>LED apagado</h1>')

ROTAS = {'/led/on': ligar, '/led/off': desligar}

def atender(texto):
    metodo, caminho = ler_pedido(texto)
    if metodo != 'GET':
        return resposta(400, 'pedido extraño')
    acao = ROTAS.get(caminho)
    if acao is None:
        return resposta(404, '<h1>404: no encontré %s</h1>' % caminho)
    return acao()

for pedido in ['GET /led/on HTTP/1.1\r\nHost: 192.168.4.1\r\n\r\n',
               'GET /favicon.ico HTTP/1.1\r\nHost: 192.168.4.1\r\n\r\n',
               'GET /led/off HTTP/1.1\r\nHost: 192.168.4.1\r\n\r\n',
               'basura\r\n\r\n']:
    r = atender(pedido)
    print(r.split('\r\n')[0], '| LED =', led.value())

Tres detalles de este ejemplo aparecen en todo servidor real:

  1. el navegador pide solo el /favicon.ico, el ícono de la pestaña, justo después de la primera página. Sin una ruta para él, el servidor responde 404, y está todo bien — pero si el código no tratara las rutas desconocidas, ese pedido extra derribaría el programa;
  2. la primera línea puede venir extraña: un pedido cortado a la mitad, un robot probando puertos, un cable de red malo. El try a la hora de separar la línea transforma el problema en una respuesta 400, en lugar de un traceback;
  3. el diccionario de rutas hace que el servidor sea fácil de hacer crecer: una ruta nueva es una función nueva y una línea en el diccionario.

El servidor completo en la placa

Juntando el punto de acceso, el socket servidor y las funciones de arriba, este es un servidor completo para la placa real. Sigue la estructura del ejemplo http_server.py que acompaña el código de MicroPython: getaddrinfo, la opción SO_REUSEADDR, bind, listen y un bucle con accept.

import socket
import network
from machine import Pin

led = Pin(2, Pin.OUT)

ap = network.WLAN(network.AP_IF)
ap.active(True)
ap.config(ssid='PyPlaca-LED', key='laboratorio123', security=network.AUTH_WPA2_PSK)
print('entra en la red PyPlaca-LED y abre http://%s/' % ap.ifconfig()[0])

PAGINA = """<!DOCTYPE html>
<html lang="es"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LED de la ESP32</title></head>
<body><h1>El LED está %s</h1>
<p><a href="/led/on">Encender</a> · <a href="/led/off">Apagar</a></p></body></html>"""

def pagina():
    return PAGINA % ('encendido' if led.value() else 'apagado')

s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(socket.getaddrinfo('0.0.0.0', 80)[0][-1])
s.listen(2)

while True:
    cliente, endereco = s.accept()          # espera que alguien pida
    try:
        cliente.settimeout(5)               # un cliente lento no traba la placa
        primeira = cliente.readline()
        while True:                         # lee y descarta los encabezados
            linha = cliente.readline()
            if not linha or linha == b'\r\n':
                break
        metodo, caminho, _ = primeira.decode().split(' ', 2)
        if caminho == '/led/on':
            led.on()
        elif caminho == '/led/off':
            led.off()
        cliente.write(b'HTTP/1.0 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\r\n')
        cliente.write(pagina().encode())
    except (OSError, ValueError) as e:
        print('problema con', endereco, e)
    finally:
        cliente.close()                     # siempre cerrar, si no los sockets se acaban

Vale entender cada elección:

  • 0.0.0.0 quiere decir "en todas las interfaces": el servidor atiende tanto por el punto de acceso como por la red de la casa, si la estación también está encendida;
  • SO_REUSEADDR deja reiniciar el programa sin esperar a que se libere el puerto. Sin él, un segundo bind() justo después de un Ctrl+C puede dar OSError de dirección en uso;
  • listen(2) es el tamaño de la cola de conexiones esperando el accept(). En un microcontrolador, las colas cortas bastan;
  • settimeout(5) evita que un cliente que conecta y no manda nada deje a la placa parada para siempre en el readline();
  • el finally: cliente.close() es lo más importante. Cada conexión abierta ocupa memoria y un socket de la pila de red; olvidar cerrar hace que el servidor deje de responder después de algunas visitas.

En ese servidor, la ruta /led/on enciende el LED y devuelve la misma página. Un problema de usabilidad: después de tocar "Encender", la dirección de la barra del navegador sigue siendo /led/on, y un "recargar" enciende de nuevo. Para las acciones, la solución clásica es responder 303 con un encabezado Location: /, que manda al navegador volver a la página principal después de ejecutar el comando.

FastMindAsistente de IA local

La IA que recuerda tu vida y vive en tu ordenador

  • Memoria a largo plazo con la fuente de cada respuesta
  • Funciona sin conexión: tus datos se quedan en tu PC
  • Lee PDF, diapositivas, fotos y grabaciones de reuniones
Descargar gratis para Windows

Plan Esencial gratis para siempre

Una página con el sensor

Lo que vuelve útil al servidor es mostrar datos en vivo. El ejemplo corre en el simulador con el circuito del DHT22 de la ruta de sensores: lee el sensor y arma la página HTML completa, con la temperatura y la humedad, exactamente como la enviaría el servidor. Ábrelo, córrelo y mira el HTML en el Serial.

from machine import Pin
import dht
import time

sensor = dht.DHT22(Pin(4))

def escapar(texto):
    return str(texto).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')

def pagina_sensor(local):
    sensor.measure()
    t = sensor.temperature()
    u = sensor.humidity()
    return """<!DOCTYPE html>
<html lang="es"><head><meta charset="utf-8">
<meta http-equiv="refresh" content="10">
<title>Sensor de la sala</title></head>
<body><h1>%s</h1>
<p>Temperatura: <strong>%.1f °C</strong></p>
<p>Humedad: <strong>%.1f %%</strong></p>
<p><small>Se actualiza sola cada 10 s.</small></p>
</body></html>""" % (escapar(local), t, u)

time.sleep(2)                 # el DHT22 necesita un tiempo después de encenderse
print(pagina_sensor('Laboratorio <B12>'))

Dos cuidados aparecen en ese ejemplo. El primero es el escapar(): todo texto que viene de afuera (el nombre del lugar escrito en un formulario, el nombre de una red) necesita tener <, > y & reemplazados antes de entrar en el HTML; si no, un texto como <B12> se vuelve una etiqueta y desaparece de la página — o, peor, alguien inyecta código en tu página. El segundo es el %%: dentro de un string formateado con %, el signo de porcentaje literal se escribe doble.

La etiqueta <meta http-equiv="refresh" content="10"> hace que el navegador recargue la página solo cada 10 segundos. Es la forma más simple de "tiempo real". Una forma más elegante es servir los datos en JSON en una ruta aparte, como /api/leitura, y dejar que un pequeño JavaScript de la página busque solo los números, sin recargar todo. La respuesta JSON es la misma idea, con Content-Type: application/json y el cuerpo hecho con json.dumps({'t': t, 'u': u}) — el mismo tema de la lección de JSON.

Formularios y parámetros en la URL

Para mandar datos a la placa — el nombre del lugar, un límite de temperatura —, la manera más simple es un formulario HTML con method="get". El navegador junta los campos en la ruta, después de un ?, en el formato /config?local=Sala+12&limite=30. El servidor necesita separar esos pares y deshacer la codificación: el espacio se vuelve + y los caracteres especiales se vuelven % seguido de dos dígitos hexadecimales.

def decodificar(texto):
    texto = texto.replace('+', ' ')
    partes = texto.split('%')
    saida = partes[0]
    for p in partes[1:]:
        try:
            saida += chr(int(p[:2], 16)) + p[2:]
        except ValueError:
            saida += '%' + p
    return saida

def parametros(caminho):
    if '?' not in caminho:
        return caminho, {}
    rota, consulta = caminho.split('?', 1)
    dados = {}
    for par in consulta.split('&'):
        if '=' in par:
            chave, valor = par.split('=', 1)
            dados[decodificar(chave)] = decodificar(valor)
    return rota, dados

Ese decodificador trata bien los textos sin tildes. Las letras con tilde llegan como varios %XX seguidos (el UTF-8 usa dos bytes para cada letra con tilde), y juntarlas bien pide trabajar con bytes en lugar de chr(). Para nombres de red y contraseñas, prefiere dejar el formulario restringido a letras sin tilde, números y algunos signos.

Sin bloquearse: asyncio.start_server

El servidor del ejemplo completo tiene un problema conocido: mientras espera en el accept(), la placa no hace nada más. Un botón apretado en ese momento se ignora, y un LED que debería parpadear queda parado. Es el mismo problema de la ruta Tiempo sin trabarse, y tiene la misma solución: el asyncio, que viene en el firmware de la ESP32.

El asyncio de MicroPython tiene el start_server(), que llama a una función tuya por cada cliente que conecta, mientras las otras tareas siguen corriendo:

import asyncio
from machine import Pin

led = Pin(2, Pin.OUT)

async def atender(leitor, escritor):
    primeira = await leitor.readline()
    while True:
        linha = await leitor.readline()
        if not linha or linha == b'\r\n':
            break
    caminho = primeira.decode().split(' ')[1] if primeira else '/'
    if caminho == '/led/on':
        led.on()
    elif caminho == '/led/off':
        led.off()
    escritor.write(b'HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n\r\n')
    escritor.write(('LED = %d\n' % led.value()).encode())
    await escritor.drain()
    escritor.close()
    await escritor.wait_closed()

async def outra_tarefa():
    while True:
        print('la placa sigue viva')
        await asyncio.sleep(5)

async def main():
    await asyncio.start_server(atender, '0.0.0.0', 80)
    await outra_tarefa()

asyncio.run(main())

El start_server() recibe la función que atiende, la dirección y el puerto. Cada cliente gana un lector y un escritor: el lector tiene readline() con await, y el escritor tiene write() seguido de await drain(), que espera a que los datos salgan. Al final, close() y wait_closed() cierran la conexión. Mientras se atiende a un cliente, la tarea outra_tarefa sigue imprimiendo — y, en un proyecto de verdad, leyendo sensores y parpadeando LED.

Seguridad: lo que todo servidor en la placa necesita considerar

Un servidor en la placa es excelente para el laboratorio y para la casa, pero tiene límites claros de seguridad:

  • sin HTTPS: el servidor de este artículo usa HTTP simple, así que todo viaja en texto abierto por la red. Quien esté en la misma red puede leer los pedidos. No hagas viajar contraseñas importantes por él;
  • sin login: cualquier persona que alcance la dirección controla la placa. En el punto de acceso, la contraseña WPA2 es tu protección; en la red de la casa, cualquier aparato de la red entra;
  • nunca expongas la placa en internet abriendo un puerto en el router. Para controlar la placa a distancia, el camino seguro es el contrario: la placa se conecta a un servicio en la nube, como un broker MQTT con usuario y contraseña, y tú hablas con ese servicio;
  • valida todo lo que llega: ruta, parámetros y tamaños. Un pedido muy grande puede acabar con la memoria; un parámetro inesperado no puede volverse un comando peligroso;
  • para las acciones que cambian algo, como encender o abrir, un token simple en la ruta (/led/on?token=...) ya impide que alguien de la red active la placa por casualidad.

Para proyectos mayores, existe Microdot, una biblioteca de terceros para MicroPython con rutas al estilo de Flask y soporte para asyncio. Ahorra trabajo, pero usar el socket directamente al menos una vez es la mejor manera de entender lo que ella hace por ti.

Diagnosticando problemas

Cuando el navegador no abre la página de la placa, sigue el orden del camino:

  1. la red: en el modo estación, ¿el Serial mostró una IP? ¿El celular está en la misma red que la placa? Las redes de invitados suelen aislar los aparatos unos de otros. En el modo punto de acceso, ¿el celular está conectado a la red de la placa, y no al 4G?
  2. la dirección: escribe http:// explícitamente. Algunos navegadores intentan https:// primero, y el servidor simple no habla HTTPS;
  3. el programa: ¿el Serial muestra la conexión llegando? Si la muestra y la página no aparece, el problema está en la respuesta — faltó la línea vacía después de los encabezados, o el Content-Type;
  4. después de algunas visitas deja de responder: casi siempre es un socket que no se cerró. Revisa el finally.

Para continuar

La ruta Wi-Fi y HTTP es la base de este artículo: la lección Wi-Fi muestra la conexión en modo estación, y la HTTP muestra el otro lado, la placa como cliente. La lección DHT22 y la máquina de estados ayudan a armar un servidor que lee sensores y reacciona a comandos sin perder el control del resto.

Los artículos ESP-NOW, sobre placas conversando sin router, y almacenamiento de datos, sobre guardar la configuración que recibe el formulario, son la continuación natural. Para los pines, consulta la ficha de la ESP32 DevKit V1 y la ficha del DHT22.

Fuentes

Para practicar en PyPlaca

Rutas

Lecciones

Fichas de componentes

FastMindAsistente de IA local

La IA que recuerda tu vida y vive en tu ordenador

  • Memoria a largo plazo con la fuente de cada respuesta
  • Funciona sin conexión: tus datos se quedan en tu PC
  • Lee PDF, diapositivas, fotos y grabaciones de reuniones
Descargar gratis para Windows

Plan Esencial gratis para siempre

FastTeacherPara profesores

Del programa al semestre planificado

  • Plan de enseñanza y calendario montados solos
  • Diario de clases listo para pegar en el SIGA
  • Diapositivas y listas de ejercicios con solucionario
Conocer FastTeacher

Versión Community gratis · Windows

FastMindAsistente de IA local

La IA que recuerda tu vida y vive en tu ordenador

  • Memoria a largo plazo con la fuente de cada respuesta
  • Funciona sin conexión: tus datos se quedan en tu PC
  • Lee PDF, diapositivas, fotos y grabaciones de reuniones
Descargar gratis para Windows

Plan Esencial gratis para siempre