Generic selectors
Exact matches only
Search in title
Search in content
Post Type Selectors
/
/
Carrinho 2WD com ESP32-C3 e Joystick Web

Carrinho 2WD com ESP32-C3 e Joystick Web

Sumário

Baixe agora a apostila Arduino Iniciante e comece seus projetos

Compartilhe seu conhecimento e receba em dinheiro ou produtos.

Introdução

Projetos de pequenos veículos robóticos de duas rodas são bastante utilizados no aprendizado de eletrônica, programação e sistemas embarcados. Além de permitirem explorar conceitos relacionados ao acionamento e controle de motores, constituem uma plataforma simples para experimentar diferentes formas de comunicação e interação entre o usuário e o dispositivo. Neste projeto apresentamos a construção de um carrinho de duas rodas controlado por um ESP32-C3 Mini, utilizando o conhecido driver de motores L298N e uma interface de controle baseada inteiramente em tecnologias Web. O principal diferencial está na forma de interação com o carrinho: em vez de utilizar um controle remoto dedicado, joystick físico ou aplicativo específico instalado no celular, o próprio ESP32-C3 cria uma rede Wi-Fi e disponibiliza uma página Web contendo um joystick virtual. Dessa forma, basta conectar um smartphone, tablet ou computador à rede criada pelo carrinho e acessar a interface pelo navegador. Os comandos do joystick são transmitidos em tempo real ao ESP32-C3 através de WebSocket, que realiza o controle independente da velocidade e do sentido de rotação dos dois motores. O projeto também incorpora recursos adicionais relacionados à autonomia e facilidade de utilização, como baterias recarregáveis 18650 em configuração 2S, circuito para recarga das baterias sem necessidade de removê-las do carrinho e acompanhamento do percentual estimado da bateria diretamente na interface Web.  

Motivação

Carrinhos robóticos de duas rodas estão entre os projetos mais tradicionais para quem inicia ou desenvolve conhecimentos em eletrônica, programação e sistemas embarcados. Justamente por serem amplamente explorados, representam também uma boa oportunidade para avaliar como novas tecnologias podem simplificar soluções anteriormente desenvolvidas com maior quantidade de hardware ou dependências externas.

A motivação deste projeto surgiu dessa perspectiva: partir de uma aplicação conhecida e repensar sua implementação com foco na simplicidade de operação, integração e autonomia.

Mais do que movimentar o carrinho para frente, para trás ou realizar curvas, buscamos construir uma solução completa e independente, que pudesse ser controlada por recursos já disponíveis no dispositivo do usuário e que também facilitasse tarefas normalmente secundárias em um protótipo, como configuração, atualização do firmware, recarga e acompanhamento da bateria.

O projeto procura, portanto, demonstrar que mesmo uma aplicação clássica pode servir como plataforma para experimentar conceitos atuais e gerar novos aprendizados quando revisitamos não apenas o que ela faz, mas principalmente como ela é implementada e utilizada.

Arquitetura do Projeto

A arquitetura do projeto foi concebida de forma que o ESP32-C3 Mini concentre as principais funções de controle e comunicação do carrinho.

Ao ser inicializado, o ESP32-C3 cria uma rede Wi-Fi própria no modo Access Point (AP). O usuário conecta seu smartphone, tablet ou computador diretamente a essa rede e acessa, através do navegador, a página Web disponibilizada pelo próprio microcontrolador.

A interface principal contém um joystick virtual responsável pelos comandos de movimentação. À medida que o joystick é deslocado, sua posição é convertida em valores que representam direção e intensidade do movimento. Esses dados são transmitidos ao ESP32-C3 através de uma conexão WebSocket.

No firmware, os comandos recebidos são processados e convertidos nos sinais necessários para controlar individualmente os dois motores através do driver L298N. Essa arquitetura permite realizar movimentos para frente e para trás, curvas e rotações, combinando diferentes sentidos e velocidades dos motores esquerdo e direito.

Além do controle de movimentação, o ESP32-C3 executa outras funções do projeto, como a leitura da tensão das baterias, atualização das informações apresentadas na página Web, armazenamento das configurações e atualização do firmware via OTA.

De forma simplificada, o fluxo principal de controle pode ser representado por:

Smartphone / Tablet / Computador → Wi-Fi → Interface Web → WebSocket → ESP32-C3 → L298N → Motores

Paralelamente, o circuito de monitoramento da bateria fornece ao ESP32-C3 uma tensão proporcional à tensão do conjunto de baterias:

Bateria 2S → Divisor Resistivo → ADC do ESP32-C3 → Interface Web

Essa organização mantém o hardware relativamente simples e concentra no software grande parte das funcionalidades do projeto. Nas próximas seções, cada um desses blocos será apresentado com mais detalhes.

Figura 1 - Diagrama do Projeto

 

Circuito e Alimentação

O sistema de alimentação do carrinho utiliza duas baterias 18650 conectadas em série, formando um conjunto 2S com tensão nominal de aproximadamente 7,4 V. Quando completamente carregado, esse conjunto pode atingir aproximadamente 8,4 V.

Essa tensão é aplicada ao módulo L298N, responsável pelo acionamento dos dois motores. Uma característica desse driver que se mostrou favorável nesta aplicação é a queda de tensão existente em seu estágio de potência. Por utilizar uma ponte H baseada em transistores bipolares, a tensão efetivamente entregue aos motores é inferior à tensão presente na alimentação do módulo.

Embora essa queda seja normalmente considerada uma desvantagem do L298N quando comparado a drivers mais modernos baseados em MOSFETs, neste projeto ela contribui para aproximar a tensão aplicada aos motores da sua faixa nominal de operação, de até 6 V.

É importante observar que essa queda não funciona como um regulador de tensão e varia de acordo com fatores como a corrente solicitada pelos motores e as condições de operação do driver. O controle PWM realizado pelo ESP32-C3 também participa do controle da potência média aplicada aos motores.

Para facilitar a utilização do carrinho, foi incorporado um módulo carregador 2S com entrada USB-C, permitindo realizar a recarga sem retirar as baterias do suporte. Dessa forma, o conjunto pode permanecer instalado durante todo o processo de carga.

O L298N possui ainda um regulador de 5 V que, nesta montagem, é utilizado para alimentar o ESP32-C3 Mini. Com isso, foi possível simplificar o circuito e eliminar a necessidade de um conversor de tensão adicional dedicado ao microcontrolador.

Durante os testes foi inicialmente utilizado um conversor Buck externo para gerar os 5 V necessários ao ESP32-C3. Entretanto, o modelo empregado especificava uma tensão mínima de entrada de aproximadamente 7,5 V, valor muito próximo da tensão apresentada pelo conjunto 2S durante sua descarga. Essa condição provocava instabilidade na alimentação e, consequentemente, na conexão Wi-Fi.

A utilização da saída regulada de 5 V disponível no L298N mostrou-se estável no protótipo e foi adotada na montagem final.

É importante observar que essa solução considera as características e o consumo específico deste projeto. A saída de 5 V do L298N possui capacidade limitada e sua utilização para alimentar outros circuitos deve sempre levar em consideração a corrente requerida pela carga e as condições de operação do regulador.

Além da alimentação, a tensão das baterias é encaminhada a um divisor resistivo, cuja saída é conectada a uma entrada analógica do ESP32-C3. Esse circuito permite acompanhar o estado da bateria sem aplicar diretamente ao microcontrolador a tensão do conjunto 2S. O funcionamento e o cálculo utilizados nessa medição serão detalhados posteriormente.

Assim, o sistema de energia pode ser dividido em três caminhos principais:

  • Bateria 2S → L298N → motores;
  • Bateria 2S → regulador de 5 V do L298N → ESP32-C3 Mini;
  • Bateria 2S → divisor resistivo → entrada ADC do ESP32-C3 Mini.

A recarga ocorre através de um caminho independente:

USB-C → módulo carregador 2S → conjunto de baterias 18650.

Figura 2 - Diagrama do Circuito de Alimentação

Figura 3 - Diagrama de Ligação do L298N, Motores e ESP32

 

Controle dos Motores

O acionamento dos dois motores é realizado pelo módulo L298N, que contém duas pontes H independentes. Isso permite controlar separadamente o sentido de rotação e a velocidade de cada motor.

Para cada canal, dois sinais digitais definem o sentido de rotação, enquanto a entrada de habilitação recebe um sinal PWM responsável pelo controle de velocidade. No projeto, um canal do L298N é utilizado para o motor esquerdo e o outro para o motor direito.

O princípio de funcionamento pode ser resumido da seguinte forma:

  • os sinais IN1 e IN2 controlam o sentido de rotação de um dos motores;
  • os sinais IN3 e IN4 controlam o sentido de rotação do outro motor;
  • as entradas ENA e ENB recebem os sinais PWM utilizados para ajustar a velocidade.

A movimentação do carrinho é obtida pela combinação entre o sentido e a velocidade aplicada aos dois lados.

Quando ambos os motores giram para frente com velocidades semelhantes, o carrinho avança em linha reta. Se um dos motores receber uma velocidade menor, o veículo realiza uma curva para esse lado. Para movimentos de rotação mais fechados, os motores podem ainda girar em sentidos opostos.

Conversão do joystick em movimento

A interface Web fornece continuamente duas informações principais:

X — posição horizontal do joystick, associada à direção;

Y — posição vertical, associada ao movimento para frente ou para trás.

A partir desses valores, o firmware calcula uma velocidade independente para cada motor.

De forma conceitual, pode-se representar essa combinação por:

motorEsquerdo = Y + X;
motorDireito  = Y - X;

Os valores resultantes são então ajustados para os limites permitidos pelo controle PWM.

Essa abordagem permite produzir curvas proporcionais à posição do joystick. Pequenos deslocamentos laterais provocam curvas suaves, enquanto deslocamentos maiores aumentam a diferença de velocidade entre os motores.

Velocidade alvo e velocidade atual

Para evitar mudanças muito bruscas de velocidade, o firmware não aplica necessariamente o novo valor recebido do joystick diretamente aos motores.

Em vez disso, são mantidos dois conjuntos de valores:

motorEsquerdoAlvo
motorDireitoAlvo

motorEsquerdoAtual
motorDireitoAtual

Os valores alvo representam o comando solicitado pelo usuário, enquanto os valores atuais correspondem ao PWM efetivamente aplicado naquele instante.

Uma rotina executada periodicamente aproxima gradualmente os valores atuais dos valores alvo. Dessa forma, acelerações, desacelerações e mudanças de direção podem ocorrer de maneira mais progressiva, reduzindo movimentos abruptos e melhorando a dirigibilidade.

Proteção contra perda de comunicação

Como os comandos são recebidos continuamente através do WebSocket, também foi implementado um mecanismo de segurança baseado em tempo.

Sempre que uma nova posição do joystick é recebida, o firmware registra o instante da última atualização. Caso nenhum novo comando seja recebido dentro de um intervalo previamente definido, os dois motores são automaticamente levados à condição de parada.

Esse comportamento é importante porque uma perda da conexão Wi-Fi, fechamento do navegador ou qualquer interrupção na comunicação não deve fazer com que o carrinho continue executando indefinidamente o último comando recebido.

Assim, além do controle proporcional dos motores, o firmware incorpora uma camada simples de proteção que contribui para uma utilização mais segura do veículo.

Interface Web e Joystick

Uma das principais características deste projeto é a utilização do próprio navegador como controle remoto do carrinho. Toda a interface é fornecida pelo ESP32-C3 Mini, dispensando a instalação de aplicativos específicos no smartphone, tablet ou computador utilizado pelo usuário.

Ao ser inicializado, o ESP32-C3 configura sua própria rede Wi-Fi no modo Access Point (AP). Para iniciar a operação, basta acessar as redes Wi-Fi disponíveis no dispositivo utilizado como controle e selecionar o SSID configurado para o carrinho.

Figura 4 - Seleção do SSID publicado pelo Carrinho

A primeira conexão a tela a seguir pode aparecer dependendo do dispositivo usado.

Figura 5 - Opções para a Conexão no SSID do Carrinho

Após estabelecer a conexão, o usuário acessa pelo navegador a página disponibilizada pelo servidor Web do ESP32-C3 utilizando a URL por IP http://192.168.4.1 ou usando o nome mDNS http://carrinho.local.

Figura 6 - Tela Principal de Movimentação do Carrinho

O elemento central dessa página é um joystick virtual, desenvolvido em HTML, CSS e JavaScript. Seu funcionamento procura reproduzir o comportamento de um joystick analógico: a posição do controle dentro da área circular determina simultaneamente a direção e a intensidade do movimento desejado.

Determinação da posição do joystick

A movimentação do joystick é tratada pelo JavaScript executado no navegador. A posição selecionada pelo usuário é convertida em duas componentes:

  • X, correspondente ao deslocamento horizontal;
  • Y, correspondente ao deslocamento vertical.

O centro do joystick representa a condição de repouso:

X = 0
Y = 0

Movimentos para a direita ou esquerda alteram o valor de X, enquanto movimentos para frente ou para trás alteram Y.

Como o joystick possui uma área circular, o software também limita o deslocamento máximo do controle ao seu raio. Dessa forma, mesmo que o usuário movimente o dedo ou o mouse para fora da região permitida, os valores utilizados pelo controle permanecem dentro dos limites estabelecidos.

Intensidade do movimento

Além da direção, a distância entre a posição atual do joystick e seu ponto central fornece uma indicação da intensidade do comando.

Um pequeno deslocamento produz uma movimentação mais lenta, enquanto a aproximação da borda do joystick aumenta progressivamente a velocidade solicitada.

Dessa forma, a interface permite controlar direção e velocidade através de um único elemento, oferecendo uma experiência mais próxima de um controle analógico do que de simples botões de avanço, retorno e direção.

Os valores obtidos são posteriormente utilizados pelo firmware para calcular de forma independente os comandos dos motores esquerdo e direito, conforme apresentado na seção anterior.

Compatibilidade com diferentes dispositivos

Como a interface é executada diretamente no navegador, o mesmo controle pode ser utilizado em diferentes tipos de dispositivos.

No computador, o joystick pode ser movimentado através do mouse. Em smartphones e tablets, a interação é realizada diretamente pela tela sensível ao toque.

A página foi desenvolvida de forma responsiva, permitindo sua utilização em diferentes dimensões de tela sem necessidade de versões específicas do aplicativo.

Informações de operação

A página principal não é utilizada apenas para transmitir comandos. Ela também apresenta informações sobre o estado do carrinho, incluindo a condição da conexão e o percentual estimado da bateria.

Essa comunicação ocorre nos dois sentidos: o navegador envia os comandos de movimentação e o ESP32-C3 pode transmitir informações atualizadas para apresentação na interface.

O navegador passa, portanto, a desempenhar simultaneamente as funções de controle remoto e painel de supervisão do carrinho.

Na próxima seção veremos como essa comunicação bidirecional e em tempo real é implementada utilizando WebSocket.
function calculaPosicao(event) {

  const rect = joystick.getBoundingClientRect();

  const centerX = rect.width / 2;
  const centerY = rect.height / 2;

  let dx = event.clientX - rect.left - centerX;
  let dy = event.clientY - rect.top - centerY;

  const knobRadius = knob.offsetWidth / 2;
  const maxRadius = centerX - knobRadius - 5;

  let distancia = Math.sqrt(dx * dx + dy * dy);

  // Limita o movimento à área circular do joystick
  if (distancia > maxRadius) {
    dx = dx / distancia * maxRadius;
    dy = dy / distancia * maxRadius;
    distancia = maxRadius;
  }

  // Normaliza X e Y para -100 ... +100
  x = Math.round(dx / maxRadius * 100);
  y = Math.round(-dy / maxRadius * 100);

  // Intensidade do movimento: 0 ... 100%
  velocidade =
    Math.round(distancia / maxRadius * 100);

  // Região central de repouso
  if (velocidade < DEAD_ZONE) {
    x = 0;
    y = 0;
    velocidade = 0;
  }

  knob.style.left = (centerX + dx) + "px";
  knob.style.top  = (centerY + dy) + "px";

  atualizaDisplay();
}

A função calculaPosicao() recebe as coordenadas fornecidas pelo evento de ponteiro e inicialmente determina o deslocamento (dx e dy) em relação ao centro do joystick.

A distância até o centro é calculada pela relação euclidiana entre essas duas componentes. Caso o usuário movimente o ponteiro para além do raio permitido, as coordenadas são proporcionalmente ajustadas para permanecer sobre a circunferência máxima do joystick.

Em seguida, os valores são normalizados para uma faixa entre -100 e +100. O eixo Y é invertido em relação às coordenadas utilizadas pelo navegador, fazendo com que o movimento para cima corresponda a valores positivos e o movimento para baixo a valores negativos.

A variável velocidade é obtida pela distância radial em relação ao centro e normalizada entre 0 e 100%. Dessa forma, a direção é determinada pelas componentes X e Y, enquanto a distância ao centro representa a intensidade do comando.

Também foi definida uma pequena "dead zone" ao redor da posição central. Movimentos inferiores a esse limite são considerados repouso, evitando que pequenas variações de posição produzam comandos involuntários.

Para tratar a interação com o joystick foram utilizados "Pointer Events" (pointerdown, pointermove, pointerup e pointercancel). Essa API permite utilizar a mesma lógica de controle para diferentes dispositivos de entrada, como mouse e telas sensíveis ao toque, evitando a necessidade de implementar tratamentos separados para cada plataforma.

 

Comunicação por WebSocket

Para transmitir os comandos do joystick ao ESP32-C3 foi utilizado o protocolo WebSocket. Diferentemente de uma comunicação baseada em sucessivas requisições HTTP, o WebSocket estabelece uma conexão persistente entre o navegador e o microcontrolador, permitindo a troca bidirecional de mensagens durante toda a operação do carrinho.

A conexão é iniciada pelo próprio JavaScript da página:

const gateway =
  `ws://${window.location.hostname}/ws`;

websocket = new WebSocket(gateway);

O uso de window.location.hostname evita a necessidade de definir no código um endereço IP fixo. O navegador utiliza automaticamente o mesmo endereço empregado para acessar a interface Web, acrescentando apenas o endpoint /ws utilizado pelo WebSocket.

Envio periódico dos comandos

Um aspecto importante da implementação é que a transmissão não ocorre diretamente a cada evento de movimentação do joystick.

Quando o usuário pressiona o joystick, é iniciado um temporizador:

const INTERVALO_ENVIO = 50;

timerEnvio = setInterval(
  enviaMovimento,
  INTERVALO_ENVIO
);

Enquanto o joystick permanece pressionado, sua posição é continuamente atualizada pelos eventos pointermove, mas a transmissão ocorre em intervalos regulares de 50 ms, correspondendo a uma frequência máxima de 20 comandos por segundo.

Essa separação evita que a quantidade de mensagens transmitidas dependa da frequência com que o navegador gera eventos de ponteiro, tornando o fluxo de comunicação mais previsvisível.

Cada mensagem de movimentação utiliza um formato textual simples:

const mensagem =
  "MOVE," +
  x + "," +
  y + "," +
  velocidade;

websocket.send(mensagem);

Uma mensagem típica poderia ser:

MOVE,25,80,84

Nesse exemplo, são transmitidas as componentes X e Y do joystick e a intensidade do movimento. Ao receber a mensagem, o ESP32-C3 separa esses valores e os utiliza para determinar os comandos destinados aos motores.

Retorno imediato à condição de repouso

Quando o usuário libera o joystick, não é necessário aguardar o próximo intervalo de transmissão. O temporizador é cancelado, as variáveis de movimento retornam a zero e uma mensagem específica é enviada imediatamente:

websocket.send("HOME");

Essa mensagem informa ao ESP32-C3 que o joystick retornou à posição central e que o carrinho deve interromper o movimento.

O mesmo procedimento é executado se o evento do ponteiro for cancelado ou se a janela do navegador perder o foco enquanto o joystick estiver pressionado.

Também foi utilizado o evento visibilitychange. Dessa forma, se o usuário mudar de aplicativo, trocar de aba ou a página deixar de estar visível durante a movimentação, o comando HOME é enviado antes que o controle permaneça indevidamente ativo.

Comunicação bidirecional

A conexão WebSocket também é utilizada no sentido inverso, permitindo que o ESP32-C3 envie informações ao navegador.

No projeto, esse recurso é empregado para transmitir periodicamente o estado da bateria. A página recebe uma mensagem em formato JSON e atualiza a indicação apresentada ao usuário:

const data = JSON.parse(event.data);

if (data.batPct !== undefined) {
  ...
  battery.innerHTML =
    `${icone} ${data.batPct}% | ${data.batV.toFixed(2)} V`;
}

Assim, uma única conexão WebSocket atende às duas necessidades principais da aplicação:

Navegador → ESP32-C3: comandos de movimentação;

ESP32-C3 → Navegador: informações de supervisão, como tensão e percentual da bateria.

A conexão também é acompanhada pela própria interface, que informa visualmente ao usuário quando o WebSocket está conectado ou desconectado. Caso ocorra uma desconexão inesperada, o JavaScript realiza automaticamente uma nova tentativa de conexão após um pequeno intervalo.

Com essa arquitetura, o WebSocket constitui o principal canal de comunicação em tempo real entre a interface Web e o firmware do carrinho.

Tratamento do WebSocket no ESP32-C3

No firmware, os eventos da conexão são tratados pela função onWebSocketEvent(). Ela é chamada tanto na conexão e desconexão de clientes quanto na chegada de novas mensagens.

Uma decisão adotada no projeto foi permitir que apenas um cliente WebSocket controle o carrinho por vez. Quando o primeiro navegador se conecta, seu identificador é armazenado como operador ativo:

case WS_EVT_CONNECT:

  if (!clienteAtivo)
  {
    clienteAtivo = true;
    clienteAtivoId = client->id();

    home();
  }
  else
  {
    client->close();
  }

  break;

Dessa forma, uma segunda conexão não pode assumir simultaneamente o controle dos motores. Essa restrição evita comandos conflitantes provenientes de diferentes smartphones ou computadores.

Na desconexão, o firmware verifica se o cliente que deixou a conexão era justamente o operador ativo. Nesse caso, o controle é liberado e o carrinho retorna imediatamente à condição de repouso:

case WS_EVT_DISCONNECT:

  if (
      clienteAtivo &&
      client->id() == clienteAtivoId
     )
  {
    clienteAtivo = false;
    clienteAtivoId = 0;

    home();
  }

  break;

Além de liberar a conexão para um novo operador, a chamada a home() funciona como mais uma camada de segurança: a perda da comunicação não permite que os motores continuem executando o último comando recebido.

Quando uma nova mensagem chega, somente os dados provenientes do cliente autorizado são processados:

case WS_EVT_DATA:
{
  if (
      !clienteAtivo ||
      client->id() != clienteAtivoId
     )
  {
    return;
  }

  AwsFrameInfo *info =
    (AwsFrameInfo*)arg;

  if (
      info->final &&
      info->index == 0 &&
      info->len == len &&
      info->opcode == WS_TEXT
     )
  {
    String mensagem;

    mensagem.reserve(len);

    for (size_t i = 0; i < len; i++)
    {
      mensagem += (char)data[i];
    }

    processaMensagem(mensagem);
  }

  break;
}

Antes de converter os dados, o firmware verifica se foi recebido um frame WebSocket textual completo. Em seguida, o conteúdo é reconstruído em uma String e encaminhado para processaMensagem(), responsável por interpretar comandos como:

MOVE,25,80,84

ou:

HOME
Essa separação mantém o tratamento da comunicação independente da lógica de movimentação: o callback WebSocket identifica o cliente, valida e recebe a mensagem, enquanto outra função interpreta o comando e define o comportamento dos motores.

Monitoramento da Bateria

Além do controle de movimentação, o projeto permite acompanhar o estado das duas baterias 18650 diretamente pela interface Web. Para realizar essa medição sem adicionar um módulo específico, foi utilizado um divisor resistivo conectado a uma entrada analógica do ESP32-C3.

Como as duas células estão ligadas em série, a tensão do conjunto 2S pode atingir aproximadamente 8,4 V quando totalmente carregado, valor que não pode ser aplicado diretamente à entrada ADC do microcontrolador. O divisor resistivo tem justamente a função de reduzir essa tensão para uma faixa adequada à medição.

Divisor resistivo

O circuito utiliza dois resistores:

  • R1 = 100 kΩ, conectado entre o positivo da bateria e a entrada ADC;
  • R2 = 47 kΩ, conectado entre a entrada ADC e o GND.

A tensão medida pelo ADC corresponde, portanto, a uma fração da tensão existente no conjunto de baterias.

Considerando os valores nominais utilizados no projeto, o divisor aplica à entrada ADC aproximadamente 31,97% da tensão da bateria. Assim, na condição de máxima carga do conjunto 2S (8,4 V), a entrada analógica recebe aproximadamente 2,69 V.

A figura a seguir apresenta o cálculo do divisor e também a equação inversa utilizada para recuperar, no firmware, a tensão correspondente ao conjunto de baterias.

Figura 7 - Equação do Divisor Resistivo

Como resultado, utilizando os valores nominais dos resistores, a tensão da bateria pode ser obtida aproximadamente por:

tensão da bateria = tensão medida no ADC × 3,128

Esse fator constitui o ponto de partida para transformar a leitura realizada pelo ESP32-C3 na tensão estimada do conjunto 2S.

Valores reais dos componentes e calibração

Na prática, os resistores possuem tolerâncias e seus valores reais não são necessariamente idênticos aos valores nominais. Por esse motivo, durante a montagem, os resistores utilizados no protótipo foram medidos com um multímetro. Foram encontrados valores próximos de 99,5 kΩ para R1 e 46,15 kΩ para R2, produzindo uma relação ligeiramente diferente daquela obtida com os valores nominais de 100 kΩ e 47 kΩ.

Esses valores medidos foram utilizados diretamente no cálculo da tensão da bateria realizado pelo firmware:

Assim, após calcular a tensão da bateria a partir da leitura do ADC e da relação do divisor resistivo, o resultado é multiplicado pelo fator de calibração:

tensaoBateria *= FATOR_CALIBRACAO;

Com esse ajuste, a tensão indicada pelo sistema ficou bastante próxima daquela obtida com o multímetro.

É importante observar que o valor 1,032 foi obtido experimentalmente para os componentes utilizados neste protótipo e não deve ser considerado uma constante aplicável a todas as montagens. Ao reproduzir o projeto, recomenda-se comparar a tensão indicada pelo firmware com uma medição realizada diretamente nos terminais da bateria e, se necessário, ajustar o valor de FATOR_CALIBRACAO.

Da tensão ao percentual da bateria

Embora a tensão seja uma informação importante, uma indicação percentual é mais intuitiva durante a utilização do carrinho. Por isso, depois de determinar a tensão do conjunto 2S, o firmware calcula também uma estimativa do estado da bateria.

É importante observar que a relação entre tensão e capacidade restante de uma bateria de íons de lítio não é perfeitamente linear. Consequentemente, o percentual apresentado pelo projeto deve ser interpretado como uma estimativa para acompanhamento da bateria, e não como uma medição precisa da energia ainda disponível.

Para facilitar a interpretação, a interface utiliza três estados visuais:

  • Normal: acima de 40%;
  • Atenção: entre 20% e 40%;
  • Crítico: abaixo de 20%.

Quando a bateria entra na condição crítica, além da mudança de apresentação, o ícone utilizado na página também é alterado, chamando a atenção do usuário para a necessidade de recarga.

A informação apresentada combina o percentual estimado e a tensão medida, por exemplo:

🔋 74% | 7,90 V

Atualização através do WebSocket

O monitoramento da bateria aproveita a mesma conexão WebSocket utilizada para o controle do carrinho.

Periodicamente, o ESP32-C3 envia ao navegador uma mensagem em formato JSON contendo a tensão calculada e o percentual estimado. No JavaScript, essas informações são recebidas através do evento onmessage:

websocket.onmessage = function(event) {

  const data = JSON.parse(event.data);

  if (data.batPct !== undefined) {

    ...

    battery.innerHTML =
      `${icone} ${data.batPct}% | ${data.batV.toFixed(2)} V`;
  }
};

Dessa forma, não são necessárias requisições HTTP adicionais para consultar o estado da bateria. A mesma conexão utilizada para transmitir os comandos do joystick funciona também no sentido inverso como canal de telemetria.

O navegador passa, assim, a fornecer uma indicação continuamente atualizada da condição das baterias durante a operação do carrinho.

Configuração do Carrinho

Embora o carrinho possa funcionar com todos os seus parâmetros definidos diretamente no firmware, foi criada uma página específica de configuração para facilitar alterações sem a necessidade de recompilar e realizar um novo upload do programa.

Os parâmetros são armazenados de forma persistente na memória Flash do ESP32-C3 utilizando o sistema de arquivos LittleFS. Dessa forma, as configurações permanecem disponíveis mesmo depois que o carrinho é desligado.

A página de configuração pode ser acessada através do navegador e permite alterar os principais parâmetros relacionados à identificação e ao acesso ao dispositivo.

Figura 8 - Parâmetros de Configuração do Carrinho

Configuração persistente com LittleFS

O LittleFS disponibiliza uma pequena área da memória Flash como um sistema de arquivos. No projeto, as configurações são mantidas em um arquivo no formato JSON, permitindo que sejam carregadas durante a inicialização e modificadas posteriormente através da interface Web.

Conceitualmente, a configuração possui uma estrutura semelhante a:

{
  "deviceName": "carrinho",
  "apSsid": "Carrinho-2WD",
  "apPass": "********",
  "adminUser": "admin",
  "adminPass": "********"
}

Com essa abordagem, existe uma separação entre o firmware e os parâmetros de instalação. Alterar, por exemplo, o nome da rede Wi-Fi criada pelo carrinho não exige modificar o código-fonte.

Rede Wi-Fi própria

O ESP32-C3 opera como Access Point, portanto o carrinho cria sua própria rede Wi-Fi e não depende da existência de um roteador ou de acesso à Internet.

O SSID e a senha dessa rede podem ser definidos na página de configuração. Depois da alteração dos parâmetros relacionados ao Wi-Fi, o dispositivo pode ser reiniciado para que a nova configuração seja utilizada.

Essa característica torna o carrinho independente da infraestrutura de rede do local onde estiver sendo utilizado.

Identificação através de mDNS

Além do endereço IP atribuído ao ESP32-C3 no modo Access Point, o projeto utiliza mDNS (Multicast DNS) para facilitar a identificação do dispositivo.

Com o mDNS, é possível associar um nome ao ESP32-C3 e utilizá-lo no navegador no formato:

http://nome-do-dispositivo.local

O nome pode ser definido através da página de configuração, facilitando principalmente a identificação do carrinho durante atividades de configuração e manutenção.

Proteção da página de configuração

Como a página permite modificar parâmetros importantes do equipamento, seu acesso é protegido por usuário e senha de administração.

Essa proteção não pretende substituir mecanismos avançados de segurança de rede, mas evita que alterações sejam realizadas inadvertidamente por qualquer dispositivo conectado ao Access Point.

A interface de configuração complementa, portanto, a proposta de concentrar no próprio ESP32-C3 não apenas a operação do carrinho, mas também os recursos necessários para sua administração.  

Atualização do Firmware via OTA

Durante o desenvolvimento de um projeto embarcado é comum realizar sucessivas alterações no firmware. Em uma montagem já concluída, entretanto, conectar fisicamente o ESP32-C3 ao computador a cada nova versão pode se tornar pouco prático.

Para facilitar esse processo, foi incorporado ao projeto o recurso OTA (Over-the-Air), que permite atualizar o firmware através da própria conexão Wi-Fi do carrinho.

Neste projeto foi utilizada especificamente a biblioteca AsyncElegantOTA, integrada ao servidor Web assíncrono utilizado pelo ESP32-C3. É importante observar essa distinção, pois existem implementações e versões diferentes para atualização OTA, entre elas a ElegantOTA. O código deste projeto foi desenvolvido utilizando a AsyncElegantOTA. Adotamos essa solução porque a biblioteca ElegantOTA gerou algumas incompatibilidade com outras bibliotecas do meu ambiente de desenvolvimento, mas é perfeitamente possível usar a ElegantOTA adaptando alguns métodos que diferenciam em sintase da AsyncElegantOTA.

Acesso à atualização

Depois de conectar o computador à rede Wi-Fi criada pelo carrinho, a página de atualização pode ser acessada diretamente pelo navegador através do endereço:

http://192.168.4.1/update

Por se tratar de uma operação que permite substituir o firmware do microcontrolador, o acesso à página é protegido por autenticação.

Figura 9 - Tela de Autenticação da Atualização

Após informar as credenciais corretas, é apresentada a interface do AsyncElegantOTA.

 

Figura 10 - Tela de Definição do Firmware a ser carregado

O novo firmware deve ser previamente compilado na Arduino IDE, gerando o respectivo arquivo binário (.bin). Esse arquivo é então selecionado na página de atualização e enviado ao ESP32-C3 através da rede Wi-Fi.

Durante a transferência, a própria interface permite acompanhar o progresso da atualização.

Reinicialização após a atualização

A versão da biblioteca AsyncElegantOTA utilizada neste projeto não disponibiliza o parâmetro autoReboot encontrado em outras implementações ou versões da solução OTA.

Por esse motivo, após a transferência do novo firmware, a reinicialização do ESP32-C3 é realizada separadamente.

Para evitar a necessidade de desligar e ligar fisicamente o carrinho, foi acrescentado à página de configuração um botão Reiniciar. Ao acioná-lo, uma requisição é enviada ao ESP32-C3 e o firmware executa a reinicialização do microcontrolador.

Dessa forma, o processo completo de manutenção pode ser realizado sem conexão USB com o computador:

Compilar o firmware → conectar ao Wi-Fi do carrinho → acessar /update → autenticar → enviar o arquivo .bin → reiniciar o ESP32-C3

Esse recurso mostrou-se particularmente útil durante o desenvolvimento e também facilita futuras alterações no firmware depois que toda a eletrônica já estiver instalada no carrinho.

Montagem e Testes

Após a validação individual dos principais blocos do projeto, os componentes foram instalados sobre o chassi do carrinho de duas rodas.

A disposição dos módulos procurou manter o conjunto compacto e, ao mesmo tempo, permitir acesso aos principais elementos para eventuais ajustes e manutenção. Na parte superior foram instalados o ESP32-C3 Mini, o driver L298N, o sistema de alimentação e os demais componentes eletrônicos.

Figura 11 - Visão de Cima do Carrinho

Na parte inferior encontram-se os dois motores responsáveis pela tração, as rodas e os elementos de fixação do conjunto.

Figura 12 - Visão do Fundo do Carrinho

Figura 13 - Diagrama do Circuito do Carrinho

Observação: Na figura acima, a representação da alimentação do ESP32C3 pelo Pino de 5V é meramente ilustrativa pois a alimentação na montagem é feita através de um conector USB Fêmea que permite a conexão de um cabo Type C tradicional diretamente no microcontrolador, conforme pode ser observado na Figura 11. Outro ponto importante que pode ser observado na Figura 11 é a conexão do GND da placa de expansão do ESP32C3 no GND do L298N que por sua vez está conectado ao GND do circuito da bateria. Portanto, todas as partes possuem o GND em comum (Circuito da Bateria, L298N e o ESP32C3).

Testes de movimentação

Com a montagem concluída, foram realizados testes utilizando o próprio joystick Web para verificar diferentes condições de movimentação.

Foram avaliados comandos para frente e para trás, curvas em diferentes intensidades e rotações produzidas pela combinação dos dois motores. O controle proporcional proporcionado pelo joystick permite variar simultaneamente direção e velocidade, tornando a condução mais natural do que uma interface baseada apenas em comandos digitais de direção.

A suavização implementada no firmware também contribui para evitar mudanças excessivamente bruscas na velocidade dos motores.

Testes de comunicação

A comunicação foi testada utilizando dispositivos conectados diretamente ao Access Point criado pelo ESP32-C3.

Durante a operação, os comandos do joystick são transmitidos pelo WebSocket em intervalos regulares, enquanto informações da bateria percorrem a mesma conexão no sentido inverso.

Também foram verificadas situações de interrupção da operação. Ao liberar o joystick, a mensagem HOME é transmitida imediatamente. Caso o navegador perca o foco ou a página deixe de estar visível durante a movimentação, o mesmo procedimento é executado.

No lado do ESP32-C3, a desconexão do cliente ativo também provoca a parada dos motores. Além disso, o firmware mantém um timeout para os comandos de movimentação, constituindo uma proteção adicional caso a comunicação seja interrompida inesperadamente.

Testes de alimentação

A alimentação foi um dos pontos que exigiu atenção durante o desenvolvimento.

Inicialmente foi utilizado um conversor Buck externo para alimentar o ESP32-C3. Entretanto, a tensão mínima de entrada especificada para o módulo utilizado era muito próxima da tensão do conjunto de baterias 2S durante sua descarga, provocando instabilidade na alimentação e reflexos na conexão Wi-Fi.

Nos testes finais, a alimentação do ESP32-C3 através da saída regulada de 5 V disponível no L298N apresentou comportamento estável para esta montagem, permitindo também eliminar um módulo adicional do circuito.

O acompanhamento da tensão das baterias através do divisor resistivo foi comparado com medições realizadas por multímetro, permitindo verificar e ajustar a indicação apresentada na interface Web.

Resultado final

Ao término dos testes, o protótipo reuniu em uma única plataforma os recursos inicialmente propostos: controle proporcional pelo navegador, comunicação WebSocket, configuração persistente, acompanhamento da bateria, recarga do conjunto 2S e atualização do firmware através da rede Wi-Fi.

O resultado demonstra também uma característica importante da proposta: apesar da quantidade de funcionalidades implementadas, a operação cotidiana permanece simples. Para utilizar o carrinho, basta ligá-lo, conectar-se à rede Wi-Fi criada pelo ESP32-C3 e abrir a interface de controle no navegador.

Código Fonte

O código-fonte do projeto foi organizado em três arquivos, separando o firmware do ESP32-C3 das páginas Web utilizadas para operação e configuração do carrinho:

  • esp32_car_2wd.ino — programa principal, contendo a inicialização do hardware, controle dos motores, servidor Web, WebSocket, leitura da bateria, configuração e demais funções do firmware;
  • html_main.h — página Web principal, responsável pela interface de operação, joystick virtual e apresentação das informações do carrinho;
  • html_config.h — página utilizada para configuração dos parâmetros do dispositivo.

A separação das páginas HTML em arquivos próprios evita concentrar todo o conteúdo HTML, CSS e JavaScript dentro do arquivo .ino, facilitando a leitura, organização e manutenção do projeto.

Para criar essa estrutura, os arquivos html_main.h e html_config.h podem ser adicionados como abas do mesmo projeto (sketch) na Arduino IDE, conforme ilustrado na figura a seguir.

Figura 14 - Configuração do IDE para receber os arquivos fontes

Observação: neste artigo foi utilizada a Arduino IDE versão 2.3.10. A disposição dos menus e o procedimento para criação ou inclusão de novas abas podem sofrer alterações em versões posteriores da IDE.

Após a criação das abas, a estrutura do projeto ficará composta pelo arquivo principal esp32_car_2wd.ino e pelos dois arquivos .h, todos pertencentes ao mesmo sketch.

Para a compilação, os dois parâmetros devem ser ajustados conforme a figura a seguir, lembrando que a porta COMx pode variar para o computador do leitor.

Figura 15 - Parâmetros para a Compilação

A figura a seguir mostra como acionar a compilação gerando a imagem em disco:

Figura 16 - Como Salvar a Imagem para Disco

A pasta .\build será criada abaixo da pasta do projeto e nela você encontrará três arquivos com a extensão .bin, sendo o primeiro do aplicativo, o segundo do bootloader e o terceiro da partição. Você só precisará usar o .bin do aplicativo para fazer upload via AsyncElegantOTA.

Fonte do Aplicativo

//--------------------------------------------------------------------------------------------
//   Programa   : Implementação de um Carrinho 2WD
//   Recursos   : . ESP32-C3 + AsyncWebServer + WebSocket
//                . Modo WiFi AP somente
//                . Módulo L298N para controle do Motores
//                . Joystick Web via html
//                . Tela de Configuração do Carrinho
//                . Módulo de Recarga das baterias 2S 18650 
//                . Indicador do Nível da Bateria na Tela Principal
//                . Atualização do Firmware via Web utilizando a biblioteca AsyncElegantOTA
//
//  Autor       : Dailton Menezes
//
//  Versão      : 1.0 Ago/2026 
//--------------------------------------------------------------------------------------------

#include <WiFi.h>
#include <ESPmDNS.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <LittleFS.h>
#include <ArduinoJson.h>
#include <AsyncElegantOTA.h>
#include "html_main.h"
#include "html_config.h"

//-------------------------
// Pinagem para o Motor A
//-------------------------

#define PIN_ENA   5
#define PIN_IN1   6
#define PIN_IN2   7

//-------------------------
// Pinagem para o Motor B
//-------------------------

#define PIN_ENB   4
#define PIN_IN3   3
#define PIN_IN4  10

//-------------------------
// Pinagem para o User LED
//-------------------------

#define pinLED    8
#define ledON     LOW
#define ledOFF    HIGH

//--------------------------------
// Outras Definições da Aplicação
//--------------------------------

#define PWM_MAX           255
#define RAMPA_STEP        10
#define RAMPA_INTERVALO   20
#define DEAD_ZONE_X       20
#define DEAD_ZONE_Y       10
#define RESTART_DELAY_MS  1000
#define FATOR_CALIBRACAO  1.032F

#define PIN_BATERIA       1

//-------------------------------
// SERVIDOR / WEBSOCKET
//-------------------------------

AsyncWebServer server(80);
AsyncWebSocket ws("/ws");

//-------------------------------
// Parâmteros da Aplicação
//-------------------------------

struct AppConfig
{
  String deviceName;

  String adminUser;
  String adminPass;

  String apSsid;
  String apPass;
};

AppConfig config;

//-------------------------------
// CONTROLE / SEGURANÇA
//-------------------------------

// Valores de X/Y menores que este valor serão considerados zero
const int DEAD_ZONE_EIXO = 5;

// Tempo máximo sem receber comando de movimento
const unsigned long TIMEOUT_MOVIMENTO_MS = 300;

// Momento em que recebemos o último MOVE
unsigned long ultimoComandoMs = 0;

// Indica que atualmente existe um comando de movimento ativo
bool movimentoAtivo = false;

// Controle do RESTART após atualização firware 
bool restartPendente = false;
unsigned long restartSolicitadoMs = 0;

//-------------------------------
// CONTROLE DE CLIENTE ÚNICO
//-------------------------------

uint32_t clienteAtivoId = 0;
bool clienteAtivo = false;

//---------------------------
// Controle da Movimentação
//---------------------------

int motorEAtual  = 0;
int motorDAtual  = 0;

int motorEAlvo   = 0;
int motorDAlvo   = 0;

unsigned long ultimoPassoMotor = 0;

//----------------------
// Controle da Bateria
//----------------------

const unsigned long INTERVALO_BATERIA_MS = 1000;
const unsigned long TEMPO_REPOUSO_BATERIA_MS = 2000;

float tensaoBateriaInstantanea = 0.0;
float tensaoBateriaReferencia  = 0.0;
int percentualBateria = 0;

unsigned long ultimoTempoBateria = 0;
unsigned long ultimaAtividadeMotor = 0;
unsigned long ultimoEnvioBateria = 0;

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

void home();
void simulaMovimento(int x, int y, int velocidade);
void processaMensagem(String mensagem);
void onWebSocketEvent(
  AsyncWebSocket *server,
  AsyncWebSocketClient *client,
  AwsEventType type,
  void *arg,
  uint8_t *data,
  size_t len
);
void carregaDefaults();
bool salvaConfig();
bool carregaConfig();
void enviaConfig(
  AsyncWebServerRequest *request
);
void recebeConfig(
  AsyncWebServerRequest *request,
  uint8_t *data,
  size_t len,
  size_t index,
  size_t total
);
void initMDNS();
void aplicaMotor(
  int velocidade,
  int pinEN,
  int pinIN1,
  int pinIN2
);
int aproxima(
  int atual,
  int alvo
);
void serviceMotores();
float lerTensaoBateria();
void serviceBateria();
int calculaPercentualBateria(float tensao);
void enviaBateriaWebSocket();

//-------------------------------
// SETUP da Aplicação
//-------------------------------

void setup()
{
  // Inicializa a Serial
  Serial.begin(115200);
  delay(1000);

  analogReadResolution(12);
  analogSetPinAttenuation(PIN_BATERIA, ADC_11db);  

  // Define o pino do LED e inicializa

  pinMode(pinLED,OUTPUT);
  digitalWrite(pinLED,ledOFF);

  // Define os pinos do MotorA

  pinMode(PIN_ENA, OUTPUT);
  pinMode(PIN_IN1, OUTPUT);
  pinMode(PIN_IN2, OUTPUT);

  // Define os pinos do MotorB

  pinMode(PIN_ENB, OUTPUT);
  pinMode(PIN_IN3, OUTPUT);
  pinMode(PIN_IN4, OUTPUT);

  // Garante motores inicialmente parados

  analogWrite(PIN_ENA, 0);
  analogWrite(PIN_ENB, 0);

  digitalWrite(PIN_IN1, LOW);
  digitalWrite(PIN_IN2, LOW);

  digitalWrite(PIN_IN3, LOW);
  digitalWrite(PIN_IN4, LOW);  

  // Inicializa o LittleFS e tenta recuperar os parâmetros

  if (!LittleFS.begin(true))
  {
    Serial.println(
      "Erro ao montar LittleFS!"
    );
  }
  else
  {
    Serial.println(
      "LittleFS montado."
    );

    carregaDefaults();

    if (!carregaConfig())
    {
      Serial.println(
        "Usando configuracao default."
      );

      salvaConfig();
    }
  }  

  // Hello na Console

  Serial.println();
  Serial.println();
  Serial.println("===============================");
  Serial.println("      CARRINHO 2WD - TESTE");
  Serial.println("===============================");

  // Serial.println();

  WiFi.mode(WIFI_AP);

  bool resultado =
    WiFi.softAP(
      config.apSsid.c_str(),
      config.apPass.c_str()
    );    

  if (!resultado)
  {
    Serial.println(
      "ERRO ao criar Access Point!"
    );

    return;
  }

  // Mostra o Estado Inicial da Aplicação

  Serial.println();
  Serial.println("Access Point iniciado");

  Serial.print("SSID: ");
  Serial.println(config.apSsid);

  Serial.print("IP:   ");
  Serial.println(WiFi.softAPIP());

  // Inicializa mDNS

  initMDNS();

  // ----------------------------------------------------------
  // WEBSOCKET
  // ----------------------------------------------------------

  ws.onEvent(
    onWebSocketEvent
  );

  server.addHandler(
    &ws
  );

  // -----------------
  // Rota principal
  // -----------------

  server.on(
    "/",
    HTTP_GET,

    [](AsyncWebServerRequest *request)
    {
      request->send_P(
        200,
        "text/html",
        INDEX_HTML
      );
    }
  );

  // -----------------
  // Rota de Config
  // -----------------  

  server.on(
    "/config",
    HTTP_GET,

    [](AsyncWebServerRequest *request)
    {
      if (!request->authenticate(
            config.adminUser.c_str(),
            config.adminPass.c_str()
          ))
      {
        return request->requestAuthentication();
      }

      request->send_P(
        200,
        "text/html",
        CONFIG_HTML
      );
    }
  );

  // --------------------------
  // Rota de Envia Parâmetros
  // --------------------------
  server.on(
    "/api/config",
    HTTP_GET,
    enviaConfig
  );  

  server.on(
    "/api/config",
    HTTP_POST,

    [](AsyncWebServerRequest *request)
    {
      // A resposta será enviada por recebeConfig()
    },

    NULL,

    recebeConfig
  );  

  server.on(
    "/api/restart",
    HTTP_POST,

    [](AsyncWebServerRequest *request)
    {
      // --------------------------------------------------------
      // AUTENTICAÇÃO
      // --------------------------------------------------------

      if (!request->authenticate(
            config.adminUser.c_str(),
            config.adminPass.c_str()
          ))
      {
        return request->requestAuthentication();
      }

      // --------------------------------------------------------
      // RESPONDE ANTES DE REINICIAR
      // --------------------------------------------------------

      request->send(
        200,
        "text/plain",
        "OK"
      );

      Serial.println();
      Serial.println(
        "Reinicio solicitado pela pagina de configuracao."
      );

      home();
      restartPendente = true;
      restartSolicitadoMs = millis();
    }
  );

  // ----------------------------------------------------------
  // INICIA SERVIDOR
  // ----------------------------------------------------------

  AsyncElegantOTA.begin(&server,config.adminUser.c_str(),config.adminPass.c_str());
  server.begin();

  Serial.println();
  Serial.println(
    "Servidor HTTP iniciado."
  );

  Serial.print(
    "Conecte o celular ao WiFi: "
  );

  Serial.println(
    config.apSsid
  );

  Serial.println();

  Serial.println(
    "Acesse:"
  );

  Serial.print(
    "http://"
  );

  Serial.print(
    WiFi.softAPIP()
  );

  Serial.println("\tou ");

  Serial.print("http://");
  Serial.print(config.deviceName);
  Serial.println(".local");  

  home();
}


//-------------------------------
// LOOP da Aplicação
//-------------------------------

void loop()
{
  // Limpeza de conexões perdidas

  ws.cleanupClients();

  // Trata Movimento

  serviceMotores();

  // Verifica Bateria

  serviceBateria();

  // WatchDog do Movimento

  if (movimentoAtivo)
  {
    unsigned long tempoSemComando =
      millis() - ultimoComandoMs;

    if (tempoSemComando > TIMEOUT_MOVIMENTO_MS)
    {
      Serial.println();
      Serial.println("!!! TIMEOUT DE MOVIMENTO !!!");

      Serial.printf(
        "Nenhum comando recebido por %lu ms\n",
        tempoSemComando
      );

      home();
    }
  }

  // ----------------------------------------------------------
  // RESTART PENDENTE
  // ----------------------------------------------------------

  if (
      restartPendente &&
      millis() - restartSolicitadoMs >= RESTART_DELAY_MS
     )
  {
    Serial.println(
      "Reiniciando ESP32-C3..."
    );

    AsyncElegantOTA.restart();
  }

  delay(10);
}

//-------------------------------
// HOME SIMULADO
//-------------------------------

void home()
{
  movimentoAtivo = false;

  motorEAlvo = 0;
  motorDAlvo = 0;  

  Serial.println();
  Serial.println("---------------------------------");
  Serial.println("HOME");
  Serial.println("X          : 0");
  Serial.println("Y          : 0");
  Serial.println("Velocidade : 0%");
  Serial.println("Motor E    : 0");
  Serial.println("Motor D    : 0");
  Serial.println("---------------------------------");
}

//-------------------------------
// SIMULA CONTROLE DOS MOTORES
//-------------------------------

void simulaMovimento(
  int x,
  int y,
  int velocidade
)
{

  if (abs(x) <= DEAD_ZONE_X)
    x = 0;

  if (abs(y) <= DEAD_ZONE_Y)
    y = 0;

  float motorE = y + x;
  float motorD = y - x;

  float maior =
    max(
      abs(motorE),
      abs(motorD)
    );

  if (maior > 100.0)
  {
    motorE =
      motorE /
      maior *
      100.0;

    motorD =
      motorD /
      maior *
      100.0;
  }

  // Converte -100..100 para -255..255

  motorEAlvo =
    map(
      (int)motorE,
      -100,
      100,
      -255,
      255
    );

  motorDAlvo =
    map(
      (int)motorD,
      -100,
      100,
      -255,
      255
    );

}

//-------------------------------
// PROCESSA MENSAGEM
//-------------------------------

void processaMensagem(String mensagem)
{
  mensagem.trim();

  if (mensagem == "HOME")
  {
    home();
    return;
  }

  int x;
  int y;
  int velocidade;

  int resultado =
    sscanf(
      mensagem.c_str(),
      "MOVE,%d,%d,%d",
      &x,
      &y,
      &velocidade
    );

  if (resultado == 3)
  {
    x = constrain(x, -100, 100);
    y = constrain(y, -100, 100);
    velocidade = constrain(velocidade, 0, 100);

    ultimoComandoMs = millis();
    movimentoAtivo = true;

    simulaMovimento(
      x,
      y,
      velocidade
    );
  }
}

//-------------------------------
// EVENTOS DO WEBSOCKET
//-------------------------------

void onWebSocketEvent(
  AsyncWebSocket *server,
  AsyncWebSocketClient *client,
  AwsEventType type,
  void *arg,
  uint8_t *data,
  size_t len
)
{
  switch (type)
  {

    // --------------------------------------------------------
    // CLIENTE CONECTOU
    // --------------------------------------------------------

    case WS_EVT_CONNECT:

      if (!clienteAtivo)
      {
        clienteAtivo = true;
        clienteAtivoId = client->id();

        Serial.printf(
          "\nWebSocket conectado: ID=%u IP=%s\n",
          client->id(),
          client->remoteIP().toString().c_str()
        );
        digitalWrite(pinLED,ledON);
        home();
      }
      else
      {
        Serial.printf(
          "\nConexao recusada: ID=%u IP=%s\n",
          client->id(),
          client->remoteIP().toString().c_str()
        );
        digitalWrite(pinLED,ledON);

        client->close();
      }

      break;

    // --------------------------------------------------------
    // CLIENTE DESCONECTOU
    // --------------------------------------------------------

    case WS_EVT_DISCONNECT:

      if (
          clienteAtivo &&
          client->id() == clienteAtivoId
        )
      {
        Serial.printf(
          "\nOperador desconectado: ID=%u\n",
          client->id()
        );

        clienteAtivo = false;
        clienteAtivoId = 0;

        digitalWrite(pinLED, ledOFF);

        home();
      }
      else
      {
        Serial.printf(
          "\nCliente nao autorizado desconectado: ID=%u\n",
          client->id()
        );
      }

      break;

    // --------------------------------------------------------
    // RECEBE DADOS
    // --------------------------------------------------------

    case WS_EVT_DATA:
    {
      if (
          !clienteAtivo ||
          client->id() != clienteAtivoId
        )
      {
        return;
      }

      AwsFrameInfo *info =
        (AwsFrameInfo*)arg;

      if (
          info->final &&
          info->index == 0 &&
          info->len == len &&
          info->opcode == WS_TEXT
         )
      {
        String mensagem;

        mensagem.reserve(len);

        for (size_t i = 0; i < len; i++)
        {
          mensagem +=
            (char)data[i];
        }

        processaMensagem(
          mensagem
        );
      }

      break;
    }

    default:

      break;
  }
}

//---------------------------------------------
// Carrega os valores defaults aos parâmteros
//---------------------------------------------

void carregaDefaults()
{
  config.deviceName = "Carrinho-2WD";

  config.adminUser = "admin";
  config.adminPass = "admin";

  config.apSsid = "Carrinho-2WD";
  config.apPass = "12345678";
}

//-------------------------------------
// Persiste os parâmteros no LittleFS
//-------------------------------------

bool salvaConfig()
{
  JsonDocument doc;

  doc["deviceName"] = config.deviceName;

  doc["adminUser"] = config.adminUser;
  doc["adminPass"] = config.adminPass;

  doc["apSsid"] = config.apSsid;
  doc["apPass"] = config.apPass;

  File file =
    LittleFS.open(
      "/config.json",
      "w"
    );

  if (!file)
  {
    Serial.println(
      "Erro ao abrir /config.json para gravacao."
    );

    return false;
  }

  serializeJsonPretty(
    doc,
    file
  );

  // serializeJsonPretty(
  //   doc,
  //   Serial
  // );

  file.close();

  Serial.println(
    "Configuracao salva em /config.json"
  );

  return true;
}

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

bool carregaConfig()
{
  if (!LittleFS.exists("/config.json"))
  {
    Serial.println(
      "/config.json nao encontrado."
    );

    return false;
  }

  File file =
    LittleFS.open(
      "/config.json",
      "r"
    );

  if (!file)
  {
    Serial.println(
      "Erro ao abrir /config.json."
    );

    return false;
  }

  JsonDocument doc;

  DeserializationError erro =
    deserializeJson(
      doc,
      file
    );

  //serializeJsonPretty(doc,Serial);

  file.close();

  if (erro)
  {
    Serial.print(
      "Erro ao ler JSON: "
    );

    Serial.println(
      erro.c_str()
    );

    return false;
  }

  config.deviceName =
    doc["deviceName"] |
    "Carrinho-2WD";


  config.adminUser =
    doc["adminUser"] |
    "admin";

  config.adminPass =
    doc["adminPass"] |
    "admin";

  config.apSsid =
    doc["apSsid"] |
    "Carrinho-2WD";

  config.apPass =
    doc["apPass"] |
    "12345678";

  Serial.println(
    "Configuracao carregada."
  );

  return true;
}

void enviaConfig(
  AsyncWebServerRequest *request
)
{
  // ----------------------------------------------------------
  // AUTENTICAÇÃO
  // ----------------------------------------------------------

  if (!request->authenticate(
        config.adminUser.c_str(),
        config.adminPass.c_str()
      ))
  {
    return request->requestAuthentication();
  }

  // ----------------------------------------------------------
  // JSON
  // ----------------------------------------------------------

  JsonDocument doc;

  doc["deviceName"] =
    config.deviceName;

  doc["apSsid"] =
    config.apSsid;

  doc["adminUser"] =
    config.adminUser;

  String json;

  serializeJson(
    doc,
    json
  );

  request->send(
    200,
    "application/json",
    json
  );
}

void recebeConfig(
  AsyncWebServerRequest *request,
  uint8_t *data,
  size_t len,
  size_t index,
  size_t total
)
{
  // ==========================================================
  // AUTENTICAÇÃO
  // ==========================================================

  if (!request->authenticate(
        config.adminUser.c_str(),
        config.adminPass.c_str()
      ))
  {
    request->requestAuthentication();
    return;
  }

  // ==========================================================
  // Para nossa configuração o JSON é pequeno.
  //
  // Aceitamos somente uma mensagem completa.
  // ==========================================================

  if (index != 0 || len != total)
  {
    request->send(
      413,
      "text/plain",
      "Requisicao muito grande."
    );

    return;
  }

  // ==========================================================
  // CONVERTE JSON
  // ==========================================================

  JsonDocument doc;

  DeserializationError erro =
    deserializeJson(
      doc,
      data,
      len
    );

  if (erro)
  {
    Serial.print(
      "Erro no JSON recebido: "
    );

    Serial.println(
      erro.c_str()
    );

    request->send(
      400,
      "text/plain",
      "JSON invalido."
    );

    return;
  }


  // ==========================================================
  // OBTÉM OS CAMPOS
  // ==========================================================

  String novoDeviceName =
    doc["deviceName"] | "";

  String novoApSsid =
    doc["apSsid"] | "";

  String novoApPass =
    doc["apPass"] | "";

  String novoAdminUser =
    doc["adminUser"] | "";

  String novoAdminPass =
    doc["adminPass"] | "";

  novoDeviceName.trim();
  novoApSsid.trim();
  novoAdminUser.trim();

  // ==========================================================
  // VALIDAÇÃO NO ESP32
  //
  // Não confiamos somente no JavaScript.
  // ==========================================================

  if (
      novoDeviceName.length() == 0 ||
      novoDeviceName.length() > 32
     )
  {
    request->send(
      400,
      "text/plain",
      "Nome do dispositivo invalido."
    );

    return;
  }

  if (
      novoApSsid.length() == 0 ||
      novoApSsid.length() > 32
     )
  {
    request->send(
      400,
      "text/plain",
      "SSID invalido."
    );

    return;
  }

  // Senha vazia = manter atual

  if (
      novoApPass.length() > 0 &&
      (
        novoApPass.length() < 8 ||
        novoApPass.length() > 63
      )
     )
  {
    request->send(
      400,
      "text/plain",
      "Senha do Access Point invalida."
    );

    return;
  }

  if (
      novoAdminUser.length() == 0 ||
      novoAdminUser.length() > 32
     )
  {
    request->send(
      400,
      "text/plain",
      "Usuario administrador invalido."
    );

    return;
  }

  if (novoAdminPass.length() > 32)
  {
    request->send(
      400,
      "text/plain",
      "Senha de administrador invalida."
    );

    return;
  }

  // ==========================================================
  // GUARDA CONFIGURAÇÃO ANTERIOR
  //
  // Se LittleFS falhar, podemos restaurar a configuração
  // que estava válida em RAM.
  // ==========================================================

  AppConfig configAnterior =
    config;

  // ==========================================================
  // ATUALIZA CAMPOS
  // ==========================================================

  config.deviceName =
    novoDeviceName;

  config.apSsid =
    novoApSsid;

  config.adminUser =
    novoAdminUser;

  // Senhas somente são alteradas quando o navegador
  // enviar um valor não vazio.

  if (novoApPass.length() > 0)
  {
    config.apPass =
      novoApPass;
  }

  if (novoAdminPass.length() > 0)
  {
    config.adminPass =
      novoAdminPass;
  }

  // ==========================================================
  // GRAVA LITTLEFS
  // ==========================================================

  if (!salvaConfig())
  {
    // Restaura RAM

    config =
      configAnterior;

    request->send(
      500,
      "text/plain",
      "Erro ao gravar configuracao."
    );

    return;
  }

  // ==========================================================
  // SUCESSO
  // ==========================================================

  Serial.println();
  Serial.println(
    "Configuracao atualizada:"
  );

  Serial.print(
    "Device Name : "
  );

  Serial.println(
    config.deviceName
  );

  Serial.print(
    "SSID AP     : "
  );

  Serial.println(
    config.apSsid
  );

  Serial.print(
    "Admin User  : "
  );

  Serial.println(
    config.adminUser
  );

  // Deliberadamente NÃO mostramos as senhas.

  request->send(
    200,
    "text/plain",
    "OK"
  );
}

//------------------------
// Define o Alias no mDNS
//------------------------

void initMDNS() 
{
  if (config.deviceName.length() == 0) return;

  WiFi.setHostname(config.deviceName.c_str());

  if (MDNS.begin(config.deviceName.c_str()))
  {
    MDNS.addService("http", "tcp", 80);
    Serial.println("mDNS iniciado com sucesso...");
  } 
  else 
  {
    Serial.println("Falha ao iniciar mDNS");
  }
}

//----------------------------------------
// Aplica a Velocidade e Direcão ao Motor
//----------------------------------------

void aplicaMotor(
  int velocidade,
  int pinEN,
  int pinIN1,
  int pinIN2
)
{
  velocidade =
    constrain(
      velocidade,
      -PWM_MAX,
      PWM_MAX
    );

  if (velocidade > 0)
  {
    digitalWrite(pinIN1, HIGH);
    digitalWrite(pinIN2, LOW);

    analogWrite(
      pinEN,
      velocidade
    );
  }
  else if (velocidade < 0)
  {
    digitalWrite(pinIN1, LOW);
    digitalWrite(pinIN2, HIGH);

    analogWrite(
      pinEN,
      abs(velocidade)
    );
  }
  else
  {
    // Motor livre/parado

    analogWrite(
      pinEN,
      0
    );

    digitalWrite(pinIN1, LOW);
    digitalWrite(pinIN2, LOW);
  }
}

//-------------------------------------
// Suavização do movimento dos motores
//-------------------------------------

int aproxima(
  int atual,
  int alvo
)
{
  if (atual < alvo)
  {
    atual += RAMPA_STEP;

    if (atual > alvo)
      atual = alvo;
  }
  else if (atual > alvo)
  {
    atual -= RAMPA_STEP;

    if (atual < alvo)
      atual = alvo;
  }

  return atual;
}

//---------------------------------
// Faz a movimentação dos motores
//---------------------------------

void serviceMotores()
{
  unsigned long agora =
    millis();

  if (
      agora - ultimoPassoMotor <
      RAMPA_INTERVALO
     )
  {
    return;
  }

  ultimoPassoMotor = agora;

  motorEAtual =
    aproxima(
      motorEAtual,
      motorEAlvo
    );

  motorDAtual =
    aproxima(
      motorDAtual,
      motorDAlvo
    );

  aplicaMotor(
    motorEAtual,
    PIN_ENA,
    PIN_IN1,
    PIN_IN2
  );

  aplicaMotor(
    motorDAtual,
    PIN_ENB,
    PIN_IN3,
    PIN_IN4
  );
}

//-----------------------------
// Calcula a tensão da Bateria
//-----------------------------

// float lerTensaoBateria() 
// {
//   const int AMOSTRAS = 8;
//   uint32_t soma = 0;

//   for (int i = 0; i < AMOSTRAS; i++) {
//     soma += analogReadMilliVolts(PIN_BATERIA);
//   }

//   float mvMedio = soma / (float)AMOSTRAS;
//   float tensaoADC = mvMedio / 1000.0;

//   const float R1 = 99500.0;
//   const float R2 = 46150.0;

//   return tensaoADC * (R1 + R2) / R2;
// }

float lerTensaoBateria() 
{
  const int AMOSTRAS = 8;
  uint32_t soma = 0;

  for (int i = 0; i < AMOSTRAS; i++) {
    soma += analogReadMilliVolts(PIN_BATERIA);
  }

  float mvMedio = soma / (float)AMOSTRAS;
  float tensaoADC = mvMedio / 1000.0;

  const float R1 = 99500.0;
  const float R2 = 46150.0;

  float tensaoBateria =
      tensaoADC * (R1 + R2) / R2;

  return tensaoBateria * FATOR_CALIBRACAO;
}

//-------------------------------------------
// Faz a varredura da Bateria para Interface
//-------------------------------------------

void serviceBateria() 
{

  unsigned long agora = millis();

  // ------------------------------------------------
  // Detecta qualquer atividade ou intenção de movimento
  // ------------------------------------------------
  if (motorEAtual != 0 ||
      motorDAtual != 0 ||
      motorEAlvo  != 0 ||
      motorDAlvo  != 0) {

    ultimaAtividadeMotor = agora;
  }

  // ------------------------------------------------
  // Leitura apenas a cada 1 segundo
  // ------------------------------------------------
  if (agora - ultimoTempoBateria < INTERVALO_BATERIA_MS)
    return;

  ultimoTempoBateria = agora;

  tensaoBateriaInstantanea = lerTensaoBateria();

  // ------------------------------------------------
  // Atualiza referência somente após 2 s de repouso
  // ------------------------------------------------
  bool motoresParados =
      motorEAtual == 0 &&
      motorDAtual == 0 &&
      motorEAlvo  == 0 &&
      motorDAlvo  == 0;

  if (motoresParados &&
      agora - ultimaAtividadeMotor >= TEMPO_REPOUSO_BATERIA_MS) 
  {

    if (tensaoBateriaReferencia == 0.0) 
    {

      tensaoBateriaReferencia = tensaoBateriaInstantanea;

    } 
    else 
    {

      const float ALPHA_REF = 0.20;

      tensaoBateriaReferencia =
          tensaoBateriaReferencia * (1.0 - ALPHA_REF) +
          tensaoBateriaInstantanea * ALPHA_REF;
    }
    
  }

  percentualBateria = calculaPercentualBateria(tensaoBateriaReferencia);

  if (millis() - ultimoEnvioBateria >= 5000) {
    ultimoEnvioBateria = millis();
    enviaBateriaWebSocket();
  }  

  Serial.printf(
      "Bat inst = %.2f V | Bat ref = %.2f V | Bat = %d%%\n",
      tensaoBateriaInstantanea,
      tensaoBateriaReferencia,
      percentualBateria
  );
}

//----------------------------------------------
// Calcula o percentual da Bateria interpolando
//----------------------------------------------

int calculaPercentualBateria(float tensao) 
{

  const float tensoes[] = {
    6.40, 6.60, 6.80, 7.00, 7.20,
    7.40, 7.60, 7.80, 8.00, 8.20, 8.40
  };

  const int percentuais[] = {
     0, 10, 20, 30, 40,
    50, 60, 70, 80, 90, 100
  };

  const int N = sizeof(tensoes) / sizeof(tensoes[0]);

  if (tensao <= tensoes[0])
    return 0;

  if (tensao >= tensoes[N - 1])
    return 100;

  for (int i = 0; i < N - 1; i++) {

    if (tensao >= tensoes[i] &&
        tensao <= tensoes[i + 1]) {

      float proporcao =
          (tensao - tensoes[i]) /
          (tensoes[i + 1] - tensoes[i]);

      return round(
          percentuais[i] +
          proporcao *
          (percentuais[i + 1] - percentuais[i])
      );
    }
  }

  return 0;
}

//--------------------------------------------
// Envia o status da Bateria para a Interface
//--------------------------------------------

void enviaBateriaWebSocket() 
{

  if (ws.count() == 0)
    return;

  char json[80];

  snprintf(
    json,
    sizeof(json),
    "{\"batV\":%.2f,\"batPct\":%d}",
    tensaoBateriaReferencia,
    percentualBateria
  );

  ws.textAll(json);
}

Html Principal

//-------------------------------
// HTML De Controle do Carrinho
//-------------------------------

#pragma once

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, maximum-scale=1.0,
                 user-scalable=no">

  <title>Carrinho 2WD</title>

  <style>

    * {
      box-sizing: border-box;
      -webkit-user-select: none;
      user-select: none;
      -webkit-tap-highlight-color: transparent;
    }

    body {
      margin: 0;
      min-height: 100vh;

      font-family: Arial, Helvetica, sans-serif;

      background:
        radial-gradient(circle at top, #263859 0%, #131c2b 45%, #080d14 100%);

      color: white;

      display: flex;
      justify-content: center;
      align-items: center;

      overflow: hidden;
      touch-action: none;
    }

    .container {
      width: 100%;
      max-width: 500px;

      padding: 22px;

      text-align: center;
    }

    h1 {
      margin: 0;
      font-size: 27px;
      font-weight: 600;
    }

    .subtitle {
      margin-top: 6px;
      margin-bottom: 20px;

      color: #9aa9bd;
      font-size: 14px;
    }

    /* --------------------------------------------------------
       STATUS WEBSOCKET
       -------------------------------------------------------- */

    .status-box {
      display: inline-flex;
      align-items: center;
      gap: 8px;

      padding: 7px 14px;
      margin-bottom: 25px;

      border-radius: 20px;
      background: rgba(255,255,255,0.07);

      font-size: 13px;
    }

    .status-led {
      width: 10px;
      height: 10px;

      border-radius: 50%;
      background: #ff4d4d;

      box-shadow: 0 0 8px #ff4d4d;
    }

    .status-led.connected {
      background: #42e875;
      box-shadow: 0 0 8px #42e875;
    }

    /* --------------------------------------------------------
       JOYSTICK
       -------------------------------------------------------- */

    .joystick-wrapper {
      display: flex;
      justify-content: center;
      align-items: center;

      margin: 5px auto 25px auto;
    }

    #joystick {
      position: relative;

      width: 260px;
      height: 260px;

      border-radius: 50%;

      background:
        radial-gradient(circle,
          rgba(255,255,255,0.08) 0%,
          rgba(255,255,255,0.04) 50%,
          rgba(0,0,0,0.15) 100%);

      border: 3px solid rgba(255,255,255,0.20);

      box-shadow:
        inset 0 0 35px rgba(0,0,0,0.5),
        0 8px 30px rgba(0,0,0,0.45);

      touch-action: none;
    }

    /* linhas de referência */

    #joystick::before {
      content: "";

      position: absolute;
      left: 50%;
      top: 10%;

      width: 1px;
      height: 80%;

      background: rgba(255,255,255,0.12);
    }

    #joystick::after {
      content: "";

      position: absolute;
      top: 50%;
      left: 10%;

      height: 1px;
      width: 80%;

      background: rgba(255,255,255,0.12);
    }

    #knob {
      position: absolute;

      width: 88px;
      height: 88px;

      left: 50%;
      top: 50%;

      transform: translate(-50%, -50%);

      border-radius: 50%;

      background:
        radial-gradient(circle at 35% 30%,
          #77c7ff 0%,
          #268bea 50%,
          #1261ad 100%);

      border: 3px solid rgba(255,255,255,0.5);

      box-shadow:
        0 6px 16px rgba(0,0,0,0.5),
        inset 0 2px 5px rgba(255,255,255,0.3);

      z-index: 5;
    }

    .direction {
      position: absolute;

      color: rgba(255,255,255,0.42);

      font-size: 18px;
      font-weight: bold;
    }

    .dir-up {
      top: 12px;
      left: 50%;
      transform: translateX(-50%);
    }

    .dir-down {
      bottom: 12px;
      left: 50%;
      transform: translateX(-50%);
    }

    .dir-left {
      left: 15px;
      top: 50%;
      transform: translateY(-50%);
    }

    .dir-right {
      right: 15px;
      top: 50%;
      transform: translateY(-50%);
    }

    /* --------------------------------------------------------
       VALORES
       -------------------------------------------------------- */

    .values {
      display: grid;

      grid-template-columns: repeat(3, 1fr);

      gap: 10px;
    }

    .value-box {
      padding: 12px 5px;

      background: rgba(255,255,255,0.07);

      border-radius: 12px;

      border: 1px solid rgba(255,255,255,0.08);
    }

    .value-title {
      font-size: 11px;
      color: #91a2b8;

      margin-bottom: 5px;
    }

    .value {
      font-size: 22px;
      font-weight: bold;
    }

    /* --------------------------------------------------------
       MENSAGEM HOME / MOVIMENTO
       -------------------------------------------------------- */

    #movement {
      margin-top: 16px;

      height: 26px;

      font-size: 15px;
      color: #75c8ff;
    }

    .footer {
      margin-top: 15px;

      font-size: 11px;
      color: #64758a;
    }

    .config-link {
      color: #75c8ff;

      text-decoration: none;

      font-size: 12px;
    }

    .config-link:hover {
      text-decoration: underline;
    }    

    .battery {
      font-weight: bold;
      padding: 6px 10px;
      border-radius: 8px;
      display: inline-block;
    }

    .battery.normal {
      color: #22c55e;
    }

    .battery.atencao {
      color: #f59e0b;
    }

    .battery.critico {
      color: #ef4444;
    }  

  </style>
</head>

<body>

<div class="container">

  <h1>🚙 Carrinho 2WD</h1>

  <div class="subtitle">
    Controle de Movimento
  </div>

  <div class="status-box">
    <div id="statusLed" class="status-led"></div>
    <span id="statusText">Conectando...</span>
  </div>

  <div id="batteryText" class="battery normal">
    🔋 --% | --.-- V
  </div>

  <div class="joystick-wrapper">

    <div id="joystick">

      <div class="direction dir-up">▲</div>
      <div class="direction dir-down">▼</div>
      <div class="direction dir-left">◀</div>
      <div class="direction dir-right">▶</div>

      <div id="knob"></div>

    </div>

  </div>


  <div class="values">

    <div class="value-box">
      <div class="value-title">EIXO X</div>
      <div id="xValue" class="value">0</div>
    </div>

    <div class="value-box">
      <div class="value-title">EIXO Y</div>
      <div id="yValue" class="value">0</div>
    </div>

    <div class="value-box">
      <div class="value-title">VELOCIDADE</div>
      <div id="speedValue" class="value">0%</div>
    </div>

  </div>

  <div id="movement">
    HOME
  </div>

  <div class="footer">
    ESP32-C3 • WebSocket • Modo AP
    <br><br>
    <a href="/config" class="config-link">
      ⚙ Configuração
    </a>
  </div>

</div>


<script>

// ============================================================
// WEBSOCKET
// ============================================================

let websocket;

function initWebSocket() {

  const gateway =
    `ws://${window.location.hostname}/ws`;

  websocket = new WebSocket(gateway);


  websocket.onopen = function() {

    console.log("WebSocket conectado");

    document.getElementById("statusLed")
            .classList.add("connected");

    document.getElementById("statusText")
            .innerHTML = "Conectado";
  };

  websocket.onmessage = function(event) {

    try {

      const data = JSON.parse(event.data);

    if (data.batPct !== undefined) {

      const battery =
        document.getElementById("batteryText");

      let icone = "🔋";

      battery.classList.remove(
        "normal",
        "atencao",
        "critico"
      );

      if (data.batPct < 20) {

        // CRÍTICO
        battery.classList.add("critico");
        icone = "🪫";

      } else if (data.batPct <= 40) {

        // ATENÇÃO
        battery.classList.add("atencao");

      } else {

        // NORMAL
        battery.classList.add("normal");
      }

      battery.innerHTML =
        `${icone} ${data.batPct}% | ${data.batV.toFixed(2)} V`;
    }

    } catch (e) {

      console.log("Mensagem WebSocket não JSON:", event.data);
    }
  };

  websocket.onclose = function() {

    console.log("WebSocket desconectado");

    document.getElementById("statusLed")
            .classList.remove("connected");

    document.getElementById("statusText")
            .innerHTML = "Desconectado";

    // tenta novamente
    setTimeout(initWebSocket, 1500);
  };


  websocket.onerror = function(error) {

    console.log("Erro WebSocket:", error);
  };
}


// ============================================================
// JOYSTICK
// ============================================================

const joystick =
  document.getElementById("joystick");

const knob =
  document.getElementById("knob");


const xDisplay =
  document.getElementById("xValue");

const yDisplay =
  document.getElementById("yValue");

const speedDisplay =
  document.getElementById("speedValue");

const movementDisplay =
  document.getElementById("movement");


// ------------------------------------------------------------
// ESTADO
// ------------------------------------------------------------

let pressionado = false;

let x = 0;
let y = 0;
let velocidade = 0;

let timerEnvio = null;


// ------------------------------------------------------------
// CONFIGURAÇÃO
// ------------------------------------------------------------

// Frequência de transmissão:
// 50 ms = 20 comandos por segundo

const INTERVALO_ENVIO = 50;

// pequena região central considerada repouso

const DEAD_ZONE = 5;


// ============================================================
// POINTER DOWN
// ============================================================

joystick.addEventListener(
  "pointerdown",
  function(event) {

    pressionado = true;

    joystick.setPointerCapture(event.pointerId);

    calculaPosicao(event);

    enviaMovimento();

    timerEnvio = setInterval(
      enviaMovimento,
      INTERVALO_ENVIO
    );
  }
);


// ============================================================
// POINTER MOVE
// ============================================================

joystick.addEventListener(
  "pointermove",
  function(event) {

    if (!pressionado)
      return;

    calculaPosicao(event);
  }
);


// ============================================================
// POINTER UP
// ============================================================

joystick.addEventListener(
  "pointerup",
  function(event) {

    voltaHome();
  }
);


// ============================================================
// POINTER CANCEL
// ============================================================

joystick.addEventListener(
  "pointercancel",
  function(event) {

    voltaHome();
  }
);


// ============================================================
// CALCULA X / Y
// ============================================================

function calculaPosicao(event) {

  const rect =
    joystick.getBoundingClientRect();

  const centerX =
    rect.width / 2;

  const centerY =
    rect.height / 2;


  let dx =
    event.clientX -
    rect.left -
    centerX;

  let dy =
    event.clientY -
    rect.top -
    centerY;


  // Raio permitido ao centro do knob

  const knobRadius =
    knob.offsetWidth / 2;

  const maxRadius =
    centerX - knobRadius - 5;


  let distancia =
    Math.sqrt(
      dx * dx +
      dy * dy
    );


  // Limita ao círculo externo

  if (distancia > maxRadius) {

    dx =
      dx / distancia *
      maxRadius;

    dy =
      dy / distancia *
      maxRadius;

    distancia = maxRadius;
  }


  // ----------------------------------------------------------
  // VALORES NORMALIZADOS
  // -100 .. +100
  //
  // Y é invertido:
  //
  // cima  = +100
  // baixo = -100
  // ----------------------------------------------------------

  x =
    Math.round(
      dx /
      maxRadius *
      100
    );

  y =
    Math.round(
      -dy /
      maxRadius *
      100
    );


  velocidade =
    Math.round(
      distancia /
      maxRadius *
      100
    );


  // DEAD ZONE

  if (velocidade < DEAD_ZONE) {

    x = 0;
    y = 0;
    velocidade = 0;
  }


  // ----------------------------------------------------------
  // MOVE O KNOB
  // ----------------------------------------------------------

  knob.style.left =
    (centerX + dx) + "px";

  knob.style.top =
    (centerY + dy) + "px";


  atualizaDisplay();
}


// ============================================================
// ATUALIZA DISPLAY
// ============================================================

function atualizaDisplay() {

  xDisplay.innerHTML = x;

  yDisplay.innerHTML = y;

  speedDisplay.innerHTML =
    velocidade + "%";


  if (velocidade === 0) {

    movementDisplay.innerHTML =
      "HOME";

  } else {

    movementDisplay.innerHTML =
      determinaMovimento();
  }
}


// ============================================================
// DESCRIÇÃO APENAS VISUAL
// ============================================================

function determinaMovimento() {

  if (Math.abs(y) > Math.abs(x)) {

    if (y > 0) {

      if (x > 25)
        return "↗ Frente / Direita";

      if (x < -25)
        return "↖ Frente / Esquerda";

      return "↑ Frente";

    } else {

      if (x > 25)
        return "↘ Ré / Direita";

      if (x < -25)
        return "↙ Ré / Esquerda";

      return "↓ Ré";
    }

  } else {

    if (x > 0)
      return "↻ Direita";

    if (x < 0)
      return "↺ Esquerda";
  }

  return "HOME";
}


// ============================================================
// ENVIA MOVIMENTO
// ============================================================

function enviaMovimento() {

  if (!pressionado)
    return;

  if (!websocket)
    return;

  if (websocket.readyState !== WebSocket.OPEN)
    return;

  const mensagem =
    "MOVE," +
    x + "," +
    y + "," +
    velocidade;

  websocket.send(mensagem);
}


// ============================================================
// HOME
// ============================================================

function voltaHome() {

  if (!pressionado)
    return;


  pressionado = false;


  if (timerEnvio !== null) {

    clearInterval(timerEnvio);

    timerEnvio = null;
  }


  x = 0;
  y = 0;
  velocidade = 0;


  knob.style.left = "50%";
  knob.style.top  = "50%";


  atualizaDisplay();


  // HOME deve ser enviado imediatamente

  if (
      websocket &&
      websocket.readyState === WebSocket.OPEN
     ) {

    websocket.send("HOME");
  }
}


// ============================================================
// SEGURANÇA ADICIONAL
// ============================================================

// perdeu foco da janela

window.addEventListener(
  "blur",
  function() {

    if (pressionado)
      voltaHome();
  }
);


// página ficou invisível

document.addEventListener(
  "visibilitychange",
  function() {

    if (document.hidden && pressionado)
      voltaHome();
  }
);


// ============================================================
// INICIALIZAÇÃO
// ============================================================

window.addEventListener(
  "load",
  function() {

    initWebSocket();
  }
);

// ============================================================
// ENCERRA WEBSOCKET AO SAIR DA PÁGINA
// ============================================================

window.addEventListener(
  "pagehide",
  function()
  {
    if (websocket)
    {
      websocket.onclose = null;

      if (
          websocket.readyState === WebSocket.OPEN ||
          websocket.readyState === WebSocket.CONNECTING
         )
      {
        websocket.close();
      }
    }
  }
);

</script>

</body>

</html>
)rawliteral";

Html de Configuração

#pragma once

const char CONFIG_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,
                 maximum-scale=1.0,
                 user-scalable=no">

  <title>Configuração - Carrinho 2WD</title>

  <style>

    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      min-height: 100vh;

      font-family: Arial, Helvetica, sans-serif;

      background:
        radial-gradient(
          circle at top,
          #263859 0%,
          #131c2b 45%,
          #080d14 100%
        );

      color: white;

      display: flex;
      justify-content: center;
      align-items: flex-start;
    }

    .container {
      width: 100%;
      max-width: 500px;

      padding: 24px 22px 35px 22px;
    }

    h1 {
      margin: 10px 0 5px 0;

      text-align: center;

      font-size: 27px;
      font-weight: 600;
    }

    .subtitle {
      text-align: center;

      margin-bottom: 25px;

      color: #9aa9bd;

      font-size: 14px;
    }

    .card {
      background: rgba(255,255,255,0.07);

      border:
        1px solid rgba(255,255,255,0.09);

      border-radius: 16px;

      padding: 20px;

      margin-bottom: 18px;

      box-shadow:
        0 8px 25px rgba(0,0,0,0.25);
    }

    .card-title {
      font-size: 14px;
      font-weight: bold;

      color: #75c8ff;

      margin-bottom: 18px;
    }

    .field {
      margin-bottom: 17px;
    }

    .field:last-child {
      margin-bottom: 0;
    }

    label {
      display: block;

      margin-bottom: 7px;

      font-size: 12px;

      color: #a8b6c8;
    }

    input {
      width: 100%;

      padding: 12px 13px;

      border-radius: 9px;

      border:
        1px solid rgba(255,255,255,0.14);

      outline: none;

      background:
        rgba(0,0,0,0.22);

      color: white;

      font-size: 15px;
    }

    input:focus {
      border-color: #268bea;

      box-shadow:
        0 0 0 2px rgba(38,139,234,0.18);
    }

    .hint {
      margin-top: 5px;

      color: #687b91;

      font-size: 10px;
    }

    .buttons {
      display: grid;

      grid-template-columns: 1fr 1fr;

      gap: 12px;

      margin-top: 22px;
    }

    button,
    .button {
      width: 100%;

      padding: 13px;

      border: none;
      border-radius: 10px;

      font-size: 14px;
      font-weight: bold;

      cursor: pointer;

      text-align: center;
      text-decoration: none;
    }

    .save {
      background:
        linear-gradient(
          135deg,
          #268bea,
          #1261ad
        );

      color: white;

      box-shadow:
        0 5px 15px rgba(38,139,234,0.25);
    }

    .return {
      display: flex;

      align-items: center;
      justify-content: center;

      background:
        rgba(255,255,255,0.08);

      color: #c8d3df;

      border:
        1px solid rgba(255,255,255,0.10);
    }

    #message {
      display: none;

      margin-top: 18px;

      padding: 12px;

      border-radius: 10px;

      text-align: center;

      font-size: 13px;
    }

    .success {
      display: block !important;

      background:
        rgba(66,232,117,0.12);

      border:
        1px solid rgba(66,232,117,0.30);

      color: #71ed96;
    }

    .error {
      display: block !important;

      background:
        rgba(255,77,77,0.12);

      border:
        1px solid rgba(255,77,77,0.30);

      color: #ff7c7c;
    }

    .footer {
      margin-top: 25px;

      text-align: center;

      font-size: 11px;

      color: #64758a;
    }

    .btn-restart {
      background: #d97706;
      color: white;

      border: none;
      border-radius: 8px;

      padding: 12px 20px;

      font-size: 14px;
      font-weight: 600;

      cursor: pointer;
    }

    .btn-restart:hover {
      opacity: 0.9;
    }    

  </style>

</head>


<body>

<div class="container">

  <h1>⚙ Configuração</h1>

  <div class="subtitle">
    Carrinho 2WD
  </div>


  <form id="configForm">


    <!-- =====================================================
         DISPOSITIVO
         ===================================================== -->

    <div class="card">

      <div class="card-title">
        DISPOSITIVO
      </div>


      <div class="field">

        <label for="deviceName">
          Nome do dispositivo
        </label>

        <input
          type="text"
          id="deviceName"
          maxlength="32"
          autocomplete="off"
          required>

      </div>

    </div>


    <!-- =====================================================
         ACCESS POINT
         ===================================================== -->

    <div class="card">

      <div class="card-title">
        REDE WI-FI — ACCESS POINT
      </div>


      <div class="field">

        <label for="apSsid">
          SSID
        </label>

        <input
          type="text"
          id="apSsid"
          maxlength="32"
          autocomplete="off"
          required>

      </div>


      <div class="field">

        <label for="apPass">
          Senha
        </label>

      <input
        type="password"
        id="apPass"
        minlength="8"
        maxlength="63"
        autocomplete="new-password"
        placeholder="Deixe em branco para manter">

      <div class="hint">
        Deixe em branco para manter a senha atual.
        Nova senha: mínimo de 8 caracteres.
      </div>

      </div>

    </div>


    <!-- =====================================================
         ADMINISTRAÇÃO
         ===================================================== -->

    <div class="card">

      <div class="card-title">
        ADMINISTRAÇÃO
      </div>


      <div class="field">

        <label for="adminUser">
          Usuário
        </label>

        <input
          type="text"
          id="adminUser"
          maxlength="32"
          autocomplete="username"
          required>

      </div>


      <div class="field">

        <label for="adminPass">
          Senha
        </label>

        <input
          type="password"
          id="adminPass"
          maxlength="32"
          autocomplete="new-password"
          placeholder="Deixe em branco para manter">

        <div class="hint">
          Deixe em branco para manter a senha atual.
        </div>

      </div>

    </div>


    <!-- =====================================================
         BOTÕES
         ===================================================== -->

    <div class="buttons">

      <a
        href="/"
        class="button return">
        ← RETORNAR
      </a>

      <button
        type="submit"
        class="save">
        SALVAR
      </button>

      <button
        type="button"
        class="btn-restart"
        onclick="reiniciarDispositivo()">
        REINICIAR
      </button>      

    </div>


    <div id="message"></div>


  </form>


  <div class="footer">
    ESP32-C3 • Carrinho 2WD
  </div>

</div>


<script>

// ============================================================
// ELEMENTOS
// ============================================================

const form =
  document.getElementById("configForm");

const message =
  document.getElementById("message");


// ============================================================
// MOSTRA MENSAGEM
// ============================================================

function mostraMensagem(texto, tipo)
{
  message.className = "";

  message.innerHTML = texto;

  message.classList.add(tipo);
}


// ============================================================
// CARREGA CONFIGURAÇÃO
// ============================================================

async function carregaConfig()
{
  try
  {
    const response =
      await fetch("/api/config");


    if (!response.ok)
    {
      throw new Error(
        "Erro HTTP " + response.status
      );
    }


    const config =
      await response.json();


    document.getElementById("deviceName").value =
      config.deviceName || "";

    document.getElementById("apSsid").value =
      config.apSsid || "";

    document.getElementById("apPass").value =
      config.apPass || "";

    document.getElementById("adminUser").value =
      config.adminUser || "";

    document.getElementById("adminPass").value =
      config.adminPass || "";
  }
  catch (erro)
  {
    console.log(erro);

    mostraMensagem(
      "Não foi possível carregar a configuração.",
      "error"
    );
  }
}


// ============================================================
// SALVA CONFIGURAÇÃO
// ============================================================

form.addEventListener(
  "submit",

  async function(event)
  {
    event.preventDefault();


    const config =
    {
      deviceName:
        document.getElementById("deviceName").value.trim(),

      apSsid:
        document.getElementById("apSsid").value.trim(),

      apPass:
        document.getElementById("apPass").value,

      adminUser:
        document.getElementById("adminUser").value.trim(),

      adminPass:
        document.getElementById("adminPass").value
    };


    // --------------------------------------------------------
    // Validação adicional
    // --------------------------------------------------------

    if (config.deviceName.length === 0)
    {
      mostraMensagem(
        "Informe o nome do dispositivo.",
        "error"
      );

      return;
    }


    if (config.apSsid.length === 0)
    {
      mostraMensagem(
        "Informe o SSID do Access Point.",
        "error"
      );

      return;
    }

    if (
        config.apPass.length > 0 &&
        config.apPass.length < 8
      )
    {
      mostraMensagem(
        "A nova senha do Access Point deve possuir pelo menos 8 caracteres.",
        "error"
      );

      return;
    }

    if (config.adminUser.length === 0)
    {
      mostraMensagem(
        "O usuário de administração é obrigatório.",
        "error"
      );

      return;
    }

    // --------------------------------------------------------
    // Envia JSON
    // --------------------------------------------------------

    try
    {
      const response =
        await fetch(
          "/api/config",
          {
            method: "POST",

            headers:
            {
              "Content-Type":
                "application/json"
            },

            body:
              JSON.stringify(config)
          }
        );


      if (!response.ok)
      {
        const texto =
          await response.text();

        throw new Error(texto);
      }


      mostraMensagem(
        "Configuração salva. Reinicie o dispositivo para aplicar alterações da rede.",
        "success"
      );
    }
    catch (erro)
    {
      console.log(erro);

      mostraMensagem(
        "Erro ao salvar a configuração.",
        "error"
      );
    }

  }
);

async function reiniciarDispositivo()
{
  const confirma = confirm(
    "Deseja realmente reiniciar o dispositivo?"
  );

  if (!confirma)
  {
    return;
  }


  try
  {
    mostraMensagem(
      "Reiniciando dispositivo...",
      "success"
    );


    const resposta =
      await fetch(
        "/api/restart",
        {
          method: "POST"
        }
      );


    if (!resposta.ok)
    {
      throw new Error(
        "Erro HTTP " + resposta.status
      );
    }


    // Após o restart esta conexão será perdida.
    // Portanto não fazemos novas requisições aqui.

  }
  catch (erro)
  {
    mostraMensagem(
      "Não foi possível solicitar o reinício.",
      "error"
    );

    console.error(erro);
  }
}

// ============================================================
// INICIALIZAÇÃO
// ============================================================

window.addEventListener(
  "load",
  carregaConfig
);

</script>

</body>

</html>
)rawliteral";
 

Conclusão

O objetivo deste projeto não foi propor uma nova arquitetura de veículo robótico, mas demonstrar como recursos disponíveis nos microcontroladores atuais podem ser utilizados para simplificar e modernizar uma aplicação tradicional. A adoção do ESP32-C3 permitiu concentrar em um único dispositivo o controle dos motores, a criação da rede Wi-Fi, o servidor Web, a comunicação WebSocket, a configuração do equipamento, o acompanhamento da bateria e os recursos de atualização do firmware. O joystick implementado diretamente no navegador elimina a necessidade de controles físicos ou aplicativos específicos, permitindo que praticamente qualquer smartphone, tablet ou computador com Wi-Fi seja utilizado para operar o carrinho. Da mesma forma, a utilização de baterias 18650 associada a um módulo de recarga 2S e ao acompanhamento do estado da bateria procura aproximar o protótipo de uma solução mais prática para utilização contínua, e não apenas de uma montagem experimental de bancada. Mais do que apresentar um carrinho de duas rodas, esperamos que o projeto sirva como plataforma didática para explorar a integração entre sistemas embarcados e aplicações Web. A mesma abordagem pode ser expandida pela comunidade para diferentes aplicações de robótica móvel, incorporando sensores de distância, iluminação, telemetria, modos autônomos de movimentação, câmeras ou outros periféricos. Nesse sentido, o carrinho funciona também como um ponto de partida simples para experimentar uma ideia cada vez mais presente em projetos com ESP32: utilizar o próprio navegador como interface universal de controle e supervisão de dispositivos embarcados.

Neste projeto apresentamos a construção de um carrinho de duas rodas controlado por um ESP32-C3 Mini, utilizando o conhecido driver de motores L298N e uma interface de controle baseada inteiramente em tecnologias Web.

Precisa dos componentes para este projeto?

Encontre tudo na Loja Eletrogate com frete grátis para compras acima de R$ 200