Skip to the content.

Telemetria — cadência e lote automáticos: medições e plano

Pedido (Ângelo, 07/09/2026): o intervalo de telemetria regia a cadência dos lotes; a cadência deve virar automática, e o tamanho do lote também. Resposta rápida do servidor → próximo lote imediato; resposta lenta → intervalo cresce; onde couber, o aparelho hiberna e continua depois. Considerar todas as variáveis, maximizar eficiência, otimizar energia. Encher o LittleFS com dados sintéticos, medir com servidor no PC, medir com criptografia. Base: feature/simut-air, build Air. Bancada: tools/telemetry_bench/phase_cadence.py, servidor server_http.py (modos ok, slow, --tls, --keepalive), relatório cadence_report.py, limpeza cadence_cleanup.py. Irmãos: SIMUT_AIR_PLANO_ENERGIA.md (duas cadências, rádio raro), SIMUT_AIR_PLANO_FIX.md.


0. Resposta curta

Está implementado, medido e no ferro (§3.7 diz onde ficou cada peça; §3.10 tem a validação). O que muda, com a configuração que o aparelho do Ângelo tem hoje (t_int = 300 s, leitura a 1 min, lote 100):

  antes depois
dreno em M0, HTTP 0,33 reg/s (31 h para 35 mil registros) 1.276 reg/s (o mesmo backlog em 27 s)
dreno em M0, HTTPS 0,33 reg/s 140 reg/s
wake de telemetria (M1) 0 registros, 57 s acordado com o rádio ligado 12.225 registros, 48,7 s, sobrando 11 s de sono
wake com coletor lento (3 s) 325 registros e dorme, em vez de esperar
  1. t_int deixou de ser piso entre lotes e virou período entre drenos. Era ele que fazia o teto de vazão: um lote a cada cinco minutos. Agora um dreno vai até esvaziar, e o ritmo dentro dele vem do servidor.
  2. A cadência é o ciclo medido, não a configuração. Ciclo abaixo da marca rápida do transporte (400 ms puro, 2.500 ms TLS) → próximo lote na hora; acima → o gap é o próprio ciclo; e só dobra quando o ciclo piora ≥ 25 % sobre a média — a versão que dobrava sempre foi medida e piorava um coletor saudável de 0,5 s em 5,6× (§3.9).
  3. O lote é automático dentro do teto de heap: cresce metade a cada sucesso, cai pela metade a cada falha, entre 10 e 250. Visível no fio: 50 → 75 → 100 num wake com coletor lento.
  4. O wake do Air se dimensiona pelo intervalo de leitura e hiberna em vez de esperar: o orçamento do FLUSH é o menor entre o teto configurado e o que resta do intervalo depois da cauda e do sono mínimo; se o gap pedido não cabe, dorme e continua no próximo wake. Fim do OVERRUN com leitura a 1 min.
  5. Onde o ganho NÃO está: com um coletor lento (0,5 a 3,5 s) a cadência nova empata com a antiga, porque 1,5 × EMA_POST e 1 × ciclo dão quase o mesmo número quando é o servidor que manda. O ganho está no coletor rápido, na configuração de campo e no wake.
  6. Para energia, o que importa segue sendo o wake, não o envio: dos 48,7 s acordado, ~27 s são boot e amostragem. Enviar raramente (a cada N wakes, airTelemetryDue( )) e drenar rápido é a regra; o envio em si custa ~1,4 ms por registro no HTTP.
  7. HTTPS: cada lote paga um handshake, e o keep-alive de sessão medido em §2.5 vale 3,4× a 5,2×. Continua atrás de TEL_TLS_KEEPALIVE_EXPERIMENT (default 0) até a phase_survive rodar com ele — é a decisão D-12.
  8. Retratação: a primeira matriz (01:00) mostrava “22–35 % de reenvio” e números 1,5× maiores; era a bancada, não o firmware (§2.6). Dois bugs reais apareceram no caminho e estão corrigidos: o cursor não ia ao flash durante um dreno rápido, e o wake de telemetria não enviava nada.
  9. Três reboots silenciosos (sem autópsia, sem marcador de hibernação), todos colados a uma falha de envio, nenhum reproduzível sob observação. Não é watchdog nem caminho de reboot do firmware; a hipótese é perda de energia no USB (§2.7).

1. Todas as variáveis da telemetria (o que existe hoje, e o que cada uma faz)

variável onde papel hoje destino no plano
telInterval (ms, 0 = off) SystemConfig / t_int piso entre lotes: effectiveInt = max(telInterval, 1,5 × EMA_latência) × penalidade_RSSI, teto 60 s deixa de ser piso entre lotes; vira só o período de telemetria do Air (airTelemetryDue( ), em wakes inteiros) e, no M0, o período entre drenos
telBatchSize (1..250) t_bat máximo configurado por lote vira teto; o tamanho real é automático (§3.2)
safeBatchLimit( ) TelemetryManager.cpp:502 clamp por heap: (livre − reserva) / bytes_por_registro, reserva 32 K (TLS) / 12 K (plain), 350 B JSON / 160 B CSV, cap 250 mantido — é o teto físico do controlador de lote
PREFLIGHT_FLOOR idem 24.576 (TLS) / 14.336 (plain) B de heap livre para tentar mantido
telMode JSON/CSV t_mode forma do payload; 35,8 B/registro medidos no fio (JSON, 1 canal) mantido; CSV a medir
telTransport, telEncryption t_transport, t_sec HTTP/MQTT, TLS on/off mantidos; TLS ganha keep-alive de sessão
_smoothedLatencyMs EMA α = 0,3 mede o POST( ) — exclui conexão e handshake passa a medir o ciclo inteiro (do collectBatch ao fim do end( )), que é o que a cadência precisa
backoff 5 s ×2 até 300 s, jitter ±25 %, streak 10 punição por falha mantido para falhas; não se aplica a “lento”
penalidade RSSI < −85 ×2, < −75 ×1,5 alarga o intervalo mantida como multiplicador do gap entre lotes
NET_SOCKET_TIMEOUT_MS 4.000 ms timeout de leitura do POST é o penhasco: servidor > 4 s = falha (§4.2)
NET_TLS_HANDSHAKE_MS 15.000 ms teto do handshake mantido
setBufferSizes(4096, 512) TLS iobuf RX 4 K, TX 512 B — um registro TLS a cada 512 B de payload candidato a alavanca no HTTPS (§4.3)
_httpSecurePtr->stop( ) após cada lote :906 defesa contra drip condicional ao insucesso (experimento)
collectBatch( ) :545 varre /history, abre e decodifica arquivos a cada lote custo fixo por lote; cache da lista dentro de um dreno é alavanca secundária
cursor t_cursor.bin coalescido 5 s; forçado antes do sono avanço do enviado mantido
piso de 30 dias collectBatch nada mais velho que último − 30 d sai mantido
Air flushTimeoutMs air.bin, 30 s teto de parede do FLUSH passa a ser derivado do intervalo de leitura (§3.4)
Air airTelemetryDue( ) AppManager_Air.cpp rádio só a cada N wakes é o “período” da telemetria no Air
MQTT ≤ 5 / > 5 attemptMqttPublish publish por registro até 5; um payload acima mesma regra de cadência; lote automático vale igual

2. O que a bancada mediu

Bancada: LittleFS com 28 dias sintéticos (tools/gen_synth_history.py, 1 registro/min, ~35 mil registros dentro do piso de 30 dias, 58 KB em /history); servidor instrumentado no PC (192.168.3.31, HTTP :18080, HTTPS :18443, certificado autoassinado — o aparelho está sem /cert.pem, logo setInsecure( )); t_int = 1 para o piso não mandar; tel_reset antes de cada célula; janelas de 45 s cortadas do log por request do servidor (§2.6 explica por quê); três instrumentos (servidor, /api/status a cada 1 s, USB). Firmware da branch com o fix do cursor, sem sondas. Tudo abaixo é a segunda matriz (01:22–01:40).

2.1 Capacidade — HTTP puro, servidor respondendo na hora

lote ativo s req registros reg/s ms/req reg/req POST (aparelho) servidor p50 heap mín / maior bloco falhas reboots
10 46,0 629 6.290 137 73 10,0 13 ms 2 ms 95,7 / 55,9 KB 0 0
25 45,0 551 13.775 306 82 25,0 16 ms 5 ms 95,6 / 55,6 KB 0 0
50 45,1 453 22.650 502 100 50,0 24 ms 10 ms 95,6 / 55,9 KB 0 0
100 41,4 ¹ 325 32.400 782 127 99,7 30 ms 19 ms 95,6 / 55,9 KB 0 0
250 37,1 ¹ 132 30.674 827 281 232,4 (clamp de heap) 52 ms 45 ms 95,6 / 55,9 KB 0 0

¹ o backlog acabou antes dos 45 s; a taxa é sobre o tempo em que houve o que mandar.

Ajuste sobre as cinco células: t_req ≈ 54 ms + 0,94 ms × registros. Três leituras:

Nenhum reboot, nenhum poll web perdido, heap estável — o aparelho continua operável durante o dreno.

2.2 Capacidade — HTTPS (certificado autoassinado, setInsecure( ))

lote ativo s req registros reg/s ms/req reg/req POST (aparelho) servidor p50 heap mín / maior bloco falhas reboots
10 45,1 30 300 6,7 1.503 10,0 533 ms 76 ms 88,9 / 46,7 KB 0 0
25 46,1 33 825 17,9 1.397 25,0 526 ms 92 ms 89,0 / 45,4 KB 0 0
50 46,1 32 1.600 34,7 1.441 50,0 555 ms 120 ms 88,9 / 46,7 KB 0 0
100 45,5 27 2.700 59,3 1.685 100,0 619 ms 183 ms 89,0 / 79,4 KB 0 0
250 45,4 25 3.950 86,9 1.816 158,0 (clamp de heap TLS) 682 ms 247 ms 88,9 / 41,4 KB 0 0

Ajuste: t_req ≈ 1,38 s + 2,7 ms × registros.

(A/B do keep-alive de sessão em §2.5.)

2.3 Latência do servidor injetada (HTTP, lote 50)

Servidor em modo slow: lê a requisição, dorme o atraso, responde 200. Firmware atual (regra max(t_int, 1,5 × EMA do POST)), t_int = 1, janelas de 45 s.

atraso do servidor req reg/s s entre envios POST (aparelho) falhas reboots
0 (§2.1) 453 502 0,10 24 ms 0 0
0,5 s 34 37,2 1,34 520 ms 0 0
1 s 18 19,9 2,52 1.020 ms 0 0
2 s 9 10,9 4,60 2.023 ms 0 0
3,5 s 5 6,7 7,50 3.528 ms 0 0
6 s 3 ¹ 3,1 16,4 — (timeout) ≥ 3 (ctx=−11) 1

¹ requisições que o servidor recebeu; o aparelho desistiu de todas aos 4 s (NET_SOCKET_TIMEOUT_MS).

2.4 Um wake M1 por configuração (sonda GP16)

Leitura a 1 min (h_int = 1), tel_reset antes de hibernar (backlog inteiro, ~35 mil registros), um wake cronometrado pela sonda passiva da PicoHand no GP16, firmware atual.

O harness manda air hibernate de M0: o aparelho roda um ciclo no lugar (FLUSH até o teto, dorme) e depois o wake cronometrado pela sonda. Os dois aparecem no log do servidor como dois trechos separados pelo sono; a tabela é só o wake (o trecho de dentro da janela da sonda).

transporte lote t_int acordado (sonda) FLUSH do wake req registros ms/req s acordado / 1.000 reg
HTTP 100 60.000 ms 56,9 s 30 s, ocioso 0 0
HTTP 100 1.000 ms 57,0 s 29,1 s 28 2.800 1.077 20,4
HTTP 100 1 ms 54,6 s 27,6 s 196 19.600 142 2,79
HTTPS 100 1 ms 57,8 s 29,9 s 20 2.000 1.571 28,9

Correção implementada (F05, “modo dreno”): o FLUSH do Air passa a chamar TelemetryManager::setDrainMode(true) ao entrar e false ao sair (e no air stop); em modo dreno o update( ) ignora t_int e manda um lote por chamada enquanto houver pendentes, mantendo o backoff após falha (é ele que encerra o wake com coletor mudo). t_int continua decidindo em que wake o rádio liga (airTelemetryDue( )). Validação: §2.8.

2.8 Validação do modo dreno (dois wakes, t_int = 60 s, HTTP lote 100)

wake acordado (sonda) FLUSH do wake req registros ms/req s acordado / 1.000 reg
antes (§2.4, mesmo t_int) 56,9 s 30 s, ocioso 0 0
depois, backlog inteiro (tel_reset) sonda sem janela ¹
depois, em regime (sem tel_reset) 57,2 s 30,0 s 188 18.800 160 3,04

¹ O ciclo no lugar deste wake drenou 127 lotes (12.700 registros) em 25 s e terminou numa falha (o servidor registrou um TimeoutError numa conexão), dormiu o mínimo (OVERRUN) e o boot seguinte subiu em M0: um lote por minuto, sem dormir, até o harness intervir. Boot sem marcador de hibernação e sem autópsia — a mesma assinatura do reboot da §2.7. Não se repetiu no wake em regime, que fez o ciclo completo (dreno, sono de 60 s, wake, dreno de 30 s, sono) com a sonda confirmando.

O wake em regime é a prova do F05: de 0 para 18.800 registros no mesmo wake de 57 s, com o mesmo t_int de 60 s da configuração — t_int passou a dizer em que wake o rádio liga, e dentro do wake o dreno vai na velocidade do servidor. O ganho seguinte é o teto derivado (§3.4): este wake ainda parou no teto de 30 s com ~16 mil registros na fila.

2.5 A/B: sessão TLS mantida entre lotes

Mesma bancada, HTTPS, 45 s. “Firmware KA” = TEL_TLS_KEEPALIVE_EXPERIMENT 1 (o stop( ) da sessão só no insucesso; HTTPClient persistente no caminho TLS; guarda de 3 s de ociosidade). “Servidor KA” = server_http.py --keepalive (responde Connection: keep-alive e continua lendo no mesmo socket).

célula lote req reg/s ms/req POST (aparelho) handshakes TLS heap mín / maior bloco
base (firmware e servidor fecham) — §2.2 25 33 17,9 1.397 526 ms 33 89,0 / 45,4 KB
A: firmware KA + servidor KA 25 170 92,9 269 90 ms 1 80,0 / 63,8 KB
base — §2.2 100 27 59,3 1.685 619 ms 27 89,0 / 79,4 KB
A: firmware KA + servidor KA 100 91 201,0 498 178 ms 1 80,0 / 70,6 KB
B (controle): firmware KA + servidor que fecha 100 28 62,2 1.607 613 ms 28 88,5 / 71,6 KB

2.6 Retratação: os “22–35 % de reenvio” eram a bancada, não o firmware

A primeira matriz (01:00) mostrou, em quase toda janela HTTP, o fio recomeçando do registro mais antigo no meio da medição — 22 a 35 % dos registros recebidos repetidos. Fui atrás no firmware: instrumentei os dois únicos caminhos que zeram o cursor (cursor ahead of data e a leitura do t_cursor.bin com o cache vazio) mais uma linha por lote (cursor=… first=… last=… new=…), e rodei três janelas de lote 100 com a serial capturada o tempo todo (serial_probe.py):

rodada req registros reenviados sonda de reset cursor
1 351 34.881 3 (0 %) nenhuma monotônico
2 351 34.883 2 (0 %) nenhuma monotônico
3 351 34.884 1 (0 %) nenhuma monotônico

O fio das janelas antigas explicou o resto: cada uma começava no meio do backlog (o primeiro registro recebido era de 12, 20 ou 26 de agosto, nunca o mais antigo) e só depois pulava para o mais antigo. É a sequência do próprio harness: o servidor era ligado antes do commit_all; o aparelho reiniciava, retomava o dreno do cursor persistido (o saveConfiguration( ) grava o cursor junto com a config) enquanto o harness ainda fazia login; e então o tel_reset do harness recomeçava do mais antigo — como deve. Os “duplicados” eram registros enviados antes do tel_reset, contados pelo servidor porque ninguém cortava a janela. Os 1–3 restantes são o bloco fora de ordem que já existe nos arquivos reais de agosto (relógio provisório de 14/08), não reenvio.

Correção da bancada: window( ) passa a cortar a janela do log por request do servidor, por relógio de parede, e reporta o que chegou antes dela em pre_window_records. A matriz foi refeita com isso (v2, abaixo). A coluna “únicos/s” das tabelas antigas sai: media o meu artefato.

O que era bug, e ficou corrigido no caminho: setLastSentTimestamp( ) reiniciava a janela de coalescência de 5 s a cada chamada. A um lote a cada ~50 ms a janela deslizava para sempre e o cursor nunca ia para o flash durante um dreno — milhares de lotes sem nada persistido; uma queda de energia no meio reenviaria o dreno inteiro no boot seguinte (no Air o flush forçado antes de dormir cobria o caso do sono, não o da queda). A janela agora ancora no primeiro set sujo: uma escrita a cada 5 s sob carga, que era a intenção original.

2.7 O reboot da célula de 6 s

Cronologia pelo log persistido (/api/logs, decodificado com logcodes.tsv): boot às 01:45:52 (pós-flash); tel_reset da célula às 01:50:11; falhas ctx=−11 (read timeout de 4 s) às 01:50:15, 01:50:25 e 01:50:40 (up=264/274/288 s, backoff 5 → 10 → 20 s); boot novo às 01:50:46 (up=17 s às 01:51:03), sem nenhum registro SYS_BOOT — nem [FTL] nem INFO.

Pela autópsia do LogManager (performCrashAutopsy), um boot silencioso só sai de dois caminhos: reboot limpo marcado por markCleanReboot( ) ou reset físico / power-on (registro REASON zerado). Os chamadores de safeReboot( ) são CLI, OTA, commit_all e o timeout do modo AP — nenhum se aplica ao instante; o sono do Air não estava armado. Um watchdog real teria escrito [FTL] SYS_BOOT ctx=2xx, e um reset via picotool, INFO. Sobra o reset físico: o alvo vive no USB do PC, e o rig já registrou “reboot sem causa = perda de energia”. Heap 95,6 KB mínimo na célula, polls web todos respondidos até o instante — nada apontando para o firmware.

Terceira ocorrência, 07/09 08:59, na célula HTTPS da validação da cadência: o log traz SYS_TEL_SENT às 08:58:53 e um boot às 08:59:45 (up=17 s) sem nenhum SYS_BOOT — a assinatura idêntica das outras duas. A mesma célula, repetida 25 minutos depois com o firmware final, correu inteira sem reset. Três eventos, todos silenciosos, nenhum reproduzível sob observação: continua não sendo o watchdog (que escreveria [FTL] ctx=2xx) nem qualquer caminho de reboot do firmware.

Repetida às 02:20 com a serial acampada (serial_probe.py --delay 6 --seconds 75): quatro timeouts de 4 s (read Timeout (-11)), backoff 5 → 10 → 20 → 40 s, nenhum reboot em 75 s e nenhum banner de boot na serial. Fica classificado como não reproduzido, provável perda de energia — com o registro de que o 1º wake de validação do modo dreno teve um boot com a mesma assinatura (§2.8 ¹), também colado numa falha de envio. Se acontecer uma terceira vez, a serial acampada durante um soak com coletor lento é o teste. O que a célula mostrou de verdade, e que é do firmware, está na §2.3: acima de 4 s o coletor fica invisível e o aparelho gira em backoff.


3. Desenho proposto

3.1 Cadência automática entre lotes

depois de um envio com sucesso, com ciclo medido c (ms, do collectBatch ao end):
    gap = 0                              se c ≤ FAST_MS[transporte]  (rápido: próximo lote já)
    gap = c                              se c > FAST_MS              (lento e estável: dá a ele o tempo que levou)
    gap = min(2 × gap_anterior, GAP_MAX)  se além disso c > 1,25 × EMA(c)  (PIORANDO: recua de verdade)
    gap *= penalidade_RSSI
depois de uma falha (código ≠ 2xx, timeout, socket):
    backoff exponencial como hoje (5 s ×2 … 300 s, jitter); gap volta a 0 no próximo sucesso rápido

3.2 Lote automático (AIMD dentro do teto de heap)

tetoHeap  = safeBatchLimit(telBatchSize)          // já existe
lote      = clamp(loteAtual, LOTE_MIN, tetoHeap)
sucesso (rápido ou lento) → lote = min(lote × 3/2, LOTE_MAX)   // o teto real é o heap, em collectBatch
falha / timeout           → lote = max(lote / 2, LOTE_MIN); backoff

Crescer só no sucesso rápido foi tentado e medido pior (§3.9): contra um coletor de 3 s o lote nunca saía de 50. Quem responde à lentidão é o gap; o lote responde à falha.

3.3 Custo fixo por lote — as alavancas

alavanca ganho risco
keep-alive TLS entre lotes (TEL_TLS_KEEPALIVE_EXPERIMENT) elimina um handshake por lote; mede-se em §2.5 a defesa contra drip fica só no caminho de insucesso; o end( ) do framework drena resto de corpo com prazo e derruba _canReuse
TX buffer TLS 512 → 2048 B 4× menos registros TLS por POST +1,5 KB de heap por sessão
cache da lista de arquivos dentro de um dreno corta a varredura de diretório por lote invalidar quando o histórico sela um bloco
keep-alive TCP no HTTP puro evita um connect por lote (~ms) WiFiClient persistente como membro; ganho pequeno frente aos 48 ms

3.4 Air: hibernar e continuar depois

no início do FLUSH:
    orçamento = tetoFlush − (millis() − início do wake)
    tetoFlush = min(flushTimeoutMs, histIntervalMs − amostragem − margem)     // nunca estourar a leitura
a cada lote:
    estimativa = pendentes / vazãoMedida
    se estimativa > orçamento restante → envia até o orçamento e dorme; o contador de wakes retoma
    se gap calculado > orçamento restante → dorme agora (não vale a pena ficar acordado esperando)

3.5 O que muda para o operador

3.6 Onde mexer (esboço, com os pontos verificados no código)

peça hoje mudança
cadência entre lotes TelemetryManager::update( ), src/TelemetryManager.cpp:349-364: effectiveInt = max(telInterval, 1,5 × EMA) × RSSI, teto 60 s, e o return quando now − _lastCheckTime < effectiveInt effectiveInt passa a ser o gap da §3.1: 0 se o ciclo anterior foi rápido, senão min(max(ciclo, 2 × gap_anterior), GAP_MAX) × RSSI. telInterval sai dessa conta e entra só em _lastDrainEnd: um dreno novo começa quando now − _lastDrainEnd ≥ telInterval ou quando ainda há pendentes do dreno corrente
o que a EMA mede :883-885: _smoothedLatencyMs só vê o POST( ) — 13 ms no HTTP, enquanto o ciclo inteiro custa ~70 ms t0 antes do collectBatch( ), t1 depois do http.end( ); a EMA passa a ser do ciclo, que é o que o gap precisa. metr.tl continua sendo o POST (é o que o painel chama de latência)
lote automático safeBatchLimit( ), :516: min(configurado, HARD_CAP, teto de heap) min(configurado, HARD_CAP, teto de heap, _batchAuto); _batchAuto cresce ×3/2 após sucesso rápido, mantém após sucesso lento, cai /2 após falha (§3.2); nasce em 50
várias rodadas por passada do loop update( ) envia um lote por chamada e volta ao loop( ); o resto do loop (web, sensores) entra entre dois lotes com gap 0, update( ) pode encadear até N lotes na mesma chamada enquanto millis( ) − t0 < TEL_SLICE_MS (~250 ms), alimentando o watchdog entre eles; acima disso devolve o loop. Mede-se quanto do custo fixo é o loop e quanto é o ciclo
sessão TLS entre lotes attemptHttpUpload( ), :920-929, atrás de TEL_TLS_KEEPALIVE_EXPERIMENT; o reuso é estado da instância do HTTPClient (_canReuse nasce falso), por isso a instância é membro (_httpKeepPtr) no caminho seguro decisão D-12 pela §2.5: entra como padrão ou vira bit de config
Air: orçamento do FLUSH AppManager_Air.cpp:319-359: sai quando pendentes == 0, backoff > 0, rede caiu ou flushTimeoutMs (30 s de parede) orçamento = min(flushTimeoutMs, histMs − amostragem_medida − margem); a cada lote, pendentes / vazão_medida contra o restante; se o gap calculado > restante, dorme já. airTelemetryDue( ) (a regra de N wakes) não muda
MQTT attemptMqttPublish( ) publica ≤ 5 registros um a um, acima disso um payload mesma cadência e mesmo _batchAuto; o “ciclo” é do connect( ) ao último publish( ) confirmado
o que não muda backoff por falha (5 s ×2 … 300 s, jitter), penalidade RSSI, PREFLIGHT_FLOOR, reserva de heap, piso de 30 dias, cursor coalescido 5 s + forçado antes do sono

3.7 O que foi implementado (07/09, feature/simut-air)

O desenho acima está no firmware. Onde ficou cada peça:

peça onde comportamento
período entre drenos TelemetryManager::update( ) telInterval só decide quando um dreno começa (_lastDrainEnd); dentro do dreno não gateia nada
gap entre lotes idem 0 se o ciclo ≤ TEL_FAST_MS_PLAIN (400 ms) / TEL_FAST_MS_TLS (2.500 ms); senão dobra a cada lote lento até TEL_GAP_MAX_MS (10 s), e um lote rápido zera. Penalidade de RSSI multiplica o gap
ciclo medido idem t0 antes do collectBatch( ), t1 depois do transporte — em _lastCycleMs. O _smoothedLatencyMs continua sendo só o POST (é o que metr.tl e o painel mostram)
lote automático collectBatch( ) + pós-envio _batchAuto (nasce em 50) cresce ×3/2 no sucesso rápido, mantém no lento, cai pela metade na falha, entre TEL_BATCH_MIN (10) e TEL_BATCH_MAX (250) — sempre abaixo do safeBatchLimit( ) do heap
dedo na tela ganha do backlog idem TouchPriority::isActive( ) adia o próximo lote em 1 s; nas imagens sem display não existe provider e o portão é sempre falso
orçamento do wake AppManager::airFlushBudgetMs( ) min(flushTimeoutMs, intervalo_de_leitura − AIR_FLUSH_TAIL_MS − tempo já gasto no wake); só num wake de verdade (_airWokeFromSleep), porque um ciclo disparado por air hibernate de M0 não tem essa âncora
hibernar e continuar FLUSH do Air se o gap pedido não cabe no que resta do orçamento, dorme já (notWorthWaiting) — o dado fica no flash e o próximo wake de telemetria continua
diagnóstico air status bat= (lote corrente) e cyc= (último ciclo em ms)

Fora do escopo desta rodada, medido e anotado como alavanca: encadear vários lotes por passada do loop( ) (§3.6) e cachear a lista de arquivos dentro de um dreno.

3.8 Validação no ferro (07/09, firmware da cadência automática)

V1 — a configuração de campo deixa de estrangular. Mesma célula da §2.1/§2.2 (lote 100, 45 s), mas com t_int = 300.000 ms, que é o que o aparelho do Ângelo tem configurado:

transporte antes (t_int = 300 s) matriz v2 (t_int = 1 ms) agora (t_int = 300 s)
HTTP 0,33 reg/s 782 reg/s (127 ms/req) 1.259 reg/s (79 ms/req)
HTTPS 0,33 reg/s 59 reg/s (1.685 ms/req) 142 reg/s (704 ms/req)

Duas leituras:

3.9 O que a validação pegou (dois defeitos meus, medidos antes de subir)

1. A escalada do gap punia um servidor saudável. A regra que eu tinha escrito na §3.1 — gap = max(ciclo, 2 × gap_anterior) para todo ciclo acima da marca rápida — dobra a cada lote lento. Um coletor estável em 0,5 s (o que um endpoint de ingestão em nuvem parece num dia bom) tem todo ciclo acima da marca, então o gap subia 0,56 → 1,1 → 2,2 → 4,5 → 9 → teto de 10 s e ficava lá. Medido, lote 50, janelas de 45 s:

atraso do servidor firmware antigo (piso fixo) escalada ingênua regra corrigida
0,5 s 1,34 s entre envios · 37,2 reg/s 7,57 s · 6,6 reg/s (§3.10)
1 s 2,52 s · 19,9 reg/s 7,77 s · 6,4 reg/s (§3.10)
2 s 4,60 s · 10,9 reg/s 8,35 s · 6,0 reg/s (§3.10)
3,5 s 7,50 s · 6,7 reg/s 10,30 s · 4,9 reg/s (§3.10)
6 s (penhasco) falhas + 1 reboot 3 falhas, backoff, 0 reboots (§3.10)

Uma regressão de até 5,6× contra o firmware que eu estava substituindo. A correção separa “lento” de “piorando”: o gap é o ciclo (o servidor ganha o mesmo tempo que levou para responder) e só dobra quando o ciclo fica ≥ 25 % acima da média recente — que é o sinal de que a pressão é real. Era exatamente a decisão D-15 que eu havia deixado em aberto no plano; a bancada respondeu por ela.

2. O lote parava de crescer com um servidor lento. A regra dizia “cresce no sucesso rápido, mantém no lento”. Contra um coletor de 3 s, todo ciclo é lento, então o lote ficava nos 50 iniciais pelo wake inteiro — cada ciclo caro carregando metade do que podia. O sinal de “grande demais” é a falha, não a lentidão; a lentidão já é respondida pelo gap. Agora cresce em qualquer sucesso (×3/2) e cai pela metade na falha, sempre sob o teto de heap.

3. O orçamento do FLUSH cortava metade do que sobrava. airFlushBudgetMs( ) devolvia “o que falta a partir de agora” enquanto quem o consome compara com “o tempo desde o início do FLUSH”. Com o FLUSH começando aos 25 s e o limite em 55 s, a fase terminava quando t − 25 ≥ 55 − t, ou seja aos 40 s — metade do orçamento real. A janela acordada de 40,3 s medida no primeiro wake parecia o resultado desejado e era um erro de conta. Agora o orçamento é relativo ao início da fase, e a reserva inclui o sono mínimo (AIR_FLUSH_TAIL_MS + AIR_MIN_SLEEP_SEC): sem o termo do sono a aritmética “cabe” e o ciclo estoura assim mesmo, porque sobram 3 s para dormir e o piso é 5 s.

3.10 Validação final (firmware com as três correções)

Latência injetada, HTTP, lote 50, janelas de 45 s. As três colunas são o mesmo aparelho, a mesma célula, três firmwares:

atraso firmware antigo (piso 1,5 × EMA) escalada ingênua regra corrigida falhas reboots
0,5 s 1,34 s · 37,2 reg/s 7,57 s · 6,6 reg/s 1,39 s · 36,0 reg/s 0 0
1 s 2,52 s · 19,9 reg/s 7,77 s · 6,4 reg/s 2,67 s · 18,7 reg/s 0 0
2 s 4,60 s · 10,9 reg/s 8,35 s · 6,0 reg/s 4,78 s · 10,4 reg/s 0 0
3,5 s 7,50 s · 6,7 reg/s 10,30 s · 4,8 reg/s 8,62 s · 5,8 reg/s 0 0
6 s (penhasco) 16,40 s · 3,0 reg/s 7,43 s · 5,0 reg/s 7,40 s · 6,8 reg/s 3 0

Capacidade com a configuração de campo (t_int = 300 s), lote 100:

transporte antes agora backlog
HTTP 0,33 reg/s 1.276 reg/s (78 ms/req) 36.207 → 1 pendentes em 26,7 s
HTTPS 0,33 reg/s 140 reg/s (716 ms/req) 6.300 registros na janela, 0 falhas

Wakes M1 com a sonda GP16 (leitura a 1 min, t_int = 60 s, lote 100). Todos os números são do wake, cortado do log do servidor pelo instante do air hibernate:

firmware servidor acordado FLUSH req registros s/1.000 reg
original (F05 aberto) rápido 56,9 s 30 s ocioso 0 0
modo dreno, sem orçamento rápido 57,2 s 30,0 s 188 18.800 3,04
+ orçamento com o erro de metade rápido 40,3 s 13,4 s 83 8.225 2,82
final rápido 48,7 s 21,7 s 123 12.225 2,39
escalada ingênua lento (3 s) 38,4 s ~10 s 2 100 127,9
final lento (3 s) 48,4 s 19 s 4 325 64,6

3.11 Segunda mudança de semântica: o gatilho vira quantidade (07/09, pedido do Ângelo)

“Quero que o intervalo da telemetria seja definido pela quantidade de pacotes pendentes. Se eu definir o lote mínimo de 10 (no campo que hoje é o intervalo) e o máximo de 100 (no campo de batch), a telemetria aconteceria quando houvesse 10 pendentes e, havendo mais de 100, eles seriam enviados em lotes de 100 até acabar. Com o mínimo em 0, a telemetria fica desabilitada.”

O relógio sai de cena por completo. Os dois campos que já existem passam a ser os dois extremos do lote:

campo web/CLI antes (v21) agora (v22)
telInterval t_int intervalo entre envios, em ms lote mínimo: registros pendentes que disparam um envio; 0 desliga
telBatchSize t_bat máximo por requisição lote máximo: igual, mas agora é o único botão de tamanho

Por que é melhor no Air. A pergunta que o operador tem é “quanto de dado vale uma transmissão?”, e a resposta em registros vale igual na tomada e na bateria. Um wake que não tem o que dizer não liga o CYW43; um que tem, envia e volta a dormir. A regra antiga (a cada N wakes) ligava o rádio no relógio, mesmo sem dados novos, e um aparelho que passou o dia sem rede acordava para não enviar nada.

O que a mudança exigiu, além de trocar a comparação:

  1. Migração de config (v21→v22). O campo não muda de tamanho, de tipo nem de posição — só de significado, que é exatamente o que um CRC e um teste de tamanho não pegam. Sem migração, um aparelho com o default antigo de 300000 leria “300 mil registros pendentes” e a telemetria ficaria muda para sempre, com a página web mostrando o número que o operador configurou. A conversão preserva a intenção: quantos registros teriam se acumulado naquele intervalo, na cadência de leitura do aparelho (5 min com leitura de 1 min → 5 registros). A aritmética está em telMinBatchFromLegacyMs( ) com teste nativo, porque é o único ponto entre uma atualização e uma telemetria silenciosa.
  2. Freio para coletor morto. Com o gatilho por quantidade a fila só cresce quando o servidor não responde, então o gatilho seria verdadeiro em todo wake e o rádio esvaziaria a bateria falando sozinho. O backoff do M0 não serve: vive na RAM e todo wake é um boot. Um wake cujo envio falhou passa a reservar AIR_TEL_FAIL_SKIP_WAKES (5) wakes de silêncio, guardados no mesmo campo do scratch[1] que era o contador de wakes. É plano, não exponencial: a escalada precisaria de um segundo contador que sobrevivesse ao sono, e o único espaço que resta nesse registrador são os segundos dormidos, que precisam da faixa inteira para o relógio provisório.
  3. O lote automático passa a nascer no máximo configurado. Começar em 50 e crescer fazia o operador ter que reconquistar o próprio ajuste a cada boot; o AIMD existe para recuar de um servidor que não aguenta, não para racionar por padrão.
  4. t_int ganhou teto (20.000 registros): acima do piso de 30 dias do collectBatch( ) não há o que enviar, então um mínimo maior que isso nunca dispararia. O campo aceitava 24 h em ms.

O que a primeira medição pegou, e era grave. Com lote mínimo 5 e leitura a 1 min, sete wakes seguidos saíram assim:

[AIR] wake: radio=off (pending=0 min=5 skip=0)     ← sete vezes, com a fila crescendo de verdade

Duas causas, as duas do mesmo tipo — perguntar a alguém que ainda não sabe:

É o padrão que a bancada já tinha registrado noutra forma: no Air não existe “depois” — nem para adiar uma escrita, nem para um contador que alguém vai preencher mais tarde.

Validação no ferro (phase_minbatch.py, lote mínimo 5, máximo 100, leitura a 1 min, 9 min de ciclo, serial acampada através dos sonos):

wake pendentes rádio
1, 2 0 desligado
3, 4, 5, 6 1, 2, 3, 4 desligado
7 5 ligado — envia e a fila zera
8 0 desligado

Oito wakes, um com o CYW43 energizado, oito registros entregues ao coletor. É o comportamento pedido, e o ganho de bateria é a razão de 7 para 8: o rádio ficou desligado em todo wake que não tinha o que dizer. Com o mínimo em 0 a telemetria não dispara nunca (T06b da suíte).

4. Testes que fecham cada parâmetro

parâmetro teste aceite resultado 07/09
FAST_MS §2.1 + §2.2: maior ciclo com sucesso sem handshake ciclo p90 do HTTP < FAST_MS < ciclo p10 do HTTPS sem keep-alive HTTP ≤ 281 ms, HTTPS ≥ 1,4 s → 400 ms / 2.500 ms por transporte (§3.1)
GAP_MAX_MS / penhasco §2.3: atrasos 0,5 … 6 s gap medido ≈ atraso injetado até 3,5 s; a 6 s o aparelho entra em backoff sem reboot cadência atual = 2,5 × atraso; a 6 s backoff e um reboot silencioso (§2.7) — aceite não atingido até a serial acampada dizer o que foi
lote máximo por transporte §2.1/§2.2 até 250 vazão cresce até o clamp de heap sem falhas; HTTPS revela ou não um teto menor ✅ monotônico nos dois; clamp 232 (plain) / 158 (TLS); 0 falhas em 2.237 req
keep-alive TLS §2.5 A/B, mesma célula com e sem ms/req cai pelo custo do handshake; drip, huge, close_early da phase_survive continuam sem reboot ✅ 5,2× / 3,4×, controle = base; phase_survive com KA ainda por rodar (D-12)
modo dreno (F05) um wake com t_int = 60 s e leitura a 1 min registros entregues > 0 e o wake dorme ✅ 0 → 18.800 registros, dormiu (§2.8)
teto do FLUSH derivado um wake com leitura a 1 min e coletor mudo sem OVERRUN; o wake seguinte cai na cadência por implementar (§3.4); hoje o wake de 57 s com leitura a 60 s já é OVERRUN
energia §2.4: s acordado por 1.000 registros HTTP ≪ 1 s / 1.000; HTTPS com keep-alive próximo disso HTTP 2,8–3,0 s/1.000 dominado pelos 25 s fixos do wake; o envio em si custa 1,4 ms/registro

5. Decisões para o Ângelo


6. Registro de execução

quando o quê resultado
07/09 00:30 28 dias sintéticos gerados e enviados por /api/upload (26 arquivos; 3 reais preservados em backup) 30 arquivos, 58.066 B, fs_u 262 KB de 1 MB
07/09 00:40 shakedown do harness, HTTP lote 50, 30 s 552 req, 27.600 reg, 920 reg/s, 0 falhas
07/09 00:44 matriz de capacidade HTTP+HTTPS × {10,25,50,100,250}, 45 s (v1, contadores acumulados do servidor) HTTP até 994 reg/s (lote 100); HTTPS 8,5–111 reg/s; “22–35 % de reenvio” — ver §2.6
07/09 01:05–01:15 caça ao reenvio: 3 sondas seriais com lote 100 (serial_probe.py) 0 % de reenvio, nenhum reset; artefato do harness (§2.6); fix real da coalescência do cursor
07/09 01:22–01:40 matriz v2 HTTP+HTTPS × {10,25,50,100,250}, 45 s, janela por request HTTP 137→827 reg/s (54 ms + 0,94 ms/reg); HTTPS 6,7→86,9 reg/s (1,38 s + 2,7 ms/reg); 0 falhas em 2.237 req (§2.1, §2.2)
07/09 01:40–01:45 keep-alive TLS: A (firmware KA + servidor KA, lotes 25 e 100) e B (controle, servidor fecha) 5,2× e 3,4×; controle = base; 1 handshake por janela (§2.5)
07/09 01:45–01:51 latência injetada HTTP lote 50: 0,5 / 1 / 2 / 3,5 / 6 s s entre envios ≈ 2,5 × d; a 6 s só timeouts + backoff e um reboot silencioso (§2.3, §2.7)
07/09 01:51–02:06 wakes com sonda GP16, h_int 1 min: t_int 60 s / 1 s / 1 ms e HTTPS 1 ms 0 / 2.800 / 19.600 / 2.000 registros por wake de ~57 s (§2.4) — F05 medido
07/09 02:08–02:18 modo dreno (F05) gravado; dois wakes de validação com t_int = 60 s em regime: 18.800 registros no wake, 3,04 s/1.000 (§2.8); 1º wake subiu em M0 após um ciclo no lugar terminado em falha (§2.8 ¹)
07/09 02:20 célula de 6 s repetida com a serial capturada (banner de boot), 75 s 4 timeouts, backoff 5→40 s, 0 reboots — não reproduziu (§2.7)
07/09 02:23 portões: 6 envs nativos, check_air_consistency C1–C8, builds release/test/asserts/alpha/air tudo OK; pico_w_debug já não cabia na flash (−99.884 B, pré-existente)
07/09 02:24 limpeza: dias sintéticos removidos, 3 arquivos reais devolvidos, cursor zerado, config do Ângelo restaurada cadence_cleanup.py