
Como criar um portal Wi-Fi embarcado no ESP32
Portal que o técnico abre pelo celular para configurar o Wi-Fi sem cabo: varredura de redes, gravação na NVS e teste de conexão sem travar a resposta HTTP.
Requisitos de hardware:
- Placa ESP32 ou ESP32-S3 (referência: módulo N16R8)
- Cabo USB com transmissão de dados
- Um celular para abrir o portal
- Rede Wi-Fi de 2,4 GHz
Baixe o material deste tutorial:
- Projeto completo (ZIP)153 KBFirmware Arduino com o portal, varredura de redes e gravação na NVS, definição da placa N16R8, modelo de configuração, README e o manual em PDF.
- Manual em PDF (7 páginas)168 KBPasso a passo em oito etapas, o padrão de resposta que evita travar o portal, tabela de solução de problemas e checklist final.
Quer receber novos manuais e esquemas industriais? Entre no nosso canal do Telegram / WhatsApp
O problema que o portal resolve
Depois que o firmware está gravado, o dispositivo ainda não sabe em qual rede entrar. Recompilar com o SSID e a senha embutidos funciona na bancada e não funciona em campo: cada instalação teria um binário diferente, e o técnico precisaria de um notebook com o ambiente de compilação para trocar uma senha.
O portal embarcado inverte isso. A placa sobe uma rede própria, o técnico conecta o celular nela, abre uma página, escolhe a rede e digita a senha. As credenciais vão para a memória não volátil e sobrevivem a reinício e a nova gravação. Nenhum cabo, nenhum compilador.
Este tutorial documenta o portal do firmware AnalictY e é o complemento do tutorial de NAT: um configura o dispositivo, o outro roteia o tráfego dele.
Aqui o Arduino serve, e no NAT não
Vale explicar a escolha, porque parece incoerente à primeira vista.
O repetidor NAT precisa de ESP-IDF porque o NAPT depende de opções de compilação do lwIP que o Arduino Core não expõe. Já o portal de configuração não usa nada disso: ele precisa de Wi-Fi em modo ponto de acesso, um servidor HTTP e armazenamento persistente. Os três existem no Arduino Core, prontos e estáveis.
Usar ESP-IDF aqui só adicionaria complexidade sem ganho. A regra é escolher a camada pelo requisito, não por preferência.
WebServer síncrono, não assíncrono
Muitos tutoriais escolhem o ESPAsyncWebServer por reflexo. Este portal usa o WebServer que já vem no core:
| 1 | #include <WiFi.h> |
| 2 | #include <WebServer.h> |
| 3 | #include <Preferences.h> |
| 4 | WebServer webServer(80); |
| 5 | Preferences preferences; |
O servidor síncrono atende uma requisição por vez, no laço principal. Para um portal que um técnico abre por dois minutos, isso é mais que suficiente — e evita uma dependência externa, que no ESP32 costuma trazer conflito de versão.
A regra prática: o assíncrono compensa quando há vários clientes simultâneos ou conexões longas, como WebSocket. Provisionamento não é esse caso.
Onde o HTML mora, e por que não é LittleFS
Outro reflexo comum é guardar a página em um sistema de arquivos. Este portal embute o HTML direto no binário, usando a macro F para manter o texto na flash em vez da RAM:
| 1 | page += F("<div class='api'>Celular no AP: http://192.168.4.1<br>"); |
| 2 | page += F("GET /api/status<br>GET /api/wifi/scan<br>"); |
| 3 | page += F("GET /api/config<br>POST /api/config</div>"); |
A vantagem é operacional: o firmware é um arquivo só. Não existe a etapa separada de subir a imagem do sistema de arquivos, nem o risco clássico de gravar o firmware novo e esquecer de atualizar a interface, deixando a página antiga servindo endpoints que já mudaram.
O custo é que alterar a interface exige recompilar. Para uma página de configuração que muda pouco, é uma troca vantajosa.
As rotas do portal
São nove rotas, cada uma com sua variante OPTIONS para responder ao CORS quando o portal é consumido por outra origem:
| 1 | GET / pagina do portal |
| 2 | GET /api/status diagnostico em JSON |
| 3 | GET /api/capabilities recursos suportados pela placa |
| 4 | GET /api/wifi/scan redes encontradas na varredura |
| 5 | GET /api/config configuracao atual |
| 6 | POST /api/config grava SSID, senha e parametros |
| 7 | GET /api/program programa do runtime |
| 8 | POST /api/program grava o programa |
| 9 | GET /api/runtime/status estado de execucao |
| 10 | POST /api/reboot reinicia |
| 11 | POST /api/reset-config apaga a configuracao da NVS |
Separar leitura e escrita em métodos distintos da mesma rota mantém a API previsível: GET em `/api/config` devolve o que está gravado, e POST na mesma rota grava.
A varredura que não desiste na primeira tentativa
A varredura de Wi-Fi no ESP32 falha com frequência quando o rádio está ocupado, especialmente com o ponto de acesso ativo. Um scan que devolve erro faz o técnico concluir que a rede não existe, quando ela está ali.
Por isso a varredura tenta duas vezes, parando a anterior e limpando o resultado antes de repetir:
| 1 | int scanWifiNetworks() { |
| 2 | for (int attempt = 0; attempt < 2; attempt++) { |
| 3 | prepareWifiScanMode(); |
| 4 | esp_wifi_scan_stop(); |
| 5 | WiFi.scanDelete(); |
| 6 | delay(attempt == 0 ? 120 : 450); |
| 7 | int count = WiFi.scanNetworks(false, true, false, 650); |
| 8 | if (count >= 0) { |
| 9 | return count; |
| 10 | } |
| 11 | WiFi.scanDelete(); |
| 12 | WiFi.mode(apMode ? WIFI_AP_STA : WIFI_STA); |
| 13 | delay(350); |
| 14 | } |
| 15 | return -1; |
| 16 | } |
Os quatro argumentos importam. O primeiro false faz a varredura bloqueante, devolvendo o resultado pronto. O segundo true inclui redes ocultas. O terceiro false usa varredura ativa, que encontra mais redes que a passiva. E o 650 é o tempo em milissegundos por canal — abaixo disso, redes distantes somem do resultado.
A segunda tentativa espera mais que a primeira, 450 contra 120 milissegundos, dando tempo ao rádio de se reorganizar antes de repetir.
O detalhe que separa portal bom de portal ruim
Este é o ponto central do artigo.
A tentação é receber o formulário, conectar no Wi-Fi e só então responder ao navegador com o resultado. Não faça isso. Se a senha estiver errada, o ESP32 fica até vinte segundos tentando, o celular estoura o tempo de espera e a página morre sem resposta. O técnico conclui que não gravou, digita de novo e cai no mesmo lugar.
O correto é gravar, responder imediatamente e testar depois:
| 1 | void handleConfigPost() { |
| 2 | saveConfigFromRequest(); |
| 3 | lastWifiStatus = WL_IDLE_STATUS; |
| 4 | lastWifiError = "Configuracao gravada. Teste Wi-Fi iniciado..."; |
| 5 | wifiConnectTestPending = true; |
| 6 | String body; |
| 7 | body += F("<meta http-equiv='refresh' content='8;url=/'>"); |
| 8 | body += F("<h1>Configuracao gravada</h1>"); |
| 9 | webServer.send(200, "text/html", body); |
| 10 | } |
A gravação acontece, a resposta sai na hora, e a flag fica marcada. O teste de conexão roda no laço principal, fora do ciclo da requisição:
| 1 | if (wifiConnectTestPending) { |
| 2 | wifiConnectTestPending = false; |
| 3 | connectWiFiOnce(20000); |
| 4 | } |
E a página que o técnico recebe traz um meta refresh de 8 segundos apontando para a raiz. Ele não precisa fazer nada: a tela volta sozinha ao portal, agora mostrando se conectou ou por que falhou.
O ganho de percepção é enorme. Gravar sempre confirma na hora. O teste, que é demorado e pode falhar, acontece em separado.
Diagnóstico que diz o que fazer
Mensagem de erro genérica em provisionamento é inútil. O portal expõe o último estado em `/api/status`, com o motivo provável já traduzido:
| 1 | { |
| 2 | "wifi_connected": false, |
| 3 | "wifi_ssid": "MinhaRede-2.4G", |
| 4 | "last_wifi_status": "desconectado", |
| 5 | "last_wifi_error": "Falha: desconectado. Normalmente senha errada, WPA3-only, filtro MAC ou roteador recusando.", |
| 6 | "last_wifi_channel": 6, |
| 7 | "ap_active": true, |
| 8 | "ap_ip": "192.168.4.1" |
| 9 | } |
Repare no ap_active continuando verdadeiro depois da falha. É contrato do portal: o ponto de acesso não cai quando a conexão falha. Se caísse, o técnico ficaria sem caminho de volta e precisaria do cabo — exatamente o que o portal existe para evitar.
Quando conecta, o mesmo endpoint devolve o endereço obtido e a intensidade do sinal, o que ajuda a decidir o posicionamento da antena ainda em campo.
Tutorial: do zero ao Wi-Fi configurado pelo celular
Passo 1: preparar o ambiente
Instale o VS Code e a extensão PlatformIO IDE. Se nunca fez isso, o tutorial de primeiros passos com o PlatformIO cobre a instalação em detalhe.
Passo 2: configurar o platformio.ini
Este portal roda em Arduino Core. O arquivo mínimo para uma ESP32-S3 N16R8:
| 1 | [env:esp32-s3-n16r8] |
| 2 | platform = espressif32 |
| 3 | board = esp32-s3-devkitc-1 |
| 4 | framework = arduino |
| 5 | monitor_speed = 115200 |
| 6 | upload_speed = 921600 |
| 7 | board_build.arduino.memory_type = qio_opi |
| 8 | build_flags = |
| 9 | -DBOARD_HAS_PSRAM |
| 10 | lib_deps = |
| 11 | bblanchon/ArduinoJson@^7.2.1 |
O ArduinoJson monta as respostas dos endpoints. O WebServer e o Preferences já vêm no core, sem declaração.
Passo 3: gravar e abrir o monitor
Conecte a placa e grave. Na ESP32-S3 com duas portas USB, use a marcada UART:
| 1 | pio run -t upload |
| 2 | pio device monitor -b 115200 |
No log de boot, procure a confirmação de que o ponto de acesso subiu e o servidor está escutando na porta 80.
Passo 4: conectar o celular
Abra a lista de redes Wi-Fi do celular. A rede da placa aparece com o nome definido no firmware. Conecte nela.
O celular vai avisar que essa rede não tem internet — é esperado, porque neste momento a placa ainda não está conectada a lugar nenhum. Escolha manter a conexão.
Passo 5: abrir o portal e escanear
No navegador do celular, abra:
| 1 | http://192.168.4.1 |
Toque em Scan. A varredura leva alguns segundos, e a lista traz cada rede com o sinal em dBm e o canal. Redes com sinal abaixo de -80 dBm tendem a conectar e cair; prefira as mais fortes.
Se a sua rede não aparecer nem na segunda tentativa, use o campo de SSID manual — redes ocultas exigem isso, e o campo existe para esse caso.
Passo 6: salvar e acompanhar
Escolha a rede, digite a senha e salve. A confirmação aparece na hora e a página volta sozinha ao portal em oito segundos.
Se conectou, o painel mostra o endereço que a placa recebeu na sua rede. Se falhou, mostra o motivo — e o ponto de acesso continua no ar para você corrigir sem cabo.
Lembre que o ESP32 não conecta em redes de 5 GHz. Se a sua tem sufixo 5G, use a de 2,4 GHz.
Passo 7: confirmar pela rede principal
Desconecte o celular do ponto de acesso e volte para a sua rede normal. Abra o endereço que o painel mostrou. Se o portal responder por ali, o provisionamento terminou: a placa está na sua rede e continua acessível.
Erros comuns e como resolver
A rede não aparece no scan. Confirme que é 2,4 GHz. Se for oculta, use o SSID manual. Se estiver longe, aproxime a placa e escaneie de novo — o tempo por canal favorece redes próximas.
Salvei e a página não respondeu. É o sintoma clássico de tentativa bloqueante. Confirme que o handler responde antes de testar a conexão, como mostrado acima.
Diz que gravou, mas não conecta. Veja o `last_wifi_error` em `/api/status`. Senha errada, WPA3-only e filtro de MAC no roteador dão o mesmo sintoma e mensagens diferentes.
O ponto de acesso some quando a conexão falha. Não deveria. Verifique se o código não está desligando o modo AP no tratamento de erro — sem ele o técnico perde o caminho de volta.
Perdi as credenciais e quero recomeçar. Chame o endpoint de reset da configuração, que apaga o que está na NVS e devolve a placa ao estado lógico de fábrica.
