Módulos Wifi

Termostato Digital com DS18B20 e ESP32

Eletrogate 30 de maio de 2025

Introdução

Este projeto tem como objetivo o desenvolvimento de uma estação de monitoramento de temperatura utilizando sensores DS18B20 e ESP32. A estação é capaz de ler múltiplas temperaturas simultaneamente e, com base em parâmetros configuráveis, acionar relés de forma automática. O controle e visualização são realizados por meio de uma interface web responsiva, hospedada no próprio ESP32, com comunicação em tempo real via WebSocket.


Motivação

Sistemas de automação baseados em temperatura são amplamente utilizados em aplicações domésticas e industriais, como em estufas, tanques de aquecimento, refrigeração e fermentação. A proposta deste projeto é fornecer uma solução personalizável e acessível, com interface amigável e de fácil configuração, que possa funcionar com ou sem conexão com a Internet.

🌱 Estufas (horticultura)

  • Objetivo: Em estufas agrícolas, controlar a temperatura é essencial para manter o ambiente ideal para o crescimento de plantas.

  • Aplicação:

    • Se a temperatura cair abaixo do mínimo, o sistema pode ligar um aquecedor automaticamente.

    • Se a temperatura subir acima do máximo, pode acionar exaustores ou abrir venezianas para reduzir o calor.

  • Importância:

    • Mantém a faixa ideal para germinação, floração e frutificação.

    • Evita estresse térmico que pode prejudicar as plantas.

    🌡️ Tanques de aquecimento (ex: água industrial, aquarismo)

    • Objetivo: Em tanques que precisam de temperatura constante (como tanques de peixes, sistemas industriais ou boilers de água), o controle é fundamental.

    • Aplicação:

      • Quando a água esfria demais, um aquecedor pode ser ligado automaticamente.

      • Quando superaquece, o sistema pode desligar o aquecedor ou até ativar mecanismos de resfriamento.

    • Importância:

      • Em aquários, a temperatura errada pode matar os peixes.

      • Em processos industriais, pode comprometer a qualidade de produtos ou causar falhas no sistema.

    ❄️ Refrigeração (câmaras frias, geladeiras industriais)

    • Objetivo: Manter produtos perecíveis (como alimentos ou medicamentos) em temperaturas específicas.

    • Aplicação:

      • Se a temperatura subir acima do máximo, o sistema aciona um compressor ou aumenta a potência de refrigeração.

      • Se a temperatura cair abaixo do mínimo, pode desligar o compressor para evitar congelamento indevido.

    • Importância:

      • Mantém alimentos seguros, evita perdas financeiras.

      • Em laboratórios, preserva a eficácia de medicamentos e amostras biológicas.

    🍺 Fermentação (cervejas artesanais, vinhos, alimentos)

    • Objetivo: Durante a fermentação, a temperatura influencia diretamente a qualidade do produto.

    • Aplicação:

      • Se a fermentação esquentar demais (o que pode acontecer naturalmente), um resfriamento pode ser acionado (tipo uma serpentina com água gelada).

      • Se esfriar além do ideal, aquecedores são ativados.

    • Importância:

      • Controla o tipo de aroma e sabor das bebidas fermentadas.

      • Evita a morte das leveduras ou produção de subprodutos indesejados.


      Funções adicionais

      • Inclusão de Alias no DNS da Rede Local (mDNS). Ex: http://termostato.local
      • Implementação para o Modo AP (desconectado da Internet) ou para o Modo WiFi (conectado)
      • Sincronização do Relógio interno com o Serviço NTP quando no Modo WiFi ou sincronismo com o relógio do Navegador quando no Modo AP
      • Atualização do Código via Interface Web (ElegantOTA) através da URL : http://termostato.local/update
      • Modo de Configuração dos Parâmetros
      • Modo de Controle Manual dos Relés
      • Suporta N x Relés e N x Sensores, cada sensor associado a um relé
      • Compilação condicional para ESP32, ESP32C3, ESP32S3 e ESP32 LOLIN32

      Sobre o ESP32 Wemos LoLin32 OLED

      A placa LoLin32 é uma variante do ESP32 com suporte a displays OLED 128×64 integrados. Isso facilita o desenvolvimento de aplicações embarcadas que precisam de uma saída visual local, como exibição de temperatura em tempo real, mensagens de status e alarmes.

      Principais características:

      • Microcontrolador ESP32 dual-core
      • Interface I2C compartilhada com o display
      • Alimentação por microUSB ou bateria Li-Ion
      • Boa compatibilidade com bibliotecas da Adafruit SSD1306

      Figura 1 – Pinagem do ESP32 LoLin32 OLED


      Sobre o Sensor DS18B20

      O DS18B20 é um sensor digital de temperatura com protocolo de comunicação OneWire, ideal para medições em ambientes úmidos (versão à prova d’água). Suporta encadeamento de múltiplos sensores no mesmo barramento e tem resolução configurável de 9 a 12 bits, com precisão de até ±0.5 °C. Cada sensor tem um número serial único que pode ser obtido através de API e serve para diferenciar em caso de um barramento maior com múltiplos sensores.

      Figura 2 – Sensor DS18B20 a Prova D’Água


      Detalhes da Implementação

      • Interface Web: baseada em HTML/CSS responsivo, com três abas: Medições, Parâmetros e Relés.
      • WebSocket: utilizado para comunicação em tempo real entre ESP32 e navegador.
      • JSON: usado como formato para transmissão e armazenamento de configurações, conforme formato a seguir:
      {
        "modo": "WiFi",
        "alias": "termostato",
        "ssid": "Brsky fibra Dom_2.4G",
        "senha": "########",
        "varredura": 5000,
        "ntpServer": "pool.ntp.org",
        "timezone": "<-03>3",
        "resolucao": 12,
        "user_OTA": "admin",
        "pass_OTA": "#####",
        "autoRebootOTA": true,
        "sensores": [
          {
            "id": "#1",
            "desc": "Sensor 1",
            "min": 27,
            "acaoMin": "Desligar",
            "max": 30,
            "acaoMax": "Ligar",
            "rele": 1,
            "hab": true
          },
          {
            "id": "#2",
            "desc": "Sensor 2",
            "min": 20,
            "acaoMin": "Ligar",
            "max": 30,
            "acaoMax": "Desligar",
            "rele": 2,
            "hab": true
          },
          {
            "id": "",
            "desc": "Sensor 1",
            "min": 20,
            "acaoMin": "Desligar",
            "max": 30,
            "acaoMax": "Ligar",
            "rele": 1,
            "hab": true
          }
        ]
      }
      • SPIFFS: armazena os parâmetros em arquivo config.json, conforme estruturas a seguir:
      //-------------------------------------------------------
      // Estrutura para representar os parâmetros dos sensores
      //-------------------------------------------------------
      
      struct SensorConfig 
      {
        String idSensor;
        String descricao;
        float minTemp;
        String acaoMin; // "Ligar" ou "Desligar"
        float maxTemp;
        String acaoMax;
        int releID;
        bool habilitado;
      };
      
      //-------------------------------------------------------
      // Estrutura para representar os parâmetros no SPIFFS
      //-------------------------------------------------------
      
      struct AppConfig 
      {
        std::vector<SensorConfig> sensores;
        unsigned long varredura;
        String modo;
        String alias;
        int resolucao;
        String ntpServer;
        String timezone;
        String ssid;
        String senha;
        String user_OTA;
        String pass_OTA;
        bool autoRebootOTA;
      };
      • Display OLED: mostra o ID do sensor e a temperatura no centro da tela, alternando caso o número de sensores seja maior que 1. O tempo com as informações ativas no display é ajustado de modo que a soma para todos os sensores seja menor que 90% do tempo da varredura das temperaturas. Como utilizamos o ESP32 Wemos LoLin32 que já possui o display OLED integrado, não mostramos a forma de ligação de um display separado para uso com outros modelos de ESP32 sem display nativo (veja na seção Materiais Necessários e Opcional a indicação de um display). Entretanto, existem diversos tutoriais na Internet que podem ser utilizados para adaptar para o seu modelo de ESP32 ao display  (Referência 4 é um exemplo).
      • Alarmes: implementados via recursos nativos da biblioteca DallasTemperature, com lógica adicional no newAlarmHandler() para acionamento dos relés.
      • Modularização: rotinas como aplicarParametros() e carregarConfig() centralizam a lógica de configuração e inicialização.
      • Ligação dos Sensores: utilizamos um borne kre de 3 vias para formar o barramento de sensores. Teoricamente, é possível ter um número grandes de sensores. Existe uma constante no código que precisa ser modificada caso o número seja maior que 5 (MAX_SENSORES). Todos os fios pretos dos sensores são ligados na via negativa do borne, todos os fios amarelos dos sensores são ligados na via de sinal (meio) e todos os fios vermelhos dos sensores são ligados na via positiva do borne (3.3V). No setup() da aplicação, o programa faz uma varredura no barramento para identificar o número de sensores presentes. Caso nenhum sensor seja identificado, uma mensagem de Nenhum Dispositivo será mostrada no display OLED e a aplicação será congelada pois não teria sentido continuar.
      • Varredura dos SSID’s: a varredura dos SSID’s da Rede WiFi é feita de forma assíncrona para não comprometer a TASK do Servidor Web Assíncrono. A obtenção da lista de SSID’s pode consumir um tempo excessivo dependendo das condições da rede e quantidade de nós. Por isso, optamos em fazer a varredura de forma assíncrona e, com isso, é importante o usuário aguardar quando selecionar a ABA de Parâmetros, antes de fazer alterações e salvamento.
      • Número de Relés x Número de Sensores: é ideal que a relação sensores versus relés seja de 1 para 1. Neste projeto utilizamos dois sensores e um relé de estado sólido de dois canais (alimentado com 5V), mas poderia ser um relé tradicional de dois canais. Em caso de uso de relés de 4, 8 ou 16 canais, a constante MAX_RELES precisa ser ajustada no código fonte. Lembrando que a amperagem máxima da CARGA para o relé de estado sólido que utilizamos é de 2 A. Relés tradicionais suportam uma corrente maior, 10 A, 20 A e até mesmo 30 A.
      • Compilação do Código: utilizamos o IDE na versão 2.3.5. A placa mostrada na figura a seguir deve ser selecionada. Adicionalmente, o botão de BOOT (na parte de trás do módulo) deve ser pressionado quando o compilador tentar entrar no modo de UPLOAD. A placa que utilizei não entra automaticamente no modo de UPLOAD do código, sendo necessário pressionar o botão de BOOT e manter pressionado até que o BOOTLOADER detecte e comece a fazer o UPLOAD do código.

      Figura 3 – Seleção da Placa para Compilação

      Figura 4 – Circuito em Bancada

      Figura 5 – Diagrama do Circuito

      Figura 6 – Tela de Medições

      Figura 7 – Tela de Parâmetros

      Figura 8 – Tela Controle Relés

      Figura 9 – Tela Autenticação Atualização

      Figura 10 – Tela Atualização


      Bibliotecas Utilizadas

      #include <WiFi.h>                            // Biblioteca para a rede wifi
      #include <AsyncTCP.h>                        // Biblioteca usada pelo Servidor Assíncrono 
      #include <ESP32Ping.h>                       // Biblioteca Ping                      
      #include <ESPAsyncWebServer.h>               // Biblioteca para Servidor Web Assíncrono
      #include <ESPmDNS.h>                         // Biblioteca para adiocionar aliases no DNS da Rede Local
      #include <ArduinoJson.h>                     // Biblioteca para manipulação de estrutiras JSON 
      #include <SPIFFS.h>                          // Biblioteca que implementa o filesystem
      #include <ArduinoJson.h>                     // Biblioteca para manipulação de estrutiras JSON 
      #include <SPI.h>                             // Biblioteca para interface com periféricos
      #include <Wire.h>                            // Biblioteca para comunicação I2C
      #include <Adafruit_GFX.h>                    // Biblioteca para Display OLED
      #include <Adafruit_SSD1306.h>                // Biblioteca para o Display OLED
      #include <Fonts/FreeSerif12pt7b.h>           // Biblioteca de fontes para dipslay OLED
      #include <OneWire.h>                         // Bublioteca para comunicação ONE-WIRE
      #include <DallasTemperature.h>               // Biblioteca para tratar os sensores DS18D20
      #include <ElegantOTA.h>                      // Biblioteca para atualização via Web
      #include "esp_system.h"                      // Bibliotecas para consumo de recursos

      Código Fonte

      //-----------------------------------------------------------------------------------------
      // Função   : Este programa tem como objetivo implementar uma estação de monitoramento
      //            de temperatura utilizando múltiplos sensores DS18B20 e, adicionalmente, 
      //            permitir o acionamento de relés de acordo com a faixa de MÍNIMO e MÁXIMO 
      //            de temperatura por sensor. A aplicação implementa uma interface HTML 
      //            numa aplicação AsyncWebServer para ESP32 com interação com o Navegador 
      //            via WebSocket.
      //
      // Funções adicionais
      //
      //   1) Inclusão de Alias no DNS da Rede Local (mDNS)
      //   2) Pode operar no Modo AP (desconectado da Internet) ou no Modo WiFi (conectado)
      //   3) Sincronização do Relógio interno com o Serviço NTP quando no Modo WiFi
      //      ou sincronismo com o relógio do Navegador quando no Modo AP
      //   4) Atualização do Código via Interface Web (ElegantOTA)
      //   5) Modo de Configuração dos Parãmetros
      //   6) Suporta N x Relés e N x Sensores, cada sensor associado a um relé  
      //   7) Compilação condicional para ESP32, ESP32C3 e ESP32S3 
      //
      // Referências
      //  
      //   1) https://randomnerdtutorials.com/esp32-built-in-oled-ssd1306/
      //   2) https://blog.eletrogate.com/guia-completo-sobre-sensor-de-temperatura-ds18b20-a-prova-dagua/
      //   3) https://blog.eletrogate.com/automatizacao-do-processo-de-brassagem/ 
      //   4) https://blog.eletrogate.com/guia-completo-do-display-oled-parte-2-como-programar-3/
      //
      // Autor     : Dailton Menezes
      // Versão    : 1.0 Abr/2025
      //-----------------------------------------------------------------------------------------
      
      #include <WiFi.h>                            // Biblioteca para a rede wifi
      #include <AsyncTCP.h>                        // Biblioteca usada pelo Servidor Assíncrono 
      #include <ESP32Ping.h>                       // Biblioteca Ping                      
      #include <ESPAsyncWebServer.h>               // Biblioteca para Servidor Web Assíncrono
      #include <ESPmDNS.h>                         // Biblioteca para adiocionar aliases no DNS da Rede Local
      #include <ArduinoJson.h>                     // Biblioteca para manipulação de estrutiras JSON 
      #include <SPIFFS.h>                          // Biblioteca que implementa o filesystem
      #include <ArduinoJson.h>                     // Biblioteca para manipulação de estrutiras JSON 
      #include <SPI.h>                             // Biblioteca para interface com periféricos
      #include <Wire.h>                            // Biblioteca para comunicação I2C
      #include <Adafruit_GFX.h>                    // Biblioteca para Display OLED
      #include <Adafruit_SSD1306.h>                // Biblioteca para o Display OLED
      #include <Fonts/FreeSerif12pt7b.h>           // Biblioteca de fontes para dipslay OLED
      #include <OneWire.h>                         // Bublioteca para comunicação ONE-WIRE
      #include <DallasTemperature.h>               // Biblioteca para tratar os sensores DS18D20
      #include <ElegantOTA.h>                      // Biblioteca para atualização via Web
      #include "esp_system.h"                      // Bibliotecas para consumo de recursos
      
      //-------------------------------------
      // Define os Pinos usados pelo programa
      //-------------------------------------
      
      #if defined(CONFIG_IDF_TARGET_ESP32C3)       // ESP32C3 Mini
        #define pinBUS              4              // Pino do barramento de sensores
        #define pinRELE1            2              // Pino do Relé 1
        #define pinRELE2            3              // Pino do Relé 2
      #elif defined(CONFIG_IDF_TARGET_ESP32S3)     // XIAO ESP32S3
        #define pinBUS              4              // Pino do barramento de sensores
        #define pinRELE1            2              // Pino do Relé 1
        #define pinRELE2            3              // Pino do Relé 2
      #elif defined(ARDUINO_LOLIN32)               // ESP32 WEMOS LOLIN32 com OLED
        #define pinBUS              16             // Pino do barramento de sensores
        #define pinRELE1            25             // Pino do Relé 1
        #define pinRELE2            26             // Pino do Relé 2
      #elif defined(CONFIG_IDF_TARGET_ESP32)       // ESP32 genérico
        #define pinBUS              4              // Pino do barramento de sensores
        #define pinRELE1            2              // Pino do Relé 1
        #define pinRELE2            3              // Pino do Relé 2  
      #endif
      
      //--------------------------------
      // Definições Gerais
      //--------------------------------
      
      #define MAX_RELES             2              // Números de Relés
      #define RELE_ON               LOW            // Estado para ligar o Relé
      #define RELE_OFF              HIGH           // Estado para desligar o Relé
      #define MAX_SENSORES          5              // N. máximo de DS18B20 no barramento
      #define INTERVALO_CLEANUP     60000          // Intervalo entre de CleanUP
      #define SCREEN_WIDTH          128            // Resolução horizontal do Display
      #define SCREEN_HEIGHT         64             // Resolução vertical do display
      #define INTERVALO_SCAN        15000          // Máximo intervalo de SCAN em ms
      
      //-------------------------------
      // Definições para  WebServer
      //-------------------------------
      
      AsyncWebServer server(80);
      AsyncWebSocket ws("/ws");
      
      //------------------------------------
      // Configuração para os Sensores 
      //------------------------------------
      
      OneWire oneWire(pinBUS);             // Prepara uma instância oneWire para comunicar com qualquer outro dispositivo oneWire  
      DallasTemperature sensors(&oneWire); // Passa uma referência oneWire para a biblioteca DallasTemperature
      int nDevices = 0;                    // N. de dispositivos
      DeviceAddress deviceAddr;            // Endereço do dispositivo
      
      //--------------------------------
      // Configuração do Display OLED
      //--------------------------------
      
      Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
      
      //-------------------------
      // Configuração dos Relés
      //-------------------------
      
      int pinReles[MAX_RELES] = {pinRELE1, pinRELE2};
      bool statusReles[MAX_RELES] = {false, false};
      
      //-------------------------------
      // Controles de Tempo
      //-------------------------------
      
      unsigned long lastScan = 0;
      unsigned long intervaloVarredura = 5000;
      unsigned long tempoExibicao = 1000; 
      unsigned long tempoUltimaExibicao = 0;
      unsigned long lastCleanup = 0;
      
      //--------------------------
      // Controle da Temperatura
      //--------------------------
      
      float temperaturas[MAX_SENSORES] = {};
      bool leituraPronta=false;
      int sensorIndex=0;
      
      //---------------------------------
      // Controle do ScanWiFi assíncrono
      //---------------------------------
      
      bool iniciarScanSSID = false;
      bool scanEmAndamento = false;
      unsigned long tempoScanInicio = 0;
      int resultadoScan = -1;
      AsyncWebSocketClient *clienteScanSSID = nullptr;
      
      //--------------------------------
      // Controle Sincronismo NTP Server
      //---------------------------------
      
      IPAddress ip (1, 1, 1, 1);                // The remote ip to ping, DNS do Google
      
      //-------------------------------
      // Nome do arquivo de parâmetros
      //-------------------------------
      
      #define CONFIG_PATH "/config.json"
      
      //-------------------------------------------------------
      // Estrutura para representar os parâmetros dos sensores
      //-------------------------------------------------------
      
      struct SensorConfig 
      {
        String idSensor;
        String descricao;
        float minTemp;
        String acaoMin; // "Ligar" ou "Desligar"
        float maxTemp;
        String acaoMax;
        int releID;
        bool habilitado;
      };
      
      //-------------------------------------------------------
      // Estrutura para representar os parâmetros no SPIFFS
      //-------------------------------------------------------
      
      struct AppConfig 
      {
        std::vector<SensorConfig> sensores;
        unsigned long varredura;
        String modo;
        String alias;
        int resolucao;
        String ntpServer;
        String timezone;
        String ssid;
        String senha;
        String user_OTA;
        String pass_OTA;
        bool autoRebootOTA;
      };
      
      AppConfig appConfig;
      
      //-------------------------
      // Prototipação de Rotinas
      //-------------------------
      
      bool carregarConfig();
      void setupWiFi();
      void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client,
                     AwsEventType type, void *arg, uint8_t *data, size_t len);
      void setupServer();
      bool setDNSNAME(String nome);
      void setupSensores();
      void processaSensores();
      void printAddress(const DeviceAddress deviceAddress);
      void newAlarmHandler(const uint8_t* deviceAddress);
      void printCurrentTemp(DeviceAddress deviceAddress);
      void printTemp(const DeviceAddress deviceAddress);
      void printAlarmInfo(const DeviceAddress deviceAddress);
      int getDeviceIndex(const DeviceAddress deviceAddress);
      void centerText(String text, int textSize);
      void showError();
      void salvarConfig();
      void deleteFile(const char* path);
      void displayTemperatura(int index);
      void aplicarParametros();
      String getTimeStamp(); 
      bool getNTPtime(int sec);
      void enviarStatusParaTodosClientes();
      void alterarEstadoRele(int releIndex, bool estado);
      void printFreeRAM(String context);
      
      //--------------------
      // HTML da Aplicação
      //--------------------
      
      const char index_html[] PROGMEM = R"rawliteral(
      <!DOCTYPE html>
      <html lang="pt-br">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
        <title>Termostato V1.0</title>
        <style>
          * {
            box-sizing: border-box;
          }
      
          body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 0;
            background-color: #f0f0f0;
          }
      
          header {
            background-color: #000;
            color: #fff;
            padding: 10px 20px;
            text-align: center;
          }
      
          nav {
            display: flex;
            background-color: #333;
            overflow: auto;
          }
      
          nav button {
            flex: 1;
            padding: 14px;
            background-color: #333;
            color: white;
            border: none;
            cursor: pointer;
            font-size: 16px;
          }
      
          nav button.active {
            background-color: #666;
          }
      
          .tabcontent {
            display: none;
            padding: 20px;
          }
      
          .tabcontent.active {
            display: block;
          }
      
          @media (max-width: 600px) {
            nav button {
              font-size: 14px;
              padding: 12px;
            }
          }
      
          /* === SENSOR CARD === */
      
          .sensor-card {
            background-color: #fff;
            border: 1px solid #ccc;
            border-radius: 10px;
            box-shadow: 2px 2px 6px rgba(0,0,0,0.1);
            max-width: 400px;
            margin: 15px auto;
            padding: 10px 15px;
            font-family: Arial, sans-serif;
          }
      
          .sensor-card.alarme-baixo {
            border: 2px solid #007BFF; /* Azul */
          }
      
          .sensor-card.alarme-alto {
            border: 2px solid #dc3545; /* Vermelho */
          }
      
          .sensor-header {
            font-size: 13px;
            display: flex;
            justify-content: space-between;
            margin-bottom: 4px;
            color: #555;
          }
      
          .sensor-id {
            font-size: 24px;
            font-weight: bold;
            text-align: center;
            margin: 4px 0;
          }
      
          .sensor-temp {
            font-size: 28px;
            font-weight: bold;
            text-align: center;
            margin: 6px 0;
          }
      
          .sensor-temp.azul {
            color: blue;
          }
      
          .sensor-temp.verde {
            color: green;
          }
      
          .sensor-temp.vermelho {
            color: red;
          }
      
          .sensor-footer {
            display: flex;
            justify-content: space-between;
            align-items: center;
            font-size: 13px;
            margin-top: 10px;
            color: #444;
            white-space: nowrap;
          }
      
          .sensor-footer span {
            flex: 1;
            text-align: center;
          }
      
          .sensor-footer .min-temp {
            text-align: left;
          }
      
          .sensor-footer .max-temp {
            text-align: right;
          }
      
          .sensor-footer .timestamp {
            white-space: nowrap;
          }
      
          .sensor-alerta {
            background-color: #ffc107;
            color: #000;
            text-align: center;
            font-weight: bold;
            padding: 4px 6px;
            margin-bottom: 6px;
            border-radius: 6px;
          }
      
          h4 {
            margin-top: 8px;
            margin-bottom: 6px;
            font-size: 18px;
            font-weight: bold;
          }
      
          /* === FORMS / SELECT === */
      
          select {
            width: 95%;
          }
        </style>
      
      </head>
      <body>
        <header>
          <h2>Termostato V1.0 Abr/2025</h2>
        </header>
      
        <nav>
          <button onclick="openTab('medicoes')" class="active">Medições</button>
          <button onclick="openTab('parametros')">Parâmetros</button>
          <button onclick="openTab('reles')">Relés</button>
        </nav>
      
        <div id="medicoes" class="tabcontent active">
          <div id="painelMedicoes">
            <!-- Painéis dos sensores serão inseridos via JavaScript -->
          </div>
        </div>
      
        <div id="parametros" class="tabcontent">
      
          <!-- 🔷 Parâmetros por Sensor -->
          <div style="border: 1px solid #ccc; border-radius: 10px; padding: 15px; background-color: #fff; margin-bottom: 20px;">
            <h4>Parâmetros por Sensor</h4>
            <form id="formSensor">
              <label for="sensorID">Sensor ID:</label><br>
              <select id="sensorID" onchange="carregarSensorConfig()" style="width: 95%;"></select>
              <br><br>
      
              <table style="width: 100%; border-collapse: collapse;">
                <tr>
                  <td>
                    <label for="descricao">Descrição:</label><br>
                    <input type="text" id="descricao" value="" style="width: 95%;">
                  </td>
                  <td>
                    <label for="minTemp">Min Temp (°C):</label><br>
                    <input type="number" step="0.1" id="minTemp" value="" style="width: 95%;">
                  </td>
                </tr>
                <tr>
                  <td>
                    <label for="acaoMin">Ação Min:</label><br>
                    <select id="acaoMin" style="width: 95%;">
                      <option value="Ligar">Ligar</option>
                      <option value="Desligar">Desligar</option>
                    </select>
                  </td>
                  <td>
                    <label for="maxTemp">Max Temp (°C):</label><br>
                    <input type="number" step="0.1" id="maxTemp" value="" style="width: 95%;">
                  </td>
                </tr>
                <tr>
                  <td>
                    <label for="acaoMax">Ação Max:</label><br>
                    <select id="acaoMax" style="width: 95%;">
                      <option value="Ligar">Ligar</option>
                      <option value="Desligar">Desligar</option>
                    </select>
                  </td>
                  <td>
                    <label for="releID">Relé ID:</label><br>
                    <select id="releID" style="width: 95%;"></select>
                  </td>
                </tr>
                <tr>
                  <td colspan="2">
                    <label>
                      <input type="checkbox" id="habilitado"> Habilitado
                    </label>
                  </td>
                </tr>
              </table>
      
              <div style="margin-top: 10px; text-align: right;">
                <button type="button" onclick="salvarSensorConfig()">Salvar Sensor</button>
              </div>
            </form>
          </div>
      
          <!-- 🟨 Parâmetros Globais -->
          <div style="border: 1px solid #ccc; border-radius: 10px; padding: 15px; background-color: #fff;">
            <h4>Parâmetros Globais da Aplicação</h4>
            <form id="formApp">
              <table style="width: 100%; border-collapse: collapse;">
                <tr>
                  <td>
                    <label for="varredura">Varredura (ms):</label><br>
                    <input type="number" id="varredura" value="" style="width: 95%;">
                  </td>
                  <td>
                    <label for="modo">Modo:</label><br>
                    <select id="modo" style="width: 95%;">
                      <option value="WiFi">WiFi</option>
                      <option value="AP">AP</option>
                    </select>
                  </td>
                </tr>
                <tr>
                  <td>
                    <label for="alias">Alias mDNS:</label><br>
                    <input type="text" id="alias" value="" style="width: 95%;">
                  </td>
                  <td>
                    <label for="resolucao">Resolução (9–12):</label><br>
                    <input type="number" id="resolucao" min="9" max="12" value="" style="width: 95%;">
                  </td>
                </tr>
                <tr>
                  <td>
                    <label for="ntpServer">Servidor NTP:</label><br>
                    <input type="text" id="ntpServer" value="" style="width: 95%;">
                  </td>
                  <td>
                    <label for="timezone">Fuso horário:</label><br>
                    <input type="text" id="timezone" value="" style="width: 95%;">
                  </td>
                </tr>
                <tr>
                  <td>
                    <label for="ssid">SSID WiFi:</label><br>
                    <select id="ssid" style="width: 95%;"></select>
                  </td>
                  <td>
                    <label for="senha">Senha:</label><br>
                    <input type="password" id="senha" value="" style="width: 95%;">
                  </td>
                </tr>
                <tr>
                  <td>
                    <label for="user_OTA">Usuário OTA:</label><br>
                    <input type="text" id="user_OTA" value="" style="width: 95%;">
                  </td>
                  <td>
                    <label for="pass_OTA">Senha OTA:</label><br>
                    <input type="password" id="pass_OTA" value="" style="width: 95%;">
                  </td>
                </tr>
                <tr>
                  <td colspan="2">
                    <label><input type="checkbox" id="autoRebootOTA"> Reboot após OTA</label>
                  </td>
                </tr>
              </table>
      
              <div style="margin-top: 10px; text-align: center;">
                <button type="button" onclick="salvarAppConfig()">Salvar Configuração</button>
                <button type="button" onclick="cancelarParametros()">Cancelar</button>
                <button type="button" onclick="sincronizarRelogio()">Sincronizar</button>
              </div>
            </form>
          </div>
        </div>
      
        <div id="reles" class="tabcontent">
          <div id="painelControles">
            <!-- Painel de controle manual -->
            <div style="margin-bottom: 20px; border: 1px solid #ccc; border-radius: 10px; padding: 15px; background-color: #fff;">
              <h4>Controle Manual</h4>
              <label for="releManual">Relé:</label>
              <select id="releManual"></select>
      
              <div style="margin-top: 10px;">
                <button onclick="comandoReleManual('ligar')">Ligar</button>
                <button onclick="comandoReleManual('desligar')">Desligar</button>
              </div>
            </div>      
          </div>
          <div id="painelStatus">
            <!-- Painel de status dos relés -->
            <div style="border: 1px solid #ccc; border-radius: 10px; padding: 15px; background-color: #fff;">
              <h4>Status dos Relés</h4>
              <div id="statusReles">
                <!-- Preenchido via WebSocket -->
              </div>
            </div>      
          </div>
        </div>
      
        <script>
          let socket;   
          let ssidPersistido = "";
      
          function conectarSocket() {
            // Antes de abrir um novo socket, sempre verifica se já existe
            if (!socket || socket.readyState === WebSocket.CLOSED) {
                socket = new WebSocket(`ws://${location.hostname}/ws`);
            }
      
            socket.onopen = () => {
              console.log("WebSocket conectado");
              enviarComando({ cmd: "getReles" });
              enviarComando({ cmd: "getSensorList" });
              enviarComando({ cmd: "getReleList" });
              enviarComando({ cmd: "getSSIDList" }); 
              setTimeout(() => enviarComando({ cmd: "getStatusApp" }), 800);     
            };
      
            socket.onmessage = (event) => {
              const msg = JSON.parse(event.data);
      
              if (msg.cmd === "listaReles") {
                preencherSelectRele(msg.reles);
                atualizarStatusReles(msg.status);
              }
              else if (msg.cmd === "statusReles") {
                atualizarStatusReles(msg.status);
              }
              else if (msg.cmd === "sensorList") {
                console.log("Recebido sensorList", msg.lista); // Adicione esta linha 👈
                const sel = document.getElementById("sensorID");
                sel.innerHTML = '<option value="" disabled selected>Selecione...</option>';
                msg.lista.forEach(id => {
                  const opt = document.createElement("option");
                  opt.value = id;
                  opt.text = id;
                  sel.appendChild(opt);
                });
              }
              else if (msg.cmd === "releList") {
                const sel = document.getElementById("releID");
                sel.innerHTML = "";
                msg.lista.forEach(rid => {
                  const opt = document.createElement("option");
                  opt.value = rid;
                  opt.text = rid;
                  sel.appendChild(opt);
                });
              }
              else if (msg.cmd === "sensorConfig") {
                document.getElementById("descricao").value = msg.descricao || "";
                document.getElementById("minTemp").value = msg.minTemp || 0;
                document.getElementById("acaoMin").value = msg.acaoMin || "Ligar";
                document.getElementById("maxTemp").value = msg.maxTemp || 0;
                document.getElementById("acaoMax").value = msg.acaoMax || "Ligar";
                document.getElementById("releID").value = msg.releID || "";
                document.getElementById("habilitado").checked = msg.habilitado || false;
              }
              else if (msg.cmd === "ssidList") {
                const sel = document.getElementById("ssid");
                sel.innerHTML = "";
                msg.lista.forEach(ssid => {
                  const opt = document.createElement("option");
                  opt.value = ssid;
                  opt.text = ssid;
                  sel.appendChild(opt);
                });
                // 🔥 Depois de popular o combobox, tenta selecionar o persistido
                if (ssidPersistido) {
                  sel.value = ssidPersistido;
                }
              }
              else if (msg.cmd === "statusApp") {
                ssidPersistido = msg.ssid || "";
                document.getElementById("varredura").value = msg.varredura || "";
                document.getElementById("modo").value = msg.modo || "AP";
                document.getElementById("alias").value = msg.alias || "";
                document.getElementById("resolucao").value = msg.resolucao || 12;
                document.getElementById("ntpServer").value = msg.ntpServer || "";
                document.getElementById("timezone").value = msg.timezone || "";
                document.getElementById("ssid").value = msg.ssid || "";
                document.getElementById("senha").value = msg.senha || "";
                document.getElementById("user_OTA").value = msg.user_OTA || "";
                document.getElementById("pass_OTA").value = msg.pass_OTA || "";
                document.getElementById("autoRebootOTA").checked = msg.autoRebootOTA || false;
      
                // 🔵 Adiciona aqui para atualizar o SELECT do SSID:
                const selSSID = document.getElementById("ssid");
                if (selSSID) {
                    selSSID.value = ssidPersistido;
                }          
              }
              else if (msg.cmd === "tempUpdate") {
                const painelId = `sensor-${msg.id}`;
                let painel = document.getElementById(painelId);
      
                if (!painel) {
                  painel = document.createElement("div");
                  painel.className = "sensor-card";
                  painel.id = painelId;
      
                  painel.innerHTML = `
                    <div class="sensor-header">
                      <span class="descricao">${msg.descricao}</span>
                      <span class="serial">${msg.serial}</span>
                    </div>
                    <div class="sensor-id">${msg.id}</div>
                    <div class="sensor-temp">--</div>
                    <div class="sensor-footer">
                      <span class="min">Min: --</span>
                      <span class="timestamp">--</span>
                      <span class="max">Max: --</span>
                    </div>
                  `;
      
      
                  document.getElementById("painelMedicoes").appendChild(painel);
                }
      
                // Atualiza temperatura e cor
                const tempEl = painel.querySelector(".sensor-temp");
                tempEl.textContent = `${msg.temp.toFixed(1)}°C`;
      
                if (msg.temp < msg.minTemp) {
                  tempEl.className = "sensor-temp azul";
                } else if (msg.temp > msg.maxTemp) {
                  tempEl.className = "sensor-temp vermelho";
                } else {
                  tempEl.className = "sensor-temp verde";
                }
      
                // Atualiza timestamp
                const min = (typeof msg.minTemp === "number") ? msg.minTemp : 0;
                const max = (typeof msg.maxTemp === "number") ? msg.maxTemp : 0;
      
                painel.querySelector(".min").textContent = `Min: ${min.toFixed(1)}°C`;
                painel.querySelector(".max").textContent = `Max: ${max.toFixed(1)}°C`;
      
                const data = new Date();
                const stamp = `${data.toLocaleDateString("pt-BR")} ${data.toLocaleTimeString("pt-BR")}`;
                painel.querySelector(".timestamp").textContent = stamp;
      
              }
              else if (msg.cmd === "saveSensorAck") {
                console.log(`Sensor ${msg.id} salvo com sucesso!`);
                // ✅ Mensagem visual simples
                alert(`Sensor ${msg.id} salvo com sucesso!`);
              }
              else if (msg.cmd === "saveAppAck") {
                console.log("Configuração da Aplicação salva com sucesso!");
                // ✅ Mensagem visual simples
                alert("Configuração da Aplicação salva com sucesso!");
              }
            };
      
            socket.onclose = () => {
              console.warn(`WebSocket fechado. Código: ${event.code}, Motivo: ${event.reason}`);
              setTimeout(conectarSocket, 2000); // reconecta com delay
            };
      
            socket.onerror = (err) => {
              console.error("Erro no WebSocket:", err);
              socket.close();
            };
      
          };
      
          window.onload = () => {
            conectarSocket();
          }; 
      
          function preencherSelectRele(lista) {
            const sel = document.getElementById("releManual");
            sel.innerHTML = "";
            lista.forEach((item, i) => {
              const opt = document.createElement("option");
              opt.value = item.id;
              opt.text = `Relé #${item.id}`;
              sel.appendChild(opt);
            });
          }
      
          function atualizarStatusReles(statusArray) {
            const painel = document.getElementById("statusReles");
            painel.innerHTML = "";
            statusArray.forEach(st => {
              const p = document.createElement("p");
              p.textContent = `Relé #${st.id}: ${st.estado ? "🟢 Ligado" : "🔴 Desligado"}`;
              painel.appendChild(p);
            });
          }
      
          function comandoReleManual(acao) {
            const id = document.getElementById("releManual").value;
            enviarComando({
              cmd: "releManual",
              id: parseInt(id),
              acao: acao
            });
          }
      
          function openTab(tabName) {
            const contents = document.querySelectorAll(".tabcontent");
            const buttons = document.querySelectorAll("nav button");
      
            contents.forEach(div => div.classList.remove("active"));
            buttons.forEach(btn => btn.classList.remove("active"));
      
            document.getElementById(tabName).classList.add("active");
            event.target.classList.add("active");
      
            if (tabName === "parametros") {
              enviarComando({ cmd: "getSensorList" });
      
              setTimeout(() => {
                enviarComando({ cmd: "getReleList" });
              }, 100);  // 100ms depois
      
              setTimeout(() => {
                enviarComando({ cmd: "getSSIDList" });
              }, 200);  // 200ms depois
            }
      
          }
      
          function salvarSensorConfig() {
            const msg = {
              cmd: "saveSensor",
              id: document.getElementById("sensorID").value,
              descricao: document.getElementById("descricao").value,
              minTemp: parseFloat(document.getElementById("minTemp").value),
              acaoMin: document.getElementById("acaoMin").value,
              maxTemp: parseFloat(document.getElementById("maxTemp").value),
              acaoMax: document.getElementById("acaoMax").value,
              releID: parseInt(document.getElementById("releID").value.replace("#", "")),
              habilitado: document.getElementById("habilitado").checked
            };
            enviarComando(msg);
          }
      
          function salvarAppConfig() {
            const msg = {
              cmd: "saveApp",
              varredura: parseInt(document.getElementById("varredura").value),
              modo: document.getElementById("modo").value,
              alias: document.getElementById("alias").value,
              resolucao: parseInt(document.getElementById("resolucao").value),
              ntpServer: document.getElementById("ntpServer").value,
              timezone: document.getElementById("timezone").value,
              ssid: document.getElementById("ssid").value,
              senha: document.getElementById("senha").value,
              user_OTA: document.getElementById("user_OTA").value,
              pass_OTA: document.getElementById("pass_OTA").value,
              autoRebootOTA: document.getElementById("autoRebootOTA").checked
            };
            enviarComando(msg);
          }
      
          function carregarSensorConfig() {
            const id = document.getElementById("sensorID").value;
            if (id === "") return; // não faz nada se ainda não selecionou
            enviarComando({ cmd: "getSensor", id: id });
          }
      
          function cancelarParametros() {
            enviarComando({ cmd: "getStatusApp" });
          }
      
          function sincronizarRelogio() {
            const agora = new Date();
            const timestamp = Math.floor(agora.getTime() / 1000); // segundos desde 1970
            enviarComando({
              cmd: "sincronizar",
              timestamp: timestamp
            });
          }
      
          function criarPainelSensor(dado) {
            const card = document.createElement("div");
      
            const timestamp = new Date().toLocaleString("pt-BR", {
              day: "2-digit", month: "2-digit", year: "numeric",
              hour: "2-digit", minute: "2-digit", second: "2-digit"
            });
      
            // Determina a classe de temperatura e se está em alarme
            let tempClass = "verde";  // padrão
            let cardClass = "";
            let alerta = "";
      
            if (dado.temperatura < dado.minTemp) {
              tempClass = "azul";
              cardClass = "alarme-baixo";
              alerta = `<div class="sensor-alerta">⚠️ Temperatura abaixo do mínimo!</div>`;
            } else if (dado.temperatura > dado.maxTemp) {
              tempClass = "vermelho";
              cardClass = "alarme-alto";
              alerta = `<div class="sensor-alerta">⚠️ Temperatura acima do máximo!</div>`;
            }
      
            // Agora sim, aplica as classes ao card
            card.classList.add("sensor-card");
            if (cardClass) {
              card.classList.add(cardClass);
            }
      
            // Conteúdo do painel do sensor
            card.innerHTML = `
              ${alerta}
              <div class="sensor-header">
                <span class="sensor-descricao">${dado.descricao}</span>
                <span class="sensor-serial">${dado.serial}</span>
              </div>
              <div class="sensor-id">${dado.id}</div>
              <div class="sensor-temp ${tempClass}">${dado.temperatura.toFixed(1)}°C</div>
              <div class="sensor-footer">
                <span class="min">Min: ${dado.minTemp.toFixed(1)}°C</span>
                <span class="timestamp">${timestamp}</span>
                <span class="max">Max: ${dado.maxTemp.toFixed(1)}°C</span>
              </div>
            `;
      
            return card;
          }
         
          function enviarComando(obj) {
            if (socket && socket.readyState === WebSocket.OPEN) {
              socket.send(JSON.stringify(obj));
            } else {
              console.warn("WebSocket não está pronto. Ignorando comando:", obj);
            }
          }
      
          function mostrarSpinner() {
            const painelMedicoes = document.getElementById("painelMedicoes");
            painelMedicoes.innerHTML = '<p>Carregando...</p>';
          }
      
          function ocultarSpinner() {
            const painelMedicoes = document.getElementById("painelMedicoes");
            painelMedicoes.innerHTML = '';
          }
      
          // Evento para fechamento da janela do Navegador
          window.addEventListener("unload", function() {
              if (socket) {
                  socket.close();
              }
          });    
      
          document.addEventListener('visibilitychange', () => {
              if (document.visibilityState === 'visible') {
                  if (!socket || socket.readyState !== WebSocket.OPEN) {
                      conectarSocket(); 
                  }
              }
          });        
        </script>
      
      </body>
      </html>
      
      )rawliteral";  
      
      //--------------------------------------------
      // Rotina de Inicialização Geral da Aplicação
      //--------------------------------------------
      
      void setup() 
      {
        // Inicializa a Serial
      
        Serial.begin(115200);
        while (!Serial);
      
        // Hello na Serial
      
        Serial.println("Termostato V1.0 Abr/2025");  
        Serial.flush();
      
      #if defined(ARDUINO_LOLIN32)               
        // I2C no ESP32 Wemos Lolin32 usa SDA = 5 e SCL = 4 para o display OLED
        Wire.begin(5, 4);
      #endif
      
        // Inicializa com o I2C addr 0x3C
      
        display.begin(SSD1306_SWITCHCAPVCC, 0x3C); 
        display.clearDisplay();     
      
        // Inicializa os sensores
      
        setupSensores();
      
        // Carrega as Configurações se existerem
      
        carregarConfig();
      
        // Inicializa a Rede Wifi ou AP Mode
      
        setupWiFi();
      
        // Aplicar os parâmetros persistidos
      
        aplicarParametros();      
      
        // Inicializa o Modo Web Server
      
        setupServer();
      
        // Define os pinos dos Relés
      
        pinMode(pinRELE1, OUTPUT);
        pinMode(pinRELE2, OUTPUT);
      
        // Estado inicial dos Relés: desligados
      
        digitalWrite(pinRELE1,RELE_OFF);
        digitalWrite(pinRELE2,RELE_OFF);
      
        // Mostra Consumo de Memória 
      
        printFreeRAM("Setup...");
      
      }
      
      //--------------------------------------------
      // Rotina de Loop Principal da Aplicação
      //--------------------------------------------
      
      void loop() 
      {
         // Verifica se deve fazer uma varredura dos sensores
      
         if (nDevices > 0 && (millis() - lastScan) > appConfig.varredura)
         {
            lastScan = millis();
            processaSensores();
            leituraPronta = true;
         }
      
         // Verifica se deve mostrar a temperatura no Display
      
         if (nDevices > 0 && leituraPronta && (millis() - tempoUltimaExibicao) >= tempoExibicao) 
         {
            tempoUltimaExibicao = millis(); 
            displayTemperatura(sensorIndex); 
            sensorIndex = (sensorIndex+1) % nDevices;  
            if (sensorIndex==0) leituraPronta = false; 
         }   
      
         // Preparação para o SCAN da rede WiFi assíncrono para não comprometer o AsyncWebServer
      
         if (iniciarScanSSID && !scanEmAndamento) 
         {
            resultadoScan = WiFi.scanNetworks(true); // ⚠️ Modo assíncrono!
            scanEmAndamento = true;
            iniciarScanSSID = false;
            tempoScanInicio = millis();
         }
      
         // Verifica se terminou o SCAN assíncrono
      
         if (scanEmAndamento && WiFi.scanComplete() >= 0) 
         {
            DynamicJsonDocument doc(1024);
            doc["cmd"] = "ssidList";
            JsonArray lista = doc.createNestedArray("lista");
      
            for (int i = 0; i < WiFi.scanComplete(); i++) 
            {
               lista.add(WiFi.SSID(i));
            } 
      
            String out;
            serializeJson(doc, out);
            if (clienteScanSSID) clienteScanSSID->text(out);
            WiFi.scanDelete();
            scanEmAndamento = false;
            clienteScanSSID = nullptr;
         }
      
         // 🔴 Se passar muito tempo esperando e nada acontecer, cancela
      
         if (scanEmAndamento && millis() - tempoScanInicio > INTERVALO_SCAN) 
         {
            Serial.println("[ERRO] Timeout no scan de SSID");
            WiFi.scanDelete();
            scanEmAndamento = false;
            clienteScanSSID = nullptr;
         }  
      
         // Verifica se deve fazer o Cleanup
      
         if (millis() - lastCleanup > INTERVALO_CLEANUP) 
         {
            ws.cleanupClients();
            lastCleanup = millis();
            printFreeRAM("Pós Cleanup...");  
         }
      
         // Verifica o OTA para saber se há atualização
      
         ElegantOTA.loop();       
      
      }
      
      //-------------------------------------------------
      // Rotina para carregar as configurações do SPIFFS
      //-------------------------------------------------
      
      bool carregarConfig() 
      {
        Serial.println("Tentando carregar o Config do SPIFFS...");
        if (!SPIFFS.begin(true)) 
        {
           Serial.println("Não foi possível incializar o SPIFFS...");
           return false;
        }
      
        //deleteFile(CONFIG_PATH);    // Deleta o arquivo de configuração se necessário
      
        if (!SPIFFS.exists(CONFIG_PATH)) 
        {
          Serial.println("Config não existe. Adotando default...");
          // Valores padrão se não existir o arquivo
          appConfig.modo = "AP";
          appConfig.alias = "termostato";
          appConfig.ssid = "";
          appConfig.senha = "";
          appConfig.varredura = 5000;
          appConfig.ntpServer = "pool.ntp.org";
          appConfig.timezone = "<-03>3";
          appConfig.resolucao = 12;   
          appConfig.user_OTA = "admin";
          appConfig.pass_OTA = "admin";
          appConfig.autoRebootOTA = true;
      
          for (int i=1;i<=nDevices;i++)
          {
              SensorConfig s;
              String aux = String(i);
              s.idSensor = "#" + aux;
              s.descricao = "Sensor " + aux;
              s.minTemp = 20.0;
              s.acaoMin = "Ligar";
              s.maxTemp = 30.0;
              s.acaoMax = "Desligar";
              s.releID = 1;
              s.habilitado = true;
              appConfig.sensores.push_back(s);
          }
          salvarConfig();
          return false;
        }
      
        Serial.println("Lendo o Config existente...");
        File file = SPIFFS.open(CONFIG_PATH);
        if (!file) 
        {
          Serial.println("Erro a abrir o Config...");
          return false;
        }
      
        JsonDocument doc;
        DeserializationError error = deserializeJson(doc, file);
        serializeJsonPretty(doc, Serial);
        Serial.println();
        Serial.flush();
        file.close();
        if (error) return false;
      
        // Corrigido: usa | diretamente no JsonVariant, sem cast
        appConfig.modo      = doc["modo"]        | "AP";
        appConfig.alias     = doc["alias"]       | "termostato";
        appConfig.ssid      = doc["ssid"]        | "";
        appConfig.senha     = doc["senha"]       | "";
        appConfig.varredura = doc["varredura"]   | 5000;
        appConfig.ntpServer = doc["ntpServer"]   | "pool.ntp.org";
        appConfig.timezone  = doc["timezone"]    | "<-03>3";
        appConfig.resolucao = doc["resolucao"]   | 12;
        appConfig.user_OTA = doc["user_OTA"] | "admin";
        appConfig.pass_OTA = doc["pass_OTA"] | "admin";
        appConfig.autoRebootOTA = doc["autoRebootOTA"] | false;
      
        // Sensores
        appConfig.sensores.clear();
        JsonArray sensores = doc["sensores"].as<JsonArray>();
        for (JsonObject s : sensores) {
          SensorConfig sc;
          sc.idSensor  = s["id"]        | "";
          sc.descricao = s["desc"]      | "";
          sc.minTemp   = s["min"]       | 0.0;
          sc.acaoMin   = s["acaoMin"]   | "";
          sc.maxTemp   = s["max"]       | 0.0;
          sc.acaoMax   = s["acaoMax"]   | "";
          sc.releID    = s["rele"]      | 0;
          sc.habilitado= s["hab"]       | false;
          appConfig.sensores.push_back(sc);
        }
      
        return true;
      }
      
      //------------------------------------------------
      // Rotina para configurar o Modo AP ou o Modo WiFi
      //------------------------------------------------
      
      void setupWiFi() 
      {
        if (appConfig.modo == "WiFi" && appConfig.ssid.length()) {
          WiFi.begin(appConfig.ssid.c_str(), appConfig.senha.c_str());
          Serial.print("Conectando ao WiFi");
          unsigned long start = millis();
          while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) {
            delay(500); Serial.print(".");
          }
          if (WiFi.status() == WL_CONNECTED) {
            Serial.println("\nWiFi conectado: " + WiFi.localIP().toString());
      
            // Imprime o MAC
        
            Serial.print("MAC: ");
            Serial.println(WiFi.macAddress());
        
            // Imprime o Sinal Wifi
        
            Serial.print("Sinal: ");
            Serial.print(WiFi.RSSI());
            Serial.println(" db");  
        
            // 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");
            }
        
            // 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());
        
            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");
            } 
            else 
            {
               Serial.println("\nTimer interno não foi sincronizado");
               //ESP.restart();
            }
      
          } else {
            Serial.println("\nErro no WiFi, entrando em modo AP...");
            appConfig.modo = "AP";
          }
        }
      
        if (appConfig.modo == "AP") {
          WiFi.softAP(appConfig.alias.c_str());
          Serial.println("Modo AP iniciado: " + WiFi.softAPIP().toString());
        }
      
      }
      
      //--------------------------------------------
      // Rotina para tratar os eventos de WebSocket
      //--------------------------------------------
      
      void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client,
                     AwsEventType type, void *arg, uint8_t *data, size_t len) 
      {
        uint32_t clientId = client->id();
        if (type == WS_EVT_CONNECT) 
        {
          Serial.printf("Cliente conectado: %u\n", clientId);
        }
        else if (type == WS_EVT_DISCONNECT) {
          Serial.printf("Cliente desconectado: %u\n", clientId);
          lastCleanup = 0;
        }  
        else if (type == WS_EVT_DATA) 
        {
          // A processar comandos...
          AwsFrameInfo *info = (AwsFrameInfo*)arg;
          if (info->opcode == WS_TEXT) {
            data[len] = 0;
            DynamicJsonDocument doc(512);
            deserializeJson(doc, data);
      
            String cmd = doc["cmd"];
            if (cmd == "getReles") {
              Serial.printf("Recebido: %s\n",cmd);
              DynamicJsonDocument resp(512);
              resp["cmd"] = "listaReles";
      
              JsonArray rel = resp.createNestedArray("reles");
              JsonArray sts = resp.createNestedArray("status");
      
              for (int i = 0; i < 2; i++) {
                JsonObject r = rel.createNestedObject();
                r["id"] = i+1;
      
                JsonObject s = sts.createNestedObject();
                s["id"] = i+1;
                s["estado"] = !digitalRead(pinReles[i]);
              }
      
              String out;
              serializeJson(resp, out);
              client->text(out);
            }
            else if (cmd == "releManual") 
            {
              Serial.printf("Recebido: %s\n",cmd);
              int id = doc["id"];
              String acao = doc["acao"];
              alterarEstadoRele(id-1, (acao == "ligar") ? true : false);
            }
            else if (cmd == "getSensor") {
              Serial.printf("Recebido: %s\n",cmd);
              if (!doc.containsKey("id")) {
                Serial.println("[ERRO] getSensor sem campo 'id'");
                return;
              }
              String id = doc["id"];
              for (auto &sc : appConfig.sensores) {
                if (sc.idSensor == id) {
                  DynamicJsonDocument resp(512);
                  resp["cmd"] = "sensorConfig";
                  resp["descricao"] = sc.descricao;
                  resp["minTemp"] = sc.minTemp;
                  resp["acaoMin"] = sc.acaoMin;
                  resp["maxTemp"] = sc.maxTemp;
                  resp["acaoMax"] = sc.acaoMax;
                  resp["releID"] = "#" + String(sc.releID);
                  resp["habilitado"] = sc.habilitado;
                  String out;
                  serializeJson(resp, out);
                  client->text(out);
                  break;
                }
              }
            }
            else if (cmd == "saveSensor") {
              Serial.printf("Recebido: %s\n", cmd);
              bool encontrado = false;
              String id = doc["id"];
              for (auto &sc : appConfig.sensores) {
                if (sc.idSensor == id) {
                  sc.descricao = doc["descricao"] | "";
                  sc.minTemp = doc["minTemp"] | 0;
                  sc.acaoMin = doc["acaoMin"] | "Ligar";
                  sc.maxTemp = doc["maxTemp"] | 0;
                  sc.acaoMax = doc["acaoMax"] | "Ligar";
                  sc.releID = doc["releID"] | 1;
                  sc.habilitado = doc["habilitado"] | false;
                  encontrado = true;
                  break;
                }
              }
      
              // Se não encontrado, adiciona como novo
              if (!encontrado) {
                SensorConfig novo;
                novo.idSensor = id;
                novo.descricao = doc["descricao"] | "";
                novo.minTemp = doc["minTemp"] | 0;
                novo.acaoMin = doc["acaoMin"] | "Ligar";
                novo.maxTemp = doc["maxTemp"] | 0;
                novo.acaoMax = doc["acaoMax"] | "Ligar";
                novo.releID = doc["releID"] | 1;
                novo.habilitado = doc["habilitado"] | false;
                appConfig.sensores.push_back(novo);
              }
      
              salvarConfig();  // Salva no SPIFFS
              aplicarParametros(); // Atualiza alarmes/resoluções se necessário
      
              // 🔥 Responde com ACK para o navegador
              DynamicJsonDocument ack(128);
              ack["cmd"] = "saveSensorAck";
              ack["id"] = id;
              String out;
              serializeJson(ack, out);
              client->text(out);
      
              Serial.println("[INFO] Sensor salvo e ACK enviado");
            }
      
            else if (cmd == "saveApp") {
              Serial.printf("Recebido: %s\n",cmd);
              appConfig.varredura = doc["varredura"] | 5000;
              appConfig.modo = doc["modo"] | "AP";
              appConfig.alias = doc["alias"] | "termostato";
              appConfig.resolucao = doc["resolucao"] | 12;
              appConfig.ntpServer = doc["ntpServer"] | "pool.ntp.org";
              appConfig.timezone = doc["timezone"] | "<-03>3";
              appConfig.ssid = doc["ssid"] | "";
              appConfig.senha = doc["senha"] | "";
              appConfig.user_OTA = doc["user_OTA"] | "admin";
              appConfig.pass_OTA = doc["pass_OTA"] | "admin";
              appConfig.autoRebootOTA = doc["autoRebootOTA"] | true;
      
              salvarConfig(); // salva no SPIFFS
              // ✅ Atualiza configuração de horário
              configTzTime(appConfig.timezone.c_str(), appConfig.ntpServer.c_str());
      
              // 🔥 Envia ACK
              DynamicJsonDocument ack(128);
              ack["cmd"] = "saveAppAck";
              String out;
              serializeJson(ack, out);
              client->text(out);
      
              Serial.println("[INFO] Configuração da Aplicação salva e ACK enviado");        
            }
            else if (cmd == "getSSIDList") {
              Serial.println("Recebido: getSSIDList");
              if (!scanEmAndamento) {
                iniciarScanSSID = true;
                clienteScanSSID = client;  // salva o ponteiro
              }
            }
            else if (cmd == "getSensorList") {
              Serial.printf("Recebido: %s\n", cmd);
              DynamicJsonDocument doc(512);
              doc["cmd"] = "sensorList";
              JsonArray lista = doc.createNestedArray("lista");
      
              // Adiciona todos os sensores detectados no barramento
              for (int i = 0; i < nDevices; i++) {
                String id = "#" + String(i + 1);
                lista.add(id);
              }
      
              String out;
              serializeJson(doc, out);
              client->text(out);
            }
            else if (cmd == "getReleList") {
              Serial.printf("Recebido: %s\n",cmd);
              DynamicJsonDocument doc(512);
              doc["cmd"] = "releList";
              JsonArray lista = doc.createNestedArray("lista");
              for (int i = 1; i <= MAX_RELES; i++) {
                lista.add("#" + String(i));
              }
              String out;
              serializeJson(doc, out);
              client->text(out);
            }  
            else if (cmd == "getStatusApp") {
              Serial.printf("Recebido: %s\n", cmd);
              DynamicJsonDocument resp(512);
              resp["cmd"] = "statusApp";
              resp["varredura"] = appConfig.varredura;
              resp["modo"] = appConfig.modo;
              resp["alias"] = appConfig.alias;
              resp["resolucao"] = appConfig.resolucao;
              resp["ntpServer"] = appConfig.ntpServer;
              resp["timezone"] = appConfig.timezone;
              resp["ssid"] = appConfig.ssid;
              resp["senha"] = appConfig.senha;
              // ⚠️ Campos adiconados posteriormente
              resp["user_OTA"] = appConfig.user_OTA;
              resp["pass_OTA"] = appConfig.pass_OTA;
              resp["autoRebootOTA"] = appConfig.autoRebootOTA;
      
              String out;
              serializeJson(resp, out);
              client->text(out);
            }
            else if (cmd == "sincronizar") {
              time_t ts = doc["timestamp"] | 0;
              struct timeval now = { ts, 0 };
              settimeofday(&now, nullptr);
              Serial.print("Horário atualizado: ");
              Serial.println(ctime(&ts));
            }
          }    
        }
      }
      
      //------------------------------------------
      // Rotina para inicializar o AsyncWebServer
      //------------------------------------------
      
      void setupServer() 
      {
        ws.onEvent(onWsEvent);
        server.addHandler(&ws);
      
        server.on("/", HTTP_GET, [](AsyncWebServerRequest *req) {
          req->send(200, "text/html", index_html);
        });
      
        server.begin();
      
        ElegantOTA.begin(&server, appConfig.user_OTA.c_str(), appConfig.pass_OTA.c_str());
        ElegantOTA.setAutoReboot(appConfig.autoRebootOTA);  
      }
      
      //-------------------------------------------------------
      // 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;
      }
      
      //-----------------------------------------------------------------------------
      // Rotina para inicializar os sensores e determinar quantos estão no barramento
      //-----------------------------------------------------------------------------
      
      void setupSensores()
      {
        // Inicia o sensor de temperatura
        sensors.begin();  
      
        // Mostra o GPIO do Sensor
        Serial.print("\nGPIO usado: ");
        Serial.println(pinBUS);
      
        // Localizando os sensores
        nDevices = sensors.getDeviceCount();
        Serial.print("N. de Dispositivos encontrados: ");
        Serial.println(nDevices);
      
        if (nDevices > 0)
        {
           Serial.println("Dispositivo\tEndereço");
           Serial.println("-----------------------------------");
      
           // Loop through each device, print out address
           for (int i = 0; i < nDevices; i++)
           {
             // Obtém o endereço do dispositivo
             if (sensors.getAddress(deviceAddr, i))
             {
                // Mostra na Serial
                Serial.print("     ");
                Serial.print(i+1, DEC);
                Serial.print("\t     ");
                printAddress(deviceAddr);
                Serial.println();
                // Programa o alarme de temperatura mínima e máxima
                //sensors.setLowAlarmTemp(deviceAddr, MIN_TEMP);
                //sensors.setHighAlarmTemp(deviceAddr, MAX_TEMP);          
             }
             else 
             {
                Serial.print("     ");
                Serial.print(i, DEC);
                Serial.println("\t     Endereço não reconhecido");
             }
          }     
        }
        else
        {
           Serial.println("Verifique o hardware e reinicie...");
           showError();
        }   
      }
      
      //----------------------------------------------
      // Rotina para fazer as leituras de temperaturas
      // e notificar a interface html
      //----------------------------------------------
      
      void processaSensores()
      {
        // Manda comando para ler temperaturas
        sensors.requestTemperatures(); 
      
        // Verifica ocorrência de alarme
        sensors.processAlarms();  
      
        // Faz o loop nos dispositivos
        for (int i=0; i<nDevices; i++)
        {
           // Obtém a temperatura em Celsius
      
           temperaturas[i] = sensors.getTempCByIndex(i);
      
           Serial.print("Temperatura[#");
           Serial.print(i+1);
           Serial.print("]: ");
           Serial.print(temperaturas[i]);
           Serial.println(" graus");
      
           if (ws.count() > 0) {
              DynamicJsonDocument doc(256);
              doc["cmd"] = "tempUpdate";
              doc["id"] = "#" + String(i + 1);  // Ex: #1, #2
      
              // Recupera o endereço do sensor e converte para  string
              if (sensors.getAddress(deviceAddr, i)) 
              {
                char serial[17] = {0};
                for (uint8_t j = 0; j < 8; j++) {
                  sprintf(&serial[j * 2], "%02X", deviceAddr[j]);
                }
                doc["serial"] = serial;
              } 
              else 
              {
                doc["serial"] = "Desconhecido";
              }
      
              doc["temp"] = temperaturas[i];
      
              // Procura limites definidos para esse sensor
              String id = "#" + String(i + 1);
              for (auto &sc : appConfig.sensores) 
              {
                if (sc.idSensor == id) 
                {
                  doc["minTemp"] = sc.minTemp;
                  doc["maxTemp"] = sc.maxTemp;
                  doc["descricao"] = sc.descricao;
                  break;
                }
              }
      
              String out;
              serializeJson(doc, out);
              ws.textAll(out);  // Envia para todos os clientes
            }
        }
         
      }
      
      //---------------------------------------------
      // Imprime o Endereço do Dispositico na Serial
      //---------------------------------------------
      
      void printAddress(const DeviceAddress deviceAddress)
      {
        for (uint8_t i = 0; i < 8; i++)
        {
          if (deviceAddress[i] < 16) Serial.print("0");
          Serial.print(deviceAddress[i], HEX);
        }
      }
      
      //-------------------------------------------------
      // Tratamento de Alarme de temperatura dos sensores
      //-------------------------------------------------
      
      void newAlarmHandler(const uint8_t* deviceAddress) 
      {
        int index = getDeviceIndex(deviceAddress);
        if (index < 0 || index >= appConfig.sensores.size()) return;
      
        SensorConfig sc = appConfig.sensores[index];
        if (!sc.habilitado) return;
      
        float temp = sensors.getTempC(deviceAddress);
        float low = (float)sensors.getLowAlarmTemp(deviceAddress);
        float high = (float)sensors.getHighAlarmTemp(deviceAddress);
      
        int releIdx = sc.releID - 1;
        if (releIdx < 0 || releIdx >= MAX_RELES) return;
      
        Serial.printf("[ALARM] %s: %.1f°C (limites: %.1f–%.1f)\n", 
                      sc.idSensor.c_str(), temp, low, high);
      
        if (temp < low) 
        {
          Serial.printf("→ Abaixo do limite. Ação MIN: %s\n", sc.acaoMin.c_str());
          alterarEstadoRele(releIdx, (sc.acaoMin == "Ligar") ? true : false);
        }
        else if (temp > high) 
        {
          Serial.printf("→ Acima do limite. Ação MAX: %s\n", sc.acaoMax.c_str());
          alterarEstadoRele(releIdx, (sc.acaoMax == "Ligar") ? true : false);
        }
      }
      
      //-----------------------------------------
      // Mostra a corrente temperatura na Console
      //-----------------------------------------
      
      void printCurrentTemp(DeviceAddress deviceAddress)
      {
        printAddress(deviceAddress);
        printTemp(deviceAddress);
        Serial.println();
      }
      
      //-----------------------------------------
      // Mostra a corrente temperatura na Console
      //-----------------------------------------
      
      void printTemp(const DeviceAddress deviceAddress)
      {
        float tempC = sensors.getTempC(deviceAddress);
        if (tempC != DEVICE_DISCONNECTED_C)
        {
          Serial.print("Temp Atual: ");
          Serial.print(tempC);
          Serial.print("C");
        }
        else Serial.print("DEVICE DISCONNECTED");
        Serial.print(" ");
      }
      
      //-----------------------------------------
      // Mostra as informações do Sensor
      //-----------------------------------------
      
      void printAlarmInfo(const DeviceAddress deviceAddress)
      {
        char temp;
        printAddress(deviceAddress);
        Serial.print(" ");
        Serial.print("Limite Inferior: ");
        temp = sensors.getLowAlarmTemp(deviceAddress);
        Serial.print(temp, DEC);
        Serial.print("C");  
        temp = sensors.getHighAlarmTemp(deviceAddress);
        Serial.print(" Limite Superior: ");
        Serial.print(temp, DEC);
        Serial.print("C");
        Serial.print(" ");
      }
      
      //--------------------------------------------
      // Obtém o índice do sensor a partir do Serial
      //--------------------------------------------
      
      int getDeviceIndex(const DeviceAddress deviceAddress)
      {
         DeviceAddress device; 
      
         for (int i=0; i < nDevices; i++)
         {
            if (sensors.getAddress(device, i) && memcmp(device,deviceAddress,sizeof(DeviceAddress))==0) return i;   
         }
         return -1;
      }
      
      //-------------------------------------
      // Centraliza um texto no Dispplay OLED
      //-------------------------------------
      
      void centerText(String text, int textSize)
      {
          // 6 pixel x tamanho da fonte = ocupação em pixel de um char
          int charWidth = 6 * textSize;  
          // Limpa o display
          display.clearDisplay();  
          // Define as características do texto 
          display.setTextColor(WHITE);
          display.setTextSize(textSize); // Tamanho da fonte para o texto principal
          int txtWidth = charWidth * text.length();
          // Posiciona o cursor  
          display.setCursor((128-txtWidth)/2, (64-charWidth)/2); // Para Display de 128x64
          // Mostra o Texto
          display.print(text);
          display.display();
      }
      
      //---------------------------------------------------------
      // Mostra Erro quando não encontrou sensores no barramento
      //---------------------------------------------------------
      
      void showError()
      {
          // Limpa o display
          display.clearDisplay();  
          // Define as características do texto 
          display.setTextColor(WHITE);
          display.setTextSize(1); 
          // Posiciona o cursor  
          display.setCursor(0,20);
          // Mostra o Texto
          display.println("Nenhum Dispositivo");
          display.println("encontrado..."); 
          display.println("Check o Hardware");
          display.println("e Reinicie...");   
          display.display();
          while (true)
          {
            display.startscrollright(0x00, 0x0f);
            delay(7000);
            display.stopscroll();
            delay(1000);
            display.startscrollleft(0x00, 0x0f); // Movimenta texto para a esquerda
            delay(7000);
            display.stopscroll();
            delay(1000);  
          }
      }
      
      //----------------------------------------------
      // Rotina para persistir os parâmetros no SPIFFS
      //-----------------------------------------------
      
      void salvarConfig() 
      {
        Serial.println("Salvando a configuração...");
        JsonDocument doc;
      
        doc["modo"] = appConfig.modo;
        doc["alias"] = appConfig.alias;
        doc["ssid"] = appConfig.ssid;
        doc["senha"] = appConfig.senha;
        doc["varredura"] = appConfig.varredura;
        doc["ntpServer"] = appConfig.ntpServer;
        doc["timezone"] = appConfig.timezone;
        doc["resolucao"] = appConfig.resolucao;
        doc["user_OTA"] = appConfig.user_OTA;
        doc["pass_OTA"] = appConfig.pass_OTA;
        doc["autoRebootOTA"] = appConfig.autoRebootOTA;
      
        JsonArray sensores = doc.createNestedArray("sensores");
        for (auto &sc : appConfig.sensores) {
          JsonObject s = sensores.createNestedObject();
          s["id"] = sc.idSensor;
          s["desc"] = sc.descricao;
          s["min"] = sc.minTemp;
          s["acaoMin"] = sc.acaoMin;
          s["max"] = sc.maxTemp;
          s["acaoMax"] = sc.acaoMax;
          s["rele"] = sc.releID;
          s["hab"] = sc.habilitado;
        }
      
        File file = SPIFFS.open(CONFIG_PATH, FILE_WRITE);
        if (file) {
          serializeJsonPretty(doc, file);
          serializeJsonPretty(doc, Serial);
          Serial.println();
          Serial.flush();
          file.close();
          Serial.println("Configuração salva no SPIFFS");
          aplicarParametros();
        }
      }
      
      //----------------------------------------------
      // Função para delatar arquivos do SPIFFS
      // Útil para resetar estados iniciais
      //----------------------------------------------
      
      void deleteFile(const char* path) 
      {
          // Verifica se o arquivo existe
          if (SPIFFS.exists(path)) 
          { 
              if (SPIFFS.remove(path)) 
              {
                  Serial.printf("Arquivo %s deletado com sucesso\n", path);
              } 
              else 
              {
                  Serial.printf("Falha ao deletar o arquivo %s\n", path);
              }
          } 
          else 
          {
              Serial.printf("Arquivo %s não encontrado\n", path);
          }
      }
      
      //--------------------------------------------------
      // Rotina para mostrar a temperatura no Display OLED
      //--------------------------------------------------
      
      void displayTemperatura(int index)
      {
      
        int auxTemp = round(temperaturas[index]);
        String txtTemp = String(auxTemp);
      
        // Limpa o display
        display.clearDisplay();
      
        // Configure os tamanhos e posições iniciais
      
        int txtWidth = (128 / 7) * (txtTemp.length() + 1.5); // Em 128 bits cabem cerca de 7 chars
                                                             // 1 para C e 0.5 para símbolo Centígrados
        display.setTextColor(WHITE);
        display.setTextSize(3); // Tamanho da fonte para o texto principal  
      
        String txtID = "#" + String(index+1);
        int idWidth = (128 / 7) * txtID.length();
        display.setCursor((128-idWidth)/2, 0);
        display.print(txtID);
      
        display.setCursor((128-txtWidth)/2, 30); // Posição inicial da Temperatura
        display.print(txtTemp);
      
        // Calcula a posição para o caractere "°"
        display.setCursor((128/7)*(txtTemp.length()+1.5)+4, 26); // Posiciona o caractere "°"
        display.setTextSize(2); // Tamanho menor para o símbolo "°"
        display.write(9); // Código ASCII para o símbolo "°"
      
        // Exibe o "C" após o símbolo "°"
        display.setTextSize(3); // Retorna ao tamanho original para "C"
        display.setCursor((128/7)*(txtTemp.length()+2)+2, 30);
        display.print("C");
      
        display.display();
      
      }
      
      //-----------------------------------------------------------
      // Rotina para aplicar os parãmetros redefinidos na interface
      //-----------------------------------------------------------
      
      void aplicarParametros() 
      {
        Serial.println("[INFO] Aplicando parâmetros...");
      
        // Reseta os alarmes antes de reprogramar
      
        //sensors.resetAlarmSearch(); 
        sensors.setLowAlarmTemp(deviceAddr, 0);
        sensors.setHighAlarmTemp(deviceAddr, 0);    
      
        // ✅ Resolução global
        sensors.setResolution(appConfig.resolucao);
      
        // Mostra a Resolução adotada
        Serial.print("Resolução: ");
        Serial.println(sensors.getResolution());    
      
        // ✅ Configuração individual de sensores
        for (auto &sc : appConfig.sensores) {
          int index = sc.idSensor.substring(1).toInt() - 1;
      
          if (index >= 0 && index < nDevices && sensors.getAddress(deviceAddr, index)) {
            //sensors.setHighAlarmTemp(deviceAddr, (char)round(sc.maxTemp));
            //sensors.setLowAlarmTemp(deviceAddr, (char)round(sc.minTemp));
            sensors.setLowAlarmTemp(deviceAddr, (char)floor(sc.minTemp));
            sensors.setHighAlarmTemp(deviceAddr, (char)ceil(sc.maxTemp));    
      
            Serial.printf("[DEBUG] Sensor %s - setLow = %d, setHigh = %d\n", 
                          sc.idSensor.c_str(),
                          sensors.getLowAlarmTemp(deviceAddr),
                          sensors.getHighAlarmTemp(deviceAddr));        
      
            if (sc.habilitado) {
              sensors.setAlarmHandler(newAlarmHandler); // handler global
            } else {
              sensors.resetAlarmSearch(); // desativa alarmes para todos
            }
          }
        }
      
        // ✅ DNS Name (mDNS)
        if (setDNSNAME(appConfig.alias.c_str())) {
          Serial.println("mDNS iniciado: http://" + appConfig.alias + ".local");
        }
        else Serial.println("Erro ao adicionar no MDNS!");  
      
        // Configura o TimeZone
      
        configTzTime(appConfig.timezone.c_str(), appConfig.ntpServer.c_str());  
      
        // Monta o tempo de exibição no display
      
        if (nDevices > 0) 
        {
           tempoExibicao = max(200UL, (unsigned long)((appConfig.varredura * 0.9) / nDevices));
           Serial.printf("tempoExibicao ajustado para %lu ms\n", tempoExibicao);
        } 
      
        // ✅ Detecta mudança de modo de rede e reinicia se necessário
        static bool primeiraAplicacao = true;
        static String modoAnterior = "";
      
        if (!primeiraAplicacao && appConfig.modo != modoAnterior) {
          Serial.printf("[INFO] Modo de rede alterado de %s para %s. Reiniciando...\n", modoAnterior.c_str(), appConfig.modo.c_str());
          delay(1000);
          ESP.restart();
        }
      
        modoAnterior = appConfig.modo;
        primeiraAplicacao = false;
      
        Serial.println("[INFO] Parâmetros aplicados com sucesso.");
      }
      
      //------------------------------------------------
      // Devolve o localtime dd/mm/aaaa hh:mm:ss
      //------------------------------------------------
      
      String getTimeStamp()
      {
        time_t now;
        time(&now);
        char timestamp[30];
        strftime(timestamp, 30, "%d/%m/%Y %T", localtime(&now));
        return String(timestamp);
      }
      
      //---------------------------------------------------------
      // 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 para avisar aos clientes que Status de Relé mudou
      //----------------------------------------------------------
      
      void enviarStatusParaTodosClientes() 
      {
          if (ws.count()==0) return;
      
          DynamicJsonDocument resp(256);
          resp["cmd"] = "statusReles";
          JsonArray sts = resp.createNestedArray("status");
      
          for (int i = 0; i < 2; i++) {
            JsonObject s = sts.createNestedObject();
            s["id"] = i+1;
            s["estado"] = !digitalRead(pinReles[i]);
          }
      
          String out;
          serializeJson(resp, out);
          ws.textAll(out); // Envia para todos os clientes conectados
      }
      
      //------------------------------------------------------------
      // Função para alterar o estado do Rele e avisar na interface
      //------------------------------------------------------------
      
      void alterarEstadoRele(int releIndex, bool estado)
      {
          digitalWrite(pinReles[releIndex], estado ? RELE_ON : RELE_OFF);
          statusReles[releIndex] = estado;
          enviarStatusParaTodosClientes(); 
      }
      
      //--------------------------------------------------
      // 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));    
      }
      

      Conclusão

      O projeto buscou mostrar uma solução flexível para monitoramento e controle térmico utilizando ESP32. A combinação de sensores DS18B20 com relés configuráveis, suporte a múltiplos modos de operação (AP/WiFi), atualização OTA e sincronização de horário resulta numa estação compacta e versátil. O projeto pode ser escalável e pode ser adaptado facilmente a diferentes contextos — desde aplicações domésticas até controle de processos industriais simples.

      Como o projeto se encaixaria?

      • Medição contínua → Verifica a temperatura dos ambientes/tanques.
      • Alarme e ação automática → Quando ultrapassa limites, o sistema decide ligar ou desligar relés.
      • Configuração flexível → O usuário ajusta facilmente MIN, MAX e ações pelo navegador.

      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

      30 de maio 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.

      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!