Pular para o conteúdo
IDE livre

Servidor web

Servidor web na ESP32: controlar a placa pelo navegador

A ESP32 como servidor HTTP com socket no MicroPython: estação e ponto de acesso, o texto do pedido e da resposta, rotas, página com sensor e asyncio.

Por Prof. Rodrigo Faustino · Publicado em · atualizado em · 12 min de leitura

Métricas deste artigo

Palavras
2.321
Caracteres
13.394
Tempo de leitura
~12 min

Em todos os projetos de rede vistos até aqui, a ESP32 foi o cliente: ela pede uma página, envia uma leitura ou publica no MQTT, e quem responde é um servidor em algum lugar. Este artigo inverte os papéis. A ESP32 vira o servidor: ela fica esperando, e quem pede é o navegador do seu celular. Você digita o endereço da placa, aparece uma página com a temperatura da sala e dois botões, e tocar num deles acende o LED.

É um dos projetos mais queridos de quem começa com a ESP32, porque junta tudo: Wi-Fi, pinos, sensores e uma interface que qualquer pessoa usa sem instalar nada. E é também um ótimo jeito de entender como a web funciona por dentro, porque no MicroPython não há mágica: o pedido chega como texto, o programa lê esse texto e escreve outro texto de volta.

Cliente e servidor

Na web, as duas pontas têm papéis diferentes:

  • o cliente abre a conexão e pede alguma coisa. O navegador é um cliente; a ESP32 usando requests também;
  • o servidor fica esperando conexões numa porta e responde a cada pedido. A porta padrão da web sem criptografia é a 80: quando você digita http://192.168.4.1/, o navegador conecta na porta 80 desse endereço.

No MicroPython, as duas pontas usam o módulo socket. O cliente chama connect(). O servidor chama três funções, na ordem: bind(), que prende o socket a um endereço e a uma porta; listen(), que diz "pode começar a aceitar"; e accept(), que espera a próxima conexão e devolve um socket novo só para conversar com aquele cliente.

No simulador do PyPlaca, o servidor funciona. Rode o programa e clique no botão Navegador, na barra da bancada, ao lado do Wi-Fi: ele faz o papel do celular na mesma rede. Escreva o IP que a placa imprimiu (no ponto de acesso, 192.168.4.1), clique em Ir, e o pedido chega no accept() do seu programa. Os botões Wi-Fi e Navegador são as duas direções: no Wi-Fi a placa é o cliente e vê os pedidos que fez com requests; no Navegador ela é o servidor e responde aos pedidos que chegam. Os primeiros exemplos com o botão "Abrir no simulador" usam pedidos escritos à mão, para olhar cada parte separada; o servidor completo, mais abaixo, você abre pelo Navegador.

FastMindAssistente de IA local

A IA que lembra da sua vida e mora no seu computador

  • Memória de longo prazo com a fonte de cada resposta
  • Funciona offline: seus dados ficam no seu PC
  • Lê PDFs, slides, fotos e gravações de reunião
Baixar grátis para Windows

Plano Essencial grátis para sempre

Como o navegador chega à placa

Antes do servidor, o navegador precisa alcançar a ESP32. Há dois jeitos, e a escolha muda o endereço que se digita.

Comparação dos dois modos. À esquerda, modo estação: o celular e a ESP32 estão conectados ao mesmo roteador, que dá os endereços IP; o IP da placa muda e aparece no Serial. À direita, ponto de acesso: a própria ESP32 cria a rede, com o endereço 192.168.4.1, e o celular e um notebook se conectam direto a ela, sem roteador e sem internet.
No modo estação, a placa e o celular usam o roteador da casa. No modo ponto de acesso, a placa cria a própria rede e fica sempre em 192.168.4.1.

Modo estação (network.STA_IF): a ESP32 entra na rede Wi-Fi da casa ou do laboratório, como qualquer celular. O roteador dá um endereço IP para ela, e o celular, na mesma rede, acessa esse IP. É o modo do artigo Wi-Fi, HTTP e MQTT. O detalhe é que o IP pode mudar de um dia para o outro. Por isso, o programa deve imprimir wlan.ifconfig()[0] no Serial assim que conectar: é o endereço a digitar no navegador.

Ponto de acesso (network.AP_IF): a própria ESP32 cria uma rede Wi-Fi, e o celular se conecta a ela. Não precisa de roteador, o que é ótimo para levar o projeto a uma feira ou para a configuração inicial de um aparelho. O endereço da placa nesse modo vem de fábrica no ESP-IDF, o sistema sobre o qual o MicroPython da ESP32 roda: 192.168.4.1. O exemplo abaixo cria a rede no simulador e mostra a configuração.

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('rede:', ap.config('ssid'))
print('segurança:', ap.config('security'), '(3 = WPA2 com senha)')
print('abra no navegador: http://%s/' % ip)

Repare no security=network.AUTH_WPA2_PSK. No MicroPython da ESP32, o key só define a senha; quem diz que a rede usa senha é o security. Sem ele, a rede do ponto de acesso pode ficar aberta para qualquer pessoa por perto, e qualquer uma delas poderia acender e apagar o seu LED. Use uma senha com pelo menos 8 caracteres, que é o mínimo do WPA2.

Uma limitação importante do modo ponto de acesso: o celular conectado à rede da placa fica sem internet, porque a ESP32 não está ligada a nada. Muitos celulares avisam isso e tentam voltar para outra rede; aceite ficar conectado.

HTTP é texto

Quando o navegador abre http://192.168.4.1/led/on, ele conecta na porta 80 e envia algumas linhas de texto. A resposta da ESP32 também é texto. A imagem mostra os dois.

Um navegador num celular pede o endereço 192.168.4.1/led/on à ESP32, que atende na porta 80. Abaixo, o texto do pedido: GET /led/on HTTP/1.1, os cabeçalhos Host, User-Agent e Accept e uma linha vazia. Ao lado, o texto da resposta: HTTP/1.0 200 OK, Content-Type text/html, uma linha vazia e o HTML com o título LED aceso e um link para apagar.
HTTP é texto: o navegador manda uma linha com o caminho e alguns cabeçalhos; a ESP32 responde com uma linha de estado, cabeçalhos, uma linha em branco e a página.

O pedido começa com a linha de requisição: o método (GET para buscar uma página), o caminho (/led/on) e a versão do protocolo. Depois vêm os cabeçalhos, um por linha, no formato Nome: valor, e o pedido termina numa linha vazia. Todas as linhas terminam com dois caracteres, \r\n. O servidor precisa ler até essa linha vazia antes de responder; se responder no meio, alguns navegadores reclamam.

A resposta segue o mesmo desenho: uma linha de estado com a versão, um código numérico e uma frase (HTTP/1.0 200 OK), os cabeçalhos — o mais importante é o Content-Type, que diz se o corpo é HTML, JSON ou texto simples —, uma linha vazia e, por fim, o corpo, que é a página em si. Os códigos mais usados são 200 (deu certo), 404 (caminho não existe), 303 (vá para outra página) e 500 (erro no servidor).

Lendo o pedido e escolhendo a rota

O coração de um servidor é decidir o que fazer com cada caminho. Esse mapa de caminhos para ações se chama roteamento. O exemplo abaixo roda no simulador com pedidos escritos à mão, do jeito que chegariam pela rede. Ele separa a linha de requisição, ignora os cabeçalhos, escolhe a rota e monta a resposta — tudo em funções que, depois, são usadas sem mudança nenhuma no servidor de verdade.

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                 # primeira linha estranha
    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 aceso</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 estranho')
    acao = ROTAS.get(caminho)
    if acao is None:
        return resposta(404, '<h1>404: não achei %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',
               'lixo\r\n\r\n']:
    r = atender(pedido)
    print(r.split('\r\n')[0], '| LED =', led.value())

Três detalhes desse exemplo aparecem em todo servidor real:

  1. o navegador pede sozinho o /favicon.ico, o ícone da aba, logo depois da primeira página. Sem uma rota para ele, o servidor responde 404, e está tudo bem — mas se o código não tratasse caminhos desconhecidos, esse pedido extra derrubaria o programa;
  2. a primeira linha pode vir estranha: um pedido cortado no meio, um robô testando portas, um cabo de rede ruim. O try na hora de separar a linha transforma o problema numa resposta 400, em vez de um traceback;
  3. o dicionário de rotas deixa o servidor fácil de crescer: uma rota nova é uma função nova e uma linha no dicionário.

O servidor completo na placa

Juntando o ponto de acesso, o socket servidor e as funções acima, este é um servidor completo. Ele roda igual na placa real e no simulador: abra no simulador, clique em Executar e depois em Navegador, com o endereço http://192.168.4.1/. Ele segue a estrutura do exemplo http_server.py que acompanha o código do MicroPython: getaddrinfo, a opção SO_REUSEADDR, bind, listen e um laço com 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('entre na rede PyPlaca-LED e abra http://%s/' % ap.ifconfig()[0])

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

def pagina():
    return PAGINA % ('aceso' 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 alguém pedir
    try:
        cliente.settimeout(5)               # um cliente lento não trava a placa
        primeira = cliente.readline()
        while True:                         # lê e descarta os cabeçalhos
            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 com', endereco, e)
    finally:
        cliente.close()                     # sempre fechar, senão os sockets acabam

Vale entender cada escolha:

  • 0.0.0.0 quer dizer "em todas as interfaces": o servidor atende tanto pelo ponto de acesso quanto pela rede da casa, se a estação também estiver ligada;
  • SO_REUSEADDR deixa reiniciar o programa sem esperar a porta ser liberada. Sem ele, um segundo bind() logo depois de um Ctrl+C pode dar OSError de endereço em uso;
  • listen(2) é o tamanho da fila de conexões esperando o accept(). Num microcontrolador, filas curtas bastam;
  • settimeout(5) evita que um cliente que conecta e não manda nada deixe a placa parada para sempre no readline();
  • o finally: cliente.close() é o mais importante. Cada conexão aberta ocupa memória e um socket da pilha de rede; esquecer de fechar faz o servidor parar de responder depois de algumas visitas.

Nesse servidor, o caminho /led/on acende o LED e devolve a mesma página. Um problema de usabilidade: depois de tocar em "Acender", o endereço da barra do navegador continua /led/on, e um "recarregar" acende de novo. Para ações, a solução clássica é responder 303 com um cabeçalho Location: /, que manda o navegador voltar à página principal depois de executar o comando.

FastTeacherPara professores

Da ementa ao semestre planejado

  • Plano de ensino e calendário montados sozinhos
  • Diário de aulas pronto para colar no SIGA
  • Slides e listas de exercícios com gabarito
Conhecer o FastTeacher

Versão Community grátis · Windows

Uma página com o sensor

O que torna o servidor útil é mostrar dados ao vivo. O exemplo roda no simulador com o circuito do DHT22 da trilha de sensores: ele lê o sensor e monta a página HTML completa, com a temperatura e a umidade, exatamente como o servidor a enviaria. Abra, rode e veja o HTML no 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="pt-BR"><head><meta charset="utf-8">
<meta http-equiv="refresh" content="10">
<title>Sensor da sala</title></head>
<body><h1>%s</h1>
<p>Temperatura: <strong>%.1f °C</strong></p>
<p>Umidade: <strong>%.1f %%</strong></p>
<p><small>Atualiza sozinha a cada 10 s.</small></p>
</body></html>""" % (escapar(local), t, u)

time.sleep(2)                 # o DHT22 precisa de um tempo depois de ligado
print(pagina_sensor('Laboratório <B12>'))

Dois cuidados aparecem nesse exemplo. O primeiro é o escapar(): todo texto que vem de fora (o nome do local digitado num formulário, o nome de uma rede) precisa ter <, > e & trocados antes de entrar no HTML; senão, um texto como <B12> vira uma etiqueta e some da página — ou, pior, alguém injeta código na sua página. O segundo é o %%: dentro de uma string formatada com %, o sinal de porcento literal é escrito dobrado.

A tag <meta http-equiv="refresh" content="10"> faz o navegador recarregar a página sozinho a cada 10 segundos. É a forma mais simples de "tempo real". Uma forma mais elegante é servir os dados em JSON num caminho separado, como /api/leitura, e deixar um pequeno JavaScript na página buscar só os números, sem recarregar tudo. A resposta JSON é a mesma ideia, com Content-Type: application/json e o corpo feito com json.dumps({'t': t, 'u': u}) — o mesmo assunto da lição de JSON.

Formulários e parâmetros na URL

Para mandar dados para a placa — o nome do local, um limite de temperatura —, o jeito mais simples é um formulário HTML com method="get". O navegador junta os campos no caminho, depois de um ?, no formato /config?local=Sala+12&limite=30. O servidor precisa separar esses pares e desfazer a codificação: espaço vira + e caracteres especiais viram % seguido de dois dígitos hexadecimais.

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

Esse decodificador trata bem textos sem acento. Letras acentuadas chegam como vários %XX seguidos (o UTF-8 usa dois bytes para cada letra acentuada), e juntá-las certo pede trabalhar com bytes em vez de chr(). Para nomes de rede e senhas, prefira deixar o formulário restrito a letras sem acento, números e alguns sinais.

Sem travar: asyncio.start_server

O servidor do exemplo completo tem um problema conhecido: enquanto espera no accept(), a placa não faz mais nada. Um botão apertado nesse momento é ignorado, e um LED que deveria piscar fica parado. É o mesmo problema da trilha Tempo sem travar, e tem a mesma solução: o asyncio, que vem no firmware da ESP32.

O asyncio do MicroPython tem o start_server(), que chama uma função sua para cada cliente que conecta, enquanto as outras tarefas continuam rodando:

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('a placa continua viva')
        await asyncio.sleep(5)

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

asyncio.run(main())

O start_server() recebe a função que atende, o endereço e a porta. Cada cliente ganha um leitor e um escritor: o leitor tem readline() com await, e o escritor tem write() seguido de await drain(), que espera os dados saírem. No fim, close() e wait_closed() fecham a conexão. Enquanto um cliente é atendido, a tarefa outra_tarefa segue imprimindo — e, num projeto de verdade, lendo sensores e piscando LEDs.

Segurança: o que todo servidor na placa precisa considerar

Um servidor na placa é ótimo para o laboratório e para casa, mas tem limites claros de segurança:

  • sem HTTPS: o servidor deste artigo usa HTTP simples, então tudo trafega em texto aberto na rede. Quem estiver na mesma rede pode ler os pedidos. Não trafegue senhas importantes por ele;
  • sem login: qualquer pessoa que alcance o endereço controla a placa. No ponto de acesso, a senha WPA2 é a sua proteção; na rede de casa, qualquer aparelho da rede entra;
  • nunca exponha a placa na internet abrindo uma porta no roteador. Para controlar a placa de longe, o caminho seguro é o contrário: a placa se conecta a um serviço na nuvem, como um broker MQTT com usuário e senha, e você fala com esse serviço;
  • valide tudo o que chega: caminho, parâmetros e tamanhos. Um pedido muito grande pode acabar com a memória; um parâmetro inesperado não pode virar um comando perigoso;
  • para ações que mudam algo, como acender ou abrir, um token simples no caminho (/led/on?token=...) já impede que alguém da rede acione a placa por acaso.

Para projetos maiores, existe a Microdot, uma biblioteca de terceiros para MicroPython com rotas no estilo do Flask e suporte a asyncio. Ela poupa trabalho, mas usar o socket diretamente pelo menos uma vez é o melhor jeito de entender o que ela faz por você.

Diagnosticando problemas

Quando o navegador não abre a página da placa, siga a ordem do caminho:

  1. a rede: no modo estação, o Serial mostrou um IP? O celular está na mesma rede que a placa? Redes de visitantes costumam isolar os aparelhos uns dos outros. No modo ponto de acesso, o celular está conectado à rede da placa, e não ao 4G?
  2. o endereço: digite http:// explicitamente. Alguns navegadores tentam https:// primeiro, e o servidor simples não fala HTTPS;
  3. o programa: o Serial mostra a conexão chegando? Se mostra e a página não aparece, o problema está na resposta — faltou a linha vazia depois dos cabeçalhos, ou o Content-Type;
  4. depois de algumas visitas para de responder: quase sempre é socket que não foi fechado. Confira o finally.

Para continuar

A trilha Wi-Fi e HTTP é a base deste artigo: a lição Wi-Fi mostra a conexão em modo estação, e a HTTP mostra o outro lado, a placa como cliente. A lição DHT22 e a máquina de estados ajudam a montar um servidor que lê sensores e reage a comandos sem perder o controle do resto.

Os artigos ESP-NOW, sobre placas conversando sem roteador, e armazenamento de dados, sobre guardar a configuração que o formulário recebe, são a continuação natural. Para os pinos, consulte a ficha da ESP32 DevKit V1 e a ficha do DHT22.

Fontes

Para praticar no PyPlaca

Trilhas

Lições

Fichas de componentes

FastTeacherPara professores

Da ementa ao semestre planejado

  • Plano de ensino e calendário montados sozinhos
  • Diário de aulas pronto para colar no SIGA
  • Slides e listas de exercícios com gabarito
Conhecer o FastTeacher

Versão Community grátis · Windows

FastMindAssistente de IA local

A IA que lembra da sua vida e mora no seu computador

  • Memória de longo prazo com a fonte de cada resposta
  • Funciona offline: seus dados ficam no seu PC
  • Lê PDFs, slides, fotos e gravações de reunião
Baixar grátis para Windows

Plano Essencial grátis para sempre

FastTeacherPara professores

Da ementa ao semestre planejado

  • Plano de ensino e calendário montados sozinhos
  • Diário de aulas pronto para colar no SIGA
  • Slides e listas de exercícios com gabarito
Conhecer o FastTeacher

Versão Community grátis · Windows