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
requeststambé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 noaccept()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 comrequests; 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.
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
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.
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.
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:
- 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; - a primeira linha pode vir estranha: um pedido cortado no meio, um robô testando portas, um cabo de rede ruim. O
tryna hora de separar a linha transforma o problema numa resposta 400, em vez de um traceback; - 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 acabamVale entender cada escolha:
0.0.0.0quer 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_REUSEADDRdeixa reiniciar o programa sem esperar a porta ser liberada. Sem ele, um segundobind()logo depois de um Ctrl+C pode darOSErrorde endereço em uso;listen(2)é o tamanho da fila de conexões esperando oaccept(). Num microcontrolador, filas curtas bastam;settimeout(5)evita que um cliente que conecta e não manda nada deixe a placa parada para sempre noreadline();- 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.
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
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('&', '&').replace('<', '<').replace('>', '>')
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, dadosEsse 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:
- 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?
- o endereço: digite
http://explicitamente. Alguns navegadores tentamhttps://primeiro, e o servidor simples não fala HTTPS; - 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; - 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
- Exemplo http_server.py do MicroPython v1.29.0:
getaddrinfo,SO_REUSEADDR,bind,listene o laço comaccept. - Referência rápida da ESP32 no MicroPython: ponto de acesso com
network.WLAN(network.WLAN.IF_AP)eap.config(ssid=...). - network_wlan.c do MicroPython v1.29.0:
key/passwordesecurity/authmodedo ponto de acesso. - esp_netif_defaults.c do ESP-IDF v5.5.2: o endereço 192.168.4.1 do ponto de acesso.
- Documentação do asyncio do MicroPython:
start_servere os fluxos de leitura e escrita. - Microdot: biblioteca de servidor web para MicroPython.
Para praticar no PyPlaca
Trilhas
- Trilha 8: Wi-Fi e HTTP
- Trilha 5: Sensores: temperatura, distância e presença
- Trilha 7: Tempo sem travar
Lições
- Wi-Fi: a placa entra na rede
- HTTP: a placa faz um pedido, o servidor responde
- JSON: os dados que o programa entende
- DHT22: temperatura e umidade
- Máquina de estados