iiOTESP32 Brasil
Modo de contingência: páginas públicas e downloads seguem disponíveis; login, comunidade e dados do servidor podem ficar indisponíveis.
Voltar para catálogo de tutoriais
Ilustração: placa ESP32 com sensores de temperatura e umidade; a conexão com a nuvem cai, os dados são guardados em um chip de memória e enviados ao servidor quando a rede volta
Imagem ilustrativa gerada por IA
AvançadoIIoTLeitura: 16 min

Buffer local na SPIFFS/LittleFS quando a rede cair

Guardar o dado quando a rede cai é a parte fácil. Difícil é não destruir a flash, saber que horas eram e não afogar o broker quando a rede volta.

Tecnologias:#ESP32#LittleFS#Store-and-Forward#Buffer circular#NTP

Requisitos de hardware:

  • Placa ESP32 ou ESP32-S3
  • Partição de sistema de arquivos no particionamento
  • Rede Wi-Fi e um broker MQTT para o teste de reenvio

O dado existe, e se perde

Um gateway que lê um medidor a cada dez segundos e publica na nuvem funciona bem — até a internet cair. Aí, durante quarenta minutos, ele continua lendo e continua jogando fora. Quando a rede volta, o histórico daquele período simplesmente não existe.

Para um painel de temperatura, é chato. Para medição de energia faturável, para rastreabilidade de processo ou para investigar por que uma máquina parou às três da manhã, é inaceitável.

Store-and-forward resolve: guardar localmente enquanto não dá para enviar, e enviar quando der. A ideia é óbvia. A implementação tem três armadilhas que quase nenhum tutorial menciona, e é sobre elas que este artigo trata.

Buffer em RAM não basta

A primeira tentação é guardar num vetor na memória. É rápido, é simples, e não desgasta nada.

E se perde no reinício. O que seria tolerável se reinício fosse raro — mas a queda de rede e o reinício andam juntos com uma frequência incômoda: falta de energia derruba os dois, o watchdog reinicia quando algo trava, e uma atualização OTA reinicia por definição.

Ou seja: o buffer em RAM tende a evaporar exatamente na hora em que ele importava.

A RAM continua tendo papel, como você vai ver na próxima seção. Só não pode ser o único lugar.

Desgaste da flash: a restrição que decide o projeto

Esta é a armadilha que separa um projeto que dura de um que morre em meses.

A flash do ESP32 suporta um número finito de ciclos de apagamento por setor — a ordem de grandeza usual é cem mil. E a granularidade de apagamento é o setor inteiro, de 4 KB, mesmo que você tenha mudado dez bytes.

Faça a conta com um registro por segundo, gravado direto com sincronização a cada escrita. Isso é, na prática, um apagamento de setor por segundo. Cem mil segundos são pouco menos de 28 horas.

Sistemas de arquivos como o LittleFS distribuem o desgaste entre os setores, o que melhora muito o quadro. Numa partição de 1 MB, com 256 setores, a mesma conta dá algo próximo de dez meses. Melhor, e ainda assim inaceitável para um equipamento industrial.

A solução não é escrever menos dados. É escrever com menos frequência:

code.cpp
cpp
1acumule em RAM ate encher um setor (4 KB)
2grave o bloco inteiro de uma vez
3sincronize uma vez

Com registros de 64 bytes, um setor comporta 64 leituras. Gravando uma vez por minuto em vez de uma vez por segundo, o desgaste cai por um fator de sessenta — e a vida útil sai de meses para décadas.

O preço é perder até um minuto de dados num reinício abrupto. Compare com destruir a flash em dez meses e a escolha fica fácil. Se o seu caso não tolera nem isso, o caminho é uma memória FRAM externa, que suporta ordens de grandeza mais ciclos.

Registro de tamanho fixo, não linha de JSON

A segunda tentação é gravar uma linha de JSON por leitura. É legível e conveniente.

E é uma armadilha por três motivos. O arquivo cresce sem limite, porque não há como sobrescrever a linha mais antiga sem reescrever o arquivo inteiro. Não dá para saltar direto para o registro N sem ler tudo o que veio antes. E o JSON gasta de três a cinco vezes mais bytes que a informação que carrega, o que multiplica o desgaste da seção anterior.

Registro binário de tamanho fixo resolve os três:

code.cpp
cpp
1struct __attribute__((packed)) Registro {
2 uint32_t marcaTempo;
3 float valor;
4 uint16_t sequencia;
5 uint8_t origem;
6 uint8_t estado;
7};

Doze bytes por leitura, sempre no mesmo tamanho. Isso permite calcular o endereço do registro N por multiplicação, sobrescrever o mais antigo sem tocar no resto, e caber cerca de trezentas leituras em cada setor de 4 KB.

O atributo packed importa: sem ele o compilador insere preenchimento para alinhar os campos, e o tamanho do registro deixa de ser o que você calculou.

O buffer circular

Com registros de tamanho fixo, o buffer vira um anel: escreve-se na posição da cabeça, lê-se da cauda, e ambos dão a volta ao chegar no fim.

code.cpp
cpp
1typedef struct {
2 uint32_t cabeca;
3 uint32_t cauda;
4 uint32_t total;
5 uint32_t marca;
6} Indice;

O índice precisa ser persistido também, senão o reinício perde a noção de onde estava. Grave-o num arquivo separado e pequeno, e não a cada escrita — junto com o bloco, na mesma sincronização.

A marca de validade cumpre o mesmo papel do artigo de watchdog: sem ela, um índice corrompido faz o firmware ler lixo como se fosse dado.

Descartar o mais velho ou o mais novo?

Quando o buffer enche, alguma coisa vai ser perdida. Qual é decisão de projeto, e depende do dado.

Para telemetria contínua — temperatura, corrente, pressão — descarte o mais antigo. Se a rede ficou fora por seis horas e o buffer só guarda duas, você prefere as duas últimas horas às duas primeiras.

Para eventos e alarmes, o oposto. Um alarme de sobrecarga não pode ser descartado porque veio muita telemetria depois dele. Nesse caso o certo são dois buffers separados: um circular grande para telemetria, com descarte do mais antigo, e um pequeno e protegido para eventos, que nunca descarta e sinaliza quando enche.

Misturar os dois no mesmo anel garante que, no pior momento, você vai perder justamente o registro que explicava o problema.

O carimbo de tempo é o problema escondido

Aqui está a falha que só aparece depois que tudo parece funcionar.

O dado guardado offline precisa carregar o instante em que foi lido, não o instante em que foi enviado. Se o servidor carimba na chegada, seis horas de buffer viram seis horas de leituras com o mesmo horário, e o histórico fica inútil.

Só que o ESP32 não tem relógio ao ligar. Ele descobre a hora por NTP, que exige a rede — a mesma que caiu.

O cenário completo: falta energia, o dispositivo religa sem rede, começa a ler e a bufferizar. Ele não sabe que horas são. Mesmo assim precisa carimbar cada registro.

A saída é gravar o tempo relativo e converter depois:

code.cpp
cpp
1guarde millis() no registro, junto de um contador de sessao
2quando o NTP sincronizar, calcule o instante real da sincronizacao
3para cada registro: instante = referencia - (millisAgora - millisDoRegistro)

Assim os registros carregam o horário correto mesmo tendo sido gerados quando o relógio não existia. O contador de sessão evita misturar leituras de antes e depois de um reinício, cujos valores de millis não são comparáveis.

É trabalho a mais. Sem ele, o buffer entrega dados com horário errado — o que costuma ser pior que não entregar.

Só apague depois de confirmar

Reenviar e apagar em seguida parece natural, e perde dado.

Se a conexão cair entre o envio e a confirmação, o registro foi apagado e nunca chegou. A ordem certa é: enviar, esperar a confirmação do broker, e só então avançar a cauda do buffer.

Com MQTT, isso significa qualidade de serviço 1 no mínimo, porque o nível 0 não confirma nada. E significa aceitar que o mesmo registro pode chegar duas vezes se a confirmação se perder — daí o campo de sequência no registro, que permite ao lado de lá descartar duplicata.

A regra do ofício: é melhor entregar duas vezes do que zero vezes. Deduplicar no servidor é trivial; recuperar dado perdido é impossível.

Reenvio sem afogar a rede

Quando a rede volta com seis horas de buffer acumulado, a reação ingênua é despejar tudo o mais rápido possível.

Três coisas quebram. O broker pode aplicar limite de taxa e derrubar a conexão. O laço principal fica preso no reenvio e para de ler o sensor, criando um novo buraco no histórico. E o consumo de pico pode provocar aquele brownout do artigo de watchdog.

O correto é reenviar em lotes pausados, intercalados com o trabalho normal:

code.cpp
cpp
1a cada volta do laco, envie no maximo N registros
2depois volte a ler sensor e tratar o resto
3se falhar, pare o lote e espere a proxima volta

Vinte registros por volta, com o laço rodando dez vezes por segundo, escoam duzentos por segundo — seis horas de buffer a cada dez segundos se esvaziam em pouco tempo, sem travar nada.

Tutorial: do zero ao buffer que sobrevive

Passo 1: reservar espaço no particionamento

O buffer precisa de uma partição de sistema de arquivos. Se você já seguiu o tutorial de OTA, a tabela de lá reserva o spiffs no fim — é ele.

Numa placa de 4 MB com OTA, sobra pouco. Dimensione conscientemente: com registros de 12 bytes, 512 KB guardam cerca de quarenta mil leituras.

Passo 2: definir o registro e calcular a capacidade

Escolha os campos e some os bytes. Depois divida o tamanho da partição pelo tamanho do registro para saber quantas leituras cabem, e multiplique pelo intervalo de leitura para saber quantas horas de autonomia você tem.

Esse número é o que você deve informar a quem opera. Autonomia de buffer é especificação, não detalhe.

Passo 3: acumular em RAM antes de gravar

Implemente o acúmulo primeiro, e só depois a gravação. Com registros de 12 bytes, acumular 340 deles enche um setor de 4 KB.

Grave também quando o acúmulo passar de um tempo máximo — caso contrário, um sensor lento deixaria dados na RAM por horas, sem proteção.

Passo 4: gravar e testar a persistência

Grave o bloco, sincronize e reinicie a placa de propósito. Ao voltar, o firmware deve encontrar o índice, saber quantos registros existem e continuar de onde parou.

Se ele voltar zerado, o índice não está sendo persistido junto do bloco.

Passo 5: testar o giro do anel

Reduza o tamanho da partição, ou o limite de registros, para algo pequeno de propósito. Encha o buffer e continue gravando.

O comportamento correto é sobrescrever os mais antigos sem crescer o arquivo e sem corromper o índice. Confirme que a contagem para de subir quando o anel está cheio.

Passo 6: testar o reenvio com a rede caindo no meio

Acumule algumas centenas de registros com o broker parado. Suba o broker e observe o escoamento em lotes.

No meio do reenvio, derrube o broker de novo. O buffer deve parar de escoar sem perder o que ainda não foi confirmado, e retomar quando o broker voltar.

Esse é o teste que prova a implementação. Se ao final da bagunça o número de registros entregues bater com o número gerado, está pronto.

Erros comuns e como resolver

A flash morreu em poucos meses. Gravação por leitura em vez de por bloco. Acumule em RAM e grave um setor de cada vez.

O arquivo cresce até encher a partição. Você está anexando em vez de usar buffer circular. Registro de tamanho fixo e sobrescrita do mais antigo.

Depois do reinício o buffer volta vazio. O índice não está persistido, ou está sendo gravado sem sincronização.

Os dados chegam todos com o mesmo horário. O carimbo está sendo posto na chegada. Grave o tempo relativo no registro e converta na hora do envio.

Faltam registros depois de uma queda no meio do reenvio. Você apaga antes de confirmar. Avance a cauda só depois do reconhecimento do broker.

O broker derruba a conexão quando a rede volta. Reenvio sem limite de taxa. Envie em lotes pausados.

O sensor para de ser lido durante o reenvio. O laço está preso no escoamento. Limite quantos registros saem por volta.

Perdi um alarme importante e sobrou telemetria. Buffers misturados. Separe: circular grande para telemetria, pequeno e protegido para eventos.

Ver mais tutoriais técnicosIIoT ESP32 Brasil — Documentação Prática