Módulos Wifi

Controle Centralizado: ESP-01 com Relé e DHT11 via ESP-NOW usando ESP32

Eletrogate 14 de novembro de 2025

Introdução

No post anterior ESP8266 Relay com Programação de Eventos Temporais apresentamos uma aplicação voltada ao controle autônomo de relés, programando horários de acionamento diretamente no módulo ESP-01S acoplado ao adaptador ESP8266 Relay.

Neste novo projeto, avançamos um passo além: a criação de uma rede de módulos ESP-01 equipados com Relé ou Sensor DHT11, todos se comunicando entre si e com uma Estação Central baseada em ESP32 por meio do protocolo ESP-NOW.

A Estação Central atua como um hub de gerenciamento, recebendo periodicamente as informações publicadas por cada nó e disponibilizando, via interface Web responsiva, uma visão consolidada de toda a rede.
Por essa interface é possível:

  • visualizar o estado atual de cada relé;
  • acionar remotamente os dispositivos de forma instantânea;
  • acompanhar em tempo real as leituras de temperatura e umidade dos módulos com DHT11;
  • identificar cada módulo pelo seu alias configurável;
  • e até colocar qualquer nó em modo de configuração diretamente pela interface.

Tudo isso sem depender de roteadores externos ou infraestrutura de rede Wi-Fi complexa, pois a comunicação principal entre os módulos ocorre via ESP-NOW, um protocolo leve, rápido e de baixo consumo de energia.


Motivação

Projetos anteriores mostraram o potencial do ESP8266 em aplicações simples de automação e monitoramento. Contudo, cada módulo operava de forma independente, exigindo acesso direto ao seu IP ou página de configuração para qualquer ajuste — um processo viável em pequenas instalações, mas pouco prático quando se deseja escalar o sistema para vários pontos de controle.

A motivação central deste projeto foi, portanto, evoluir de módulos autônomos para uma arquitetura coordenada, onde uma única Estação Central pudesse:

  • descobrir automaticamente todos os nós ativos na rede;
  • gerenciar múltiplos dispositivos com feedback instantâneo;
  • simplificar a configuração e manutenção remota de cada módulo;
  • reduzir o consumo de recursos dos nós (RAM, Flash) mantendo a estabilidade do ESP-01S;
  • e eliminar dependências de conexão Wi-Fi entre roteadores, usando ESP-NOW como canal direto.

Além de otimizar o controle em aplicações domésticas e industriais, esse modelo cria uma base sólida para futuras expansões, como:

  • integração com plataformas em nuvem,
  • sincronismo de hora via NTP,
  • atualização OTA (Over-the-Air), e
  • suporte a novos tipos de sensores usando o mesmo framework de comunicação.

Em resumo, este projeto demonstra como o pequeno ESP-01S pode participar de um ecossistema distribuído de automação, robusto, configurável e gerenciável de forma centralizada, mantendo a simplicidade e o baixo custo que tornaram o ESP8266 tão popular.


Materiais Necessários

Figura 1 – Adaptador ESP01 com Relé

Figura 2 – Adaptador ESP01 com DHT11

Figura 3 – Módulo ESP01

Figura 4 – Módulo ESP32 30 Pinos (Opção 1)

Figura 5 – Módulo ESP32C3 Mini (Opção 2)


Funções Adicionais

O projeto foi estruturado para suportar diversas funções complementares que aumentam sua abrangência e demonstram boas práticas em IoT com recursos limitados:

Estação Central (ESP32)

  • AsyncWebServer — servidor HTTP assíncrono para a interface Web dinâmica e responsiva.
  • WebSocket — comunicação em tempo real entre a interface e o ESP32, refletindo instantaneamente o estado dos nós.
  • WiFiManager — configuração simplificada da rede (SSID, senha e parâmetros diversos).
  • ElegantOTA — atualização remota de firmware pela própria interface Web.
  • NTP Client — sincronização automática de data e hora via Internet.
  • mDNS (DNS local) — acesso pelo nome amigável da estação (ex.: http://central.local).
  • ESP-NOW — canal de comunicação direta com os nós ESP-01S, sem uso de roteador.
  • RSSI – Determinação do RSSI da comunicação entre a Central e o ESP01 utilizando recursos do SNIFFER .

Estações ESP-01 (Relé e DHT11)

  • Modo AP de Configuração — página Web nativa para definir SSID e Alias do módulo.
  • ESP-NOW — comunicação com a estação central para envio de dados e recepção de comandos.
  • Modo OTA Local — atualização de firmware via navegador, acessando o AP do próprio módulo.
  • Armazenamento SPIFFS — persistência das configurações de rede e alias do dispositivo.
  • Modo AP ou Comando Remoto — entrada em modo AP de configuração de forma automática ou sob comando da Estação Central.

Essas funções tornam o projeto uma plataforma modular e evolutiva, equilibrando simplicidade de uso com recursos típicos de sistemas IoT mais avançados.

Uso do WifiManager (Estação Central)

A biblioteca WifiManager foi utilizada pela permitir a definição da rede Wifi (na Estação Central) a ser utilizada pelo próprio usuário evitando assim que o SSID/Senha fiquem internos no código implicando em recompilação do aplicativo toda vez que a rede fosse mudada ou a senha alterada. Além disso, o WifiManager permite que o usuário defina certos parâmetros da aplicação dando maior flexibilidade e liberdade.

Passo a passo de como utilizar:

  • Na primeira execução do programa, o WifiManager não estará configurado e entrará no AP MODE, onde poderá ser visto na lista de AP’s da Rede WiFi. Opcionalmente, pressionando-se o botão BOOT do ESP32 no Modo Normal, o modo de configuração também será ativado.
  • Deve-se selecionar o ESP_XXXX (onde XXXX é o serial do ESP32).
  • Uma vez conectado no ESP32, deve-se acessar a URL http://192.168.4.1:8080 para a tela de entrada do WiFiManager.
  • Uma tela aparecerá com os parâmetros a serem definidos antes de pressionar SAVE. Quando os parâmetros estiverem todos definidos e conferidos (inclusive o SSID da Rede WiFi e a senha), deve-se pressionar o botão SAVE mas abaixo na tela. O WiFiManager salvará os parâmetros no SPIFFS e o ESP32 entrará com a aplicação ativa, conectado na Rede WiFi selecionada e poderá ser acessado via a URL http://central.local , supondo que o DNSNAME definido seja central.

Figura 6 – Tela Inicial do WifiManager

Figura 7 – Tela de Conexão no Modo AP do WifiManager

Figura 8 – Tela de Funções do WifiManager

Figura 9 – Tela de Definição da Rede Wifi e Parâmetros

Utilização da ElegantOTA (Estação Central e Estações ESP01 Relé/DHT11)

A biblioteca ElegantOTA foi utilizada para permitir a atualização do aplicativo pela interface Web sem a necessidade de levar o circuito do ESP32 até a estação de compilação. Isso permite, por exemplo, que o desenvolvedor libere uma nova versão em qualquer lugar do mundo e o próprio usuário instale a nova versão dando maior independência.

Importante: na Referência 3 existe um link para instruções de como configurar a biblioteca ElegantOTA, de forma manual,  em Assíncrono Mode. Isso é importante para a compilação dos fontes das três estações. Basta seguir os passos no tutorial.

Na Estação Central, a atualização é acionada através da URL http://central.local/update e, nas estações com relé e dht11, pressionando o botão “Atualizar Firmware”. Em seguida, basta autenticar com as credenciais definidas no WifiManager e selecionar o arquivo contendo a imagem a ser carregada.

A imagem pode ser gerada  no Arduino IDE através do Menu conforme a figura a seguir. O arquivo .bin será gerado numa subpasta dentro da pasta do projeto.

Figura 10 – Geração da Imagem para Atualização via ElegantOTA

 

Figura 11 – Autenticação para Atualização de Versão

Figura 12 – Tela de Seleção da Versão a ser Atualizada


Detalhes de Implementação

A arquitetura da solução baseia-se em uma rede ESP-NOW composta por uma Estação Central (ESP32) e vários nós periféricos ESP01S, configurados em duas modalidades:

  • Módulos de Relé, responsáveis por acionar cargas externas;
  • Módulos de Sensor DHT11, responsáveis por enviar temperatura e umidade ao sistema central.

O que é uma Rede ESP-NOW

O ESP-NOW é um protocolo de comunicação sem fio desenvolvido pela Espressif que permite a troca direta de mensagens entre módulos ESP8266 e ESP32, sem a necessidade de um roteador Wi-Fi. Ele utiliza o mesmo hardware de rádio do Wi-Fi tradicional, porém em um modo de operação “peer-to-peer”, com baixa latência e consumo reduzido de energia. Os dispositivos comunicam-se diretamente entre si (nós e estação central) por meio de endereços MAC, sendo essencial que todos estejam operando no mesmo canal de rádio. Embora uma rede ESP-NOW possa coexistir com o Wi-Fi convencional, ela não depende dele — o roteador é opcional e serve apenas para oferecer conectividade adicional, como dashboards web ou integração com nuvem.

Figura 13 – Exemplo de Rede ESP-NOW

Fluxo Geral de Comunicação

O fluxo de operação é ilustrado na figura a seguir.

Figura 14 – Fluxo  de Operação das Estações

A Estação Central (no centro do diagrama) atua como controlador e servidor Web, recebendo periodicamente informações dos nós e permitindo o envio de comandos de controle.

  • As estações com Relé trocam mensagens bidirecionais com a Central:
    enviam status (ligado/desligado) e recebem comandos ON/OFF ou de reconfiguração (MSG_CFG).
  • As estações com DHT11 transmitem periodicamente seus valores de temperatura e umidade para a Central, sem necessidade de resposta, caracterizando um fluxo unidirecional otimizado.
  • A Estação Central mantém um mapa interno de nós ativos, atualizando a interface Web via WebSocket.
    Cada nó é representado por um card dinâmico, mostrando o tipo, alias, canal, modo de operação e tempo desde a última atualização.

Figura 15 – Tela Principal da Estação Central no Desktop

Figura 16 – Tela Principal da Estação Central no Celular

Gerenciamento e Estabilidade

Para garantir estabilidade:

  • As mensagens ESP-NOW são compactas e não utilizam broadcast excessivo, reduzindo colisões;
  • Cada nó envia periodicamente uma mensagem de heartbeat (status/sensor) para manter-se ativo na lista da Central;
  • Caso um nó fique inativo por mais de 30 s, a Central o remove automaticamente da interface;
  • As comunicações são realizadas no mesmo canal Wi-Fi da rede principal, permitindo que a Central opere simultaneamente como STA conectada ao roteador.

Modo de Configuração e Atualização

Cada nó ESP01S pode entrar em modo de configuração de duas formas:

  1. Automaticamente na primeira vez antes de qualquer configuração prévia;
  2. Comando remoto MSG_CFG, enviado pela Estação Central via ESP-NOW quando o botão Configurar for pressionado.

No modo de configuração, o módulo opera como Access Point (AP), exibindo uma página HTML simples para parametrização do SSID e alias.

A tabela a seguir mostra como os SSID’s aparecerão na Rede Wifi para a conexão e configuração:

Estação

SSID na Rede Wifi

ESP01-Relé

ESP_Relay_nnnnnn

ESP01-DHT11

ESP_DHT11_nnnnnn

 

Nesta mesma interface, é possível acessar o recurso ElegantOTA, permitindo atualização de firmware remota diretamente pelo navegador pressionando o botão Atualizar Firmware.

Figura 17 – Tela de Configuração nas Estações com Relé e DHT11

Determinação do RSSI em Rede ESP‑NOW

Nesta implementação, o monitoramento do nível de sinal (“RSSI” — Received Signal Strength Indicator) entre os nós (ESP01S) e a estação central (ESP32) foi tratado, porém com uma particularidade técnica importante, a saber: a versão do core usada no ESP32. A seguir, descrevemos o método adotado e como esse panorama evolui em versões posteriores do framework.

Contexto técnico

Para a versão **Arduino-ESP32 Core 2.0.17que utilizamos neste projeto, o callback padrão da função esp_now_register_recv_cb(…) utiliza a assinatura:

void onRecv(const uint8_t *mac_addr, const uint8_t *data, int len);

Essa versão não disponibiliza direto no callback o RSSI ou outros metadados do pacote recebido. Documentação e fóruns apontam que a assinatura com estrutura esp_now_recv_info_t (que inclui rx_ctrl.rssi) só está disponível em ambientes baseados no ESP‑NOW Library mais recentes ou nas versões do Arduino Core baseadas em IDF 5.x. Arduino Forum+2Developer Portal+2 Portanto, na versão 2.0.17, para obter o RSSI, utilizou-se uma técnica “sniffer” em modo promíscuo da interface WiFi.

Método implementado

  1. A interface WiFi entra em modo promíscuo logo após a inicialização do WiFi/ESP-NOW.
  2. Um callback snifferCb(…) captura todos os pacotes (geralmente de tipo MGMT ou DATA) e extrai o endereço MAC transmissor (hdr->addr2) e o RSSI (pkt->rx_ctrl.rssi).
  3. Esses valores são armazenados em um cache leve (MAC → RSSI) com timestamp.
  4. Quando o callback padrão onNowRecv(…) do ESP-NOW é invocado (com os dados da mensagem), o sistema busca no cache o último RSSI para aquele MAC e o inclui no evento enfileirado.
  5. Após processamento, o valor do RSSI é gravado no NodeInfo e enviado ao front-end via WebSocket, permitindo visualização da qualidade do sinal em cada nó.
  6. Para garantir a associação correta entre o pacote ESP-NOW recebido e o valor de RSSI capturado pelo modo promíscuo (sniffer), foi implementado um buffer circular fixo de 16 posições, responsável por armazenar temporariamente pares {MAC, RSSI, timestamp}. Esse tamanho foi definido como compromisso entre desempenho e confiabilidade, sendo suficiente para redes com poucos nós e baixo tráfego, como no presente projeto. Sempre que um novo pacote é detectado, o sistema atualiza a entrada existente para o mesmo MAC ou, caso o buffer esteja cheio, substitui a mais antiga (menor timestamp) — evitando assim qualquer possibilidade de overflow ou crescimento dinâmico de memória. Esse mecanismo simples e determinístico garante baixa latência, uso constante de recursos e operação estável mesmo em ambientes com ruído de redes vizinhas.

Esse método, apesar de “adicional”, mostrou-se confiável e com impacto negligenciável no desempenho da rede.

Futuro (versões Core 3.x ou baseadas em IDF 5.x)

Com a introdução da Arduino-ESP32 Core 3.x (ou versões baseadas em ESP-IDF 5.x), a API de ESP-NOW permite registrar o callback na nova assinatura:

void onNowRecv(const esp_now_recv_info_t *info, const uint8_t *data, int len);

Nesse contexto, o parâmetro info contém diretamente campos como info->src_addr, info->rx_ctrl.rssi, info->rx_ctrl.channel, etc. Espressif Docs+1 Dessa forma, não é mais necessário usar o sniffer ou cache externo: basta ler info->rx_ctrl.rssi dentro do callback e gravar no NodeInfo de imediato.

Comparativo rápido

Ambiente

Callback suportado

Método para RSSI

Arduino-ESP32 Core 2.0.17

void func(const uint8_t*, const uint8_t*, int)

Sniffer + cache + busca MAC

Arduino-ESP32 Core 3.x / IDF 5

void func(const esp_now_recv_info_t*, const uint8_t*, int)

Leitura direta de info->rx_ctrl.rssi

 

Considerações finais

A escolha do método reflete uma decisão de compatibilidade: manter estabilidade e compatibilidade com a versão 2.0.17, ao mesmo tempo garantindo funcionalidade completa. Para futuros upgrades e adoção de versões mais novas, recomenda-se migrar para a assinatura moderna, simplificando o código e eliminando a sobrecarga de sniffer/cache. Assim, a documentação técnica do projeto está alinhada tanto com o cenário atual quanto com a evolução esperada da plataforma.

Preparação para carga do programa no ESP01S

Para carregamos o código no ESP01S precisaremos utilizar o adaptador USB com uma pequena modificação soldando dois fios nos pinos definidos na figura a seguir:

Figura 18 – Pinos para Soldar os fios para carga de programa

Utilizaremos a garra jacaré para unir os dois fios (curto circuito) para colocar o ESP01S no modo de carga de programa quando for rebootado. A figura a seguir mostra o adaptador já preparado:

Figura 19 – Adaptador USB preparado para usar na carga de programas no ESP01S

A tabela a seguir mostra qual Placa deve ser selecionada no ambiente Arduino IDE para a compilação dos fontes:

Microcontrolador

Placa a selecionar no IDE

ESP32

ESP32 Dev Module

ESP01S

Generic ESP8266 Module

 


Código da Estação Central

//-----------------------------------------------------------------------------------------
// Função   : Este programa tem como objetivo escutar broadcast de estações ESP8266 Relay 
//            e manter uma lista de nós para permitir o acesso Web ESP32 AsyncWebServer.
//            As estações ESP8266 Relay poderão ser gerenciadas a partir do html
//            e fazer o gerenciamento de todos os módulos numa interface html central.
//
//            Resumo: 1) Recebe a auto divulgação via broadcast na Rede ESP-NOW
//                    2) Envia comandos de LIGAR/DESLIGAR/GETSTATUS
//                    3) Recebe o Status
//
// Funções adicionais
//
//   1) Inclusão de Alias no DNS da Rede Local (mDNS) para permitir o acesso através da
//      URL http://central.local
//   2) Sincronização do Relógio interno com o Serviço NTP quando no Modo WiFi
//      ou sincronismo com o relógio do Navegador junto com os Parâmetros
//   3) Utilização do WifiManager para configurações de Rede e Parâmetros. Durante a execução,
//      pressionando-se o botão BOOT o WifiManager é ativado para reconfiguração da rede wifi
//      e parâmetros. Quando o WifiManager é ativado no modo AP, o SSID=ESP_nnnn será
//      visto nas redes disponíveis onde nnnn é o número de série do ESP32 em uso. Uma vez 
//      conectado neste SSID, o WifiManager pode ser acionado pela URL http://192.168.4.1:8080
//   4) Utilização da biblioteca ElegantOTA para atualização do código pela interface Web através
//      da URL http://central.local/updade ou através do botão na interface.
//
//-----------------------------------------------------------------------------------------
// Autor     : Dailton Menezes
// Versão    : 1.0 Out/2025 
//-----------------------------------------------------------------------------------------

#include <WiFi.h>                         // Biblioteca para a rede wifi genérica 
#include <esp_wifi.h>                     // Biblioteca para a rede wifi do ESP32
#include <esp_wifi_types.h>               // Bivlioteca para os types da rede Wifi
#include <esp_now.h>                      // Biblioteca do Protocolo ESP-NOW
#include <ArduinoJson.h>                  // Biblioteca para manipulação de estrutiras JSON  
#include <SPIFFS.h>                       // Biblioteca que implementa o filesystem 
#include <FS.h>                           // Biblioteca para tratar arquivos 
#include <unordered_map>                  // Biblioteca para manuseio de lista não ordenadas
#include <vector>                         // Biblioteca para manuseio de vetores                       
#include <AsyncTCP.h>                     // Biblioteca usada pelo Servidor Assíncrono
#include <ESP32Ping.h>                    // Biblioteca para fazer Ping  
#include <WiFiManager.h>                  // Biblioteca WiFi Manager      
#include <ESPAsyncWebServer.h>            // Biblioteca para Servidor Web Assíncrono
#include <ESPmDNS.h>                      // Biblioteca para adiocionar aliases no DNS da Rede Local
#include <freertos/FreeRTOS.h>            // Biblioteca do Sistema Operacional
#include <freertos/queue.h>               // Biblioteca para filas
#include <ElegantOTA.h>                   // Biblioteca para atualização via Web

//------------------------
// Definições do Programa
//------------------------

#define BAUDRATE         115200           // Baudrate para a Console
#define QUEUE_SIZE       32               // Tamanho da fila de mensagens
#define SNIFFER_SIZE     16               // Tamanho do buffer para SNIFFER
#define INTERVALO_VIDA   3000             // Intervalo de verificação do tempo de vida das estações
#define TEMPO_VIDA       30000            // 20s de limite sem receber mensagem da estação      
#define NTP_SERVER       "pool.ntp.org"   // NTP Server para sincronismo do clock
#define TIMEZONE_LOCAL   "<-03>3"         // Timezone do Brasil
#define DNS_NAME         "central"        // Nome DNS     
#define CONFIG_FILE      "/appconfig.json" // Nome do Arquivo de COnfiguração no SPIFFS
#define USER_OTA         "admin"          // Default Usuário de atualização OTA
#define PASS_OTA         "admin"          // Default Senha   de atualização OTA    
#define AUTOREBOOT_OTA   true             // Default AutoReboot OTA  
#define LED_BUILTIN      GPIO_NUM_2       // Usado para ligar o LED se conectado no Wifi 
#define LED_ON           HIGH             // Estado para ligar o Led BuiltIn
#define LED_OFF          LOW              // Estado para desligar o Led BuiltIn   
#define PIN_BOOT         GPIO_NUM_0       // Pino para forçar entrar no WifiManager   
#define WIFIMANAGER_PORT 8080             // Porta para o WifiManager   
#define INTERVALO_RECONECT 30000          // Intervalo para checar conexaõ com roteador
#define INTERVALO_CLEANUP  60000          // Intervalo de 10s para fazer o cleanup websocket
#define INTERVALO_NTP      30000          // Intervalo para retentar o sincronismo NTP

//--------------------------------------
// Definições de Constantes Importantes
//--------------------------------------
constexpr float RSSI_ALPHA = 0.25f;       // suavização do RSSI móvel
using MacKey = uint64_t;

//--------------------
// Tipos de Mensagens
//--------------------

enum MsgType : uint8_t 
{ 
  MSG_DISC=1,                             // Brodacast de divulgação das estações para Central
  MSG_CMD=2,                              // Comando ON/OFF do Relé 
  MSG_STATUS=3,                           // Status do Relé
  MSG_GETALL=4,                           // Broadcast quem está aí a pedido da Central 
  MSG_SENSOR=5,                           // Status de estações cm DHT11
  MSG_CFG=6                               // Pedido da Central para a estação entrar em configuração
};

//-------------------------------
// Estrutura da Mensagem ESP-NOW
//-------------------------------

struct __attribute__((packed)) NowMsg 
{
  uint8_t ver,                            // Parar diferenciar registros entre outras aplicação
          type,                           // Tipo de Mensagem
          relay,                          // Estado do Relé 
          mode,                           // Modo Wifi da Estação 
          channel,                        // Canal da Rede ESP-NOW 
          aliasLen,                       // Tamanho do campo alias
          rsv[2],                         // Para alinhamento
          mac[6];                         // MAC da estação
  char alias[32];                         // Alias da Estação (identificação)
  int16_t  tempC10;                       // temperatura em décimos de grau (ex.: 253 = 25.3°C)
  uint8_t  hum;                           // Umidade em %
};

//------------------------------------------------------------
// Estrutura da Fila de Mensagens recebidas das estações 8266
//------------------------------------------------------------

struct RxEvt 
{
  uint8_t mac[6];     // MAC recebido 
  uint8_t len;        // tamanho
  NowMsg  msg;        // regsitro Recebido
  uint32_t ts;        // timestamp do enfileiramento
  int8_t   rssi;      // RSSI em dBm
};

//---------------------------------
// Estrutura da Mensagem WebSocket
//---------------------------------

struct NodeInfo 
{
  uint8_t  mac[6];     // MAC da estação em bytes
  String   macStr;     // MAC da estação em string
  String   alias;      // Nome da Estação
  uint8_t  relay;      // estado do relé
  uint8_t  mode;       // Modo da Rede Wifi
  uint8_t  channel;    // Canal da Rede ESP-NOW
  uint32_t lastSeen;   // millis() da última atualização
  int16_t tempC10;     // temperatura em décimos de °C (253 => 25.3°C)
  uint8_t hum;         // umidade relativa em %
  bool    isSensor;    // true = card de sensor; false = card de relé  
  int8_t   rssi;       // último RSSI instantâneo
  float    rssiAvg;    // média móvel  
};

//-------------------------------------------------------
// Estrutura para representar os parâmetros no SPIFFS
//-------------------------------------------------------

struct AppConfig 
{
  int queue_size;      // Intervalo de envio das medições em min
  String alias;        // Nome para adiocnar ao mDNS da Rede
  String ntpServer;    // URL do Servidor NTP para sinconizar relógio
  String timezone;     // String do Timezone 
  String user_OTA;     // Usuário para atualização OTA
  String pass_OTA;     // Senha para atualização OTA
  bool autoRebootOTA;  // Se deve rebootar após a atualização OTA
};

AppConfig appConfig;   // Estrutura para conter os parâmetros

//---------------------------------
// Cache de RSSI visto pelo sniffer
//---------------------------------

struct RssiEntry 
{
  uint8_t  mac[6];
  int8_t   rssi;
  uint32_t ts;
};

RssiEntry gRssiCache[SNIFFER_SIZE]; 

//-----------------------------------------------------------
// Cabeçalho 802.11 (suficiente para pegar addr2 = remetente)
//------------------------------------------------------------

typedef struct __attribute__((packed)) 
{
  uint8_t frame_ctrl[2];
  uint8_t duration[2];
  uint8_t addr1[6];
  uint8_t addr2[6]; // TRANSMITTER (fonte)
  uint8_t addr3[6];
  uint8_t seq_ctrl[2];
} wifi_ieee80211_mac_hdr_t;

//-------------------
// Variáveis Globais
//-------------------

AsyncWebServer server(80);                   // Servidor Web para receber acessos http
AsyncWebSocket ws("/ws");                    // WebSocket para comunicação com o Navegador
IPAddress ip (1, 1, 1, 1);                   // The remote ip to ping, DNS do Google
std::unordered_map<MacKey, NodeInfo> nodes;  // hash nativo p/ uint64_t existe
uint8_t gMyMac[6];                           // MAC da estação
uint32_t tVarredura = 0;                     // ùltima varredura de tempo de vida das estações
QueueHandle_t qNow;                          // Fila de Mensagens recebidas
volatile bool buttonState = false;           // Estado do botão Boot para Reconfiguração do WiFi
unsigned long lastCleanup = 0;               // última limpeza de conexões perdidas WebSocket
unsigned long lastNTP = 0;                   // última tentatita de sincronismo com NTP Server 
unsigned long lastReconect = 0;              // ùltima tentativa de reconexão com o rpteador
bool sincronizado = false;                   // Se sincronizado ao NTP Server

//------------------------------
// Variáveis para o WifiManager
//------------------------------

WiFiManager wm;                           // Instância do Serviço WifiManager
WiFiManagerParameter *p_queue_size;       // Parâmetro InetrvaloEnvio
WiFiManagerParameter *p_alias;            // Parãmetro Alias
WiFiManagerParameter *p_ntpServer;        // Parãmetro NTP Server 
WiFiManagerParameter *p_timezone;         // Parãmetro Timezone
WiFiManagerParameter *p_user_OTA;         // Parãmetro User OTA
WiFiManagerParameter *p_pass_OTA;         // Parãmetro Senha OTA
WiFiManagerParameter *p_autoRebootOTA;    // Parãmetro autoRebbotOTA

//-------------------
// HTML Principal
//-------------------

const char* HTML = R"HTML(
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Central ESPNOW</title>
<style>
.badge.rssi-excelente { background:#d4edda; color:#155724; border-color:#b6e2b8; }
.badge.rssi-bom       { background:#fff3cd; color:#856404; border-color:#ffe08a; }
.badge.rssi-fraco     { background:#f8d7da; color:#721c24; border-color:#f1aeb5; }
.badge.rssi-ruim      { background:#f5c6cb; color:#721c24; border-color:#eb9fa4; }

/* grupo dos botões */
.controls { display:flex; gap:8px; }

/* botões com largura fixa, sem crescer no flex */
.controls .btn {
  flex: 0 0 90px !important;   /* largura fixa */
  width: 90px !important;
  white-space: nowrap;
}

.meta { 
  display:flex; 
  gap:8px; 
  align-items:center; 
  margin-top:10px;
}
.meta .btn-cfg {
  margin-left:auto;            /* encosta à direita */
  flex: 0 0 auto;
  padding: 6px 12px;
  white-space: nowrap;
}

/* Base */
body{
  font-family:system-ui,Segoe UI,Arial;
  margin:20px;
}

/* Grade: no desktop empilha horizontal e quebra quando precisa */
.grid{
  display:grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap:12px;
  align-items:stretch;
}

/* Card */
.card{
  border:1px solid #ddd;
  border-radius:12px;
  padding:12px;
  width:auto;
  max-width:100%;
  box-sizing:border-box;
}
.card.sensor .top .state{ opacity: .9 }
.card .bottom-line {
  display:flex;
  justify-content:space-between;
  align-items:center;
  margin-top:8px;
}

/* Cabeçalho do card */
.top{
  display:flex;
  justify-content:space-between;
  align-items:center;
}

/* Badge e MAC: evitar quebra feia */
.badge, .mac{
  font-size:12px;
  border-radius:999px;
  word-break:break-all;
}
.badge{ padding:2px 8px; border:1px dashed #999; }
.mac{ font-family:monospace; color:#555; border:none; }

/* Botões amigáveis ao toque */
.btn {
  padding: 6px 10px;
  border-radius: 8px;
  border: 1px solid #aaa;
  background: #f7f7f7;
  cursor: pointer;
  flex: 1;                 /* <<< cada botão ocupa metade da linha */
  text-align: center;
  white-space: nowrap;     /* evita quebra de texto */
}

.btn:hover {
  filter: brightness(0.97);
}


.state{ font-weight:700; }

/* ===== Responsivo ===== */

/* Celular: força 1 coluna (vertical) e aumenta áreas de toque */
@media (max-width: 480px){
  body{ margin:10px; font-size:15px; }
  .grid{ grid-template-columns: 1fr; }    /* só vertical */
  .btn{ padding:12px 14px; font-size:15px; }
}

/* (Opcional) Telas muito largas: cards um pouco maiores antes de quebrar */
@media (min-width: 1280px){
  .grid{ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
}
</style>
</head><body>
<h2>Central de Estações ESPNOW</h2>
<div id="grid" class="grid"></div>
<script>
let ws;
// mapa para controlar "idade" por MAC
const nodeAges = {}; // { "E8:DB:84:...": { localTs: Date.now(), lastSeenMs: n.lastSeen } }
const grid = document.getElementById('grid');
const cards = new Map();

// formata texto "atualizado há ..."
function formatElapsed(s){
  if (s < 5)  return "atualizado agora";
  if (s < 60) return `atualizado há ${s}s`;
  const m = Math.floor(s/60);
  if (m < 60) return `atualizado há ${m}min`;
  const h = Math.floor(m/60);
  return `atualizado há ${h}h`;
}

// atualiza a badge de um nó específico
function updateAges(){
  const now = Date.now();
  document.querySelectorAll('.age').forEach(span=>{
    const ts = parseInt(span.dataset.ts || '0', 10);
    if (!ts) return;
    const sec = Math.floor((now - ts)/1000);
    span.textContent = (sec < 1) ? 'atualizado agora' : `atualizado há ${sec} s`;
  });
}

// roda a cada 1s e atualiza todas as badges
setInterval(updateAges, 1000);

// Renderiza os Cards de Relé e DHT11
function renderCard(n){
  let el = cards.get(n.mac);
  const isSensor = !!n.isSensor; // ou derive por ('tempC' in n)

  if(!el){
    el = document.createElement('div'); 
    el.className='card';
    // bloco comum do topo
    el.innerHTML = `
      <div class="top">
        <div><div class="state" id="st"></div><div class="mac" id="mac"></div></div>
        <div class="badge" id="alias"></div>
      </div>
      <div style="margin-top:8px">Modo: <span id="mode"></span> · Canal: <span id="ch"></span></div>
      <div id="body" style="margin-top:10px"></div>
      <!-- linha inferior com idade, rssi e botão Configurar -->
      <div class="meta">
        <span class="badge age" data-ts="${Date.now()}">atualizado há 0 s</span>
        <span class="badge" id="rssi">–</span>
        <button class="btn btn-cfg" id="cfg">Configurar</button>
      </div>
    `;

    // corpo específico por tipo
    const body = el.querySelector('#body');
    if (isSensor) {
      body.innerHTML = `
        <div style="display:flex; justify-content:space-between; gap:12px;">
          <div>Temperatura: <b id="t"></b></div>
          <div>Umidade: <b id="h"></b></div>
        </div>
      `;
    } else {
      body.innerHTML = `
        <div class="controls">
          <button class="btn" id="on">Ligar</button>
          <button class="btn" id="off">Desligar</button>
        </div>
      `;
      el.querySelector('#on').onclick  = ()=> send({cmd:'set', mac:n.mac, relay:1});
      el.querySelector('#off').onclick = ()=> send({cmd:'set', mac:n.mac, relay:0});
    }

    // ação do botão Configurar (envia comando para o nó entrar em Modo AP)
    el.querySelector('#cfg').onclick = () => {
      if (confirm(`Colocar ${n.alias || n.mac} em Modo AP de Configuração?\nIsso irá reiniciar o módulo.`)) {
        send({ cmd: 'config', mac: n.mac }); // <<< use "config"
        // feedback visual opcional por 3s
        const b = el.querySelector('#cfg');
        const old = b.textContent;
        b.disabled = true; b.textContent = 'Enviando...';
        setTimeout(()=>{ b.disabled=false; b.textContent=old; }, 3000);
      }
    };  

    cards.set(n.mac, el); 
    grid.appendChild(el);
  }

  // atualizações comuns
  el.querySelector('#mac').textContent   = n.mac;
  el.querySelector('#alias').textContent = n.alias || '(sem alias)';
  el.querySelector('#mode').textContent  = ({0:'STA',1:'AP',2:'AP+STA'})[n.mode]||n.mode;
  el.querySelector('#ch').textContent    = n.channel;

  // estado / leituras
  if (isSensor) {
    el.querySelector('#st').textContent = 'Sensor DHT11';
    if (el.querySelector('#t')) el.querySelector('#t').textContent = 
      (n.tempC !== undefined) ? `${n.tempC.toFixed(1)} °C` : '–';
    if (el.querySelector('#h')) el.querySelector('#h').textContent = 
      (n.hum !== undefined) ? `${n.hum} %` : '–';
  } else {
    el.querySelector('#st').textContent = n.relay ? 'Ligado' : 'Desligado';
  }

  // RSSI (comum)
  if (el.querySelector('#rssi')) {
    const rssi = n.rssi;
    let text = '–';
    let cls = 'rssi-ruim'; // default

    if (rssi !== undefined) {
      const cat = rssiClass(rssi);
      text = `${rssi} dBm (${cat})`;
      cls = 'rssi-' + cat;
    }

    const span = el.querySelector('#rssi');
    span.textContent = text;
    span.className = 'badge ' + cls;
  }

  const ageSpan = el.querySelector('.age');
  if (ageSpan) ageSpan.dataset.ts = String(Date.now());

  // “idade” (comum) — você já tem seu ticker de age; aqui só atualiza o carimbo
  if (!nodeAges[n.mac]) nodeAges[n.mac] = {};
  nodeAges[n.mac].localTs   = Date.now();
  nodeAges[n.mac].lastSeen  = n.lastSeen; // vindo do backend
}

function rssiClass(dbm){
  if (dbm >= -60) return 'excelente';
  if (dbm >= -70) return 'bom';
  if (dbm >= -80) return 'fraco';
  return 'ruim';
}

function isValidMacStr(s){
  return typeof s === 'string' && /^([0-9A-F]{2}:){5}[0-9A-F]{2}$/i.test(s);
}

function applySnapshot(nodesArray){
  if (!Array.isArray(nodesArray)) {
    // caso venha como objeto {mac:node,...}
    nodesArray = Object.values(nodesArray || {});
  }
  for (const n of nodesArray) {
    if (isValidMacStr(n?.mac)) renderCard(n);
    else console.warn('[UI] Snapshot item sem MAC válido:', n);
  }
}

function send(o) {
  ws.send(JSON.stringify(o)); 
}

function removeCard(mac){
  if (!isValidMacStr(mac)) return;
  const el = cards.get(mac);
  if (el && el.parentNode) el.parentNode.removeChild(el);
  cards.delete(mac);
  delete nodeAges[mac]; // para o setInterval não tentar atualizar a badge
}

function inicializaWebSocket() {
  const proto = location.protocol === 'https:' ? 'wss' : 'ws'; 
  ws = new WebSocket(`${proto}://${location.host}/ws`);
  ws.onmessage = (ev)=>{
    let msg;
    try { msg = JSON.parse(ev.data); } catch(e){ return; }

    if (msg.type === 'snapshot') {
      applySnapshot(msg.nodes);
    } else if (msg.type === 'update') {
      renderCard(msg);
    } else if (msg.type === 'remove') {
      removeCard(msg.mac);          
    }
  };
}

window.onload = inicializaWebSocket;
window.addEventListener("unload", () => ws?.close());
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === 'visible' && (!ws || ws.readyState !== WebSocket.OPEN)) {
    inicializaWebSocket();
  }
});
</script>
</body></html>
)HTML";

//-------------------------
// Prototipação de Rotinas
//-------------------------

String macToStr(const uint8_t m[6]);
//void ensurePeer(const uint8_t mac[6], uint8_t channel);
void broadcastNodesListToWS();
void onNowRecv(const uint8_t *mac, const uint8_t *data, int len);
void onWsMessage(void *arg, uint8_t *data, size_t len);
void setupEspNowCentral();
void setupWifi();
void setupServer();
static inline MacKey macToKey(const uint8_t m[6]);
static inline bool strToMac(const String& s, uint8_t out[6]);
static inline bool macIsZero(const uint8_t m[6]);
void expireStaleNodesAndNotify(uint32_t ttlMs = 20000);
bool setDNSNAME(String nome);
void ensurePeerOnHomeChannel(const uint8_t mac[6]);
esp_err_t sendNowWithPeerFix(const uint8_t mac[6], const uint8_t* buf, size_t len);
void debugPeer(const uint8_t mac[6]);
void drainNowQueue();
String makeUpdateJson(const NodeInfo& n);         // para nós de RELÉ
String makeSensorUpdateJson(const NodeInfo& n);   // para nós DHT
String macToStr(const uint8_t m[6]);
void printFreeRAM(String context) ;
String formatTimeStamp(time_t now);
String getTimeStamp();
bool getNTPtime(int sec);
bool refreshNTPServer();
void WiFiEvent(WiFiEvent_t event);
void configModeCallback(WiFiManager *myWiFiManager);
bool loadConfig();
bool saveConfig();
void buttonISR();
static inline bool macEq(const uint8_t a[6], const uint8_t b[6]);
static void rssiCachePut(const uint8_t mac[6], int8_t rssi);
static bool rssiCacheGet(const uint8_t mac[6], int8_t &out);
static void IRAM_ATTR snifferCb(void* buf, wifi_promiscuous_pkt_type_t type);
static void enableSniffer();

//---------------------------
// Inicialização do Programa
//---------------------------

void setup() 
{
  // Inicializa a Serial

  Serial.begin(BAUDRATE);
  delay(1000);
  Serial.println(F("Central de Relay V1.0 Out/2025"));
  Serial.flush();

  // Inicializa o Botão interno do ESP para Acionar o WifiManager

  pinMode(PIN_BOOT, INPUT_PULLUP);   

  // Configura a interrupção para detectar a borda de descida do botão Boot

  attachInterrupt(digitalPinToInterrupt(PIN_BOOT), buttonISR, FALLING);     

  // Define o LED_BUILTIN como indicador WiFi. Será ligado pela rotina de
  // eventos quando conectado no WiFi ou desligado quando fora 

   pinMode(LED_BUILTIN,OUTPUT);   

  // Carrega o Config

  loadConfig();

  // Cria a fila para enfileiramento de mensagens

  qNow = xQueueCreate(appConfig.queue_size, sizeof(RxEvt));  

  // Inicializa o Wifi

  setupWifi();

  // Inicializa a Rede ESP-NOW

  setupEspNowCentral();

  // Inicializa o Servidor Web

  setupServer();

  // Mostra Consumo de Memória 

  printFreeRAM("Setup...");  

}

//----------------------------
// Loop Prinicpal do Programa
//----------------------------

void loop() 
{
  // Esgota a fila de Mensagens

  drainNowQueue();

  // Verifica se tem que fazer a varredura de tempo de vida das estações

  uint32_t now = millis();
  if (now - tVarredura >= INTERVALO_VIDA) 
  {
    tVarredura = now;
    expireStaleNodesAndNotify(TEMPO_VIDA); // 20 s de TTL
  }

  //--------------------------------------------------------------------------------------------------
  // Verifica se o botão de BOOT foi apertado para forçar a entrada no modo de configuração. 
  // É útil quando a senha do wifi mudou ou está se conectando em outra rede wifi. Isso 
  // evita ter o SSID/senha no código, a recompilação e upload do código no ESP32.
  //--------------------------------------------------------------------------------------------------

  if (buttonState)
  {
    // Reseta o estado do botão

    buttonState = false;

    // Força a entrada em modo de configuração

    wm.resetSettings();   
    ESP.restart();
      
  }  

  // Verifica se precisa reconectar a WiFi caso o roteador tenha sido rebootado

  if ((WiFi.status() != WL_CONNECTED) && (now - lastReconect >=INTERVALO_RECONECT)) 
  { 
      Serial.println("Reconnecting to WiFi..."); 
      WiFi.disconnect(); 
      WiFi.reconnect(); 
      lastReconect = now; 
  }

  // Verifica se não está sincronizado e no intervalo para tentar sincronizar novamente
  
  if (!sincronizado && millis()-lastNTP > INTERVALO_NTP)
  {
     sincronizado = refreshNTPServer();
     lastNTP = millis();
  }

  // Verifica se deve fazer o Cleanup de conexões perdidas

  if (now - lastCleanup > INTERVALO_CLEANUP) {
    lastCleanup = now;
    ws.cleanupClients();
  }

   // Verifica o OTA para saber se há atualização

   ElegantOTA.loop();     

}

//--------------------------------------
// Convertet o MAC de bytes para String
//--------------------------------------

String macToStr(const uint8_t m[6]) 
{
  char b[18]; 
  sprintf(b, "%02X:%02X:%02X:%02X:%02X:%02X", m[0],m[1],m[2],m[3],m[4],m[5]); 
  return String(b);
}

//----------------------------------------------------------------
// Envia a lista de Estações na Rede ESP-NOW para a interface Web
//----------------------------------------------------------------

void broadcastNodesListToWS() 
{
  // Monta JSON manualmente para evitar alocação excessiva
  String json;
  json.reserve(256 + nodes.size() * 96);
  json = F("{\"type\":\"snapshot\",\"nodes\":[");
  bool first = true;

  for (auto &kv : nodes) {
    const NodeInfo &n = kv.second;
    if (!first) json += ',';
    first = false;

    json += F("{\"mac\":\"");     json += n.macStr;     json += F("\",");
    json += F("\"alias\":\"");    json += n.alias;      json += F("\",");
    json += F("\"relay\":");      json += String(n.relay);   json += F(",");
    json += F("\"mode\":");       json += String(n.mode);    json += F(",");
    json += F("\"channel\":");    json += String(n.channel); json += F(",");
    json += F("\"last\":");       json += String(n.lastSeen);
    json += F("}");
  }

  json += F("]}");
  ws.textAll(json);
}

//---------------------------------------------------
// Trata a recepção de mensagens da rede ESP-NOW.
// enfileirando para tratar depois e não comprometer
// a recepção sem colisão (perda de mensagem)
//---------------------------------------------------

void onNowRecv(const uint8_t *mac, const uint8_t *data, int len) 
{
  RxEvt evt{};
  memcpy(evt.mac, mac, 6);
  evt.len = (uint8_t)min(len, (int)sizeof(NowMsg));
  memcpy(&evt.msg, data, evt.len);
  evt.ts = millis();
  int8_t r; 
  evt.rssi = rssiCacheGet(mac, r) ? r : -127; // fallback se não achar  
  xQueueSendFromISR(qNow, &evt, nullptr);     // não bloqueia
}

//-------------------------------------------
// Trata as mensagens recebidas do WebSocket
//-------------------------------------------

void onWsMessage(void *arg, uint8_t *data, size_t len)
{
  AwsFrameInfo *info = (AwsFrameInfo*)arg;
  if (!(info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT)) return;

  StaticJsonDocument<200> doc;
  if (deserializeJson(doc, data, len)) return;

  String cmd  = doc["cmd"]  | "";   // "set", "get", "config"
  String macS = doc["mac"]  | "";   // "AA:BB:CC:DD:EE:FF"

  // MAC obrigatório para comandos por nó
  uint8_t macBytes[6];
  if (!strToMac(macS, macBytes)) {
    Serial.println(F("[WS] MAC inválido no comando"));
    return;
  }
  MacKey key = macToKey(macBytes);

  auto it = nodes.find(key);
  if (it == nodes.end()) {
    Serial.printf("[WS] Nó %s não encontrado na tabela\n", macS.c_str());
    return;
  }
  auto &n = it->second;

  // Monta a mensagem ESP-NOW
  NowMsg out{};
  out.ver = 1;

  if (cmd == "set") {
    out.type  = MSG_CMD;
    out.relay = (uint8_t)(doc["relay"] | 0);   // 0 = OFF, 1 = ON
  }
  else if (cmd == "get") {
    out.type  = MSG_CMD;
    out.relay = 2;                              // 2 = GET (status)
  }
  else if (cmd == "config") {
    // Pedido para o nó entrar em modo de configuração:
    // apagar arquivo de config e reiniciar (o nó implementa isso no RX)
    out.type  = MSG_CFG;
    out.relay = 1;                              // subcódigo simples
  }
  else {
    Serial.printf("[WS] Comando desconhecido: %s\n", cmd.c_str());
    return;
  }

  // Log e envio
  debugPeer(n.mac);  // seu helper de depuração de peer

  // IMPORTANTE: não use n.channel diretamente; deixe o sendNowWithPeerFix
  // garantir que o peer está no canal correto do STA.
  esp_err_t rc = sendNowWithPeerFix(n.mac, (uint8_t*)&out, sizeof(NowMsg));
  Serial.printf("[WS] cmd=%s -> %s rc=%d (homeCh=%u)\n",
                cmd.c_str(), macS.c_str(), rc, WiFi.channel());
}

//----------------------------------
// Trata os eventos do Servidor Web
//----------------------------------

void wsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type,
             void *arg, uint8_t *data, size_t len) 
{
  if (type == WS_EVT_CONNECT) 
  {
    // manda snapshot inicial
    broadcastNodesListToWS();
  } 
  else if (type == WS_EVT_DATA) 
  {
    onWsMessage(arg, data, len);
  }
}

//----------------------------------------
// Rotina para Inicializar a Rede ESP-NOW
//----------------------------------------

void setupEspNowCentral() 
{
  //WiFi.mode(WIFI_STA);
  //WiFi.disconnect(); // mantém livre para trocar canal por peer
  Serial.print("Iniciando a Rede ESP-NOW ... ");
  if (esp_now_init()!=ESP_OK) 
  {
    Serial.println("Erro ao inicializar");
    ESP.restart();
  }
  
  int rc = esp_now_register_recv_cb(onNowRecv);
  Serial.printf("iniciada com sucesso e callback registrado Rc=%d\n",rc);

  // Habilita a escuta Sniffer

  enableSniffer();  
}

//----------------------------------------
// Rotina para Inicializar a Rede Wifi
//----------------------------------------

void setupWifi()
{
  Serial.print(F("Iniciando a Conexão Wifi "));
  WiFi.onEvent(WiFiEvent);
  WiFi.mode(WIFI_STA);
  WiFi.setAutoReconnect(true);  

  // Defina a porta do WiFiManager para 8080 no modo AP para não conflitar com a
  // porta 80 que vamos utilizar para responder as requisições

  wm.setHttpPort(WIFIMANAGER_PORT);     // Porta 
  wm.setConfigPortalTimeout(180);       // Timeout de 3 minutos  
  wm.setTimeout(180);                   // o Portal fica aberto por 180s
  wm.setConnectTimeout(30);             // tenta conectar por 30 segundos antes de entrar no Portal  
  wm.setAPCallback(configModeCallback); // Callback para o Modo AP de Configuração
  wm.setWiFiAPChannel(6);               // Força o canal ser 6 para ser mais fácil identificado

  // Criação dos parâmetros

  p_queue_size    = new WiFiManagerParameter("queueSize", "Tamanho da Fila", String(appConfig.queue_size).c_str(), 3);
  p_alias         = new WiFiManagerParameter("alias", "Alias mDNS", appConfig.alias.c_str(), 30);
  p_ntpServer     = new WiFiManagerParameter("ntpServer", "Servidor NTP", appConfig.ntpServer.c_str(), 64);
  p_timezone      = new WiFiManagerParameter("timezone", "Fuso horário", appConfig.timezone.c_str(), 64);
  p_user_OTA      = new WiFiManagerParameter("user_OTA", "Usuário OTA", appConfig.user_OTA.c_str(), 32);
  p_pass_OTA      = new WiFiManagerParameter("pass_OTA", "Senha OTA", appConfig.pass_OTA.c_str(), 32);
  p_autoRebootOTA = new WiFiManagerParameter("autoRebootOTA", "Reiniciar após OTA", "true", 6);

  // Adiciona os parâmetros ao WiFiManager

  wm.addParameter(p_queue_size);
  wm.addParameter(p_alias);
  wm.addParameter(p_ntpServer);
  wm.addParameter(p_timezone);
  wm.addParameter(p_user_OTA);
  wm.addParameter(p_pass_OTA);
  wm.addParameter(p_autoRebootOTA);

  // Nome da rede AP temporária

  char ssid[32];
  sprintf(ssid, "ESP_%6X",(uint32_t)ESP.getEfuseMac());

  //wm.resetSettings(); // vou forçar como teste

  if (!wm.autoConnect(ssid))
  {
    Serial.println("Falha na conexão ou timeout. Reiniciando...");
    delay(3000);
    ESP.restart();
    return;
  }

  Serial.println("WiFi conectado com sucesso!");
  Serial.print("IP: ");
  Serial.println(WiFi.localIP());

  // Imprime o MAC

  Serial.print("MAC: ");
  Serial.println(WiFi.macAddress());

  // Imprime o Sinal Wifi

  Serial.print("Sinal: ");
  Serial.print(WiFi.RSSI());
  Serial.println(" db");  

  // Guarda o MAC 

  esp_wifi_get_mac(WIFI_IF_STA, gMyMac); // ou WIFI_IF_AP para modo AP
  Serial.printf("Mac=%s Canal=%d\n",macToStr(gMyMac).c_str(),WiFi.channel());

  // Desabilita Sleep na interface Wifi para não perder broadcast de estações

  WiFi.setSleep(false);
  esp_wifi_set_ps(WIFI_PS_NONE);    

  // Atualiza appConfig com os valores fornecidos

  appConfig.queue_size = atoi(p_queue_size->getValue());
  appConfig.alias      = String(p_alias->getValue());
  appConfig.ntpServer  = String(p_ntpServer->getValue());
  appConfig.timezone   = String(p_timezone->getValue());
  appConfig.user_OTA   = String(p_user_OTA->getValue());
  appConfig.pass_OTA   = String(p_pass_OTA->getValue());

  String r = String(p_autoRebootOTA->getValue());
  r.toLowerCase();
  r.trim();
  appConfig.autoRebootOTA = (r == "true" || r == "1");

  // Sincroniza o horário interno com o Servidor NTP nacional

  sincronizado = refreshNTPServer();

  // Definição do DNSName e Hostname

  Serial.println("Adicionando " + appConfig.alias + " no mDNS... ");
  if (setDNSNAME(appConfig.alias))
  {
    Serial.println("adicionado corretamente no mDNS!");
  }
  else 
  {
    Serial.println("Erro ao adicionar no mDNS!");
  }   

  // Salva o Config pois pode ter passado pelo WifiManager

  saveConfig();    

}

//----------------------------------------
// Rotina para Inicializar o Servidor Web
//----------------------------------------

void setupServer()
{
  // Define a rotima de eventos (callback)

  ws.onEvent(wsEvent);

  // Adiciona hadle do WebSocket

  server.addHandler(&ws);

  // Define a rota principal

  server.on("/", HTTP_GET, [](AsyncWebServerRequest* req){ req->send(200, "text/html", HTML); });

  // Inicializa o serviço web

  server.begin();  

  // Inicializa o serviço OTA

  ElegantOTA.begin(&server, appConfig.user_OTA.c_str(), appConfig.pass_OTA.c_str());
  ElegantOTA.setAutoReboot(appConfig.autoRebootOTA);

}

//-----------------------------------------
// Macro para gerar o hash a partir do MAC
//-----------------------------------------

static inline MacKey macToKey(const uint8_t m[6]) {
  return ((uint64_t)m[0] << 40) |
         ((uint64_t)m[1] << 32) |
         ((uint64_t)m[2] << 24) |
         ((uint64_t)m[3] << 16) |
         ((uint64_t)m[4] <<  8) |
         ((uint64_t)m[5] <<  0);
}

//------------------------------------------------
// Macro para converter o MAC de string para bytes
//------------------------------------------------

static inline bool strToMac(const String& s, uint8_t out[6]) {
  unsigned int v[6];
  if (sscanf(s.c_str(), "%02x:%02x:%02x:%02x:%02x:%02x",
             &v[0],&v[1],&v[2],&v[3],&v[4],&v[5]) != 6) return false;
  for (int i=0;i<6;i++) out[i] = (uint8_t)v[i];
  return true;
}

//---------------------------------------------------------------------
// ===== Expiração de nós inativos =====
// Remove quem ficou sem atualizar por 'ttlMs' e notifica via snapshot
//----------------------------------------------------------------------

void expireStaleNodesAndNotify(uint32_t ttlMs) 
{
  const uint32_t now = millis();
  std::vector<MacKey> toErase;
  toErase.reserve(nodes.size());

  for (auto &kv : nodes) {
    const NodeInfo &n = kv.second;
    // comparação wrap-safe
    if ((int32_t)(now - n.lastSeen) > (int32_t)ttlMs) {
      toErase.push_back(kv.first);
    }
  }

  for (auto &key : toErase) {
    const NodeInfo &n = nodes[key];
    if (!macIsZero(n.mac)) {
      StaticJsonDocument<96> d;
      d["type"] = "remove";
      d["mac"]  = macToStr(n.mac);
      String s; serializeJson(d, s);
      ws.textAll(s);                // avisa o front-end PARA REMOVER
    }
    nodes.erase(key);               // remove do mapa
  }
}

//-------------------------------------------------------
// Define o HostName como DNS NAME
//-------------------------------------------------------

bool setDNSNAME(String nome)
{
   WiFi.setHostname(nome.c_str());
   bool ok = MDNS.begin(nome.c_str());
   if (ok) 
   {
      MDNS.addService("http", "tcp", 80);
      MDNS.setInstanceName(nome.c_str()); // Adicionar o nome da instância      
   }
   return ok;
}

//--------------------------------------
// Garante o peer no mesmo canal da Rede
//--------------------------------------

void ensurePeerOnHomeChannel(const uint8_t mac[6]) 
{
  const uint8_t home = WiFi.channel();        // canal atual do STA (Central)

  if (esp_now_is_peer_exist(mac)) {
    esp_now_peer_info_t p{};
    if (esp_now_get_peer(mac, &p) == ESP_OK) {
      if (p.channel == home && p.ifidx == WIFI_IF_STA) {
        return; // já está correto
      }
    }
    esp_now_del_peer(mac); // canal/IF divergentes → remove e recria
  }

  esp_now_peer_info_t info{};
  memcpy(info.peer_addr, mac, 6);
  info.ifidx   = WIFI_IF_STA;   // Central opera em STA
  info.channel = home;          // SEMPRE o canal da Central
  info.encrypt = false;
  esp_now_add_peer(&info);
}

//-----------------------------------------------------------------------
// Envia para o peer garantido que o mesmo está registardo no canal certo
//-----------------------------------------------------------------------

esp_err_t sendNowWithPeerFix(const uint8_t mac[6], const uint8_t* buf, size_t len) 
{
  ensurePeerOnHomeChannel(mac);
  esp_err_t rc = esp_now_send(mac, buf, len);
  if (rc == 12390 /* peer/home mismatch */ || rc == 12293 /* not found, variações */) {
    esp_now_del_peer(mac);
    ensurePeerOnHomeChannel(mac);
    rc = esp_now_send(mac, buf, len);
  }
  return rc;
}

//----------------------------------
// Mostra o MAC em situação de debug
//----------------------------------

void debugPeer(const uint8_t mac[6]) 
{
  esp_now_peer_info_t p{};
  if (esp_now_get_peer(mac, &p) == ESP_OK) {
    Serial.printf("[32] peer %02X:%02X:%02X:%02X:%02X:%02X ch=%u if=%u\n",
      mac[0],mac[1],mac[2],mac[3],mac[4],mac[5], p.channel, p.ifidx);
  } else {
    Serial.println("[32] peer não encontrado");
  }
}

//-------------------------------------------------
// Rotina para esvaziar/tratar a fila de mensagens
//-------------------------------------------------

void drainNowQueue() 
{
  RxEvt evt;
  // drena alguns por loop (evita monopolizar)
  for (int i=0; i<8; ++i) {
    if (xQueueReceive(qNow, &evt, 0) != pdTRUE) break;

    // valida tamanho mínimo
    if (evt.len < 1) continue;

    // atualiza/insere nó
    MacKey key = macToKey(evt.mac);
    NodeInfo &n = nodes[key];
    memcpy(n.mac, evt.mac, 6);
    n.lastSeen = evt.ts;

    n.rssi = -127;
    n.rssiAvg = NAN;

    // RSSI
    n.rssi = evt.rssi;
    if (!isfinite(n.rssiAvg)) n.rssiAvg = evt.rssi;
    else n.rssiAvg = RSSI_ALPHA * evt.rssi + (1.0f - RSSI_ALPHA) * n.rssiAvg;    

    // DISC / STATUS (nó de RELÉ)
    if (evt.msg.type == MSG_STATUS || evt.msg.type == MSG_DISC) 
    {
      n.isSensor = false;
      n.relay    = evt.msg.relay;
      n.mode     = evt.msg.mode;
      n.channel  = evt.msg.channel;
      if (evt.msg.aliasLen) n.alias = String(evt.msg.alias);
      ws.textAll(makeUpdateJson(n));          // <<< RELÉ
    }
    // SENSOR (DHT11)
    else if (evt.msg.type == MSG_SENSOR) 
    {
      n.isSensor = true;
      n.mode     = evt.msg.mode;     // opcional
      n.channel  = evt.msg.channel;  // opcional
      if (evt.msg.aliasLen) n.alias = String(evt.msg.alias);
      n.tempC10  = evt.msg.tempC10;  // <<< use tempC10 (não tempC)
      n.hum      = evt.msg.hum;

      // RSSI (seu código atual)
      n.rssi      = evt.rssi;
      if (!isfinite(n.rssiAvg)) n.rssiAvg = evt.rssi;
      else n.rssiAvg = 0.25f*evt.rssi + 0.75f*n.rssiAvg;

      ws.textAll(makeSensorUpdateJson(n));    // <<< SENSOR
    }
  }
}

//----------------------------------------
// Monta o JSON com dados da Estação Relé
//----------------------------------------

String makeUpdateJson(const NodeInfo& n) {
  StaticJsonDocument<256> d;
  d["type"]    = "update";
  d["mac"]     = macToStr(n.mac);
  d["alias"]   = n.alias;
  d["mode"]    = n.mode;
  d["channel"] = n.channel;
  d["relay"]   = n.relay;
  d["lastSeen"]= n.lastSeen;
  d["rssi"]    = n.rssi;                 // instantâneo
  d["rssiAvg"] = lroundf(n.rssiAvg);     // média arredondada
  String s; serializeJson(d, s); return s;
}

//----------------------------------------
// Monta o JSON com dados da Estação DTH11
//----------------------------------------

String makeSensorUpdateJson(const NodeInfo& n) 
{
  StaticJsonDocument<256> d;
  d["type"]     = "update";
  d["isSensor"] = true;
  d["mac"]      = macToStr(n.mac);
  d["alias"]    = n.alias;
  d["mode"]    = n.mode;
  d["channel"] = n.channel;  
  // envia já em °C com 1 casa
  d["tempC"]    = (float)n.tempC10 / 10.0f;
  d["hum"]      = n.hum;
  d["lastSeen"] = n.lastSeen;
  d["rssi"]    = n.rssi;            
  d["rssiAvg"] = lroundf(n.rssiAvg);  
  String s; serializeJson(d, s); return s;
}

//-----------------------------------
// Verifica se o MAC não está zerado
//-----------------------------------

static inline bool macIsZero(const uint8_t m[6]) 
{
  for (int i=0;i<6;i++) if (m[i]!=0) return false;
  return true;
}

//--------------------------------------------------
// Função para monitorar o uso de memória disponível
//--------------------------------------------------

void printFreeRAM(String context) 
{
    size_t freeHeap = esp_get_free_heap_size();
    //Serial.printf("RAM disponível (%s): %u bytes\n", context.c_str(), freeHeap);
    Serial.printf("Consumo de RAM (%s) Total heap: %u, Free heap: %u, Min ever free: %u\n",
                  context.c_str(),
                  heap_caps_get_total_size(MALLOC_CAP_DEFAULT),
                  heap_caps_get_free_size(MALLOC_CAP_DEFAULT),
                  heap_caps_get_minimum_free_size(MALLOC_CAP_DEFAULT));    
}

//------------------------------------------------
// Formata Time para String
//------------------------------------------------

String formatTimeStamp(time_t now)
{
  char timestamp[30];
  strftime(timestamp, 30, "%d/%m/%Y %T", localtime(&now));
  return String(timestamp);
}

//------------------------------------------------
// Devolve o localtime dd/mm/aaaa hh:mm:ss
//------------------------------------------------

String getTimeStamp()
{
  time_t now;
  time(&now);
  return formatTimeStamp(now);
}

//---------------------------------------------------------
// Sincroniza o horário do ESP32 com  NTP server brasileiro
//---------------------------------------------------------

bool getNTPtime(int sec) 
{

  {
    uint32_t start = millis();

    tm timeinfo;
    time_t now;
    int cont=0;

    do 
    {
      time(&now);
      localtime_r(&now, &timeinfo);
      if (++cont % 80 == 0) Serial.println();
      else Serial.print(".");
      delay(10);
    } while (((millis() - start) <= (1000 * sec)) && (timeinfo.tm_year < (2016 - 1900)));
    if (timeinfo.tm_year <= (2016 - 1900)) return false;  // the NTP call was not successful
    Serial.print("\nnow ");
    Serial.println(now);
    Serial.print("Time "); 
    Serial.println(getTimeStamp());
  }

  return true;
}

//----------------------------------------------------------
// Função de fazer o sincronismo do relógio interno com o
// servidor NTP definido nos parâmetros do WifiManager
//----------------------------------------------------------

bool refreshNTPServer()
{
   // Sincroniza o horário interno com o Servidor NTP nacional

   Serial.print("Tentando sincronismo com o servidor NTP ");
   Serial.print(appConfig.ntpServer.c_str());
   Serial.print(" com TimeZone ");
   Serial.println(appConfig.timezone.c_str());   
   
   // Verifica se está navegando pela internet pois às vezes fica conectado no AP porém sem internet

   if (!Ping.ping(ip,4))
   {
       Serial.println("Sem internet no momento...");
   }
   else 
   {
      Serial.print("Internet ativa com média de ");
      Serial.print(Ping.averageTime());
      Serial.println(" ms");

      configTime(0, 0, appConfig.ntpServer.c_str());
      setenv("TZ", appConfig.timezone.c_str(), 1);
      tzset();
    
      if (getNTPtime(10))
      { // wait up to 10sec to sync
        Serial.println("NTP Server sincronizado");
        return true;
      } 
      else 
      {
         Serial.println("Timer interno não foi sincronizado");
         //ESP.restart();
      }
   }
   return false;
}

//------------------------------------------------
// Evento chamado no processo de conexão do Wifi
//------------------------------------------------

void WiFiEvent(WiFiEvent_t event)
{
  Serial.printf("[Evento Wi-Fi] evento: %d\n", event);

  switch (event)
  {
    case SYSTEM_EVENT_WIFI_READY:
      Serial.println("interface WiFi pronta");
      break;
    case SYSTEM_EVENT_SCAN_DONE:
      Serial.println("Pesquisa por AP completada");
      break;
    case SYSTEM_EVENT_STA_START:
      Serial.println("Cliente WiFi iniciado");
      break;
    case SYSTEM_EVENT_STA_STOP:
      Serial.println("Clientes WiFi  cancelados");
      break;
    case SYSTEM_EVENT_STA_CONNECTED:
      Serial.println("Conectado ao AP");
      digitalWrite(LED_BUILTIN,LED_ON);   // Liga o LED Vermelho para mostrar a conexão com WiFi
      break;                              // Observe que é invertido
    case SYSTEM_EVENT_STA_DISCONNECTED:
      Serial.println("Desconectado do AP WiFi");
      digitalWrite(LED_BUILTIN,LED_OFF);  // Desliga o LED Vermelho para mostrar a desconexão com WiFi
      break;                              // Observe que é invertido
    case SYSTEM_EVENT_STA_AUTHMODE_CHANGE:
      Serial.println("Modo de Autenticação do AP mudou");
      break;
    case SYSTEM_EVENT_STA_GOT_IP:
      Serial.print("Endereço IP obtido: ");
      Serial.println(WiFi.localIP());
      break;
    case SYSTEM_EVENT_STA_LOST_IP:
      Serial.println("Endereço IP perdido e foi resetado para 0");
      break;
    case SYSTEM_EVENT_STA_WPS_ER_SUCCESS:
      Serial.println("WPS: modo enrollee bem sucedido");
      break;
    case SYSTEM_EVENT_STA_WPS_ER_FAILED:
      Serial.println("WPS: modo enrollee falhou");
      break;
    case SYSTEM_EVENT_STA_WPS_ER_TIMEOUT:
      Serial.println("WPS: timeout no modo enrollee");
      break;
    case SYSTEM_EVENT_STA_WPS_ER_PIN:
      Serial.println("WPS: pin code no modo enrollee");
      break;
    case SYSTEM_EVENT_AP_START:
      Serial.println("AP Wifi Iniciado");
      break;
    case SYSTEM_EVENT_AP_STOP:
      Serial.println("AP Wifi parado");
      break;
    case SYSTEM_EVENT_AP_STACONNECTED:
      Serial.println("Cliente conectado");
      break;
    case SYSTEM_EVENT_AP_STADISCONNECTED:
      Serial.println("Cliente desconectado");
      break;
    case SYSTEM_EVENT_AP_STAIPASSIGNED:
      Serial.println("IP associado ao Cliente");
      break;
    case SYSTEM_EVENT_AP_PROBEREQRECVED:
      Serial.println("Requisição de probe recebida");
      break;
    case SYSTEM_EVENT_GOT_IP6:
      Serial.println("IPv6 é preferencial");
      break;
    case SYSTEM_EVENT_ETH_START:
      Serial.println("Interface Ethernet iniciada");
      break;
    case SYSTEM_EVENT_ETH_STOP:
      Serial.println("Interface Ethernet parada");
      break;
    case SYSTEM_EVENT_ETH_CONNECTED:
      Serial.println("Interface Ethernet conectada");
      break;
    case SYSTEM_EVENT_ETH_DISCONNECTED:
      Serial.println("Interface Ethernet desconectada");
      break;
    case SYSTEM_EVENT_ETH_GOT_IP:
      Serial.println("Endereço IP obtido");
      break;
    default: break;
  }
}

//----------------------------------------------------------
// Callback para WifiManager 
//----------------------------------------------------------
 
void configModeCallback(WiFiManager *myWiFiManager)
// É chamado no modo de configuração
{
  Serial.println("Entrando no modo de configuração...");

  Serial.print("Config SSID: ");
  Serial.println(myWiFiManager->getConfigPortalSSID());
 
  Serial.print("Config IP Address: ");
  Serial.print(WiFi.softAPIP());

  Serial.print("Config Port: ");
  Serial.println(WIFIMANAGER_PORT);

}

//----------------------------------------
// Carrega os parâmetros do SPIFFS
//----------------------------------------

bool loadConfig() 
{
  Serial.println("Tentando carregar o Config do SPIFFS...");
  if (!SPIFFS.begin(true)) 
  {
     Serial.println("Não foi possível incializar o SPIFFS...");
     SPIFFS.format();
     Serial.println("Sistema de Arquivo no SPIFFS foi formatado");  
     delay(100);   
     //return false;
  }

  //deleteFile(CONFIG_FILE); // para forçar os defaults durante o desenvolvimento

  if (!SPIFFS.exists(CONFIG_FILE))
  {
    Serial.println("Config não existe. Adotando default...");
    // Valores padrão se não existir o arquivo
    appConfig.alias = DNS_NAME;
    appConfig.queue_size = QUEUE_SIZE;
    appConfig.ntpServer = NTP_SERVER;
    appConfig.timezone = TIMEZONE_LOCAL; 
    appConfig.user_OTA = USER_OTA;
    appConfig.pass_OTA = PASS_OTA;
    appConfig.autoRebootOTA = AUTOREBOOT_OTA;
    saveConfig();
    return false;
  }  

  File file = SPIFFS.open(CONFIG_FILE, FILE_READ);
  if (!file) return false;

  DynamicJsonDocument doc(1024);
  DeserializationError err = deserializeJson(doc, file);
  serializeJsonPretty(doc, Serial);
  Serial.flush();  
  file.close();
  if (err) return false;

  appConfig.queue_size = doc["queueSize"]   | QUEUE_SIZE;
  appConfig.alias = doc["alias"] | DNS_NAME;
  appConfig.ntpServer = doc["ntpServer"] | NTP_SERVER;
  appConfig.timezone = doc["timezone"] | TIMEZONE_LOCAL;
  appConfig.user_OTA = doc["user_OTA"] | USER_OTA;
  appConfig.pass_OTA = doc["pass_OTA"] | PASS_OTA;
  appConfig.autoRebootOTA = doc["autoRebootOTA"] | AUTOREBOOT_OTA;

  return true;
}

//--------------------------------------
// Salva os parâmetros no SPIFFS 
//--------------------------------------

bool saveConfig() 
{
  Serial.println("Salvando a configuração...");
  DynamicJsonDocument doc(1024);

  doc["queueSize"] = appConfig.queue_size;
  doc["alias"]     = appConfig.alias;
  doc["ntpServer"] = appConfig.ntpServer;
  doc["timezone"]  = appConfig.timezone;
  doc["user_OTA"]  = appConfig.user_OTA;
  doc["pass_OTA"]  = appConfig.pass_OTA;
  doc["autoRebootOTA"] = appConfig.autoRebootOTA;

  File file = SPIFFS.open(CONFIG_FILE, FILE_WRITE);
  if (!file) return false;
  serializeJson(doc, file);
  serializeJsonPretty(doc, Serial);
  Serial.println();
  Serial.flush();  
  file.close();
  return true;
}

//--------------------------------------------------
// Rotina de Tratamento da Interrupção do Botão Boot
//--------------------------------------------------

void buttonISR() 
{
   buttonState = true;
}

//--------------------------------------------------
// Rotina de Cpmparação de MAC's
//--------------------------------------------------

static inline bool macEq(const uint8_t a[6], const uint8_t b[6]) 
{
  for (int i=0;i<6;i++) 
  {
    if (a[i]!=b[i]) return false; 
  }
  return true;
}

//--------------------------------------------------
// Rotina para colocar o MAC no Cache
//--------------------------------------------------

static void rssiCachePut(const uint8_t mac[6], int8_t rssi) 
{
  // procura slot existente
  int best = -1; uint32_t oldest = 0xFFFFFFFF;
  for (int i=0;i<SNIFFER_SIZE;i++) 
  {
    if (macEq(gRssiCache[i].mac, mac)) { best = i; break; }
    if (gRssiCache[i].ts < oldest) { oldest = gRssiCache[i].ts; best = i; }
  }
  memcpy(gRssiCache[best].mac, mac, 6);
  gRssiCache[best].rssi = rssi;
  gRssiCache[best].ts   = millis();
}

//--------------------------------------------------
// Rotina para pegar um MAC do Cache
//--------------------------------------------------

static bool rssiCacheGet(const uint8_t mac[6], int8_t &out) 
{
  for (int i=0;i<SNIFFER_SIZE;i++) 
  {
    if (macEq(gRssiCache[i].mac, mac)) { out = gRssiCache[i].rssi; return true; }
  }
  return false;
}

//--------------------------------------------------
// Rotina para escutar os pacotes SNIFFER
//--------------------------------------------------

static void IRAM_ATTR snifferCb(void* buf, wifi_promiscuous_pkt_type_t type)
{
  if (type != WIFI_PKT_MGMT && type != WIFI_PKT_DATA) return;

  const wifi_promiscuous_pkt_t* ppkt = (const wifi_promiscuous_pkt_t*)buf;
  if (!ppkt || !ppkt->payload) return;

  // payload começa no cabeçalho MAC
  const wifi_ieee80211_mac_hdr_t* hdr = (const wifi_ieee80211_mac_hdr_t*)ppkt->payload;

  // RSSI em dBm
  int8_t rssi = ppkt->rx_ctrl.rssi;

  // addr2 é o MAC de quem TRANSMITIU (nó que nos interessa)
  rssiCachePut(hdr->addr2, rssi);
}

//--------------------------------------------------
// Rotina para habilitar a escuta SNIFFER
//--------------------------------------------------

static void enableSniffer()
{
  wifi_promiscuous_filter_t f{};
  // ESPNOW usa quadros de gerenciamento (vendor action); mantenha DATA também
  f.filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT | WIFI_PROMIS_FILTER_MASK_DATA;
  esp_wifi_set_promiscuous_filter(&f);
  esp_wifi_set_promiscuous_rx_cb(&snifferCb);
  esp_wifi_set_promiscuous(true);
  Serial.println(F("[32] Sniffer promíscuo habilitado (MGMT+DATA)"));
}

Código da Estação com Relé

//-----------------------------------------------------------------------------------------
// Função   : Este programa tem como objetivo receber comandos para LIGAR/DESLIGAR o relé
//            do módulo vindos pela Rede ESP-NOW. Adicionalmente, o programa se divulga
//            para que a estação Central mantenha uma lista de todos os ESP8266 Relays
//            e faça o gerenciamento de todos os módulos numa interface html central.
//            Resumo: 1) Faz a auto divulgação vai broadcast na Rede ESP-NOW
//                    2) Responde aos comandos de LIGAR/DESLIGAR/GETSTATUS
//-----------------------------------------------------------------------------------------
// Autor     : Dailton Menezes
// Versão    : 1.0 Out/2025 
//-----------------------------------------------------------------------------------------

#include <ESP8266WiFi.h>               // Biblioteca para a rede wifi genérica 
#include <ESPAsyncTCP.h>               // Biblioteca usada pelo Servidor Assíncrono
#include <ESPAsyncWebServer.h>         // Biblioteca para Servidor Web Assíncrono
#include <espnow.h>                    // Biblioteca do Protocolo ESP-NOW
#include <FS.h>                        // Biblioteca para tratar arquivos 
#include <ElegantOTA.h>                // Biblioteca para atualização via Web
#include <Ticker.h>                    // Biblioteca para gerar timer

extern "C" 
{ 
  #include "user_interface.h" 
}

//------------------------
// Definições do Programa
//------------------------

#define RELAY_PIN   0                  // D1 (ajuste ao seu hardware)
#define DISC_MS     5000               // intervalo de divulgação
#define PMK_KEY     "SuaPMKde16b"      // opcional: 16 bytes (ex.: "1234567890ABCDEF")
#define RELE_ON     LOW                // Estado para o Relé Ativo
#define RELE_OFF    HIGH               // Estado para Relé desativado
#define BAUDRATE    115200             // Baudrate para a Console
#define CFG_PATH    "/cfg.bin"         // arquivo no SPIFFS
#define SSID_AP     "NODE-RELAY-SETUP" // SSID para Modo AP/Config
#define PASS_AP     "12345678"         // Senha do Modo AP/Config
#define USER_OTA    "admin"            // Usuário para atualização OTA
#define PASS_OTA    "admin"            // Senha para atualização OTA

//-------------------
// Variáveis Globais
//-------------------

AsyncWebServer server(80);             // Servidor Web para receber acessos http
volatile uint8_t gRelay = 0;           // Estado do Relay
uint8_t gMyMac[6];                     // MAC da Estação em bytes
uint8_t gChannel = 11;                 // Canal da Rede ESP-NOW
bool modo_config = false;              // Se estar no modo de configuração ou não
Ticker rebootTimer;                    // Timer para gerar um atraso no reboot

//-----------------------------
// Config persistida 
//-----------------------------

struct CfgNode 
{
  char ssid[33];                       // SSID até 32 chars
  char alias[24];                      // alias curto
  uint8_t magic;                       // 0xA5 = válido
};
CfgNode gCfg;

//--------------------
// Tipos de Mensagens
//--------------------

enum MsgType : uint8_t 
{ 
  MSG_DISC=1,                          // Brodacast de divulgação das estações para Central
  MSG_CMD=2,                           // Comando ON/OFF do Relé
  MSG_STATUS=3,                        // Status do Relé
  MSG_GETALL=4,                        // Broadcast quem está aí a pedido da Central
  MSG_SENSOR=5,                        // Status de estações cm DHT11
  MSG_CFG=6                            // Pedido da Central para a estação entrar em configuração
};

//-------------------------------
// Estrutura da Mensagem ESP-NOW
//-------------------------------

struct __attribute__((packed)) NowMsg 
{
  uint8_t ver,                         // Parar diferenciar registros entre outras aplicação
          type,                        // Tipo de Mensagem
          relay,                       // Estado do Relé
          mode,                        // Modo Wifi da Estação
          channel,                     // Canal da Rede ESP-NOW
          aliasLen,                    // Tamanho do campo alias
          rsv[2],                      // Para alinhamento
          mac[6];                      // MAC da estação
  char alias[32];                      // Alias da Estação (identificação)
  int16_t  tempC10;                    // temperatura em décimos de grau (ex.: 253 = 25.3°C)
  uint8_t  hum;                        // Umidade em %  
};

//----------------------
// HTML de Configuração
//----------------------

const char* HTML_CFG = R"HTML(
<!doctype html><meta charset="utf-8">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Config Nó</title>
<style>
  body{font-family:system-ui,Segoe UI,Arial;margin:16px;max-width:560px}
  label{display:block;margin-top:10px;font-weight:600}
  input,button{padding:10px;margin-top:6px;border:1px solid #ccc;border-radius:10px;font-size:16px}
  input{width:100%}
  .row{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}
  .row button{flex:1 1 220px;cursor:pointer}
  .hint{font-size:12px;color:#666;margin-top:4px}
</style>

<h3>Configuração do Nó</h3>

<label>SSID
  <input id="ssid" maxlength="32"
         placeholder="Ex.: MinhaRedeWiFi"
         title="Informe o SSID (nome) da rede Wi-Fi da Estação Central">
</label>
<div class="hint">Nome exato da rede Wi-Fi onde está a Estação Central.</div>

<label>Alias
  <input id="alias" maxlength="23"
         placeholder="Ex.: Sala, Quarto, DHT-Cozinha"
         title="Rótulo amigável para identificar este nó">
</label>
<div class="hint">Use um nome curto para aparecer nos cards (ex.: &quot;Sala&quot;).</div>

<div class="row">
  <button onclick="salvar()">Salvar &amp; Reiniciar</button>
  <button onclick="atualizar()">Atualizar Firmware</button>
</div>

<script>
async function salvar(){
  const ssid  = document.getElementById('ssid').value.trim();
  const alias = document.getElementById('alias').value.trim();
  if(!ssid){ alert('Informe o SSID.'); return; }

  const body = JSON.stringify({ssid, alias});
  try{
    const r = await fetch('/save', {
      method: 'POST',
      headers: {'Content-Type':'application/json'},
      body
    });
    // chegou resposta OK antes do reboot
    if(!r.ok) throw new Error('Falha ao salvar');
    document.body.innerHTML = '<p>Configurações salvas. Reiniciando o módulo…</p>';
    // não precisa reload aqui; o AP sumirá e está tudo certo
  }catch(e){
    // Em caso raro de erro, apenas informa sem travar a UX
    console.warn('Erro no fetch /save:', e);
    document.body.innerHTML = '<p>Configurações foram enviadas. O módulo pode já estar reiniciando…</p>';
  }
}
function atualizar(){
  // abre a rota do ElegantOTA (você cuidará do handler /update)
  location.href = '/update';
}
// pré-carrega valores atuais
fetch('/cfg')
 .then(r=>r.json())
 .then(j=>{
   const ssid  = document.getElementById('ssid');
   const alias = document.getElementById('alias');
   ssid.value  = (j && j.ssid)  ? j.ssid  : '';
   alias.value = (j && j.alias) ? j.alias : '';
   ssid.focus();
 })
 .catch(()=>{});
</script>
)HTML";

//-------------------------
// Prototipação de Rotinas
//-------------------------

void onEspNowRecv(uint8_t *mac, uint8_t *data, uint8_t len);
void sendStatus(const uint8_t* toMac);
void sendDiscovery();
String macToStr(const uint8_t m[6]);
bool loadCfg();
bool saveCfg(const char* ssid, const char* alias);
uint8_t getWiFiChannelRetry(const char* ssid, uint8_t tentativas=3, uint16_t esperaMs=400);
void startConfigAP();
void startEspNowOnChannel(uint8_t ch);
void enterConfigByEraseAndReset();

//---------------------------
// Inicialização do Programa
//---------------------------

void setup() 
{
  // Inicializa a Serial

  Serial.begin(BAUDRATE);
  delay(1000);
  Serial.println(F("ESP01S Relay V1.0 Out/2025")); 
  Serial.flush();

  // Inicializa o Relé

  pinMode(RELAY_PIN, OUTPUT);
  digitalWrite(RELAY_PIN, RELE_OFF);

  bool temCfg = loadCfg();
  if (!temCfg) 
  {
    // 1ª vez: ir direto ao modo de configuração
    startConfigAP();
    return;
  }

  // Temos SSID salvo → descobrir canal
  if (strlen(gCfg.ssid) > 0) 
  {
    uint8_t ch = getWiFiChannelRetry(gCfg.ssid, 4, 500);
    if (ch != 0) 
    {
      startEspNowOnChannel(ch);   // modo normal
      return;
    }
  }

  Serial.printf("Falhou na obtenção do canal para SSID: %s\n",gCfg.ssid);
  // SSID vazio ou não encontrado → entra em config
  startConfigAP();
  
}

//----------------------------
// Loop Prinicpal do Programa
//----------------------------

void loop() 
{
  // Verifica se está no modo config para permitir a atualização OTA

  if (modo_config)
  {
    ElegantOTA.loop();  
  }
  else
  {
    // Modo ESP_NOW mantendo a comunicação

    static uint32_t t0=0;
    uint32_t now = millis();
    if (now - t0 >= DISC_MS) 
    { 
      t0 = now; 
      sendDiscovery(); 
    }
  }

}

//------------------------------------------------
// Rotina para tratar a mensagem ESP-NOW recebida
//------------------------------------------------

void onEspNowRecv(uint8_t *mac, uint8_t *data, uint8_t len) 
{
  // Log de entrada com MAC formatado corretamente
  Serial.printf("Mensagem ESP-NOW RX: mac=%s len=%u\n",macToStr(mac).c_str(),len);
  if (len < (sizeof(NowMsg) - 32)) {
    Serial.println("RX descartado: len insuficiente para NowMsg base");
    return;
  }

  NowMsg msg; 
  memcpy(&msg, data, min((int)len, (int)sizeof(NowMsg)));

  Serial.printf("Decodificado: Ver=%u Type=%u Relay=%u Ch=%u AliasLen=%u\n",
                msg.ver, msg.type, msg.relay, msg.channel, msg.aliasLen);

  if (msg.ver != 1) {
    Serial.println(F("Versão inválida, desprezado"));
    return;
  }

  if (msg.type == MSG_CFG && msg.relay == 1) 
  {
    Serial.println(F("[NOW] Recebido CMD CONFIG -> apagar cfg e reiniciar"));
    enterConfigByEraseAndReset();
    return;
  }  

  if (msg.type == MSG_CMD) 
  { 
    if (msg.relay == 0) 
    { 
      digitalWrite(RELAY_PIN, RELE_OFF);  gRelay = 0; 
    }
    else if (msg.relay == 1)
    { 
      digitalWrite(RELAY_PIN, RELE_ON); gRelay = 1; 
    }
    // msg.relay==2 => GET status (não muda)
    sendStatus(mac); // responde STATUS ao remetente
  }
}

//-----------------------------------------------------
// Rotina para enviar o status atual pela Rede ESP-NOW
//-----------------------------------------------------

void sendStatus(const uint8_t* toMac) 
{
  NowMsg s{}; 
  s.ver=1; 
  s.type=MSG_STATUS; 
  s.relay=gRelay; 
  s.mode=(WiFi.getMode()==WIFI_STA)?0: (WiFi.getMode()==WIFI_AP)?1:2;
  s.channel = wifi_get_channel();
  s.aliasLen = strlcpy(s.alias, gCfg.alias, sizeof(s.alias));
  memcpy(s.mac, gMyMac, 6);

//  int rc = esp_now_send((uint8_t*)toMac, (uint8_t*)&s, sizeof(NowMsg));
  uint8_t bcast[6]; 
  memset(bcast, 0xFF, 6);
  int rc = esp_now_send(bcast, (uint8_t*)&s, sizeof(NowMsg));

  Serial.printf("SendStatus enviado. Rc=%d\n",rc);
}

//------------------------------------------------------
// Rotina para enviar self divulgação pela Rede ESP-NOW
//------------------------------------------------------

void sendDiscovery() 
{
  NowMsg d{}; 
  d.ver=1; 
  d.type=MSG_DISC; 
  d.relay=gRelay; 
  d.mode=(WiFi.getMode()==WIFI_STA)?0: (WiFi.getMode()==WIFI_AP)?1:2;
  d.channel = wifi_get_channel();
  d.aliasLen = strlcpy(d.alias, gCfg.alias, sizeof(d.alias));
  memcpy(d.mac, gMyMac, 6);
  uint8_t bcast[6]; 
  memset(bcast, 0xFF, 6);
  int rc = esp_now_send(bcast, (uint8_t*)&d, sizeof(NowMsg));
  Serial.printf("Discovery enviado. Rc=%d\n",rc);
}

//---------------------------------------------
// Rotina para detectar o canal utilizado pela
// Rede Wi-fi cujo o SSID foi definido com 
// retry e n. de tentativas
//---------------------------------------------

uint8_t getWiFiChannelRetry(const char* ssid, uint8_t tentativas, uint16_t esperaMs) 
{
  for (uint8_t t=0; t<tentativas; ++t) {
    int n = WiFi.scanNetworks(/*async=*/false, /*hidden=*/true);
    for (int i=0; i<n; ++i) 
    {
      String s = WiFi.SSID(i);
      if (s.length() && strcmp(s.c_str(), ssid)==0) {
        return (uint8_t)WiFi.channel(i);
      }
    }
    delay(esperaMs);
  }
  return 0;
}

//--------------------------------------
// Convertet o MAC de bytes para String
//--------------------------------------

String macToStr(const uint8_t m[6]) 
{
  char b[18]; 
  sprintf(b, "%02X:%02X:%02X:%02X:%02X:%02X", m[0],m[1],m[2],m[3],m[4],m[5]); 
  return String(b);
}

//--------------------------------------
// Recupera os parâmetros do Config
//--------------------------------------

bool loadCfg() 
{
  if (!SPIFFS.begin()) return false;
  if (!SPIFFS.exists(CFG_PATH)) return false;
  File f = SPIFFS.open(CFG_PATH, "r");
  if (!f) return false;
  if (f.size() != sizeof(CfgNode)) { f.close(); return false; }
  f.readBytes((char*)&gCfg, sizeof(CfgNode));
  f.close();
  return (gCfg.magic == 0xA5);
}

//--------------------------------------
// Persiste os parâmetros no Config
//--------------------------------------

bool saveCfg(const char* ssid, const char* alias) 
{
  strncpy(gCfg.ssid, ssid ? ssid : "", sizeof(gCfg.ssid));
  strncpy(gCfg.alias, alias ? alias : "", sizeof(gCfg.alias));
  gCfg.ssid[sizeof(gCfg.ssid)-1] = 0;
  gCfg.alias[sizeof(gCfg.alias)-1] = 0;
  gCfg.magic = 0xA5;

  if (!SPIFFS.begin()) SPIFFS.begin();
  File f = SPIFFS.open(CFG_PATH, "w");
  if (!f) return false;
  size_t wr = f.write((const uint8_t*)&gCfg, sizeof(CfgNode));
  f.close();
  return (wr == sizeof(CfgNode));
}

//-------------------------------
// Entra no Modo de Configuração
//-------------------------------

void startConfigAP() 
{
  modo_config = true;
  WiFi.mode(WIFI_AP);
  wifi_set_sleep_type(NONE_SLEEP_T);

  // Nome da rede AP temporária

  char ssid[32];
  snprintf(ssid, sizeof(ssid), "ESP_Relay_%06X", ESP.getChipId());

  WiFi.softAP(ssid, PASS_AP, 6);

  server.on("/", HTTP_GET, [](AsyncWebServerRequest *r){
    r->send(200,"text/html", HTML_CFG);
  });
  server.on("/cfg", HTTP_GET, [](AsyncWebServerRequest *r){
    String s = "{\"ssid\":\""+String(gCfg.magic==0xA5? gCfg.ssid:"")+"\",\"alias\":\""+String(gCfg.magic==0xA5? gCfg.alias:"")+"\"}";
    r->send(200,"application/json", s);
  });

  server.on("/save", HTTP_POST,
    [](AsyncWebServerRequest *r){},
    nullptr,
    [](AsyncWebServerRequest *r, uint8_t *data, size_t len, size_t, size_t){
      data[len]=0;
      String body((char*)data);

      // parse simples
      auto extrai = [&](const char* key)->String{
        String k = String("\"")+key+"\":\"";
        int p = body.indexOf(k); if (p<0) return "";
        p += k.length();
        int q = body.indexOf('"', p); if (q<0) return "";
        return body.substring(p, q);
      };
      String ssid  = extrai("ssid");
      String alias = extrai("alias");

      bool ok = saveCfg(ssid.c_str(), alias.c_str());

      // 1) responde já!
      auto* resp = r->beginResponse(200, "application/json", "{\"ok\":true}");
      resp->addHeader("Connection", "close");
      r->send(resp);

      // 2) agenda o reboot um pouco depois (sem usar delay)
      rebootTimer.once_ms(1500, [](){
        ESP.restart();
      });
    }
  );

  server.begin();
  Serial.printf("[CFG] AP iniciado em %s/%s canal 6\n",SSID_AP,PASS_AP);

  ElegantOTA.begin(&server, USER_OTA, PASS_OTA);
  ElegantOTA.setAutoReboot(true);   
}

//---------------------------------------
// Entra no Modo de Comnunicação ESP-NOW
//---------------------------------------

void startEspNowOnChannel(uint8_t ch) 
{
  modo_config = false;
  WiFi.mode(WIFI_STA);
  wifi_set_sleep_type(NONE_SLEEP_T);

  // Fixar canal sem associar ao AP:
  wifi_promiscuous_enable(1);
  wifi_set_channel(ch);
  wifi_promiscuous_enable(0);

  if (esp_now_init()!=0) {
    Serial.println(F("[NOW] falha init")); return;
  }
  esp_now_set_self_role(ESP_NOW_ROLE_COMBO);
  esp_now_register_recv_cb(onEspNowRecv);

  // peer broadcast
  uint8_t b[6]; memset(b,0xFF,6);
  esp_now_add_peer(b, ESP_NOW_ROLE_COMBO, 0 /* canal ignorado */, NULL, 0);

  Serial.printf("[NOW] iniciado no canal %u, SSID=%s, alias=%s\n", ch, gCfg.ssid, gCfg.alias);
  // TODO: registrar callbacks RX/TX e seguir com sua lógica atual…
}

//-----------------------------------------
// Força a entrada no modo de Configuração
//-----------------------------------------

void enterConfigByEraseAndReset() 
{
  // encerra ESPNOW rapidamente para evitar lixo no rádio durante reboot
  esp_now_deinit();

  if (SPIFFS.begin()) {
    if (SPIFFS.exists(CFG_PATH)) {
      SPIFFS.remove(CFG_PATH);
      Serial.println(F("[CFG] Arquivo de configuração removido."));
    } else {
      Serial.println(F("[CFG] Arquivo de configuração não existe."));
    }
  } else {
    Serial.println(F("[CFG] SPIFFS.begin() falhou (seguindo com reset)."));
  }

  delay(150);
  ESP.reset();   // reinicia; no boot cairá no modo AP /config (pois não há arquivo)
}

 


Código da Estação com DHT11

//-----------------------------------------------------------------------------------------
// Função   : Este programa tem como objetivo coletar a temperatura e a umidade do sensor
//            dht11 integrado com o adaptador ESP01S e enviar via broadcast na rede espnow
//            para estação central tratar e atualizar a interface html
//-----------------------------------------------------------------------------------------
// Autor     : Dailton Menezes
// Versão    : 1.0 Out/2025 
//-----------------------------------------------------------------------------------------

#include <ESP8266WiFi.h>               // Biblioteca para a rede wifi genérica 
#include <ESPAsyncTCP.h>               // Biblioteca usada pelo Servidor Assíncrono
#include <ESPAsyncWebServer.h>         // Biblioteca para Servidor Web Assíncrono 
#include <espnow.h>                    // Biblioteca do Protocolo ESP-NOW
#include <FS.h>                        // Biblioteca para tratar arquivos
#include <ElegantOTA.h>                // Biblioteca para atualização via Web
#include <Ticker.h>                    // Biblioteca para gerar timer

#include "DHT.h"
extern "C" 
{ 
  #include "user_interface.h" 
}

//------------------------
// Definições do Programa
//------------------------

#define ENVIO_MS    5000               // intervalo de divulgação
#define PMK_KEY     "SuaPMKde16b"      // opcional: 16 bytes (ex.: "1234567890ABCDEF")
#define BAUDRATE    115200             // Baudrate para a Console
#define CFG_PATH    "/cfg.bin"         // arquivo no SPIFFS
#define DHTPIN      2                  // GPIO2 do ESP-01S
#define DHTTYPE     DHT11              // Definição DHT11
#define SSID_AP     "NODE-DHT11-SETUP" // SSID para Modo AP/Config
#define PASS_AP     "12345678"         // Senha do Modo AP/Config
#define USER_OTA    "admin"            // Usuário para atualização OTA
#define PASS_OTA    "admin"            // Senha para atualização OTA

//-------------------
// Variáveis Globais
//-------------------

AsyncWebServer server(80);             // Servidor Web para receber acessos http 
DHT dht(DHTPIN, DHTTYPE);              // Instância do DHT11
uint8_t gMyMac[6];                     // MAC da Estação em bytes
uint8_t gChannel = 11;                 // Canal da Rede ESP-NOW
uint32_t nextTx;                       // Momento do próximo Envio
bool modo_config = false;              // Se estar no modo de configuração ou não
Ticker rebootTimer;                    // Timer para gerar um atraso no reboot

//-----------------------------
// Config persistida 
//-----------------------------

struct CfgNode 
{
  char ssid[33];                       // SSID até 32 chars
  char alias[24];                      // alias curto
  uint8_t magic;                       // 0xA5 = válido
};
CfgNode gCfg;

//--------------------
// Tipos de Mensagens
//--------------------

enum MsgType : uint8_t 
{ 
  MSG_DISC=1,                          // Brodacast de divulgação das estações para Central
  MSG_CMD=2,                           // Comando ON/OFF do Relé
  MSG_STATUS=3,                        // Status do Relé
  MSG_GETALL=4,                        // Broadcast quem está aí a pedido da Central
  MSG_SENSOR=5,                        // Status de estações cm DHT11
  MSG_CFG=6                            // Pedido da Central para a estação entrar em configuração
};

//-------------------------------
// Estrutura da Mensagem ESP-NOW
//-------------------------------

struct __attribute__((packed)) NowMsg 
{
  uint8_t ver,                         // Parar diferenciar registros entre outras aplicação
          type,                        // Tipo de Mensagem
          relay,                       // Estado do Relé
          mode,                        // Modo Wifi da Estação
          channel,                     // Canal da Rede ESP-NOW
          aliasLen,                    // Tamanho do campo alias
          rsv[2],                      // Para alinhamento
          mac[6];                      // MAC da estação
  char alias[32];                      // Alias da Estação (identificação)
  int16_t  tempC10;                    // temperatura em décimos de grau (ex.: 253 = 25.3°C)
  uint8_t  hum;                        // Umidade em %  
};

//----------------------
// HTML de Configuração
//----------------------

const char* HTML_CFG = R"HTML(
<!doctype html><meta charset="utf-8">
<meta name=viewport content="width=device-width,initial-scale=1">
<title>Config Nó</title>
<style>
  body{font-family:system-ui,Segoe UI,Arial;margin:16px;max-width:560px}
  label{display:block;margin-top:10px;font-weight:600}
  input,button{padding:10px;margin-top:6px;border:1px solid #ccc;border-radius:10px;font-size:16px}
  input{width:100%}
  .row{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}
  .row button{flex:1 1 220px;cursor:pointer}
  .hint{font-size:12px;color:#666;margin-top:4px}
</style>

<h3>Configuração do Nó</h3>

<label>SSID
  <input id="ssid" maxlength="32"
         placeholder="Ex.: MinhaRedeWiFi"
         title="Informe o SSID (nome) da rede Wi-Fi da Estação Central">
</label>
<div class="hint">Nome exato da rede Wi-Fi onde está a Estação Central.</div>

<label>Alias
  <input id="alias" maxlength="23"
         placeholder="Ex.: Sala, Quarto, DHT-Cozinha"
         title="Rótulo amigável para identificar este nó">
</label>
<div class="hint">Use um nome curto para aparecer nos cards (ex.: &quot;Sala&quot;).</div>

<div class="row">
  <button onclick="salvar()">Salvar &amp; Reiniciar</button>
  <button onclick="atualizar()">Atualizar Firmware</button>
</div>

<script>
async function salvar(){
  const ssid  = document.getElementById('ssid').value.trim();
  const alias = document.getElementById('alias').value.trim();
  if(!ssid){ alert('Informe o SSID.'); return; }

  const body = JSON.stringify({ssid, alias});
  try{
    const r = await fetch('/save', {
      method: 'POST',
      headers: {'Content-Type':'application/json'},
      body
    });
    // chegou resposta OK antes do reboot
    if(!r.ok) throw new Error('Falha ao salvar');
    document.body.innerHTML = '<p>Configurações salvas. Reiniciando o módulo…</p>';
    // não precisa reload aqui; o AP sumirá e está tudo certo
  }catch(e){
    // Em caso raro de erro, apenas informa sem travar a UX
    console.warn('Erro no fetch /save:', e);
    document.body.innerHTML = '<p>Configurações foram enviadas. O módulo pode já estar reiniciando…</p>';
  }
}

function atualizar(){
  // abre a rota do ElegantOTA (você cuidará do handler /update)
  location.href = '/update';
}
// pré-carrega valores atuais
fetch('/cfg')
 .then(r=>r.json())
 .then(j=>{
   const ssid  = document.getElementById('ssid');
   const alias = document.getElementById('alias');
   ssid.value  = (j && j.ssid)  ? j.ssid  : '';
   alias.value = (j && j.alias) ? j.alias : '';
   ssid.focus();
 })
 .catch(()=>{});
</script>
)HTML";

//-------------------------
// Prototipação de Rotinas
//-------------------------

String macToStr(const uint8_t m[6]);
float safeFloat(float valor);
bool loadCfg();
bool saveCfg(const char* ssid, const char* alias);
uint8_t getWiFiChannelRetry(const char* ssid, uint8_t tentativas=3, uint16_t esperaMs=400);
void startConfigAP();
void startEspNowOnChannel(uint8_t ch);
void onEspNowRecv(uint8_t *mac, uint8_t *data, uint8_t len);
void enterConfigByEraseAndReset();

//---------------------------
// Inicialização do Programa
//---------------------------

void setup() 
{
  // Inicializa a Serial

  Serial.begin(115200);
  delay(1000);

  Serial.println(F("ESP01S DHT11 V1.0 Out/2025"));
  Serial.flush();

  // Inicializa o DHT11

  dht.begin();

  bool temCfg = loadCfg();
  if (!temCfg) 
  {
    // 1ª vez: ir direto ao modo de configuração
    startConfigAP();
    return;
  }

  // Temos SSID salvo → descobrir canal
  if (strlen(gCfg.ssid) > 0) 
  {
    uint8_t ch = getWiFiChannelRetry(gCfg.ssid, 4, 500);
    if (ch != 0) 
    {
      startEspNowOnChannel(ch);   // modo normal
      return;
    }
  }

  // SSID vazio ou não encontrado → entra em config
  startConfigAP();

  // Inicializa o intervalo de envio

  nextTx = millis() + random(0, 1500);
}

//----------------------------
// Loop Prinicpal do Programa
//----------------------------

void loop() 
{
  // Verifica se está no modo config para permitir a atualização OTA

  if (modo_config)
  {
    ElegantOTA.loop();  
  }
  else
  {
    // Modo ESP_NOW mantendo a comunicação

    if ((int32_t)(millis() - nextTx) >= 0) 
    {
      float t = safeFloat(dht.readTemperature());  // °C
      float h = safeFloat(dht.readHumidity());     // %

      NowMsg m{}; m.ver=1; m.type=MSG_SENSOR;
      m.mode=0; m.channel=wifi_get_channel();
      uint8_t mac[6]; wifi_get_macaddr(STATION_IF, mac); memcpy(m.mac, mac, 6);
      strlcpy(m.alias, gCfg.alias, sizeof(m.alias)); m.aliasLen = strlen(m.alias);
      m.tempC10 = (int16_t)lroundf(t * 10.0f);
      m.hum     = (uint8_t)lroundf(h);

      uint8_t br[6]; memset(br, 0xFF, 6);
      int rc= esp_now_send(br, (uint8_t*)&m, sizeof(NowMsg));   // broadcast
        Serial.printf("Dados DHT11 enviados. Rc=%d\n",rc);


      nextTx += ENVIO_MS + random(-300, 300); // 5 s ± jitter
    }
  }
}

//---------------------------------------------
// Rotina para detectar o canal utilizado pela
// Rede Wi-fi cujo o SSID foi definido com 
// retry e n. de tentativas
//---------------------------------------------

uint8_t getWiFiChannelRetry(const char* ssid, uint8_t tentativas, uint16_t esperaMs) 
{
  for (uint8_t t=0; t<tentativas; ++t) {
    int n = WiFi.scanNetworks(/*async=*/false, /*hidden=*/true);
    for (int i=0; i<n; ++i) 
    {
      String s = WiFi.SSID(i);
      if (s.length() && strcmp(s.c_str(), ssid)==0) {
        return (uint8_t)WiFi.channel(i);
      }
    }
    delay(esperaMs);
  }
  return 0;
}

//--------------------------------------
// Convertet o MAC de bytes para String
//--------------------------------------

String macToStr(const uint8_t m[6]) 
{
  char b[18]; 
  sprintf(b, "%02X:%02X:%02X:%02X:%02X:%02X", m[0],m[1],m[2],m[3],m[4],m[5]); 
  return String(b);
}

//-------------------------------
// Retorna um valor Float seguro
//-------------------------------

float safeFloat(float valor)
{
   return !isnan(valor) ? valor : 0.00;
}

//--------------------------------------
// Recupera os parâmetros do Config
//--------------------------------------

bool loadCfg() 
{
  if (!SPIFFS.begin()) return false;
  if (!SPIFFS.exists(CFG_PATH)) return false;
  File f = SPIFFS.open(CFG_PATH, "r");
  if (!f) return false;
  if (f.size() != sizeof(CfgNode)) { f.close(); return false; }
  f.readBytes((char*)&gCfg, sizeof(CfgNode));
  f.close();
  return (gCfg.magic == 0xA5);
}

//--------------------------------------
// Persiste os parâmetros no Config
//--------------------------------------

bool saveCfg(const char* ssid, const char* alias) 
{
  strncpy(gCfg.ssid, ssid ? ssid : "", sizeof(gCfg.ssid));
  strncpy(gCfg.alias, alias ? alias : "", sizeof(gCfg.alias));
  gCfg.ssid[sizeof(gCfg.ssid)-1] = 0;
  gCfg.alias[sizeof(gCfg.alias)-1] = 0;
  gCfg.magic = 0xA5;

  if (!SPIFFS.begin()) SPIFFS.begin();
  File f = SPIFFS.open(CFG_PATH, "w");
  if (!f) return false;
  size_t wr = f.write((const uint8_t*)&gCfg, sizeof(CfgNode));
  f.close();
  return (wr == sizeof(CfgNode));
}

//-------------------------------
// Entra no Modo de Configuração
//-------------------------------

void startConfigAP() 
{
  modo_config = true;
  WiFi.mode(WIFI_AP);
  wifi_set_sleep_type(NONE_SLEEP_T);

  // Nome da rede AP temporária

  char ssid[32];
  snprintf(ssid, sizeof(ssid), "ESP_DHT11_%06X", ESP.getChipId());

  WiFi.softAP(ssid, PASS_AP, 6);

  server.on("/", HTTP_GET, [](AsyncWebServerRequest *r){
    r->send(200,"text/html", HTML_CFG);
  });
  server.on("/cfg", HTTP_GET, [](AsyncWebServerRequest *r){
    String s = "{\"ssid\":\""+String(gCfg.magic==0xA5? gCfg.ssid:"")+"\",\"alias\":\""+String(gCfg.magic==0xA5? gCfg.alias:"")+"\"}";
    r->send(200,"application/json", s);
  });
  server.on("/save", HTTP_POST,
    [](AsyncWebServerRequest *r){},
    nullptr,
    [](AsyncWebServerRequest *r, uint8_t *data, size_t len, size_t, size_t){
      data[len]=0;
      String body((char*)data);


      // parse simples
      auto extrai = [&](const char* key)->String{
        String k = String("\"")+key+"\":\"";
        int p = body.indexOf(k); if (p<0) return "";
        p += k.length();
        int q = body.indexOf('"', p); if (q<0) return "";
        return body.substring(p, q);
      };
      String ssid  = extrai("ssid");
      String alias = extrai("alias");

      bool ok = saveCfg(ssid.c_str(), alias.c_str());

      // 1) responde já!
      auto* resp = r->beginResponse(200, "application/json", "{\"ok\":true}");
      resp->addHeader("Connection", "close");
      r->send(resp);

      // 2) agenda o reboot um pouco depois (sem usar delay)
      rebootTimer.once_ms(1500, [](){
        ESP.restart();
      });
    }
  );
  server.begin();
  Serial.printf("[CFG] AP iniciado em %s/%s canal 6\n",SSID_AP,PASS_AP);

  ElegantOTA.begin(&server, USER_OTA, PASS_OTA);
  ElegantOTA.setAutoReboot(true);  
}

//---------------------------------------
// Entra no Modo de Comnunicação ESP-NOW
//---------------------------------------

void startEspNowOnChannel(uint8_t ch) 
{
  modo_config = false;
  WiFi.mode(WIFI_STA);
  wifi_set_sleep_type(NONE_SLEEP_T);

  // Fixar canal sem associar ao AP:
  wifi_promiscuous_enable(1);
  wifi_set_channel(ch);
  wifi_promiscuous_enable(0);

  if (esp_now_init()!=0) {
    Serial.println(F("[NOW] falha init")); return;
  }
  esp_now_set_self_role(ESP_NOW_ROLE_COMBO);
  esp_now_register_recv_cb(onEspNowRecv);

  // peer broadcast
  uint8_t b[6]; memset(b,0xFF,6);
  esp_now_add_peer(b, ESP_NOW_ROLE_COMBO, 0 /* canal ignorado */, NULL, 0);

  Serial.printf("[NOW] iniciado no canal %u, SSID=%s, alias=%s\n", ch, gCfg.ssid, gCfg.alias);
  // TODO: registrar callbacks RX/TX e seguir com sua lógica atual…
}

//------------------------------------------------
// Rotina para tratar a mensagem ESP-NOW recebida
//------------------------------------------------

void onEspNowRecv(uint8_t *mac, uint8_t *data, uint8_t len) 
{
  // Log de entrada com MAC formatado corretamente
  Serial.printf("Mensagem ESP-NOW RX: mac=%s len=%u\n",macToStr(mac).c_str(),len);
  if (len < (sizeof(NowMsg) - 32)) {
    Serial.println("RX descartado: len insuficiente para NowMsg base");
    return;
  }

  NowMsg msg; 
  memcpy(&msg, data, min((int)len, (int)sizeof(NowMsg)));

  Serial.printf("Decodificado: Ver=%u Type=%u Relay=%u Ch=%u AliasLen=%u\n",
                msg.ver, msg.type, msg.relay, msg.channel, msg.aliasLen);

  if (msg.ver != 1) {
    Serial.println(F("Versão inválida, desprezado"));
    return;
  }

  if (msg.type == MSG_CFG && msg.relay == 1) 
  {
    Serial.println(F("[NOW] Recebido CMD CONFIG -> apagar cfg e reiniciar"));
    enterConfigByEraseAndReset();
    return;
  }  

}

//-----------------------------------------
// Força a entrada no modo de Configuração
//-----------------------------------------

void enterConfigByEraseAndReset() 
{
  // encerra ESPNOW rapidamente para evitar lixo no rádio durante reboot
  esp_now_deinit();

  if (SPIFFS.begin()) {
    if (SPIFFS.exists(CFG_PATH)) {
      SPIFFS.remove(CFG_PATH);
      Serial.println(F("[CFG] Arquivo de configuração removido."));
    } else {
      Serial.println(F("[CFG] Arquivo de configuração não existe."));
    }
  } else {
    Serial.println(F("[CFG] SPIFFS.begin() falhou (seguindo com reset)."));
  }

  delay(150);
  ESP.reset();   // reinicia; no boot cairá no modo AP /config (pois não há arquivo)
}

 


Conclusão

O projeto buscou demonstrar que, mesmo com os recursos limitados do ESP-01S, é possível construir uma rede inteligente de automação utilizando o protocolo ESP-NOW como base de comunicação.
Com a combinação de módulos equipados com Relé e Sensor DHT11, e uma Estação Central ESP32 atuando como ponto de controle e visualização, obtivemos uma solução estável, responsiva e de fácil expansão.

Além do caráter técnico, o trabalho reforça o potencial educacional e experimental do ecossistema ESP, permitindo que estudantes e profissionais explorem conceitos como comunicação distribuída, configuração dinâmica, atualização OTA e gerenciamento centralizado de dispositivos IoT.

A arquitetura resultante é versátil, podendo evoluir para aplicações reais de automação residencial, monitoramento ambiental ou controle industrial leve, mantendo o equilíbrio entre simplicidade, desempenho e custo reduzido — características que fazem do ESP8266 e do ESP32 uma dupla ainda imbatível no mundo maker.


Sobre o Autor


Alberto de Almeida Menezes
[email protected]

Bacharel em Engenharia de Áudio e Produção Musical pela Berklee College of Music.


Dailton de Oliveira Menezes
[email protected]

Bacharel em Ciência da Computação pela Universidade Federal de Minas Gerais.


Eletrogate

14 de novembro de 2025

A Eletrogate é uma loja virtual de componentes eletrônicos do Brasil e possui diversos produtos relacionados à Arduino, Automação, Robótica e Eletrônica em geral.

Os comentários estão desativados.

Tenha a Metodologia Eletrogate dentro da sua Escola! Conheça nosso Programa de Robótica nas Escolas!

Eletrogate Robô

Assine nossa newsletter e
receba  10% OFF  na sua
primeira compra!